diff --git a/.dockerignore b/.dockerignore index 5b45455e..b52bf6ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,3 +23,26 @@ install.bat OmniParser_CraftOS debug_images workspace + +# ── Per-machine runtime state — never belongs in an image ────────────────── +# `COPY . .` takes whatever is in the build context, so without these a local +# `docker build` bakes in the BUILDER's data and publishes it to every user of +# the image. CI escapes it only by checking out clean, which makes this a trap +# that fires exactly once, on someone's laptop. +# +# Same failure as the old PyInstaller spec's blanket app/data entry, which +# shipped 1.1 GB of one machine's memory index and databases. +app/data/.file_index +app/data/.usage +agent_file_system +chroma_db_memory +logs +runtime +*.db +.craftbot-managed +config.json +wheelhouse +downloads-cache +npm-cache +playwright-browsers +hf-cache diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml.disabled deleted file mode 100644 index 7f61cae4..00000000 --- a/.github/workflows/ci.yml.disabled +++ /dev/null @@ -1,49 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - - dev - - "V*" - -jobs: - lint: - name: Lint (ruff) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - - - name: Install ruff - run: pip install ruff - - - name: Check formatting - run: ruff format --check . - - - name: Run ruff check - run: ruff check . - - smoke: - name: Smoke (syntax + imports) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Byte-compile source tree - run: python -m compileall -q app agent_core agents decorators skills diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml new file mode 100644 index 00000000..cff2f4ab --- /dev/null +++ b/.github/workflows/launcher.yml @@ -0,0 +1,73 @@ +name: Launcher + +# CI for the native launcher in launcher/ (Rust + Slint). The Release +# workflow builds and ships it; this one catches breakage on the way there, +# on every platform the launcher is shipped for. + +on: + push: + paths: + - "launcher/**" + - ".github/workflows/launcher.yml" + pull_request: + paths: + - "launcher/**" + - ".github/workflows/launcher.yml" + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test (${{ matrix.os_label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_label: linux + - os: windows-latest + os_label: windows + - os: macos-latest + os_label: macos + + defaults: + run: + working-directory: launcher + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: launcher + + # Same insurance as the Release workflow: Slint has no C build deps, + # but a few crates probe for these headers. + - name: Linux build prerequisites + if: matrix.os_label == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev + + - name: Format + if: matrix.os_label == 'linux' + run: cargo fmt --all -- --check + + - name: Clippy + if: matrix.os_label == 'linux' + run: cargo clippy --release -- -D warnings + + - name: Test + run: cargo test --release + + - name: Build + run: cargo build --release diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 00000000..02889cbc --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,202 @@ +# Three guarantees. This workflow turns "the install paths have drifted" from +# something discovered by reading code into a number that moves. +# +# 1. locks — the hash-pinned lock matches requirements.txt everywhere +# 2. parity — pip and conda installs produce the same environment +# 3. installer-e2e — an install produces the same thing as a checkout +# +# A lock can only be generated on the platform it describes, so they are +# generated by hand and committed. CI only verifies they are current. +# +# There is no frozen-agent job: the agent is no longer a PyInstaller bundle, +# which is what removed the whole class of divergence these jobs guard. + +name: Install parity + +on: + # No push trigger: nothing here writes to the repo, so there is nothing a + # push would accomplish that a PR does not. + pull_request: + paths: + - "requirements.txt" + - "environment.yml" + - "requirements/**" + - "scripts/generate_lock.py" + - "scripts/parity_check.py" + - "scripts/package_source.py" + - "scripts/test_install_e2e.py" + - "app/provision/**" + - "app/paths.py" + - ".github/workflows/parity.yml" + workflow_dispatch: + +jobs: + # ────────────────────────────────────────────── + # Every committed lock came from the current + # requirements.txt. + # ────────────────────────────────────────────── + locks: + name: Locks match requirements.txt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + # Checks all platforms' locks from one runner, and needs no pip resolve: + # it compares the source digest recorded in each lock's header. See + # scripts/generate_lock.py for why re-resolving here would be wrong. + # + # This only CHECKS. Locks are generated by hand — `python + # scripts/generate_lock.py` — on the platform each one describes, and + # committed. release.yml refuses to build a payload if one is missing. + - name: Check locks + run: python scripts/generate_lock.py --check + + # ────────────────────────────────────────────── + # pip vs conda must produce the same environment. + # environment.yml used to carry its own package + # list, which drifted by 15 packages. It now + # provides only the runtime, and BOTH paths + # install the same lock — so this job checks + # that the two runtimes agree, not two lists. + # ────────────────────────────────────────────── + parity: + name: pip vs conda (${{ matrix.os_label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_label: linux + - os: windows-latest + os_label: windows + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install via pip (from the lock) + shell: bash + run: | + python -m venv .venv-pip + if [ -f .venv-pip/bin/python ]; then PY=.venv-pip/bin/python; + else PY=.venv-pip/Scripts/python.exe; fi + echo "PIP_PY=$PY" >> "$GITHUB_ENV" + "$PY" -m pip install --upgrade pip + # `ls | head -1` would pick another platform's lock once Linux and + # macOS locks exist. app.provision.deps.find_lock resolves the exact + # (platform, python) tag and refuses to fall back — a Linux lock + # pins CUDA-flavoured torch wheels that do not exist on Windows. + LOCK=$("$PY" -c "from app.provision.deps import find_lock; print(find_lock('.') or '')") + if [ -z "$LOCK" ]; then + echo "::error::no lock for this platform — run scripts/generate_lock.py" + exit 1 + fi + echo "using $LOCK" + "$PY" -m pip install --require-hashes -r "$LOCK" + + - name: Fingerprint pip install + shell: bash + run: | + "$PIP_PY" scripts/parity_check.py --label pip > fp-pip.json + + - uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: craftbot + environment-file: environment.yml + auto-activate-base: false + + # environment.yml deliberately lists no Python packages — they come from + # the shared lock. Without this step the conda env holds only the + # interpreter and system binaries, and the comparison below would report + # a 200-package "divergence" that is really just a missing install. + - name: Install the lock into the conda env + shell: bash -el {0} + run: | + LOCK=$(python -c "from app.provision.deps import find_lock; print(find_lock('.') or '')") + if [ -z "$LOCK" ]; then + echo "::error::no lock for this platform — run scripts/generate_lock.py" + exit 1 + fi + echo "using $LOCK" + python -m pip install --require-hashes -r "$LOCK" + + - name: Fingerprint conda install + shell: bash -el {0} + run: python scripts/parity_check.py --label conda > fp-conda.json + + - name: Compare + shell: bash + run: python scripts/parity_check.py --compare fp-pip.json fp-conda.json + + - name: Upload fingerprints + if: always() + uses: actions/upload-artifact@v4 + with: + name: fingerprints-${{ matrix.os_label }} + path: fp-*.json + + # ────────────────────────────────────────────── + # The installer path must produce the same thing + # as a source checkout. This is the acceptance + # test for the whole architecture, so it runs on + # every platform we ship. + # ────────────────────────────────────────────── + installer-e2e: + name: Installer E2E (${{ matrix.os_label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_label: linux + - os: windows-latest + os_label: windows + - os: macos-latest + os_label: macos + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build frontend + shell: bash + env: + VITE_BACKEND_PORT: "7926" + run: | + cd app/ui_layer/browser/frontend + npm install + npx vite build + + - name: Build the source payload + shell: bash + run: | + python scripts/package_source.py + + # Structural only: verifies path resolution, the managed-install marker + # and payload completeness. The full run installs 239 packages and is + # too slow for every PR — the `parity` job covers dependency equality. + - name: Install E2E (structural) + shell: bash + run: | + python scripts/test_install_e2e.py --skip-deps + + - name: Upload payload + if: always() + uses: actions/upload-artifact@v4 + with: + name: payload-${{ matrix.os_label }} + path: dist/CraftBot-src.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6efbaae4..13929b5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,28 +69,14 @@ jobs: cache-to: type=gha,mode=max # ────────────────────────────────────────────── - # Build platform binaries with PyInstaller + # Build the source payload the launcher provisions around. + # ONE asset for every platform: it is pure Python plus data files. What + # used to differ per platform (the bundled interpreter, compiled wheels) + # is provisioned on the user's machine by install.py / app.provision. # ────────────────────────────────────────────── - pyinstaller: - name: Binary (${{ matrix.os_label }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - os_label: linux - data_sep: ":" - ext: "" - - os: windows-latest - os_label: windows - data_sep: ";" - ext: ".exe" - - os: macos-latest - os_label: macos - data_sep: ":" - ext: "" - + source: + name: Source payload + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 @@ -100,113 +86,177 @@ jobs: with: python-version: "3.10" - - name: Install Linux webview system packages - if: matrix.os_label == 'linux' - shell: bash - run: | - # PyGObject builds against these headers. WebKitGTK 4.1 + the - # webkit2-4.1 GObject introspection bindings are what pywebview's - # GTK backend talks to at runtime — the WebKit2 namespace is - # identical across 4.0 and 4.1, so pywebview needs no change. - # Ubuntu 24.04 (noble) on ubuntu-latest dropped the 4.0 packages; - # earlier 22.04 runners had only 4.0. Pin to 4.1 to match noble. - # End users need the runtime halves of these (libwebkit2gtk-4.1-0, - # gir1.2-webkit2-4.1) installed via their distro's package manager. - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-0 \ - gir1.2-webkit2-4.1 \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - libcairo2-dev \ - python3-gi \ - python3-gi-cairo - - - name: Install dependencies - shell: bash - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - # Installer-only deps (pywebview + per-OS backend bindings). Kept - # in a separate file so the agent's requirements.txt stays lean. - pip install -r packaging/requirements-installer.txt - pip install pyinstaller - - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Build frontend - shell: bash env: VITE_BACKEND_PORT: "7926" run: | + # Ships compiled into the payload: users have no Node toolchain at + # install time and must not need one to get a working UI. cd app/ui_layer/browser/frontend npm install npx vite build + - name: Write VERSION file from git tag + run: | + REF="${{ github.ref_name }}" + echo "${REF#v}" > VERSION + - name: Create default config.json - shell: bash run: | - echo "{\"use_conda\": false, \"gui_mode_enabled\": false}" > config.json + echo '{"use_conda": false, "gui_mode_enabled": false}' > config.json - - name: Write VERSION file from git tag - shell: bash + # A missing lock does not break the BUILD — it breaks the user's + # install, minutes in and hundreds of MB down. Catch it here instead. + - name: Require a lock for every platform we ship + run: python scripts/generate_lock.py --check --require-all + + - name: Build payload run: | - # github.ref_name is "v1.3.0" for tag pushes; strip the leading 'v'. - # The installer's _read_bundled_version() reads this to pin the - # download URL to the matching agent release. - REF="${{ github.ref_name }}" - VERSION="${REF#v}" - echo "$VERSION" > VERSION - echo "VERSION file content: $(cat VERSION)" + python scripts/package_source.py - - name: Build agent (CraftBotAgent) with PyInstaller - shell: bash - run: pyinstaller --noconfirm --clean packaging/CraftBotAgent.spec + - name: Upload source payload + uses: actions/upload-artifact@v4 + with: + name: release-source + path: dist/CraftBot-src.zip - - name: Zip agent payload - shell: bash + # ────────────────────────────────────────────── + # The launcher: one native binary per platform, built from launcher/. + # + # This replaced the old Python/Tk installer window. A native binary is one + # process with no bootloader and no unpacking step, which is what fixed the + # macOS bundle (two Dock icons, an inactive window, dropped taps). It needs + # no Python, Node or web runtime on the user's machine: it downloads the + # source payload above plus a portable Python, then runs craftbot.py + # install (install.py) and start (run.py) in the installed tree. + # ────────────────────────────────────────────── + launcher: + name: Launcher (${{ matrix.os_label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_label: linux + - os: windows-latest + os_label: windows + - os: macos-latest + os_label: macos + + env: + # build.rs bakes this into the binary; the launcher downloads the + # matching CraftBot-src.zip from this tag's release. + CRAFTBOT_VERSION: ${{ github.ref_name }} + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + # Both macOS architectures, so the bundle is a universal binary and + # Intel Macs are not left out by macos-latest being arm64. + targets: ${{ matrix.os_label == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: launcher + + # Slint's Linux backend loads these at runtime through dlopen, and its + # build has no C dependencies — this is insurance for the few crates + # that probe for headers, not a runtime requirement for users. + - name: Linux build prerequisites + if: matrix.os_label == 'linux' run: | - # CraftBotAgent.spec produces dist/CraftBotAgent/ (folder). - # Zip it into the asset name the installer downloads at runtime. - cd dist - if [ "${{ matrix.os_label }}" = "windows" ]; then - 7z a -tzip "CraftBot-agent-${{ matrix.os_label }}.zip" CraftBotAgent - else - zip -r "CraftBot-agent-${{ matrix.os_label }}.zip" CraftBotAgent - fi - ls -lh "CraftBot-agent-${{ matrix.os_label }}.zip" - - - name: Build installer (CraftBotInstaller) with PyInstaller - shell: bash - run: pyinstaller --noconfirm --clean packaging/CraftBotInstaller.spec + sudo apt-get update + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev + + - name: Test + if: matrix.os_label == 'linux' + working-directory: launcher + run: cargo test --release + + - name: Build (Linux / Windows) + if: matrix.os_label != 'macos' + working-directory: launcher + run: cargo build --release + + - name: Build (macOS, universal) + if: matrix.os_label == 'macos' + working-directory: launcher + run: | + set -euo pipefail + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + mkdir -p target/universal + lipo -create \ + target/aarch64-apple-darwin/release/CraftBotInstaller \ + target/x86_64-apple-darwin/release/CraftBotInstaller \ + -output target/universal/CraftBotInstaller + lipo -info target/universal/CraftBotInstaller + + # sips and iconutil are both part of macOS; nothing to install. + - name: Build the macOS app icon + if: matrix.os_label == 'macos' + run: | + set -euo pipefail + iconset="$RUNNER_TEMP/craftbot.iconset" + mkdir -p "$iconset" + for size in 16 32 64 128 256 512; do + sips -z $size $size craftbot_logo_1.png \ + --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -z $((size * 2)) $((size * 2)) craftbot_logo_1.png \ + --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$iconset" -o craftbot_logo_1.icns - - name: Rename installer artifact + - name: Package shell: bash run: | - mv "dist/CraftBotInstaller${{ matrix.ext }}" \ - "dist/CraftBotInstaller-${{ matrix.os_label }}${{ matrix.ext }}" + set -euo pipefail + mkdir -p dist + case "${{ matrix.os_label }}" in + linux) + cp launcher/target/release/CraftBotInstaller dist/CraftBotInstaller-linux + chmod +x dist/CraftBotInstaller-linux + ;; + windows) + cp launcher/target/release/CraftBotInstaller.exe dist/CraftBotInstaller-windows.exe + ;; + macos) + REF="${{ github.ref_name }}" + launcher/packaging/macos/bundle.sh \ + launcher/target/universal/CraftBotInstaller dist "${REF#v}" + # ditto, not zip: it preserves the bundle's extended attributes + # and execute bits. A plain zip loses them and the app will not + # launch after the user unzips it. + ditto -c -k --keepParent dist/CraftBotInstaller.app dist/CraftBotInstaller-macos.zip + rm -rf dist/CraftBotInstaller.app + ;; + esac + ls -la dist/ - - name: Upload installer artifact + - name: Upload launcher artifact uses: actions/upload-artifact@v4 with: name: release-installer-${{ matrix.os_label }} - path: dist/CraftBotInstaller-${{ matrix.os_label }}${{ matrix.ext }} - - - name: Upload agent zip artifact - uses: actions/upload-artifact@v4 - with: - name: release-agent-${{ matrix.os_label }} - path: dist/CraftBot-agent-${{ matrix.os_label }}.zip + path: dist/CraftBotInstaller-${{ matrix.os_label }}* # ────────────────────────────────────────────── # Create GitHub Release with all artifacts # ────────────────────────────────────────────── release: name: Publish GitHub Release - needs: [docker, pyinstaller] + needs: [docker, source, launcher] runs-on: ubuntu-latest steps: - name: Download all artifacts @@ -222,13 +272,28 @@ jobs: generate_release_notes: true body: | ### Installer (Recommended) - Download `CraftBotInstaller-` from the assets below and - run it. The installer wizard will let you choose an install - location and will download the matching agent payload - (`CraftBot-agent-.zip`) from this same release. + Download the file for your platform from the assets below and run + it. The setup window lets you choose an install location, then + downloads `CraftBot-src.zip` from this release and sets up + everything it needs — Python, Node.js and all dependencies. You do + not need Git, Python or Node installed beforehand. + + | Platform | Download | How to run | + |---|---|---| + | Windows | `CraftBotInstaller-windows.exe` | Double-click it. | + | macOS | `CraftBotInstaller-macos.zip` | Unzip, then **right-click the app and choose Open**. | + | Linux | `CraftBotInstaller-linux` | `chmod +x CraftBotInstaller-linux && ./CraftBotInstaller-linux` | + + On macOS the right-click is required the first time: the app is not + yet signed with an Apple Developer ID, so double-clicking it shows + "cannot be opened because the developer cannot be verified". + Right-click → Open gives you an Open button that double-click does + not. You only need to do this once. + + First install takes a few minutes while the runtime is prepared. ### Manual install - If you'd rather skip the wizard, download both - `CraftBotInstaller-` and `CraftBot-agent-.zip`, - place them in the same folder, and run the installer — it'll find - the local zip instead of fetching from GitHub. \ No newline at end of file + If you'd rather skip the window, download both + `CraftBotInstaller-` and `CraftBot-src.zip`, place them + in the same folder, and run the installer — it'll use the local + payload instead of fetching from GitHub. diff --git a/.gitignore b/.gitignore index 632a55a0..2cd52b59 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ agent_file_system/workspace/ craftbot/external_tools/ craftbot/generated_task_document/ **/__pycache__/ +.pytest_cache/ +# Written by the agent when boot() completes, read by run.py. Runtime +# state, not source. +.agent-ready +# Generated on macOS by the release workflow (sips + iconutil) for the +# .app bundle's icon. Derived from craftbot_logo_1.png, not source. +craftbot_logo_1.icns **/rag_docs_actions/ **/rag_docs_taskdocs/ ollama_data/ @@ -31,16 +38,16 @@ debug_images .vscode/ .idea/ # PyInstaller auto-generates a .spec when run without one — those are -# build artifacts. Our hand-written CraftBotInstaller.spec and -# CraftBotAgent.spec live under packaging/ and are source-of-truth for the -# release workflow, so they MUST be tracked. Keep the broad ignore but -# allow-list the two we own. +# build artifacts. *.spec -!packaging/CraftBotInstaller.spec -!packaging/CraftBotAgent.spec **/.whatsapp_web_sessions/ **/build **/build_* +# ...but scripts/ holds SOURCE, not build output. `build_*` there is a +# script that builds something (build_wheelhouse.py) +# and is referenced by name from code and docs. Without this negation they +# are silently absent from every clone. +!scripts/build_*.py **/dist **/release **/*wwebjs* @@ -62,3 +69,19 @@ app/data/.file_index/ .playwright-mcp # Sidecar Node runtime (install.py downloads it when the system Node is too old for Agent App) runtime/ + +# Local wheel cache built by scripts/build_wheelhouse.py (~2GB of .whl +# files). Machine- and platform-specific, and regenerable from the lock, so +# it must never be committed or land in a Docker build context. +wheelhouse/ + +# Runtime archives fetched by scripts/prefetch_runtimes.py (Python, Node, +# the VC++ redistributable). Regenerable, ~100MB, machine-specific. +downloads-cache/ + +# Prefetched by scripts/prefetch_runtimes.py so Sandbox tests do not +# re-download ~300MB every run. Regenerable, machine-specific. +npm-cache/ +playwright-browsers/ + +hf-cache/ diff --git a/agent_core/core/action_framework/loader.py b/agent_core/core/action_framework/loader.py index a4e680fe..b554ab9c 100644 --- a/agent_core/core/action_framework/loader.py +++ b/agent_core/core/action_framework/loader.py @@ -29,16 +29,19 @@ def load_actions_from_directories( Importing them triggers the @action decorator, registering them in the registry. Args: - base_dir: Base directory to scan from. Defaults to current working directory. - Supports PyInstaller frozen executables (uses sys._MEIPASS). + base_dir: Base directory to scan from. Defaults to PROJECT_ROOT, the + state directory where app/data/action is bootstrapped. paths_to_scan: List of relative paths to scan. Defaults to DEFAULT_ACTION_PATHS. """ if base_dir is None: - if getattr(sys, "frozen", False): - # PyInstaller bundles action files inside the temp _MEIPASS directory - base_dir = sys._MEIPASS # type: ignore - else: - base_dir = os.getcwd() + # PROJECT_ROOT, not the working directory. Action files live under + # app/data/action, which is bootstrapped into the state directory so + # a user can edit them; resolving from cwd only found them because + # run.py happens to chdir there first. Anything that changed the + # working directory would have silently discovered zero actions. + from app.config import PROJECT_ROOT + + base_dir = str(PROJECT_ROOT) if paths_to_scan is None: paths_to_scan = DEFAULT_ACTION_PATHS.copy() diff --git a/agent_core/core/impl/mcp/client.py b/agent_core/core/impl/mcp/client.py index ca660ecc..bb63954f 100644 --- a/agent_core/core/impl/mcp/client.py +++ b/agent_core/core/impl/mcp/client.py @@ -7,7 +7,6 @@ """ import asyncio -import sys from pathlib import Path from typing import Any, Dict, List, Optional @@ -17,15 +16,21 @@ def _default_config_path() -> Path: - """Resolve MCP config path relative to the correct base directory.""" - rel = Path("app") / "config" / "mcp_config.json" - if getattr(sys, "frozen", False): - # Prefer CWD (bootstrapped, user-editable) over _MEIPASS (bundled) - cwd_path = Path.cwd() / rel - if cwd_path.exists(): - return cwd_path - return Path(sys._MEIPASS) / rel - return Path(__file__).resolve().parent.parent.parent.parent.parent / rel + """The user-editable MCP config. + + app.config.APP_CONFIG_PATH is the one authority on where editable config + lives. Deriving it from __file__ here was wrong for a managed install: + that points into the INSTALL directory, while the copy the user edits is + bootstrapped into the per-user state directory. Edits to + mcp_config.json would have had no effect, silently. + + (The old code had a frozen branch that read CWD, which happened to be + right because run.py chdir'd there — but the agent is no longer frozen, + so that branch stopped running and took the correct behaviour with it.) + """ + from app.config import APP_CONFIG_PATH + + return Path(APP_CONFIG_PATH) / "mcp_config.json" DEFAULT_CONFIG_PATH = _default_config_path() diff --git a/app/agent_base.py b/app/agent_base.py index 4dc74111..efee2ea8 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -3772,6 +3772,30 @@ def _on_dead_letter(trig, _error: str) -> None: except Exception as e: logger.warning(f"[RESTORE] Failed to enqueue restart notice: {e}") + self._signal_ready() + + @staticmethod + def _signal_ready() -> None: + """Announce that boot() has finished, for whoever launched us. + + run.py waits on this before printing the ready banner and opening the + browser. It cannot watch our output (we inherit its stdout, so there + is nothing for it to read), and it used to settle for "the backend + port answers" — which happens long before this line, while the model + download, MCP servers, skills and scheduler are still starting. That + is why the browser opened at step 2 of 8. + + Best-effort: a failure here must never take down a working agent, so + the worst case is falling back to the old timeout behaviour. + """ + try: + from app import paths + + paths.AGENT_READY_FILE.parent.mkdir(parents=True, exist_ok=True) + paths.AGENT_READY_FILE.write_text(str(os.getpid()), encoding="utf-8") + except Exception as e: + logger.warning(f"[BOOT] Could not write the ready marker: {e}") + def _start_index_prewarm(self) -> None: """Warm the find_files index for every local drive in a background thread. diff --git a/app/config.py b/app/config.py index 9474db09..d25401cc 100644 --- a/app/config.py +++ b/app/config.py @@ -7,49 +7,27 @@ import json import os -import sys from pathlib import Path from typing import Any, Dict, Optional, Tuple - -def _frozen_user_data_root() -> Path: - """Return the per-user data directory for the frozen agent. - - When packaged as a PyInstaller binary the agent must NOT write - runtime files (agent_file_system, chroma_db_memory, logs, dbs) - into: - - sys._MEIPASS — wiped when the process exits - - the install directory (Program Files / %LOCALAPPDATA%\\Programs) - — install dirs by Windows convention are read-only-from-the-user's - perspective, and writing user data there mixes binaries with state. - - Mirrors craftbot.py's _user_data_dir() so the installer wizard and the - agent agree on where things live (e.g. logs). - """ - if sys.platform == "win32": - root = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") - path = Path(root) / "CraftBot" - elif sys.platform == "darwin": - path = Path(os.path.expanduser("~/Library/Application Support/CraftBot")) - else: - root = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") - path = Path(root) / "craftbot" - path.mkdir(parents=True, exist_ok=True) - return path +from app import paths def get_project_root() -> Path: - """Get the project root directory. + """Root for runtime STATE — agent_file_system, chroma_db_memory, dbs, logs. + + Dev checkout: the repo, so a developer's data sits beside their code. + Managed install: the per-user data dir (%LOCALAPPDATA%\\CraftBot on + Windows, ~/Library/Application Support/CraftBot on macOS, + ${XDG_DATA_HOME}/craftbot on Linux), so the install directory stays + replaceable and an upgrade cannot take the user's history with it. - Source mode: / — relative to this file. - Frozen mode: the per-user data dir (%LOCALAPPDATA%\\CraftBot on Windows, - ~/Library/Application Support/CraftBot on macOS, ${XDG_DATA_HOME}/craftbot - on Linux). Runtime state (agent_file_system, chroma_db_memory, dbs, logs) - lives there so the install dir stays clean and uninstalls don't lose data. + app/paths.py makes that call; see it for why a marker file rather than + sniffing for install.py decides which is which. """ - if getattr(sys, "frozen", False): - return _frozen_user_data_root() - return Path(__file__).resolve().parent.parent + root = paths.STATE_ROOT + root.mkdir(parents=True, exist_ok=True) + return root PROJECT_ROOT = get_project_root() @@ -176,20 +154,19 @@ def get_app_version() -> str: """Get the application version. Lookup order: - 1. _MEIPASS/VERSION — bundled by the release workflow (git tag w/o 'v') - 2. /VERSION — source mode if a dev wrote one locally + 1. /VERSION — written by the release workflow from the git + tag and shipped in the install payload + 2. /VERSION — a dev who wrote one locally 3. settings.json["version"] — legacy fallback so existing installs and dev environments without a VERSION file still report something meaningful instead of "0.0.0" 4. "0.0.0" — final fallback so the updater check fails gracefully (no bogus "update available" prompt). """ - candidates = [] - if getattr(sys, "frozen", False): - meipass = getattr(sys, "_MEIPASS", None) - if meipass: - candidates.append(Path(meipass) / "VERSION") - candidates.append(Path(__file__).resolve().parent.parent / "VERSION") + candidates = [ + paths.CODE_ROOT / "VERSION", + Path(__file__).resolve().parent.parent / "VERSION", + ] for path in candidates: try: v = path.read_text(encoding="utf-8").strip() diff --git a/app/downloads.py b/app/downloads.py new file mode 100644 index 00000000..8ed0ea89 --- /dev/null +++ b/app/downloads.py @@ -0,0 +1,197 @@ +"""Streaming download with progress, shared by every runtime we fetch. + +Both sidecar downloads used shutil.copyfileobj(), which is one blocking call +that reports nothing until it finishes. The Python runtime is ~30 MB and the +Node one 30-55 MB, so on an ordinary connection that is a minute or more of a +completely silent installer — indistinguishable, from the user's side, from a +hang. Someone watching an install sat through exactly that and reasonably +concluded it had died. + +A progress line every couple of seconds is the difference between "this is +working" and "this is broken". + +Stdlib-only: app/node_runtime.py imports this before dependencies exist, and +certifi is used only when it happens to be importable. +""" + +from __future__ import annotations + +import os +import shutil +import ssl +import time +import urllib.request +from typing import Callable, Optional + +LogFn = Callable[[str], None] + +#: How often to emit a progress line. Frequent enough to look alive, rare +#: enough not to flood a log panel that also carries pip's output. +_PROGRESS_INTERVAL_SECONDS = 2.0 + +#: Also require this much movement before reporting again. Time alone is not +#: enough on a slow link: it yields a wall of near-identical lines. +_PROGRESS_STEP_PERCENT = 10 + +_CHUNK = 256 * 1024 + + +def ssl_context() -> ssl.SSLContext: + """A verified SSL context, using certifi's roots when available. + + Windows' own store has repeatedly failed to load under some OpenSSL + builds (see the openssl pin in environment.yml), so prefer certifi and + fall back rather than assuming either works. + """ + try: + import certifi + + return ssl.create_default_context(cafile=certifi.where()) + except Exception: + return ssl.create_default_context() + + +def _human(n: float) -> str: + for unit in ("B", "KB", "MB", "GB"): + if abs(n) < 1024 or unit == "GB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1024.0 + return f"{n:.1f} GB" + + +def cache_dir() -> Optional[str]: + """Directory of previously fetched runtime archives, if one is configured. + + Set CRAFTBOT_DOWNLOAD_CACHE to reuse downloads across installs. Every + clean-machine test otherwise re-fetches the same ~100 MB — a Python + runtime, Node, and the VC++ redistributable — which on a slow connection + costs the better part of an hour per run and makes re-testing something + people avoid. It also saves a repair or reinstall from downloading them + again. + """ + configured = os.environ.get("CRAFTBOT_DOWNLOAD_CACHE", "").strip() + return configured if configured and os.path.isdir(configured) else None + + +def cache_name(url: str) -> str: + """Filename to cache a URL under. + + The last path segment, percent-decoded — these names already carry + version and platform (cpython-3.10.21+20260825-x86_64-pc-windows-msvc- + install_only.tar.gz), so they identify content precisely enough without + hashing the URL. + """ + from urllib.parse import unquote, urlparse + + name = os.path.basename(urlparse(url).path) + return unquote(name) or "download.bin" + + +def find_cached(pattern: str) -> Optional[str]: + """A cached archive matching a glob, or None. + + Lets a caller use the cache WITHOUT first resolving a download URL. + That matters more than it sounds: resolving the Python runtime's URL + means fetching a large JSON release index, and on a slow or flaky link + that request is itself a failure point — observed as + `IncompleteRead(1277952 bytes read)` in Windows Sandbox. Having the file + already and still failing because the index could not be read is an + absurd way to lose an install. + + The archive names carry version and platform, so a glob like + `cpython-3.10.*-x86_64-pc-windows-msvc-install_only.tar.gz` identifies + the right file without asking anyone. + """ + import glob as _glob + + cache = cache_dir() + if not cache: + return None + matches = sorted(_glob.glob(os.path.join(cache, pattern))) + return matches[-1] if matches else None + + +def download( + url: str, + dest: str, + log: Optional[LogFn] = None, + label: str = "", + timeout: int = 600, + progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, +) -> str: + """Stream url to dest, reporting progress. Returns dest. + + Streams to disk rather than holding the archive in memory: these are + tens of megabytes and a low-memory machine should not need twice that + just to unpack them. + + Uses CRAFTBOT_DOWNLOAD_CACHE when set — see cache_dir(). + """ + say: LogFn = log or (lambda _m: None) + what = label or os.path.basename(dest) or url + + cache = cache_dir() + cached = os.path.join(cache, cache_name(url)) if cache else None + + if cached and os.path.isfile(cached): + size = os.path.getsize(cached) + say(f" using cached {what} ({_human(size)})") + shutil.copyfile(cached, dest) + if progress_cb: + try: + progress_cb(size, size) + except Exception: + pass + return dest + + req = urllib.request.Request(url, headers={"User-Agent": "CraftBot"}) + with urllib.request.urlopen(req, timeout=timeout, context=ssl_context()) as resp: + header = resp.getheader("Content-Length") + total = int(header) if header and header.isdigit() else None + say(f" downloading {what} ({_human(total) if total else 'size unknown'})") + + read = 0 + last_report = time.monotonic() + last_pct = -_PROGRESS_STEP_PERCENT + with open(dest, "wb") as fh: + while True: + chunk = resp.read(_CHUNK) + if not chunk: + break + fh.write(chunk) + read += len(chunk) + + if progress_cb: + try: + progress_cb(read, total) + except Exception: + pass + + now = time.monotonic() + pct = (read * 100 // total) if total else None + # Report on time AND on meaningful movement. Time alone + # produced ~40 lines for one 38 MB file, most of them a + # single percent apart, burying everything else in the panel. + moved_enough = pct is None or pct - last_pct >= _PROGRESS_STEP_PERCENT + if now - last_report >= _PROGRESS_INTERVAL_SECONDS and moved_enough: + last_report = now + if pct is not None: + last_pct = pct + say(f" {pct:3d}% {_human(read)} / {_human(total)}") + else: + say(f" {_human(read)}") + + say(f" downloaded {_human(read)}") + + # Populate the cache so the next install on this machine - or the next + # test run against a mapped cache - does not fetch it again. Skipped when + # the download already went straight into the cache (the prefetch script + # does that), because copying a file onto itself raises SameFileError and + # reads as a failure when nothing is wrong. + if cached and os.path.abspath(dest) != os.path.abspath(cached): + try: + shutil.copyfile(dest, cached) + except OSError as e: + say(f" (could not cache: {str(e)[:120]})") + + return dest diff --git a/app/i18n/__init__.py b/app/i18n/__init__.py index c9892961..63ce08e4 100644 --- a/app/i18n/__init__.py +++ b/app/i18n/__init__.py @@ -26,8 +26,9 @@ Adding a new language --------------------- Drop app/i18n/errors..json alongside errors.en.json. Missing keys -fall back to "en" automatically. Packaging picks the file up via the -errors.*.json glob in packaging/CraftBotAgent.spec. +fall back to "en" automatically. No packaging change is needed: the install +payload is built from the tracked file list (scripts/package_source.py), so +a committed catalog ships automatically. """ from __future__ import annotations diff --git a/app/node_runtime.py b/app/node_runtime.py index 323ffaac..ff118dc9 100644 --- a/app/node_runtime.py +++ b/app/node_runtime.py @@ -43,7 +43,16 @@ MIN_NODE_MAJOR = 24 # keep the nodejs>=24 pin in environment.yml in sync REPO_ROOT = Path(__file__).resolve().parents[1] -SIDECAR_DIR = REPO_ROOT / "runtime" / "node" + +# app.paths is the single answer to "where does state live" — in a dev +# checkout that is the repo (so this is unchanged), and in an install it is +# the per-user data dir. Before this, SIDECAR_DIR was derived from __file__ +# and so pointed INSIDE the PyInstaller bundle: read-only, wiped between +# runs, and never populated. An installer-based user therefore had no +# reachable Node at all, and Living UI could not start. +# app.paths is stdlib-only, like this module, so the import is safe at the +# point install.py and run.py import us — before dependencies exist. +from app.paths import NODE_DIR as SIDECAR_DIR # noqa: E402 _NODE_VER_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)") @@ -239,3 +248,195 @@ def npm_cmd() -> Optional[str]: if rt and rt.npm: return rt.npm return shutil.which("npm") + + +def _platform_suffix(): + """(suffix, extension) of the Node archive for this machine. + + Windows gets the zip because tar would not preserve anything it needs; + unix gets a tarball because zip does not preserve the executable bit. + """ + import platform + + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + if sys.platform == "win32": + return f"win-{arch}", "zip" + if sys.platform == "darwin": + return f"darwin-{arch}", "tar.gz" + return f"linux-{arch}", "tar.xz" + + +def latest_download_url(log=None) -> Optional[str]: + """URL of the newest Node on the MIN_NODE_MAJOR line for this machine. + + Split out of download_sidecar so the archive can be pre-fetched into a + cache without installing it (scripts/prefetch_runtimes.py). + """ + import json + import ssl + import urllib.request + + say = log or (lambda _m: None) + + try: + import certifi + + ctx = ssl.create_default_context(cafile=certifi.where()) + except ImportError: + ctx = ssl.create_default_context() + + suffix, ext = _platform_suffix() + + try: + req = urllib.request.Request( + "https://nodejs.org/dist/index.json", headers={"User-Agent": "CraftBot"} + ) + index = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) + except Exception as e: + say(f" Could not reach the Node index: {str(e)[:160]}") + return None + + ver = next( + ( + e["version"] + for e in index + if e.get("version", "").startswith(f"v{MIN_NODE_MAJOR}.") + ), + None, + ) + if not ver: + say(f" No v{MIN_NODE_MAJOR}.x release found in the Node index") + return None + return f"https://nodejs.org/dist/{ver}/node-{ver}-{suffix}.{ext}" + + +def _extract_node(archive_src, ver, suffix, ext, say, url=None): + """Put a Node archive into SIDECAR_DIR and return its binary path. + + Shared by the cached and downloaded paths so extraction, layout and the + binary probe cannot drift between them. + """ + import tarfile + import zipfile + + dest_root = str(SIDECAR_DIR) + os.makedirs(dest_root, exist_ok=True) + archive = os.path.join(dest_root, f"_download.{ext}") + try: + if archive_src: + say(f" Using cached {os.path.basename(archive_src)}") + shutil.copyfile(archive_src, archive) + else: + # Streamed with progress: the archive is 30-55MB, and a single + # silent blocking copy is indistinguishable from a hung install. + from app import downloads + + downloads.download(url, archive, log=say, label=f"Node {ver}") + say(" Extracting...") + if ext == "zip": + zipfile.ZipFile(archive).extractall(dest_root) + else: + # tar preserves the executable bit; zip does not, which is why + # Windows gets the zip and unix the tarball. + tarfile.open(archive, mode="r:*").extractall(dest_root) + except Exception as e: + say(f" Node setup failed: {str(e)[:200]}") + return None + finally: + try: + os.remove(archive) + except OSError: + pass + + binary = os.path.join( + dest_root, + f"node-{ver}-{suffix}", + "node.exe" if sys.platform == "win32" else os.path.join("bin", "node"), + ) + return binary if os.path.isfile(binary) else None + + +def download_sidecar(log=None) -> Optional[str]: + """Download an official Node build into SIDECAR_DIR; return the binary. + + No PATH edits, nothing else touched — plain discovery picks it up on the + next resolve(refresh=True). Lives here rather than in install.py because + the installer needs it too: installer users never run install.py, so that + was the only route by which they could obtain Node, and they had none. + + Stdlib-only (certifi when importable, else the system trust store). + """ + import json + import ssl + import urllib.request + + say = log or (lambda _m: None) + + try: + import certifi + + ctx = ssl.create_default_context(cafile=certifi.where()) + except ImportError: + ctx = ssl.create_default_context() + + suffix, ext = _platform_suffix() + + # Consult the cache BEFORE the network. Resolving the version means + # fetching nodejs.org's release index, which on a slow or flaky link is + # its own failure point — and failing there while the archive is already + # on disk would be a pointless way to lose an install. + cached_archive = None + ver_from_cache = None + try: + from app import downloads + + cached_archive = downloads.find_cached( + f"node-v{MIN_NODE_MAJOR}.*-{suffix}.{ext}" + ) + if cached_archive: + m = _NODE_VER_RE.search(os.path.basename(cached_archive)) + if m: + ver_from_cache = "v" + ".".join(m.groups()) + except Exception: + cached_archive = None + + if cached_archive and ver_from_cache: + ver = ver_from_cache + url = f"https://nodejs.org/dist/{ver}/node-{ver}-{suffix}.{ext}" + return _extract_node(cached_archive, ver, suffix, ext, say, url) + + try: + # Newest release on the MIN_NODE_MAJOR line (the index is newest-first). + req = urllib.request.Request( + "https://nodejs.org/dist/index.json", headers={"User-Agent": "CraftBot"} + ) + index = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) + ver = next( + ( + e["version"] + for e in index + if e.get("version", "").startswith(f"v{MIN_NODE_MAJOR}.") + ), + None, + ) + if not ver: + say(f" ⚠ No v{MIN_NODE_MAJOR}.x release found in the Node index") + return None + + url = f"https://nodejs.org/dist/{ver}/node-{ver}-{suffix}.{ext}" + return _extract_node(None, ver, suffix, ext, say, url) + except Exception as e: + say(f" ⚠ Sidecar Node download failed: {str(e)[:200]}") + return None + + +def ensure_sidecar(log=None) -> Optional[NodeRuntime]: + """Return a usable Node >= MIN_NODE_MAJOR, downloading one if needed. + Idempotent: a no-op when a suitable Node already resolves.""" + rt = resolve() + if rt is not None: + return rt + if download_sidecar(log=log): + return resolve(refresh=True) + return None diff --git a/app/paths.py b/app/paths.py new file mode 100644 index 00000000..49314efe --- /dev/null +++ b/app/paths.py @@ -0,0 +1,167 @@ +"""Where everything lives, resolved once, for every install path. + +The answer used to be spread across app/config.py, craftbot.py, install.py +and app/node_runtime.py, each with its own `sys.frozen` branch. They agreed +by convention, and when they stopped agreeing you got bugs like SIDECAR_DIR +pointing inside the PyInstaller bundle, leaving installer users with no +reachable Node. + +This module is the single answer. Two roots, deliberately separate: + + CODE_ROOT where CraftBot's code and bundled assets live. + Treat as read-only. May sit inside an install dir or a + PyInstaller bundle. + + STATE_ROOT where the user's data lives — agent_file_system, dbs, logs, + chroma, and the downloaded runtimes. Always writable, always + survives an upgrade. + +In a dev checkout both are the repo, which is why the split has been easy to +miss: it only shows up once the code is somewhere the user cannot write to. + +Stdlib-only and imports nothing from app/: install.py and run.py import this +before dependencies exist. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +__all__ = [ + "MANAGED_MARKER", + "CODE_ROOT", + "STATE_ROOT", + "RUNTIME_DIR", + "NODE_DIR", + "is_frozen", + "is_dev_checkout", + "is_managed_install", + "mark_managed_install", + "describe", +] + +_ENV_HOME = "CRAFTBOT_HOME" + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def _repo_root() -> Path: + """The checkout containing this file (app/paths.py -> repo/).""" + return Path(__file__).resolve().parents[1] + + +def _user_data_root() -> Path: + """Per-user writable dir, matching craftbot.py's _user_data_dir().""" + if sys.platform == "win32": + root = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + return Path(root) / "CraftBot" + if sys.platform == "darwin": + return Path(os.path.expanduser("~/Library/Application Support/CraftBot")) + root = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return Path(root) / "craftbot" + + +#: Written into the install root by the installer. Its presence is the ONLY +#: reliable way to tell a managed install from a developer's checkout: the +#: install payload is the source tree, so it contains install.py, +#: requirements.txt and everything else a checkout has. Sniffing for those +#: files would classify every installed copy as a checkout and put the user's +#: agent_file_system, databases and logs inside the install directory — which +#: an upgrade replaces wholesale, and which on Windows may not be writable. +MANAGED_MARKER = ".craftbot-managed" + + +def is_managed_install() -> bool: + return (_repo_root() / MANAGED_MARKER).is_file() + + +def is_dev_checkout() -> bool: + """True when running from a source checkout rather than an install.""" + if is_frozen() or is_managed_install(): + return False + root = _repo_root() + return (root / "install.py").is_file() and (root / "requirements.txt").is_file() + + +def _resolve_state_root() -> Path: + # An explicit CRAFTBOT_HOME wins everywhere: it is how CI runs several + # installs side by side without them colliding in the user profile, and + # how a user relocates their data. + override = os.environ.get(_ENV_HOME, "").strip() + if override: + return Path(override).expanduser().resolve() + if is_dev_checkout(): + # Dev keeps state in the checkout — existing behaviour, and it keeps + # a developer's experiments out of their real profile. + return _repo_root() + return _user_data_root() + + +def _resolve_code_root() -> Path: + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return Path(meipass) + return _repo_root() + + +CODE_ROOT: Path = _resolve_code_root() +STATE_ROOT: Path = _resolve_state_root() + +# Downloaded runtimes. Under STATE_ROOT because they are written after +# install and must outlive an upgrade that replaces CODE_ROOT wholesale. +RUNTIME_DIR: Path = STATE_ROOT / "runtime" +NODE_DIR: Path = RUNTIME_DIR / "node" + +#: Written by AgentBase.boot() once initialisation has genuinely finished, +#: and deleted by run.py just before it launches the agent. +#: +#: run.py cannot tell when the agent is ready by watching its output — the +#: agent inherits run.py's stdout, so run.py has nothing to read — and "the +#: HTTP port answers" is not the same thing: the port binds early while the +#: model download, MCP connections, skills and scheduler are still going. +#: Using the port as the signal is what opened the browser at step 2 of 8, +#: onto a backend that could not serve a request yet. +AGENT_READY_FILE: Path = STATE_ROOT / ".agent-ready" + + +_MARKER_TEXT = """This file marks a managed CraftBot install. + +It tells app/paths.py to keep user data (agent_file_system, databases, logs, +the vector store) in the per-user data directory rather than in this folder, +which an upgrade replaces wholesale. + +Delete it only if you are converting this directory into a dev checkout. +""" + + +def mark_managed_install(root) -> None: + """Stamp an install root as managed. + + Called by the installer right after it extracts the source payload, and + before anything imports app.paths — the marker has to exist by the time + STATE_ROOT is resolved, since that happens at import time. + """ + (Path(root) / MANAGED_MARKER).write_text(_MARKER_TEXT, encoding="utf-8") + + +def describe() -> dict: + """Everything a bug report needs to explain 'it can't find X'.""" + return { + "frozen": is_frozen(), + "dev_checkout": is_dev_checkout(), + "managed_install": is_managed_install(), + "code_root": str(CODE_ROOT), + "state_root": str(STATE_ROOT), + "runtime_dir": str(RUNTIME_DIR), + "home_override": os.environ.get(_ENV_HOME) or None, + } + + +if __name__ == "__main__": + import json + + print(json.dumps(describe(), indent=2)) diff --git a/app/provision/__init__.py b/app/provision/__init__.py new file mode 100644 index 00000000..effbcf21 --- /dev/null +++ b/app/provision/__init__.py @@ -0,0 +1,114 @@ +"""One provisioning pipeline for every install path. + + from app import provision + report = provision.install(log=print) + +`install.py`, `craftbot.py install` and the installer wizard all call this. +That is the entire point: a fix to a stage reaches all three at once, which is +the property they have never had. The differences between the entry points are +what they do *around* the pipeline (auto-start registration, a progress bar), +never what they provision. + +Order matters and is expressed once, here: + + disk → enough free space to finish at all + python → the interpreter everything else installs into + python-deps → the locked package set + native → prove the embedding stack loads (not merely installed) + smoke → prove what actions import is importable + node → the one Node runtime + frontend → browser UI's npm tree (default UI, so required) + whatsapp → bridge's npm tree + playwright → chromium for browser-automation actions + +Node comes after Python deps because nothing before it needs Node, and a slow +optional download should not delay the failure of a required stage. +""" + +from __future__ import annotations + +import sys +from typing import List, Optional + +from app import paths +from app.provision.deps import ( + FrontendStage, + PlaywrightStage, + PythonDepsStage, + WhatsAppBridgeStage, +) +from app.provision.pipeline import format_report, run +from app.provision.runtimes import NodeStage, PythonStage +from app.provision.types import ( + Context, + LogFn, + PipelineReport, + Stage, + StageResult, + Status, +) +from app.provision.verify import DiskSpaceStage, NativeRuntimeStage, SmokeStage + +__all__ = [ + "Context", + "PipelineReport", + "Stage", + "StageResult", + "Status", + "default_stages", + "default_context", + "install", + "doctor", + "format_report", +] + + +def default_stages() -> List[Stage]: + return [ + # First: a precondition, not a provisioning step. Cheap, and failing + # here beats failing three downloads later with an ENOSPC. + DiskSpaceStage(), + PythonStage(), + PythonDepsStage(), + NativeRuntimeStage(), + SmokeStage(), + NodeStage(), + FrontendStage(), + WhatsAppBridgeStage(), + PlaywrightStage(), + ] + + +def default_context( + service_python: Optional[List[str]] = None, + conda_env: Optional[str] = None, + offline: bool = False, +) -> Context: + return Context( + code_root=str(paths.CODE_ROOT), + state_root=str(paths.STATE_ROOT), + conda_env=conda_env, + service_python=list(service_python) if service_python else [sys.executable], + offline=offline, + ) + + +def install( + log: Optional[LogFn] = None, + ctx: Optional[Context] = None, + stages: Optional[List[Stage]] = None, +) -> PipelineReport: + """Provision everything. Idempotent — safe to re-run, which is what makes + `repair` the same code path as a first install.""" + return run(stages or default_stages(), ctx or default_context(), log=log) + + +def doctor( + log: Optional[LogFn] = None, + ctx: Optional[Context] = None, + stages: Optional[List[Stage]] = None, +) -> PipelineReport: + """Report what is and is not satisfied, changing nothing.""" + return run( + stages or default_stages(), ctx or default_context(), log=log, check_only=True + ) diff --git a/app/provision/deps.py b/app/provision/deps.py new file mode 100644 index 00000000..4c8e73e2 --- /dev/null +++ b/app/provision/deps.py @@ -0,0 +1,359 @@ +"""Dependency stages: Python packages, Playwright browsers, npm trees. + +`install.py` provisions more than the plan originally listed. Beyond Python +packages there are Playwright's browser binaries, the browser frontend's npm +tree, and the WhatsApp bridge's npm tree (Baileys). Each is a real +prerequisite, and each was handled differently by the frozen build — which is +how the bridge shipped without its node_modules and could not start at all. + +Modelling them as stages means the installer and install.py provision the same +set, in the same order, with the same idempotence. +""" + +from __future__ import annotations + +import os +import sys +import sysconfig +from pathlib import Path +from typing import List, Optional + +from app import paths +from app.provision import proc +from app.provision.types import Context, LogFn, StageResult, Status + + +def _lock_tag(python: Optional[List[str]] = None) -> str: + """Identify the lock valid for an interpreter. + + Must describe the interpreter the packages are being installed INTO, not + the one running this code. Those differ constantly: install.py may run on + the system 3.14 while provisioning a 3.10 sidecar, and reading the current + process's version there picks a lock that does not exist (or worse, one + that does and is wrong). + """ + if python: + probe = ( + "import sysconfig,sys;" + "print(sysconfig.get_platform(), sys.version_info[0], sys.version_info[1])" + ) + try: + out = proc.python(python, probe, timeout=60) + if out.returncode == 0: + raw_plat, major, minor = out.stdout.strip().split()[-3:] + plat = raw_plat.replace(".", "_").replace("-", "_") + return f"{plat}-py{major}{minor}" + except Exception: + pass # fall through to this process's tag + + plat = sysconfig.get_platform().replace(".", "_").replace("-", "_") + return f"{plat}-py{sys.version_info.major}{sys.version_info.minor}" + + +def find_lock(code_root: str, python: Optional[List[str]] = None) -> Optional[Path]: + """The lock for this (platform, python), or None. + + Deliberately exact — no falling back to another platform's lock. A Linux + lock pins CUDA-flavoured torch wheels that do not exist for Windows, so a + 'close enough' match fails confusingly at install time instead of clearly + here. + """ + candidate = Path(code_root) / "requirements" / f"lock-{_lock_tag(python)}.txt" + return candidate if candidate.is_file() else None + + +def find_wheelhouse(code_root: str) -> Optional[Path]: + """A local directory of wheels to install from instead of PyPI, if any. + + Lets a clean-machine install run from local files: the download is the + overwhelming majority of install time (~2 GB), and on a slow or absent + connection it is the difference between a usable install and none. + Built by scripts/build_wheelhouse.py. + + Checked in order: + 1. $CRAFTBOT_WHEELHOUSE + 2. /wheelhouse + """ + env = os.environ.get("CRAFTBOT_WHEELHOUSE", "").strip() + if env and os.path.isdir(env): + return Path(env) + local = Path(code_root) / "wheelhouse" + return local if local.is_dir() else None + + +def npm_tree_stale(tree_dir: str) -> Optional[str]: + """Why node_modules does NOT satisfy the current package.json, or None. + + Lifted from install.py's _frontend_deps_stale so the installer and + install.py agree — duplicating it is how they drift. "node_modules exists" + only proves npm install ran once, not that it ran for the CURRENT + manifest; pulling a branch that adds a dependency left the naive check + reporting "already installed" forever. + + Two real conditions: + 1. Every declared dependency resolves to an installed package.json — + catches added packages. + 2. Neither manifest is newer than npm's own receipt + (node_modules/.package-lock.json, rewritten by every npm install) — + catches version bumps, which (1) cannot see. + + (2) also fires after a fresh clone, because git stamps checkout time on + the manifests. That errs toward reinstalling, which is safe but slow. + """ + import json + + node_modules = os.path.join(tree_dir, "node_modules") + if not os.path.isdir(node_modules): + return "node_modules is missing" + + try: + with open(os.path.join(tree_dir, "package.json"), encoding="utf-8") as fh: + manifest = json.load(fh) + except (OSError, ValueError): + # Unreadable manifest — run npm install and let npm report the real + # problem loudly instead of silently skipping. + return "package.json could not be read" + + declared = { + **manifest.get("dependencies", {}), + **manifest.get("devDependencies", {}), + } + for name in declared: + # Scoped names ("@types/react") nest one directory deeper. + pkg_json = os.path.join(node_modules, *name.split("/"), "package.json") + if not os.path.isfile(pkg_json): + return f"declared dependency '{name}' is not installed" + + receipt = os.path.join(node_modules, ".package-lock.json") + if not os.path.isfile(receipt): + return "npm's install receipt (node_modules/.package-lock.json) is missing" + installed_at = os.path.getmtime(receipt) + for filename in ("package.json", "package-lock.json"): + path = os.path.join(tree_dir, filename) + if os.path.isfile(path) and os.path.getmtime(path) > installed_at: + return f"{filename} changed after the last npm install" + + return None + + +class PythonDepsStage: + """Install the locked dependency set into the service interpreter. + + Installs from requirements/lock-*.txt with --require-hashes, never from + requirements.txt. That is what makes pip, conda and the installer land on + the same 239 packages instead of three separate resolutions. + """ + + name = "python-deps" + description = "Python dependencies" + optional = False + + #: Enough of the set to prove the install landed, without importing the + #: slow ones. A partial install is the common failure, not a total one. + PROBE = ("chromadb", "openai", "anthropic", "rank_bm25", "pdfplumber", "pypdf") + + def check(self, ctx: Context) -> StageResult: + py = ctx.python() + lock = find_lock(ctx.code_root, py) + if lock is None: + return StageResult( + Status.DEGRADED, + f"no lock for {_lock_tag(py)} — run scripts/generate_lock.py", + ) + probe = "; ".join(f"import {m}" for m in self.PROBE) + res = proc.run( + py + ["-c", probe + "; print('ok')"], lambda _m: None, timeout=180 + ) + if res.returncode == 0: + return StageResult( + Status.SATISFIED, f"lock {lock.name}", {"lock": str(lock)} + ) + missing = (res.stderr or "").strip().splitlines()[-1:] or ["import failed"] + return StageResult(Status.MISSING, missing[0][:160], {"lock": str(lock)}) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + py = ctx.python() + lock = find_lock(ctx.code_root, py) + if lock is None: + return StageResult( + Status.FAILED, + f"no lock file for {_lock_tag(py)}. Generate it with " + "`python scripts/generate_lock.py` and commit it.", + ) + + wheelhouse = find_wheelhouse(ctx.code_root) + wheel_args: List[str] = [] + if wheelhouse: + # --no-index as well as --find-links: without it pip may silently + # fall back to PyPI for anything the wheelhouse is missing, which + # turns a fast local install into a slow mixed one and hides an + # incomplete wheelhouse. + wheel_args = ["--no-index", "--find-links", str(wheelhouse)] + log(f" using local wheelhouse: {wheelhouse}") + if ctx.offline and not wheelhouse: + return StageResult( + Status.FAILED, + "offline and no wheelhouse — see scripts/build_wheelhouse.py", + ) + + res = proc.run( + py + + [ + "-u", # unbuffered, so each line reaches the log as it happens + "-m", + "pip", + "install", + "--no-color", + "--progress-bar", + "off", + "--require-hashes", + "-r", + str(lock), + ] + + wheel_args, + log, + stream=True, + ) + if res.returncode != 0: + return StageResult(Status.FAILED, proc.failure_detail(res, "pip failed")) + return self.check(ctx) + + +class PlaywrightStage: + """Playwright's Chromium download. + + Separate from PythonDepsStage because the pip package and the browser + binaries are separate downloads — having the package without the browser + is a working import that fails at first use. + """ + + name = "playwright" + description = "Playwright browser" + optional = True + + def check(self, ctx: Context) -> StageResult: + py = ctx.python() + res = proc.run( + py + + [ + "-c", + "from playwright.sync_api import sync_playwright;" + "p=sync_playwright().start();" + "print(p.chromium.executable_path);p.stop()", + ], + lambda _m: None, + timeout=180, + ) + if res.returncode != 0: + return StageResult(Status.MISSING, "playwright not importable") + path = (res.stdout or "").strip().splitlines()[-1:] or [""] + if path[0] and os.path.exists(path[0]): + return StageResult(Status.SATISFIED, "chromium present", {"path": path[0]}) + return StageResult(Status.MISSING, "chromium not downloaded") + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + if ctx.offline: + return StageResult(Status.FAILED, "offline: cannot download chromium") + res = proc.run( + ctx.python() + ["-m", "playwright", "install", "chromium"], + log, + stream=True, + ) + if res.returncode != 0: + tail = (res.stderr or "").strip().splitlines()[-2:] + return StageResult( + Status.FAILED, " | ".join(tail)[:200] or "install failed" + ) + return self.check(ctx) + + +class _NpmTreeStage: + """Shared logic for the two npm trees CraftBot ships.""" + + name = "npm" + description = "npm dependencies" + optional = True + rel_dir = "" + #: A file that only exists once `npm install` has succeeded. + sentinel = "node_modules" + + def _dir(self, ctx: Context) -> Path: + return Path(ctx.code_root) / self.rel_dir + + def check(self, ctx: Context) -> StageResult: + d = self._dir(ctx) + if not d.is_dir(): + return StageResult(Status.SKIPPED, f"{self.rel_dir} not present") + reason = npm_tree_stale(str(d)) + if reason: + return StageResult(Status.MISSING, reason) + return StageResult(Status.SATISFIED, "node_modules current") + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + if ctx.offline: + return StageResult(Status.FAILED, "offline: cannot npm install") + from app import node_runtime + + npm = node_runtime.npm_cmd() + if not npm: + return StageResult(Status.FAILED, "no npm (Node stage must run first)") + d = self._dir(ctx) + if not d.is_dir(): + return StageResult(Status.SKIPPED, f"{self.rel_dir} not present") + cmd = [npm, "install", "--no-audit", "--no-fund"] + + # A pre-warmed npm cache turns ~50 MB from the registry into local + # reads. --prefer-offline rather than --offline: it uses the cache + # for anything present but can still reach the registry for what is + # not, so a partially warmed cache degrades instead of failing. + npm_cache = os.environ.get("CRAFTBOT_NPM_CACHE", "").strip() + if npm_cache and os.path.isdir(npm_cache): + log(f" using npm cache: {npm_cache}") + cmd += ["--cache", npm_cache, "--prefer-offline"] + + # npm's lifecycle scripts spawn bare `node` through cmd.exe, which + # resolves it from PATH. Our Node is a sidecar and is NOT on PATH, so + # Baileys' engine-requirements.js died with "'node' is not recognized" + # even though npm itself had been invoked by absolute path. + # child_env() exists for exactly this and was going unused. + res = proc.run(cmd, log, cwd=str(d), stream=True, env=node_runtime.child_env()) + if res.returncode != 0: + return StageResult(Status.FAILED, proc.failure_detail(res, "npm failed")) + return self.check(ctx) + + +class FrontendStage(_NpmTreeStage): + """The browser UI's npm tree — only needed to BUILD it. + + An install ships a compiled dist/ and serves it statically + (run.py::launch_frontend), so node_modules is a dev-checkout concern. + Installing it anyway cost a large npm download over the user's network + and, when that failed, took the whole install down for something the + installed product never uses. + """ + + name = "frontend" + description = "Browser frontend dependencies" + rel_dir = os.path.join("app", "ui_layer", "browser", "frontend") + optional = False # browser mode is the default UI + + def check(self, ctx: Context) -> StageResult: + prebuilt = ( + Path(ctx.code_root) / self.rel_dir / "dist" / "index.html" + ).is_file() + if prebuilt and not paths.is_dev_checkout(): + return StageResult(Status.SKIPPED, "prebuilt UI shipped; npm not needed") + return super().check(ctx) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + pre = self.check(ctx) + if pre.status is Status.SKIPPED: + return pre + return super().apply(ctx, log) + + +class WhatsAppBridgeStage(_NpmTreeStage): + name = "whatsapp-bridge" + description = "WhatsApp bridge dependencies" + rel_dir = os.path.join("craftos_integrations", "providers", "whatsapp_web") + optional = True diff --git a/app/provision/pipeline.py b/app/provision/pipeline.py new file mode 100644 index 00000000..df7b4cae --- /dev/null +++ b/app/provision/pipeline.py @@ -0,0 +1,143 @@ +"""Run a list of stages. The one place install ordering is expressed.""" + +from __future__ import annotations + +import sys +from typing import Iterable, List, Optional + +from app.provision.types import ( + Context, + LogFn, + PipelineReport, + Stage, + StageResult, + Status, +) + + +def run( + stages: Iterable[Stage], + ctx: Context, + log: Optional[LogFn] = None, + check_only: bool = False, +) -> PipelineReport: + """Check each stage and apply the ones that need it. + + A required stage that fails stops the run: continuing past a failed + dependency install only produces a second, more confusing error further + down. Optional stages (Node, Playwright) record the failure and continue — + core chat works without them, and taking the whole install down for a + feature the user may never touch is worse than a warning. + """ + say: LogFn = safe_log(log) + report = PipelineReport() + + for stage in stages: + try: + before = stage.check(ctx) + except Exception as e: # a broken check must not abort the install + before = StageResult( + Status.DEGRADED, f"check raised {type(e).__name__}: {e}" + ) + + if check_only or before.ok: + report.results.append((stage.name, before)) + _report_line(say, stage, before, applied=False) + if not check_only and not before.ok: + break + continue + + say(f" {_marks()[Status.MISSING]} {stage.description}") + try: + after = stage.apply(ctx, say) + except Exception as e: + after = StageResult(Status.FAILED, f"{type(e).__name__}: {e}") + + report.results.append((stage.name, after)) + _report_line(say, stage, after, applied=True) + + if not after.ok and not stage.optional: + say(f" ✗ {stage.name} is required — stopping.") + break + + return report + + +_MARKS_UNICODE = { + Status.SATISFIED: "✓", + Status.SKIPPED: "–", + Status.MISSING: "…", + Status.DEGRADED: "!", + Status.FAILED: "✗", +} +_MARKS_ASCII = { + Status.SATISFIED: "OK", + Status.SKIPPED: "--", + Status.MISSING: "..", + Status.DEGRADED: "!!", + Status.FAILED: "XX", +} + + +def _stream_encoding() -> str: + return getattr(sys.stdout, "encoding", None) or "ascii" + + +def _encodable(text: str) -> bool: + try: + text.encode(_stream_encoding()) + return True + except (LookupError, UnicodeEncodeError): + return False + + +def _marks() -> dict: + """Windows consoles still default to cp1252, which cannot encode these + glyphs — printing one raises UnicodeEncodeError and takes the install down + for the sake of a tick mark. Probe the actual stream instead of assuming. + """ + return ( + _MARKS_UNICODE if _encodable("".join(_MARKS_UNICODE.values())) else _MARKS_ASCII + ) + + +def safe_log(log: Optional[LogFn]) -> LogFn: + """Wrap a log sink so no message can kill the install by being unprintable. + + Stage detail comes from subprocess output — pip, npm, node — which is not + ASCII and not under our control, and the console encoding is not either. + Sanitising every line in one place beats discovering each offending glyph + the way this function was written: by crashing on one. + """ + say: LogFn = log or (lambda _m: None) + + def _emit(message: str) -> None: + try: + say(message) + except UnicodeEncodeError: + enc = _stream_encoding() + say(message.encode(enc, errors="replace").decode(enc, errors="replace")) + + return _emit + + +def _report_line(say: LogFn, stage: Stage, res: StageResult, applied: bool) -> None: + mark = _marks()[res.status] + suffix = f" — {res.detail}" if res.detail else "" + if res.status is Status.SATISFIED and not applied: + say(f" {mark} {stage.description} (already done){suffix}") + else: + say(f" {mark} {stage.description}{suffix}") + + +def format_report(report: PipelineReport) -> str: + """Human-readable summary, used by `doctor` and by failure output.""" + lines: List[str] = [] + width = max((len(n) for n, _ in report.results), default=0) + for name, res in report.results: + detail = f" {res.detail}" if res.detail else "" + lines.append(f" {name.ljust(width)} {res.status.value}{detail}") + if report.failures: + lines.append("") + lines.append(f" {len(report.failures)} stage(s) need attention") + return "\n".join(lines) diff --git a/app/provision/proc.py b/app/provision/proc.py new file mode 100644 index 00000000..068e9f59 --- /dev/null +++ b/app/provision/proc.py @@ -0,0 +1,161 @@ +"""Running commands, once. + +Every stage shells out, and each had grown its own runner: two `_run` +functions with different signatures, one of which streamed and one of which +did not, plus a hand-rolled stand-in for CompletedProcess. Same job, three +implementations, and the differences between them were accidents rather than +decisions. + +One function, one set of behaviours: + + * no console window flashes on Windows (console=False parents make every + child pop a window otherwise), + * output can stream to a log as it happens, because a silent ten-minute + install is indistinguishable from a hung one, + * children get an environment that makes them line-buffer, since a Python + child writing to a pipe otherwise withholds ~8KB before flushing. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from typing import Callable, Iterable, List, Optional, Sequence + +LogFn = Callable[[str], None] + +#: Line prefixes worth surfacing while a long command runs. pip and npm are +#: both far too verbose to echo wholesale into a UI panel, but these show +#: forward motion, and anything with an error marker explains a failure. +PROGRESS_PREFIXES = ( + "Collecting", + "Downloading", + "Using cached", + "Installing", + "Building", + "Successfully", + "Saved", + "added", + "changed", + "audited", + "npm", + "ERROR", + "WARNING", +) + + +def _no_window_kwargs() -> dict: + if sys.platform == "win32": + return {"creationflags": subprocess.CREATE_NO_WINDOW} + return {} + + +def _is_interesting(line: str) -> bool: + return line.startswith(PROGRESS_PREFIXES) or "ERR!" in line + + +def run( + cmd: Sequence[str], + log: Optional[LogFn] = None, + cwd: Optional[str] = None, + timeout: int = 3600, + stream: bool = False, + env: Optional[dict] = None, + echo: bool = True, +) -> subprocess.CompletedProcess: + """Run cmd and return a CompletedProcess. + + stream=True for anything long: its output is echoed to `log` as it + arrives and also captured, so a failure can still be explained + afterwards. Without it, capture_output holds everything until the + process exits. + """ + say: LogFn = log or (lambda _m: None) + argv: List[str] = [str(c) for c in cmd] + if echo: + head = " ".join(argv[:6]) + say(f" $ {head}{' ...' if len(argv) > 6 else ''}") + + if not stream: + return subprocess.run( + argv, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + env=env, + **_no_window_kwargs(), + ) + + child_env = dict(env if env is not None else os.environ) + # A Python child writing to a pipe block-buffers; without this, pip says + # nothing for minutes on a slow connection and the install looks hung. + child_env.setdefault("PYTHONUNBUFFERED", "1") + child_env.setdefault("PYTHONIOENCODING", "utf-8") + + proc = subprocess.Popen( + argv, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + encoding="utf-8", + errors="replace", + env=child_env, + **_no_window_kwargs(), + ) + lines: List[str] = [] + try: + assert proc.stdout is not None + for raw in proc.stdout: + line = raw.rstrip() + if not line: + continue + lines.append(line) + if _is_interesting(line): + say(f" {line[:160]}") + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + return subprocess.CompletedProcess(argv, 1, "\n".join(lines), "timed out") + + return subprocess.CompletedProcess(argv, proc.returncode or 0, "\n".join(lines), "") + + +def python( + interpreter: Iterable[str], + code: str, + timeout: int = 300, +) -> subprocess.CompletedProcess: + """Run a snippet in another interpreter and capture the result.""" + return run(list(interpreter) + ["-c", code], timeout=timeout, echo=False) + + +def failure_detail(res: subprocess.CompletedProcess, fallback: str) -> str: + """The lines of a failed command that explain WHY. + + Two things this gets right that the obvious version does not. Streaming + merges stderr into stdout, so reading only stderr yields an empty string + and a message that says nothing. And npm prints "npm warn cleanup" lines + containing the word Error, which outrank the real cause if you match on + "error" alone - a genuine failure was once reported as an unrelated + rmdir EPERM while "'node' is not recognized" scrolled past unmentioned. + """ + text = "\n".join(filter(None, [res.stdout or "", res.stderr or ""])) + lines = [ln for ln in text.strip().splitlines() if ln.strip()] + if not lines: + return fallback + + hard = [ln for ln in lines if "npm error" in ln.lower() or "ERR!" in ln] + if not hard: + hard = [ + ln + for ln in lines + if ln.lower().startswith(("error", "fatal")) or "error:" in ln.lower() + ] + # npm and pip both put the command and its message at the END of the + # error block, so take the tail rather than the head. + chosen = hard[-4:] if hard else lines[-3:] + return " | ".join(ln.strip()[:200] for ln in chosen) diff --git a/app/provision/runtimes.py b/app/provision/runtimes.py new file mode 100644 index 00000000..238715de --- /dev/null +++ b/app/provision/runtimes.py @@ -0,0 +1,315 @@ +"""Node and Python runtime stages. + +Both follow the same rule, which is the whole point of the sidecar approach: +**never touch the system installation.** A machine may pin its default Node to +20.x or its default Python to 3.13 for reasons that have nothing to do with +CraftBot. We resolve something suitable, and if nothing suitable exists we +download a private copy into STATE_ROOT/runtime/. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from app.provision.types import Context, LogFn, StageResult, Status + +# python-build-standalone: relocatable CPython for all three platforms, with +# pip. python.org's embeddable package is Windows-only and ships without pip, +# so it cannot serve the same role. +# +# The release tag and patch version are RESOLVED FROM THE API, never +# hardcoded. Hardcoding them was tried and produced a silent 404: the guessed +# tag, patch version and platform triple were all wrong, so the download +# failed and the install aborted at the first stage. The asset name embeds +# all three (cpython-3.10.21+20260825-x86_64-pc-windows-msvc-install_only), +# so any one being stale breaks it. Same approach as node_runtime, which +# reads nodejs.org/dist/index.json rather than pinning a version. +PBS_API = ( + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" +) + +# The version the locks are generated for. NOT a minimum: a lock is valid +# for exactly one (platform, python) pair, so accepting "3.10 or newer" would +# mean a user on 3.14 has no lock at all — which is precisely what happened on +# a machine whose conda env had drifted to 3.14.7 while environment.yml still +# said 3.10.19. Anything else gets the sidecar, which costs a download and +# buys the reproducibility the lock exists for. +TARGET_PYTHON = (3, 10) + + +class NodeStage: + name = "node" + description = "Node.js runtime" + # Core chat works without Node; only Living UI hard-requires it. Failing + # the whole install because a Node download timed out would be worse than + # the feature being unavailable. + optional = True + + def check(self, ctx: Context) -> StageResult: + from app import node_runtime + + rt = node_runtime.resolve(refresh=True) + if rt is None: + return StageResult(Status.MISSING, "no Node >= 24 found") + return StageResult( + Status.SATISFIED, + f"{rt.version or '?'} ({rt.source})", + {"node": rt.node, "version": rt.version, "source": rt.source}, + ) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + if ctx.offline: + return StageResult(Status.FAILED, "offline: cannot download Node") + from app import node_runtime + + rt = node_runtime.ensure_sidecar(log=log) + if rt is None: + return StageResult(Status.FAILED, "sidecar download failed") + return StageResult( + Status.SATISFIED, + f"{rt.version or '?'} ({rt.source})", + {"node": rt.node, "version": rt.version}, + ) + + +def _probe_python(exe: str) -> Optional[tuple]: + """(major, minor) of an interpreter, or None if it will not run. + + Spawned rather than parsed from the path: the Windows Store stubs under + WindowsApps look like interpreters and exit 9009 when run. + """ + try: + kwargs = {} + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + out = subprocess.run( + [exe, "-c", "import sys;print(sys.version_info[0],sys.version_info[1])"], + capture_output=True, + text=True, + timeout=20, + **kwargs, + ) + if out.returncode != 0: + return None + major, minor = out.stdout.split()[:2] + return (int(major), int(minor)) + except Exception: + return None + + +def _pbs_triple() -> Optional[str]: + """The python-build-standalone platform triple for this machine.""" + machine = platform.machine().lower() + arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64" + if sys.platform == "win32": + # No win-arm64 build is published; arm64 Windows runs the x64 build + # under emulation, which is slower but works. + return "x86_64-pc-windows-msvc" + if sys.platform == "darwin": + return f"{arch}-apple-darwin" + return f"{arch}-unknown-linux-gnu" + + +def _pbs_download_url(log: LogFn) -> Optional[str]: + """Resolve the download URL for a TARGET_PYTHON build, or None. + + Asks the API for the newest release and picks the asset matching this + machine's triple. `install_only` is the variant that extracts to a + ready-to-run tree; the full builds carry object files and headers nobody + here needs. + """ + import json + import ssl + import urllib.request + + triple = _pbs_triple() + if not triple: + return None + + try: + import certifi + + ctx = ssl.create_default_context(cafile=certifi.where()) + except ImportError: + ctx = ssl.create_default_context() + + try: + req = urllib.request.Request(PBS_API, headers={"User-Agent": "CraftBot"}) + data = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) + except Exception as e: + log(f" could not reach the Python download index: {str(e)[:160]}") + return None + + prefix = f"cpython-{TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}." + suffix = f"-{triple}-install_only.tar.gz" + for asset in data.get("assets", []): + name = asset.get("name") or "" + if name.startswith(prefix) and name.endswith(suffix): + return asset.get("browser_download_url") + + log(f" no {prefix}*{suffix} in release {data.get('tag_name')}") + return None + + +class PythonStage: + """Ensure a real CPython on the locked version line exists. + + Needed even though the app may itself be running on Python: a frozen + build's sys.executable is the agent EXE, and `pip install` of an action's + dependency or a Python Agent App needs a genuine interpreter. That is why + executor.py has _find_real_python() and living_ui has + _resolve_python_in_command() — this stage is what makes those succeed on a + machine with no Python at all. + """ + + name = "python" + description = "Python runtime" + optional = False + + def _sidecar_exe(self, ctx: Context) -> Path: + root = Path(ctx.state_root) / "runtime" / "python" + if sys.platform == "win32": + return root / "python" / "python.exe" + return root / "python" / "bin" / "python3" + + def _accept(self, ctx: Context, exe: str, source: str, ver: tuple) -> StageResult: + """Record the interpreter every later stage must use. + + Writing it back onto the context is what keeps the pipeline coherent: + python-deps installs into THIS interpreter, and the smoke test probes + THIS interpreter. install.py's oldest bug class is those two being + different — packages land in one site-packages and the service starts + on another. + """ + ctx.service_python = [exe] + return StageResult( + Status.SATISFIED, + f"{source} {ver[0]}.{ver[1]}", + {"python": exe, "source": source}, + ) + + def check(self, ctx: Context) -> StageResult: + want = f"{TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}" + + # A conda env is chosen by the caller and owns its own interpreter — + # environment.yml pins it. Don't second-guess it with a sidecar. + if ctx.conda_env: + return StageResult(Status.SKIPPED, f"conda env {ctx.conda_env}") + + # 0. An interpreter the CALLER pinned wins over anything we would + # resolve. Without this the stage silently redirected every later + # stage at its own choice: an end-to-end test that provisioned into + # a fresh venv had its dependencies installed into the developer's + # site-packages instead, and then reported success because they + # were already there. + pinned = list(ctx.service_python or []) + if len(pinned) == 1 and os.path.isfile(pinned[0]): + ver = _probe_python(pinned[0]) + if ver and ver[:2] == TARGET_PYTHON: + return self._accept(ctx, pinned[0], "provided", ver) + + # 1. An already-downloaded sidecar wins: it is the one we control. + sidecar = self._sidecar_exe(ctx) + if sidecar.is_file(): + ver = _probe_python(str(sidecar)) + if ver and ver[:2] == TARGET_PYTHON: + return self._accept(ctx, str(sidecar), "sidecar", ver) + + # 2. The interpreter running us, when it is real and the right line. + if not getattr(sys, "frozen", False): + ver = sys.version_info[:2] + if ver == TARGET_PYTHON: + return self._accept(ctx, sys.executable, "current", ver) + + # 3. Anything matching on PATH. python3 first on unix, python first on + # Windows — there python3.exe is usually the Store redirect stub. + names = ("python", "python3") if os.name == "nt" else ("python3", "python") + for name in names: + found = shutil.which(name) + if not found: + continue + if os.name == "nt" and "WindowsApps" in found: + continue # Store stub, exits 9009 + ver = _probe_python(found) + if ver and ver[:2] == TARGET_PYTHON: + return self._accept(ctx, found, "path", ver) + + running = ".".join(str(v) for v in sys.version_info[:2]) + return StageResult( + Status.MISSING, + f"need Python {want} (this is {running}); will fetch a private copy", + ) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + if ctx.offline: + return StageResult(Status.FAILED, "offline: cannot download Python") + + import shutil as _shutil + import tarfile + + from app import downloads + + dest = Path(ctx.state_root) / "runtime" / "python" + dest.mkdir(parents=True, exist_ok=True) + archive = dest / "_download.tar.gz" + + # Look in the cache BEFORE asking the network anything. Resolving the + # URL means fetching a large release index, which is itself a failure + # point on a slow link - and failing there while holding the very file + # we need would be perverse. + triple = _pbs_triple() + cached = ( + downloads.find_cached( + f"cpython-{TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}.*-{triple}-install_only.tar.gz" + ) + if triple + else None + ) + + url = None + if not cached: + url = _pbs_download_url(log) + if not url: + return StageResult( + Status.FAILED, + f"no portable Python {TARGET_PYTHON[0]}.{TARGET_PYTHON[1]} " + f"available for {platform.machine()} on {sys.platform}", + ) + + try: + if cached: + log(f" using cached {os.path.basename(cached)}") + _shutil.copyfile(cached, archive) + else: + downloads.download( + url, + str(archive), + log=log, + label=f"Python {TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}", + ) + log(" extracting...") + # tar preserves the executable bit, which a zip would not — that + # matters on macOS/Linux where the extracted python must be +x. + with tarfile.open(archive, mode="r:*") as tf: + tf.extractall(dest) + except Exception as e: + return StageResult(Status.FAILED, f"download failed: {str(e)[:200]}") + finally: + try: + archive.unlink() + except OSError: + pass + + exe = self._sidecar_exe(ctx) + if not exe.is_file(): + return StageResult(Status.FAILED, f"extracted, but {exe} is missing") + ver = _probe_python(str(exe)) + if not ver or ver[:2] != TARGET_PYTHON: + return StageResult(Status.FAILED, f"sidecar will not run ({ver})") + return self._accept(ctx, str(exe), "sidecar", ver) diff --git a/app/provision/types.py b/app/provision/types.py new file mode 100644 index 00000000..c86be118 --- /dev/null +++ b/app/provision/types.py @@ -0,0 +1,137 @@ +"""Stage protocol shared by every install path. + +The contract that makes "one path" real: a stage answers two questions and +nothing else. + + check() — is this already satisfied? Cheap, no side effects, safe to call + on a broken install. + apply() — make it so. Idempotent: running it on a satisfied system is a + no-op, not a reinstall. + +Everything else follows from that separation: + + * `python install.py` runs check+apply over the pipeline. + * `craftbot.py install` runs the same pipeline, then registers auto-start. + * The installer wizard runs the same pipeline with its progress bar bound + to the log callback. + * `repair` is the pipeline again — every check() re-runs and only the + unsatisfied stages do work. + * `doctor` is check() alone, printed. + +So a fix to a stage reaches all four at once, which is the property the three +entry points have never had. +""" + +from __future__ import annotations + +import enum +import sys +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Protocol + +# Progress/diagnostic sink. print for CLI, a push-to-webview for the wizard. +LogFn = Callable[[str], None] + + +class Status(enum.Enum): + """Outcome of check(), and of apply().""" + + SATISFIED = "satisfied" # nothing to do + MISSING = "missing" # not present; apply() should fix it + DEGRADED = "degraded" # present but wrong (bad version, partial install) + FAILED = "failed" # apply() tried and could not + SKIPPED = "skipped" # not applicable in this context (e.g. conda mode) + + @property + def ok(self) -> bool: + return self in (Status.SATISFIED, Status.SKIPPED) + + +@dataclass +class StageResult: + status: Status + detail: str = "" + # Free-form facts worth surfacing in a report or a bug: resolved paths, + # versions, counts. Kept JSON-safe so `doctor` can emit it directly. + data: Dict[str, Any] = field(default_factory=dict) + + @property + def ok(self) -> bool: + return self.status.ok + + +@dataclass +class Context: + """Everything a stage needs to know, resolved once by the caller. + + Passed rather than imported so tests can drive a stage against a temp + directory, and so the wizard can provision into a chosen location without + the stage caring which entry point invoked it. + """ + + code_root: str + state_root: str + # None in dev/pip mode; the env name when installing into conda. + conda_env: Optional[str] = None + # Command prefix for the interpreter that will RUN CraftBot — not + # necessarily the one running the installer. install.py's long-standing + # bug class is these two diverging (deps land in one site-packages, the + # service starts on another), so it is explicit here. + # A LIST, not a path, because conda's interpreter is + # ["conda", "run", "-n", env, "python"] — several tokens, not a file. + service_python: Optional[List[str]] = None + # Skip work that needs the network; used by --offline and by tests. + offline: bool = False + + def python(self) -> List[str]: + """The interpreter command every stage should install into and probe. + + Lives here rather than on each stage: four stages had an identical + private copy of this, which is three too many, and the context is + what actually knows the answer. PythonStage writes service_python + back onto the context, so everything downstream agrees by + construction instead of by convention. + """ + return list(self.service_python or [sys.executable]) + + +class Stage(Protocol): + """A unit of provisioning.""" + + name: str + #: Human-readable one-liner shown in progress output. + description: str + #: When False, a FAILED result stops the pipeline. Node is not required + #: for core chat, so it is optional; Python dependencies are not. + optional: bool + + def check(self, ctx: Context) -> StageResult: ... + + def apply(self, ctx: Context, log: LogFn) -> StageResult: ... + + +@dataclass +class PipelineReport: + results: List[tuple] = field(default_factory=list) # (stage_name, StageResult) + + @property + def ok(self) -> bool: + return all(r.ok for _, r in self.results) + + @property + def failures(self) -> List[tuple]: + return [(n, r) for n, r in self.results if not r.ok] + + def to_dict(self) -> Dict[str, Any]: + return { + "ok": self.ok, + "stages": [ + { + "name": name, + "status": res.status.value, + "detail": res.detail, + "data": res.data, + } + for name, res in self.results + ], + } diff --git a/app/provision/verify.py b/app/provision/verify.py new file mode 100644 index 00000000..8e5de901 --- /dev/null +++ b/app/provision/verify.py @@ -0,0 +1,322 @@ +"""Prove the install actually works, before anyone says "complete". + +`install.py` already had the right instinct here — `verify_native_imports()` +exists because "INSTALLATION COMPLETE followed by a dead port is worse than a +clear error". This generalises it: a pip success only means files landed, and +the failures that matter are the ones where the files are present but will not +load (a native DLL, a half-bundled package, a model that cannot be reached). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from typing import List, Optional + +from app.provision import proc +from app.provision.types import Context, LogFn, StageResult, Status + + +def _run(py, code: str, timeout: int = 300): + kwargs = {} + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + return subprocess.run( + list(py) + ["-c", code], + capture_output=True, + text=True, + timeout=timeout, + **kwargs, + ) + + +def _vcredist_url() -> str: + """Microsoft's permanent short-link for this machine's architecture.""" + import platform + + arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64" + return f"https://aka.ms/vs/17/release/vc_redist.{arch}.exe" + + +def vcredist_installed() -> bool: + """Whether the Visual C++ runtime torch needs is present. + + torch's DLLs link against msvcp140.dll and friends, which ship in the + redistributable rather than with Windows. Without it, importing torch + fails with "WinError 126: The specified module could not be found", + naming a DLL that IS on disk — because what is missing is a DEPENDENCY of + that DLL. Deeply unobvious from the error alone. + + Present on most real machines (countless applications install it) but + absent from Windows Sandbox and other clean images. + + Falls back to looking for the DLLs themselves when the registry key is + missing: some machines have the runtime without that key. + """ + if sys.platform != "win32": + return True + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\VisualStudio .0\VC\Runtimesd", + ) as key: + installed, _ = winreg.QueryValueEx(key, "Installed") + return bool(installed) + except OSError: + sys32 = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32") + return all( + os.path.isfile(os.path.join(sys32, dll)) + for dll in ("msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll") + ) + + +def ensure_native_runtime(log: Optional[LogFn] = None) -> None: + """OS prerequisites that pip cannot provide for the native wheels + (torch, onnxruntime, ...) the memory stack imports at boot. + + Windows: the Visual C++ 2015-2022 Redistributable — torch's DLLs link + against it and a fresh Windows (observed: Windows Sandbox, 2026-08-25) + lacks it, dying at first boot with WinError 126 on torch_python.dll. + Installed silently when missing (one-time, machine-wide, UAC prompt). + Linux: libgomp/libstdc++ (missing on minimal images) — sudo territory, + so only a hint. macOS: torch wheels are self-contained. + + Lived in install.py, which the installer never runs — so an + installer-based machine hit exactly the WinError 126 this exists to + prevent. Moved here so both paths share the one implementation. + """ + say: LogFn = log or print + + if sys.platform == "win32": + if vcredist_installed(): + say(" Visual C++ Redistributable present") + return + + from app import downloads + + url = _vcredist_url() + dest = os.path.join(tempfile.gettempdir(), os.path.basename(url)) + say(" Visual C++ Redistributable missing — installing (torch needs it)") + say(" a UAC prompt may appear") + try: + downloads.download(url, dest, log=say, label="Visual C++ runtime") + proc = subprocess.run( + [dest, "/install", "/quiet", "/norestart"], + capture_output=True, + text=True, + timeout=900, + ) + code = proc.returncode + # 0 = installed, 1638 = a newer version is already present, + # 3010 = success, reboot pending (the DLLs work regardless). + if code in (0, 1638, 3010) and vcredist_installed(): + say(" Visual C++ Redistributable installed") + else: + say(f" Redistributable installer exited {code} — install it manually:") + say(f" {url}") + except Exception as e: + say(f" Could not install the Visual C++ Redistributable: {str(e)[:200]}") + say(f" Install it manually: {url}") + finally: + try: + os.remove(dest) + except OSError: + pass + + elif sys.platform.startswith("linux"): + import ctypes.util + + missing = [ + name + for name, lib in (("libgomp1", "gomp"), ("libstdc++6", "stdc++")) + if ctypes.util.find_library(lib) is None + ] + if missing: + say(f" Missing system libraries torch needs: {', '.join(missing)}") + say(f" Debian/Ubuntu/Kali: sudo apt-get install -y {' '.join(missing)}") + say(" Fedora/RHEL: sudo dnf install -y libgomp libstdc++") + + +class NativeRuntimeStage: + """The memory embedding stack must LOAD, not merely be installed. + + This is the #439 failure class: `sentence_transformers` present on disk, + `transformers` absent, so the import raises and the agent dies at startup + with a traceback rather than a message. Catching it here turns a dead + launch into an install-time error naming the fix. + """ + + name = "native-runtime" + description = "Embedding stack loads" + optional = False + + def check(self, ctx: Context) -> StageResult: + model = os.environ.get("MEMORY_EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5") + if model == "default": + return StageResult(Status.SKIPPED, "using ChromaDB's bundled embedder") + + res = proc.python(ctx.python(), "import torch, sentence_transformers") + if res.returncode == 0: + return StageResult(Status.SATISFIED, "torch + sentence-transformers load") + + tail = (res.stderr or "").strip().splitlines()[-1:] or ["(no output)"] + fix = ( + "install the Visual C++ Redistributable " + "(https://aka.ms/vs/17/release/vc_redist.x64.exe)" + if sys.platform == "win32" + else "apt-get install -y libgomp1 libstdc++6" + ) + return StageResult( + Status.DEGRADED, + f"{tail[0][:400]} — usual fix: {fix}. " + "Escape hatch: MEMORY_EMBEDDING_MODEL=default (lower retrieval quality).", + ) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + """Install the OS-level prerequisite the embedding stack needs. + + PythonDepsStage owns the packages; what can be missing HERE is a + system library. On Windows that is almost always the VC++ + redistributable, and telling a non-technical user to go download it + would break the one promise the installer makes. So we fetch it. + """ + before = self.check(ctx) + if before.ok: + return before + + if sys.platform == "win32" and not vcredist_installed(): + if ctx.offline: + return StageResult( + Status.FAILED, + "the Visual C++ runtime is missing and cannot be installed " + f"offline. Get it from {_vcredist_url()}", + ) + ensure_native_runtime(log=lambda m: log(" " + m)) + after = self.check(ctx) + if after.ok: + return after + after.detail = ( + "the Visual C++ runtime step ran, but the embedding stack still " + f"will not load. {after.detail}" + ) + return after + + # Anything else here is an OS library we cannot supply; the check's + # message already names the fix. + return before + + +class SmokeStage: + """Import the things actions import. + + Actions are exec'd from source at runtime, so nothing statically verifies + their imports — that is exactly how the frozen build shipped without + pdfplumber, trafilatura and pyperclip while openpyxl survived by accident. + """ + + name = "smoke" + description = "Action dependencies importable" + optional = False + + MODULES: List[str] = [ + "chromadb", + "openai", + "anthropic", + "tiktoken", + "rank_bm25", + "pdfplumber", + "pypdf", + "pypdfium2", + "fitz", + "pyperclip", + "websockets", + "boto3", + "requests", + "bs4", + "trafilatura", + ] + + def check(self, ctx: Context) -> StageResult: + code = ( + "import importlib.util as u, json, sys;" + f"mods={self.MODULES!r};" + "print(json.dumps([m for m in mods " + "if u.find_spec(m) is None]))" + ) + res = proc.python(ctx.python(), code) + if res.returncode != 0: + return StageResult(Status.DEGRADED, "probe failed to run") + import json + + try: + missing = json.loads((res.stdout or "[]").strip().splitlines()[-1]) + except (ValueError, IndexError): + return StageResult(Status.DEGRADED, "probe produced no result") + if missing: + return StageResult( + Status.MISSING, + f"not importable: {', '.join(missing)}", + {"missing": missing}, + ) + return StageResult(Status.SATISFIED, f"{len(self.MODULES)} modules import") + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + # PythonDepsStage installs; this only reports. If we land here the + # lock installed "successfully" yet something it declares is absent, + # which is a lock bug worth surfacing loudly rather than papering over. + res = self.check(ctx) + if not res.ok: + res.detail += " — the lock installed but these are absent; regenerate it." + return res + + +class DiskSpaceStage: + """Refuse to start an install that cannot possibly finish. + + A full CraftBot install is roughly 2.5 GB once torch, the Playwright + browser and two node_modules trees have landed, and the downloads need + room on top. Running out halfway produces whatever error the unlucky + step happens to raise — a failed wheel build, a truncated archive, an + npm ENOSPC — none of which say "you are out of disk". + + install.py checked this before doing anything; the installer did not, so + the same machine got the clear message one way and a puzzle the other. + """ + + name = "disk-space" + description = "Free disk space" + optional = False + + #: Installed footprint plus headroom for the archives being unpacked. + REQUIRED_GB = 5.0 + + def _free_gb(self, path: str) -> Optional[float]: + try: + return shutil.disk_usage(path).free / (1024**3) + except OSError: + return None # unreadable mount: do not block the install over it + + def check(self, ctx: Context) -> StageResult: + free = self._free_gb(ctx.state_root) + if free is None: + return StageResult(Status.SKIPPED, "could not read free space") + if free >= self.REQUIRED_GB: + return StageResult( + Status.SATISFIED, f"{free:.1f} GB free", {"free_gb": round(free, 1)} + ) + return StageResult( + Status.FAILED, + f"only {free:.1f} GB free at {ctx.state_root}; CraftBot needs about " + f"{self.REQUIRED_GB:.0f} GB (torch, the Playwright browser and the " + "npm trees). Free some space and re-run.", + {"free_gb": round(free, 1)}, + ) + + def apply(self, ctx: Context, log: LogFn) -> StageResult: + # Nothing to do but report — we cannot make disk space appear. + return self.check(ctx) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 055c56e7..c1fe213f 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -7919,29 +7919,44 @@ async def _handle_playbook_list(self) -> None: # Marketplace Handlers # ===================== - async def _handle_marketplace_list(self) -> None: - """Fetch marketplace catalogue from GitHub.""" - import urllib.request + @staticmethod + def _fetch_catalogue(url: str) -> dict: + """Blocking fetch + parse of the marketplace catalogue. + + Split out so the caller can run it off the event loop — see + _handle_marketplace_list. + """ import json as _json import re as _re + import ssl + import urllib.request + + import certifi + + ssl_ctx = ssl.create_default_context(cafile=certifi.where()) + req = urllib.request.Request(url, headers={"User-Agent": "CraftBot"}) + raw = urllib.request.urlopen(req, timeout=15, context=ssl_ctx).read().decode() + # Strip trailing commas before ] or } (tolerant of hand-edited JSON) + return _json.loads(_re.sub(r",\s*([}\]])", r"\1", raw)) + async def _handle_marketplace_list(self) -> None: + """Fetch marketplace catalogue from GitHub.""" from app.agent_app import marketplace_source CATALOGUE_URL = marketplace_source.catalogue_url() try: - import ssl - import certifi - - ssl_ctx = ssl.create_default_context(cafile=certifi.where()) - req = urllib.request.Request( - CATALOGUE_URL, headers={"User-Agent": "CraftBot"} + # MUST run off the event loop. urlopen is blocking, and this used + # to be called inline in an async handler: a slow fetch froze the + # whole loop, so the websocket could neither deliver this reply + # nor anything else. `timeout=15` does not bound it either — name + # resolution happens before the socket timeout applies, so a DNS + # blackhole hangs indefinitely and the UI spinner never resolves. + # Every other network call in this module already uses to_thread. + catalogue = await asyncio.wait_for( + asyncio.to_thread(self._fetch_catalogue, CATALOGUE_URL), + timeout=30, ) - response = urllib.request.urlopen(req, timeout=15, context=ssl_ctx) - raw = response.read().decode() - # Strip trailing commas before ] or } (tolerant of hand-edited JSON) - raw = _re.sub(r",\s*([}\]])", r"\1", raw) - catalogue = _json.loads(raw) # Resolve thumbnails here rather than in the frontend, which would # otherwise build them against a hard-coded branch and 404 for any # app that only exists on the ref being tested. @@ -7959,11 +7974,31 @@ async def _handle_marketplace_list(self) -> None: "data": {"success": True, "apps": apps}, } ) + except asyncio.TimeoutError: + # str(TimeoutError()) is "", which would reach the UI as a blank + # error and read as "it failed for no reason". + await self._broadcast( + { + "type": "agent_app_marketplace_list", + "data": { + "success": False, + "error": ( + "The marketplace did not respond within 30 seconds. " + "Check your internet connection and try again." + ), + "apps": [], + }, + } + ) except Exception as e: await self._broadcast( { "type": "agent_app_marketplace_list", - "data": {"success": False, "error": str(e), "apps": []}, + "data": { + "success": False, + "error": f"{type(e).__name__}: {e}", + "apps": [], + }, } ) diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx index da61d890..5b841324 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx @@ -59,6 +59,7 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen const [installCounts, setInstallCounts] = useState>(new Map()) const [configuringApp, setConfiguringApp] = useState(null) const installTimeoutsRef = useRef>>(new Map()) + const marketplaceTimeoutRef = useRef | null>(null) const [customValues, setCustomValues] = useState>({}) // Marketplace filter state @@ -134,7 +135,10 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen setCustomValues({}) setSearchQuery('') setSelectedTags(new Set()) - if (activeTab === 'marketplace' && apps.length === 0) { + // isConnected matters: opening straight onto the marketplace tab before + // the websocket is up would send the request into a closed socket. The + // effect below re-fires once the connection comes up. + if (activeTab === 'marketplace' && apps.length === 0 && isConnected) { fetchMarketplace() } } @@ -151,6 +155,10 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen useEffect(() => { const cleanups = [ onMessage('agent_app_marketplace_list', (data: any) => { + if (marketplaceTimeoutRef.current) { + clearTimeout(marketplaceTimeoutRef.current) + marketplaceTimeoutRef.current = null + } setMarketplaceLoading(false) if (data.success) { const appsWithThumbnails = (data.apps || []).map((app: any) => ({ @@ -227,8 +235,18 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen const fetchMarketplace = useCallback(() => { setMarketplaceLoading(true) setMarketplaceError(null) + // The backend always answers — but only if the socket actually delivered + // the request. Without this, a request sent into a closing or not-yet-open + // connection leaves the spinner up for ever with nothing to explain it, + // which is exactly how this looked when the marketplace "kept loading". + if (marketplaceTimeoutRef.current) clearTimeout(marketplaceTimeoutRef.current) + marketplaceTimeoutRef.current = setTimeout(() => { + marketplaceTimeoutRef.current = null + setMarketplaceLoading(false) + setMarketplaceError(t('components:createAgentApp.marketplaceFailed')) + }, 45000) send('agent_app_marketplace_list') - }, [send]) + }, [send, t]) // Derive tag list from catalogue, sorted by frequency (popular first) const allTags = useMemo(() => { diff --git a/app/updater.py b/app/updater.py index 816a0121..772613aa 100644 --- a/app/updater.py +++ b/app/updater.py @@ -104,7 +104,14 @@ async def _check_source_update( the release-tag check instead of blocking update checks on git-specific failures. """ - if getattr(sys, "frozen", False): + # A git-based update check is only meaningful in a source checkout. This + # used to test sys.frozen as a proxy for that; the agent is no longer + # frozen, so the test silently started passing and every installed + # machine began spawning git processes that can only ever answer "not a + # repository" - on machines that may not have git at all. + from app import paths + + if not paths.is_dev_checkout(): return None try: diff --git a/craftbot.py b/craftbot.py index 17e79d0c..9e8678a2 100644 --- a/craftbot.py +++ b/craftbot.py @@ -95,6 +95,11 @@ def flush(self) -> None: except ImportError: _python_runtime = None +# Where code and state live, for every install path (see app/paths.py). +# Imported unconditionally — it is stdlib-only, so it is available in the +# frozen installer EXE too, unlike the rest of app/. +from app import paths # noqa: E402 + # Agent payload (download/extract/version) lives in craftbot_payload. # These re-exports keep external callers (e.g. CraftBotInstaller.spec docstring, # any future tooling) working with the legacy `craftbot.GITHUB_OWNER` etc. @@ -141,16 +146,6 @@ def _user_data_dir() -> str: RUN_SCRIPT = os.path.join(BASE_DIR, "run.py") -def download_agent_zip( - progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, -) -> str: - return _payload.download_agent_zip(BASE_DIR, EXE_PATH, progress_cb=progress_cb) - - -def extract_agent_zip(zip_path: str, target_dir: str) -> str: - return _payload.extract_agent_zip(zip_path, target_dir) - - def default_install_location() -> str: """Return the default install directory used by the wizard's location chooser. @@ -175,8 +170,21 @@ def read_install_metadata() -> Optional[dict]: return _metadata.read(INSTALL_METADATA_FILE) -def write_install_metadata(installed_path: str, mode: str) -> None: - _metadata.write(INSTALL_METADATA_FILE, installed_path, mode) +def write_install_metadata( + installed_path: str, + mode: str, + python: Optional[str] = None, + version: Optional[str] = None, +) -> None: + """Record what was installed, where, and what runs it. + + python/version are schema-2 fields: an install is now a source tree plus + an interpreter, so both have to be recorded for cmd_start and auto-start + registration to reconstruct the launch command. + """ + _metadata.write( + INSTALL_METADATA_FILE, installed_path, mode, python=python, version=version + ) def clear_install_metadata() -> None: @@ -202,11 +210,6 @@ def installed_exe_path() -> Optional[str]: _BUNDLE_DIR = getattr(sys, "_MEIPASS", BASE_DIR) LOGO_PNG = os.path.join(_BUNDLE_DIR, "craftbot_logo_1.png") LOGO_ICO = os.path.join(_BUNDLE_DIR, "craftbot_logo_1.ico") -# Wordmark logo for the wizard header. -LOGO_TEXT_WHITE_PNG = os.path.join( - _BUNDLE_DIR, "assets", "craftbot_logo_text_no_border_dark.png" -) - # ─── Terminal colors (orange/white brand palette) ───────────────────────────── @@ -412,6 +415,55 @@ def _wait_for_startup_exit( return None +#: How long cmd_start waits for the agent to finish booting before it gives +#: up on opening the browser. Minutes, not seconds: a first run downloads the +#: embedding model, and on a slow connection that dominates startup. +BROWSER_READY_TIMEOUT_S = 600 + + +def _clear_agent_ready() -> None: + """Drop any previous run's readiness marker before we launch.""" + try: + paths.AGENT_READY_FILE.unlink(missing_ok=True) + except Exception: + pass + + +def _wait_for_ready_marker(start_offset: int, timeout: float) -> bool: + """Block until the agent reports that it finished booting. + + `_wait_for_startup_exit` is not a substitute: it returns after 8 seconds + whether or not anything is ready, because its job is only to catch a + launch that dies immediately. Opening the browser on that signal put a + tab in front of the user at around step 2 of 8. + + Two signals, checked in this order: + + 1. app.paths.AGENT_READY_FILE, written by the agent itself. This is + authoritative and is what we actually wait on. + 2. The ready banner in the log, kept only as a fallback for an older + installed agent that predates the marker file. + + The log used to be the ONLY signal, and that was a bug: run.py's stdout + is a log FILE here, so Python block-buffers it. The banner sat unflushed + in an 8 KB buffer while this function timed out around it — the install + looked stuck on "Working..." long after the agent was serving. + + Returns False on timeout; the caller decides what that means. + """ + deadline = time.time() + timeout + while time.time() < deadline: + try: + if paths.AGENT_READY_FILE.is_file(): + return True + except OSError: + pass + if CRAFTBOT_READY_MARKER in _tail_log_lines(200, start_offset): + return True + time.sleep(0.5) + return False + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -464,16 +516,6 @@ def _build_run_args(extra: List[str], service_mode: bool = True) -> List[str]: # ─── Core operations ────────────────────────────────────────────────────────── -def _open_browser_when_ready(url: str, pid_check_fn, delay: float = 4.0) -> None: - """Wait for the server to start, then open the browser.""" - time.sleep(delay) - if not pid_check_fn(): - print("\nWarning: CraftBot process exited before browser could open.") - print("Check logs: python craftbot.py logs") - return - webbrowser.open(url) - - def _open_browser_detached(url: str) -> None: """Poll the server URL and open the browser once it responds. @@ -550,14 +592,15 @@ def cmd_start(extra_args: List[str]) -> bool: run_args.append("--no-open-browser") if IS_FROZEN: - # In frozen-installer mode, spawn the installed agent EXE (downloaded - # by the install flow). The agent EXE is its own self-contained - # PyInstaller binary and runs run.py's __main__ block directly. - installed = installed_exe_path() - if not installed: - print("Error: no installed agent found — run install first.") + # Launching an install is metadata.launch_command()'s job — it is the + # only place that knows the difference between a schema-2 install + # ([python, run.py]) and a legacy schema-1 frozen bundle ([agent.exe]). + # Spreading that knowledge is how the entry points drifted before. + launch = _metadata.launch_command(INSTALL_METADATA_FILE) + if not launch: + print("Error: no usable install found — run install first.") return False - cmd = [installed] + run_args + cmd = launch + run_args else: python = _python_exe() # Use plain python.exe for CLI because pythonw has no console @@ -567,6 +610,10 @@ def cmd_start(extra_args: List[str]) -> bool: # UTF-8 with replace so the agent's Unicode banner / box-drawing chars # don't crash on Windows where the default file encoding is cp1252. + # Clear the readiness marker BEFORE launching, so a marker left by the + # previous run cannot make this one look instantly ready. + _clear_agent_ready() + log_fh = open(LOG_FILE, "a", encoding="utf-8", errors="replace") log_fh.write(f"\n{'=' * 60}\n") log_fh.write(f"CraftBot service started at {_timestamp()}\n") @@ -629,7 +676,24 @@ def cmd_start(extra_args: List[str]) -> bool: if open_browser: browser_url = _frontend_url(extra_args) print(f" {DIM}░░{RESET} {ORANGE}{browser_url}{RESET}") - _open_browser_detached(browser_url) + # Wait for the agent to actually be up before opening a tab at it. + # + # This used to open immediately after _wait_for_startup_exit(), which + # returns after 8 seconds — around step 2 of 8, while the model + # download, MCP servers, skills, integrations and the scheduler were + # all still to come. The browser then showed a backend that could not + # serve it. + # + # run.py only prints the ready marker once the agent signals that + # boot() finished (see app/paths.py AGENT_READY_FILE), so waiting for + # the marker here is waiting for the real thing. + if _wait_for_ready_marker(ready_log_offset, BROWSER_READY_TIMEOUT_S): + _open_browser_detached(browser_url) + else: + print( + f" {DIM}Still starting — open {browser_url} " + f"once it finishes.{RESET}" + ) return True @@ -873,13 +937,24 @@ def _create_desktop_shortcut_windows() -> None: print(f" (Could not create desktop shortcut: {e})") +def _installed_launch() -> Optional[List[str]]: + """Command that starts the installed CraftBot, or None. + + One accessor for all of auto-start registration, the desktop shortcut and + cmd_start. Under schema 2 an install is [python, run.py] rather than a + single EXE, and having four call sites each rebuild that is exactly how + the entry points drifted before. + """ + return _metadata.launch_command(INSTALL_METADATA_FILE) + + def _shortcut_start_command(extra_args: List[str]) -> Optional[str]: restart_args = _port_args(extra_args) if IS_FROZEN: - installed = installed_exe_path() - if not installed: + launch = _installed_launch() + if not launch: return None - return shlex.join([installed] + restart_args) + return shlex.join(launch + restart_args) return shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) @@ -965,15 +1040,17 @@ def _install_windows_registry(action: str) -> bool: def _install_windows(run_args: List[str]) -> None: if IS_FROZEN: - # Frozen mode: register the extracted agent EXE for auto-start. - target = installed_exe_path() - if not target: + # Register the installed CraftBot for auto-start: [python, run.py]. + launch = _installed_launch() + if not launch: print( - f" {RED}✗{RESET} {WHITE}No installed agent found — run install first.{RESET}" + f" {RED}✗{RESET} {WHITE}No installed CraftBot found — run install first.{RESET}" ) return - target_s = _to_short_path(target) - action = f'"{target_s}" {" ".join(run_args)}'.strip() + # 8.3 short paths avoid quoting trouble in Task Scheduler's XML for + # paths containing spaces, which %LOCALAPPDATA%\Programs often does. + quoted = " ".join(f'"{_to_short_path(part)}"' for part in launch) + action = f"{quoted} {' '.join(run_args)}".strip() else: python = _python_exe() # Use 8.3 short paths to avoid Unicode/long-path failures in schtasks /tr @@ -1068,11 +1145,11 @@ def _install_linux(run_args: List[str]) -> None: service_file = os.path.join(service_dir, f"{SYSTEMD_SERVICE}.service") if IS_FROZEN: - target = installed_exe_path() - if not target: - print("Error: no installed agent found — run install first.") + launch = _installed_launch() + if not launch: + print("Error: no installed CraftBot found — run install first.") return - exec_start = f"{target} {' '.join(run_args)}".strip() + exec_start = f"{' '.join(launch)} {' '.join(run_args)}".strip() else: python = _installed_python() exec_start = f"{python} {RUN_SCRIPT} {' '.join(run_args)}" @@ -1156,11 +1233,11 @@ def _install_macos(run_args: List[str]) -> None: plist_file = os.path.join(agents_dir, f"{LAUNCHD_LABEL}.plist") if IS_FROZEN: - target = installed_exe_path() - if not target: - print("Error: no installed agent found — run install first.") + launch = _installed_launch() + if not launch: + print("Error: no installed CraftBot found — run install first.") return - program_args = [target] + run_args + program_args = launch + run_args else: python = _installed_python() program_args = [python, RUN_SCRIPT] + run_args @@ -1198,20 +1275,37 @@ def _install_macos(run_args: List[str]) -> None: with open(plist_file, "w") as f: f.write(content) + # `launchctl load` honours RunAtLoad, so loading the agent starts CraftBot + # then and there. When one is already running — the normal case, since + # install starts it first — that second process fights the first for ports + # 7925/7926 and one of them loses. Writing the plist is enough on its own: + # launchd bootstraps ~/Library/LaunchAgents at login, so auto-start works + # from the next login whether or not it was loaded now. + pid = _read_pid() + if pid and _is_running(pid): + print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") + print("CraftBot is already running — auto-start begins at your next login.") + _print_launchd_hints(plist_file) + return + try: subprocess.run(["launchctl", "load", plist_file], check=True, timeout=10) print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") print("CraftBot will start automatically when you log in.") - print(f"\nOpen CraftBot: {BROWSER_URL}") - print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") - _create_desktop_shortcut_unix() - print(f"\nPlist file: {plist_file}") + _print_launchd_hints(plist_file) except subprocess.CalledProcessError as e: print(f"Error loading launchd agent: {e}") print(f"Plist written to: {plist_file}") print(f"Try manually: launchctl load {plist_file}") +def _print_launchd_hints(plist_file: str) -> None: + print(f"\nOpen CraftBot: {BROWSER_URL}") + print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") + _create_desktop_shortcut_unix() + print(f"\nPlist file: {plist_file}") + + def _uninstall_macos() -> None: plist_file = os.path.expanduser(f"~/Library/LaunchAgents/{LAUNCHD_LABEL}.plist") if os.path.isfile(plist_file): @@ -1270,20 +1364,48 @@ def _is_installed() -> bool: return os.path.isfile(service_file) +def _remove_legacy_agent_bundle(target_dir: str) -> None: + """Delete a schema-1 frozen-agent install, if one is here. + + Upgrading from <= 1.4.x leaves a ~2 GB CraftBotAgent/ folder that nothing + will ever run again — the agent is no longer a PyInstaller bundle. Silence + here would mean every upgraded machine quietly keeps it forever. + + User DATA is untouched: it lives in the per-user data dir, never in the + install dir (see app/paths.py). + """ + legacy = os.path.join(target_dir, "CraftBotAgent") + if not os.path.isdir(legacy): + return + print(" Removing the previous frozen agent bundle (no longer used)…") + try: + shutil.rmtree(legacy) + except OSError as e: + # Not fatal — it wastes disk but breaks nothing. + print(f" (could not remove {legacy}: {e})") + + def _full_install_frozen( target_dir: str, extra_args: List[str], progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, ) -> None: - """Frozen-mode install: download the agent zip from GitHub Releases, - extract it to target_dir, register auto-start pointing at the extracted - agent EXE, create a desktop shortcut, then start the service. + """Install CraftBot: fetch the source payload, provision a runtime around + it, register auto-start, and launch. + + This no longer downloads a frozen agent. The agent used to ship as a + PyInstaller bundle, which made an installer-based CraftBot a *different + kind of thing* from a source install — different module graph, different + data files, different embedding model — and every difference was a bug + waiting to be found one at a time. Now the installer produces a real + source install: the same tree a developer checks out, with an interpreter + and dependency set provisioned by the same app.provision pipeline that + `python install.py` runs. - No pip / dependency install runs — the agent payload is self-contained. - Called by cmd_install when IS_FROZEN, and by the wizard's Install button. + See docs/plans/unified-install-architecture.md. Args: - target_dir: Directory to extract the agent into. + target_dir: Directory to install the source into. extra_args: User-supplied flags (--cli, --browser, etc.). progress_cb: Optional download-progress callback (bytes_read, total_or_none). """ @@ -1292,47 +1414,97 @@ def _full_install_frozen( target_dir = os.path.normpath(target_dir) - # 0. If there's an existing install at this location, stop the agent and - # remove the old files first. Otherwise extraction fails with Permission - # denied because the running agent has CraftBotAgent.exe open. + # 0. Stop anything running from this location first — on Windows an open + # file cannot be replaced, so extraction would fail midway and leave a + # half-written tree. if _stop_running_agent_if_alive(): - print(" Stopped running agent before reinstalling.") + print(" Stopped running CraftBot before reinstalling.") - existing_agent_dir = os.path.join(target_dir, "CraftBotAgent") - if os.path.isdir(existing_agent_dir): - print(f" Removing previous install at {existing_agent_dir}") - try: - shutil.rmtree(existing_agent_dir) - except OSError as e: - raise RuntimeError( - f"Could not remove previous install at {existing_agent_dir} — {e}.\n" - f"Close any running CraftBotAgent.exe (Task Manager) and try again." - ) + _remove_legacy_agent_bundle(target_dir) - # 1. Download the agent payload (or use a locally-staged zip if available) - print(f" Downloading agent payload (version {_read_bundled_version()})…") - zip_path = download_agent_zip(progress_cb=progress_cb) + # 1. Source payload. + print(f" Downloading CraftBot (version {_read_bundled_version()})…") + zip_path = _payload.download_source_zip(BASE_DIR, EXE_PATH, progress_cb=progress_cb) try: - # 2. Extract into target_dir; locate the agent EXE - agent_exe = extract_agent_zip(zip_path, target_dir) - print(f" Installed agent at {agent_exe}") + src_root = _payload.extract_source_zip(zip_path, target_dir) + # Stamp it BEFORE anything imports app.paths from that tree: the + # payload contains install.py and requirements.txt, so without this + # marker the installed copy looks exactly like a dev checkout and + # would put the user's agent_file_system, databases and logs inside + # the install directory — which the next upgrade replaces wholesale. + paths.mark_managed_install(src_root) + print(f" Installed source at {src_root}") finally: - # Only delete if we downloaded it to a temp file. A locally-staged zip - # next to the installer (dev test workflow) must survive the install. + # Only delete a copy we downloaded. A locally-staged zip beside the + # installer is the dev workflow and must survive. if _payload.is_temp_zip(zip_path): try: os.unlink(zip_path) except OSError: pass - # 3. Persist install metadata so subsequent commands know where the - # installed agent lives. + # 2. Provision everything the source needs to run: the interpreter, the + # locked dependency set, Node, both npm trees, Playwright. + # + # This is the same pipeline `python install.py` drives. The installer + # used to run NONE of it — that is why an installer-based machine had + # no Node at all and Agent App could not start. + # + # CRAFTBOT_HOME is not set here: state belongs in the per-user data + # dir (app/paths.py), separate from this install dir, so an upgrade + # that replaces the source tree cannot take the user's data with it. + from app import provision + + print(" Setting up the runtime — this takes a few minutes on first install.") + ctx = provision.Context( + code_root=src_root, + state_root=str(paths.STATE_ROOT), + ) + report = provision.install(log=print, ctx=ctx) + + resolved_python = None + python_stage = dict(report.results).get("python") + if python_stage is not None: + resolved_python = python_stage.data.get("python") + + if not report.ok: + print() + print(provision.format_report(report)) + required_failed = [ + name + for name, res in report.results + if not res.ok + and name in ("python", "python-deps", "native-runtime", "smoke", "frontend") + ] + if required_failed: + raise RuntimeError( + "Setup could not complete: " + + ", ".join(required_failed) + + ". Re-run the installer to retry — finished steps are not redone." + ) + # Optional stages only (Playwright, the WhatsApp bridge): those + # degrade a feature. Refusing to finish the install over one would be + # worse than starting without it. + print(" Some optional components are unavailable; continuing.") + + if not resolved_python: + raise RuntimeError( + "Setup finished without resolving a Python interpreter — cannot " + "record how to start CraftBot." + ) + + # 3. Record what was installed and how to launch it. Schema 2: the install + # root plus the interpreter, because there is no longer an EXE to point + # at. installer/metadata.launch_command() is the only place that turns + # this back into a command. mode = "cli" if "--cli" in extra_args else "browser" - write_install_metadata(agent_exe, mode) + write_install_metadata( + src_root, mode, python=resolved_python, version=_read_bundled_version() + ) # 4. Copy the icon out of the bundled _MEIPASS dir into the persistent - # user data dir. The desktop shortcut's IconLocation will point at - # this stable copy — _MEIPASS is wiped when the installer exits. + # user data dir. The desktop shortcut's IconLocation points at this + # stable copy — _MEIPASS is wiped when the installer exits. persistent_icon = os.path.join(_user_data_dir(), "craftbot_logo_1.ico") persistent_png = os.path.join(_user_data_dir(), "craftbot_logo_1.png") for src, dest in ((LOGO_ICO, persistent_icon), (LOGO_PNG, persistent_png)): @@ -1342,13 +1514,13 @@ def _full_install_frozen( except OSError as e: print(f" (could not copy icon: {e})") - # 5. Register auto-start using the extracted agent EXE + # 5. Register auto-start. run_args = _build_run_args(extra_args, service_mode=True) _helpers.dispatch_per_platform( win=_install_windows, mac=_install_macos, linux=_install_linux )(run_args) - # 6. Start the service via the extracted agent EXE + # 6. Start. if not cmd_start(extra_args): raise RuntimeError("CraftBot installed but failed to start.") @@ -1432,29 +1604,39 @@ def cmd_install(extra_args: List[str]) -> bool: else: print(f" {DIM}(install.py not found — skipping dependency install){RESET}\n") - # ── Step 2: Register auto-start ──────────────────────────────────────── + # ── Step 2: Start the service now ────────────────────────────────────── + # + # Starting BEFORE registering auto-start, not after. The macOS plist + # carries RunAtLoad, so `launchctl load` starts an instance there and + # then; registering first therefore left two run.py processes racing for + # ports 7925/7926, and the loser died with "Failed to start browser + # frontend" while the winner quietly served the UI. cmd_start's own guard + # could not catch it — it reads craftbot.pid, which the launchd instance + # has not written yet a second into its boot. Start first and the pid + # exists by the time registration looks for it (see _install_macos). + _retro_step(2, 3, "Starting CraftBot") + if not cmd_start(extra_args): + print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") + return False + print() + + # ── Step 3: Register auto-start ──────────────────────────────────────── if _is_installed(): print( - f"\n {DIM}▸ STEP 2/3 ░░ AUTO-START ALREADY REGISTERED — SKIPPING{RESET}" + f"\n {DIM}▸ STEP 3/3 ░░ AUTO-START ALREADY REGISTERED — SKIPPING{RESET}" ) if _PLATFORM == "win32": _create_desktop_shortcut_windows() elif _PLATFORM != "darwin": _create_desktop_shortcut_unix() else: - _retro_step(2, 3, "Registering auto-start") + _retro_step(3, 3, "Registering auto-start") run_args = _build_run_args(extra_args, service_mode=True) _helpers.dispatch_per_platform( win=_install_windows, mac=_install_macos, linux=_install_linux )(run_args) print() - # ── Step 3: Start the service now ────────────────────────────────────── - _retro_step(3, 3, "Starting CraftBot") - if not cmd_start(extra_args): - print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") - return False - print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") print(f" {DIM}░░{RESET} {ORANGE}{_frontend_url(extra_args)}{RESET}") print("You can close this window now.") @@ -1511,16 +1693,24 @@ def cmd_uninstall() -> None: # Reinstall regenerates everything else from the bundled defaults. _remove_pid() installed = installed_exe_path() - if installed and os.path.isfile(installed): - install_dir = os.path.dirname(installed) - if os.path.basename(install_dir).lower() == "craftbotagent": - install_dir = os.path.dirname(install_dir) + install_dir = None + if installed: + # Schema 2 records the install ROOT (a directory holding run.py). + # Schema 1 recorded the agent EXE, so walk up from it — and up + # again past the CraftBotAgent/ wrapper the old zip extracted to. + if os.path.isdir(installed): + install_dir = installed + elif os.path.isfile(installed): + install_dir = os.path.dirname(installed) + if os.path.basename(install_dir).lower() == "craftbotagent": + install_dir = os.path.dirname(install_dir) + if install_dir and os.path.isdir(install_dir): try: shutil.rmtree(install_dir, ignore_errors=False) - print(f"Removed installed agent directory: {install_dir}") + print(f"Removed installed directory: {install_dir}") except OSError as e: print(f"Warning: could not remove {install_dir} — {e}") - print("(It may be in use; close the wizard / installed EXE first.)") + print("(It may be in use; close the wizard / stop CraftBot first.)") # Compute user data dir path independently of _user_data_dir() (that # helper recreates the dir, which we don't want here). @@ -1565,7 +1755,17 @@ def cmd_uninstall() -> None: print("\nUninstall complete.") return - # Source mode: uninstall pip packages + # Source mode: uninstall pip packages. + # + # Not for a managed install: there the interpreter is a sidecar under the + # user data directory that the launcher removes wholesale right after + # this returns, so uninstalling packages from it one by one would only + # add minutes to the uninstall. + if paths.is_managed_install(): + print("\n(managed install — the launcher removes the runtime)") + print("\nUninstall complete.") + return + req_file = os.path.join(BASE_DIR, "requirements.txt") if os.path.isfile(req_file): print("\nUninstalling pip packages...") @@ -1610,12 +1810,18 @@ def cmd_repair( return installed = meta["installed_path"] - # The install location is the dir CONTAINING the agent EXE's nested - # CraftBotAgent/ folder (or the agent EXE directly if extracted flat). - # _full_install_frozen is idempotent and will overwrite either layout. - target_dir = os.path.dirname(installed) - if os.path.basename(target_dir).lower() == "craftbotagent": - target_dir = os.path.dirname(target_dir) + if _metadata.schema_of(meta) >= 2: + # Schema 2 records the install root directly. + target_dir = installed + else: + # Schema 1 recorded the agent EXE: walk up to the directory it was + # extracted into, past the CraftBotAgent/ wrapper if present. Repair + # from a legacy install is also the upgrade path — the reinstall below + # replaces the frozen bundle with a real source install and + # _remove_legacy_agent_bundle() deletes what it superseded. + target_dir = os.path.dirname(installed) + if os.path.basename(target_dir).lower() == "craftbotagent": + target_dir = os.path.dirname(target_dir) print(f" Repairing CraftBot at {target_dir}") # Stop the existing service so we can overwrite the agent files @@ -1657,8 +1863,14 @@ def _get_parent_pid() -> Optional[int]: def _close_console_window() -> None: - """Close the current console/terminal window on Windows then exit.""" - if _PLATFORM != "win32": + """Close the current console/terminal window on Windows then exit. + + Only when this process owns a console. Launched from a script, a pipe or + the CraftBot launcher, the "parent" is not a cmd.exe window at all — it + is whatever started us, and killing it would take the launcher's window + down with it. + """ + if _PLATFORM != "win32" or not sys.stdout.isatty(): sys.exit(0) # Use PowerShell to kill the parent cmd.exe after a short delay try: @@ -1758,6 +1970,24 @@ def main() -> None: launch_wizard() + elif command == "doctor": + # Every provisioning stage's check(), and nothing else — no downloads, + # no installs. This is the same pipeline `install` runs, so what it + # reports is exactly what an install would act on. Answers "why won't + # it start?" without anyone guessing which of the three install paths + # this machine went through. + from app import provision + + report = provision.doctor(log=print) + print() + print(provision.format_report(report)) + print() + print(f" code : {paths.CODE_ROOT}") + print(f" state: {paths.STATE_ROOT}") + if not report.ok: + print("\n Run `python craftbot.py install` to fix.") + sys.exit(1) + else: print(f"Unknown command: '{command}'") print("Run 'python craftbot.py --help' for usage.") diff --git a/environment.yml b/environment.yml index 83bb3a7a..1661c636 100644 --- a/environment.yml +++ b/environment.yml @@ -1,59 +1,35 @@ name: craftbot + +# Conda provides the RUNTIME and system-level binaries only. +# +# Python packages are NOT listed here. They come from +# requirements/lock--.txt, installed into this env by +# app.provision's python-deps stage — the same lock the pip path and the +# installer use. This file used to carry its own copy of that list and the +# two drifted badly, so `install.py --conda` built a broken environment. +# One list, one lock, no drift. + channels: - conda-forge - defaults + dependencies: - python=3.10.19 - pip=26.0.1 - - requests=2.32.5 - - pyyaml=6.0.3 - - loguru=0.7.3 - - nest-asyncio=1.6.0 - - pymongo=4.16.0 - - tzlocal=5.3.1 - - pillow=12.1.1 - - pytesseract=0.3.13 - - tesseract=5.5.2 - - aiohttp=3.13.3 + # PINNED: openssl 3.6.3 / 3.5.7 regress the Windows cert-store load # (ssl.SSLError ASN1: NOT_ENOUGH_DATA in _load_windows_store_certs, crashes # aiohttp at import). Broke 2026-06-22 and AGAIN 2026-08-19 when a nodejs # install transitively bumped it — keep this pin, verify before raising: # conda run -n craftbot python -c "import ssl; ssl.create_default_context()" - openssl=3.6.2 + # Agent App builds: the lui CLI is TypeScript run by Node's native type # stripping — needs Node >= 24 (older majors ERR_UNKNOWN_FILE_EXTENSION). + # Inside the env this leads PATH when CraftBot runs, so node_runtime + # resolves it and the sidecar download is skipped. - nodejs>=24 - - beautifulsoup4=4.14.3 - - chardet=5.2.0 - - lxml=6.0.2 - - scikit-learn=1.7.2 - - pip: - - croniter - - openai==2.21.0 - - google-genai==1.0.0 - - anthropic==0.83.0 - - chromadb==1.5.1 - - tiktoken==0.12.0 - - langgraph==1.0.9 - - neo4j==6.1.0 - - mss==10.1.0 - - httpx==0.28.1 - - trafilatura==2.0.0 - - markdown2==2.5.4 - - python-docx==1.2.0 - - fake-useragent==2.2.0 - - fpdf2==2.8.6 - - googlesearch-python==1.3.0 - - pyautogui==0.9.54 - - pygetwindow==0.0.9 - - python3-xlib==0.15 - - tenacity==9.1.4 - - docling==2.74.0 - - gradio_client==2.1.0 - - python-dotenv==1.2.1 - - watchdog==6.0.0 - - telethon==1.42.0 - - playwright==1.58.0 - - qrcode==8.2 - - sentence-transformers==6.0.0 + + # The tesseract BINARY (pytesseract, the Python wrapper, is in the lock). + # Conda is the only one of our install paths that can provide it. + - tesseract=5.5.2 diff --git a/hooks/hook-rich._unicode_data.py b/hooks/hook-rich._unicode_data.py deleted file mode 100644 index a7ac2efd..00000000 --- a/hooks/hook-rich._unicode_data.py +++ /dev/null @@ -1,10 +0,0 @@ -"""PyInstaller hook: include rich._unicode_data/*.py as data files. - -These files have hyphenated names (e.g. unicode17-0-0.py) which PyInstaller's -static analysis cannot discover. We include them as data files so they exist -on the filesystem at runtime, where our runtime hook can load them. -""" - -from PyInstaller.utils.hooks import collect_data_files - -datas = collect_data_files("rich._unicode_data", include_py_files=True) diff --git a/install.py b/install.py index abb3df26..792d4357 100644 --- a/install.py +++ b/install.py @@ -31,8 +31,6 @@ # and the single resolved Node runtime — see app/node_runtime.py (both are # stdlib-only; app/__init__.py is empty, so safe before any deps exist). from app import python_runtime -from app import node_runtime -from app.node_runtime import MIN_NODE_MAJOR multiprocessing.freeze_support() @@ -56,6 +54,24 @@ # ========================================== # TERMINAL COLORS (orange/white brand palette) # ========================================== +def _force_utf8_stdio() -> None: + """Make stdout/stderr able to carry the glyphs this script prints. + + A Windows console defaults to cp1252, which cannot encode ✓ ⚠ ▓ ░ — and + a bare `print` of one raises UnicodeEncodeError. That is not cosmetic: it + killed the installer mid-run while printing its OWN Python-version + warning, so the user saw a traceback instead of the advice. + + Runs before anything prints. errors="replace" is the backstop for streams + that cannot be reconfigured at all (a pipe with a fixed encoding). + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError, OSError): + pass + + def _enable_windows_vtp() -> None: """Enable ANSI/VT100 virtual terminal processing on Windows 10+.""" if sys.platform != "win32": @@ -338,6 +354,7 @@ def _run_step(cmd: list) -> bool: sys.exit(0) +_force_utf8_stdio() _enable_windows_vtp() _USE_COLOR = sys.stdout.isatty() @@ -931,768 +948,19 @@ def verify_conda_env(env_name: str) -> bool: return False -def _install_node_sidecar() -> Optional[str]: - """Download an official Node build into /runtime/node — no - PATH edits, nothing else touched; node_runtime discovery picks it up. - Returns the new binary path, or None.""" - import platform - import ssl - import tarfile - import urllib.request - import zipfile - - try: - import certifi - - ctx = ssl.create_default_context(cafile=certifi.where()) - except ImportError: - ctx = ssl.create_default_context() - - machine = platform.machine().lower() - arch = "arm64" if machine in ("arm64", "aarch64") else "x64" - if sys.platform == "win32": - suffix, ext = f"win-{arch}", "zip" - elif sys.platform == "darwin": - suffix, ext = f"darwin-{arch}", "tar.gz" - else: - suffix, ext = f"linux-{arch}", "tar.xz" - - try: - # Latest release of the MIN_NODE_MAJOR line (index is newest-first). - req = urllib.request.Request( - "https://nodejs.org/dist/index.json", headers={"User-Agent": "CraftBot"} - ) - index = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) - ver = next( - ( - e["version"] - for e in index - if e.get("version", "").startswith(f"v{MIN_NODE_MAJOR}.") - ), - None, - ) - if not ver: - print(f" ⚠ No v{MIN_NODE_MAJOR}.x release found in the Node index") - return None - - url = f"https://nodejs.org/dist/{ver}/node-{ver}-{suffix}.{ext}" - dest_root = os.path.join(BASE_DIR, "runtime", "node") - os.makedirs(dest_root, exist_ok=True) - print(f" Downloading {url} (may take a minute)...") - req = urllib.request.Request(url, headers={"User-Agent": "CraftBot"}) - # Stream to disk — the archive is 30-55MB and low-memory VPS - # deployments shouldn't hold it in RAM (twice) just to extract it. - archive = os.path.join(dest_root, f"_download.{ext}") - try: - with urllib.request.urlopen(req, timeout=600, context=ctx) as resp: - with open(archive, "wb") as fh: - shutil.copyfileobj(resp, fh) - if ext == "zip": - zipfile.ZipFile(archive).extractall(dest_root) - else: - # tarfile preserves the executable bits - tarfile.open(archive, mode="r:*").extractall(dest_root) - finally: - try: - os.remove(archive) - except OSError: - pass - binary = os.path.join( - dest_root, - f"node-{ver}-{suffix}", - "node.exe" if sys.platform == "win32" else os.path.join("bin", "node"), - ) - return binary if os.path.isfile(binary) else None - except Exception as e: - print(f" ⚠ Sidecar Node download failed: {str(e)[:200]}") - return None - - -def ensure_nodejs() -> bool: - """Use a suitable existing Node (>= MIN_NODE_MAJOR) for everything, else - download the sidecar. Resolution and the never-touch-the-system-Node - contract live in app/node_runtime.py. On False the install continues - with whatever PATH npm exists (frontend and bridge tolerate Node 20), - but Agent App stays off until fixed.""" - rt = node_runtime.resolve(refresh=True) - if rt is not None: - source = { - "override": "CRAFTBOT_NODE", - "path": "PATH", - "discovered": "a discovered install", - }[rt.source] - print( - f"✓ Node.js {rt.version or '(version unprobed)'} via {source}: " - f"{rt.node} — used for all components" - ) - if not (rt.npm or shutil.which("npm")): - # A bare node binary (e.g. CRAFTBOT_NODE at a lone executable) - # can't install frontend/bridge deps. - print("⚠ That Node has no npm beside it and none is on PATH —") - print(" frontend/bridge dependency installs below will fail.") - print(" Point CRAFTBOT_NODE at a full Node install (bin/ with npm).") - return False - return True - - print(f"\n🔧 No Node.js >= {MIN_NODE_MAJOR} found — downloading a sidecar copy") - print(" (no system changes; any existing Node stays untouched)...") - if _install_node_sidecar(): - rt = node_runtime.resolve(refresh=True) - if rt is not None: - print( - f"✓ Node.js {rt.version or ''} sidecar ready: {rt.node} — " - "used for all components" - ) - return True - - print(f"\n⚠ Could not set up Node.js >= {MIN_NODE_MAJOR}.") - print(" Browser frontend, WhatsApp bridge and Agent App apps need it. Options:") - print(f" - nvm install {MIN_NODE_MAJOR} (auto-discovered, default unchanged)") - print(f" - set CRAFTBOT_NODE to a Node >= {MIN_NODE_MAJOR} binary") - print(f" - install Node {MIN_NODE_MAJOR} LTS from https://nodejs.org/") - print(" Then re-run: python install.py") - return False - - def ensure_native_runtime() -> None: - """OS prerequisites that pip cannot provide for the native wheels - (torch, onnxruntime, ...) the memory stack imports at boot. - - Windows: the Visual C++ 2015-2022 Redistributable — torch's DLLs link - against it and a fresh Windows (observed: Windows Sandbox, 2026-08-25) - lacks it, dying at first boot with WinError 126 on torch_python.dll. - Installed silently when missing (one-time, machine-wide, UAC prompt). - Linux: libgomp/libstdc++ (missing on minimal images) — sudo territory, - so only a hint. macOS: torch wheels are self-contained.""" - if sys.platform == "win32": - import winreg - - def _redist_installed() -> bool: - try: - key = winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64", - ) - installed, _ = winreg.QueryValueEx(key, "Installed") - return bool(installed) - except OSError: - sys32 = os.path.join( - os.environ.get("SystemRoot", r"C:\Windows"), "System32" - ) - return all( - os.path.isfile(os.path.join(sys32, dll)) - for dll in ( - "msvcp140.dll", - "vcruntime140.dll", - "vcruntime140_1.dll", - ) - ) - - if _redist_installed(): - print("✓ Visual C++ Redistributable present") - return - import platform - import urllib.request - - arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64" - url = f"https://aka.ms/vs/17/release/vc_redist.{arch}.exe" - dest = os.path.join(BASE_DIR, f"vc_redist.{arch}.exe") - print( - "\n🔧 Visual C++ Redistributable missing — installing (torch needs it)..." - ) - print(f" {url} (a UAC prompt may appear)") - try: - urllib.request.urlretrieve(url, dest) - result = run_command( - [dest, "/install", "/quiet", "/norestart"], - check=False, - capture=True, - quiet=True, - show_error=False, - ) - code = getattr(result, "returncode", None) - # 0 = installed, 1638 = a newer version is already present, 3010 = reboot pending - if code in (0, 1638, 3010) and _redist_installed(): - print("✓ Visual C++ Redistributable installed") - else: - print( - f"⚠ Redistributable installer exited with {code} — install it manually:" - ) - print(f" {url}") - except Exception as e: - print(f"⚠ Could not install the Visual C++ Redistributable: {str(e)[:200]}") - print(f" Install it manually: {url}") - finally: - try: - os.remove(dest) - except OSError: - pass - elif sys.platform.startswith("linux"): - import ctypes.util - - missing = [ - name - for name, lib in (("libgomp1", "gomp"), ("libstdc++6", "stdc++")) - if ctypes.util.find_library(lib) is None - ] - if missing: - print(f"⚠ Missing system libraries torch needs: {', '.join(missing)}") - print( - f" Debian/Ubuntu/Kali: sudo apt-get install -y {' '.join(missing)}" - ) - print(" Fedora/RHEL: sudo dnf install -y libgomp libstdc++") - - -def verify_native_imports(python_cmd: list) -> bool: - """Prove the memory stack's native wheels actually LOAD in the interpreter - that will run the service — a pip success only means the files landed. - This is the cross-platform half of ensure_native_runtime: whatever the - OS-specific gap is, it surfaces here at install time with the fix, - instead of as a dead port after "INSTALLATION COMPLETE".""" - if os.environ.get("MEMORY_EMBEDDING_MODEL") == "default": - return True # ChromaDB's bundled embedder; torch never loads - result = run_command( - python_cmd + ["-c", "import torch, sentence_transformers"], - check=False, - capture=True, - quiet=True, - show_error=False, - ) - if result is not None and getattr(result, "returncode", 1) == 0: - print("✓ Memory embedding stack loads (torch, sentence-transformers)") - return True - tail = (getattr(result, "stderr", "") or "").strip().splitlines()[-1:] or [ - "(no output)" - ] - print("\n✗ The memory embedding stack is installed but does not load:") - print(f" {tail[0][:300]}") - if sys.platform == "win32": - print(" Usual cause: Visual C++ Redistributable missing/failed —") - print(" https://aka.ms/vs/17/release/vc_redist.x64.exe, then re-run install.") - elif sys.platform.startswith("linux"): - print( - " Usual cause: sudo apt-get install -y libgomp1 libstdc++6, then re-run install." - ) - print(" Escape hatch: set MEMORY_EMBEDDING_MODEL=default (ChromaDB's bundled") - print(" embedder, no torch) — memory retrieval quality is lower.") - return False - - -def install_playwright_browser(use_conda: bool = False): - """Install Playwright Chromium for the agent's browser-automation - actions. (The WhatsApp bridge no longer uses a browser — it speaks the - protocol directly via Baileys.)""" - print("\nInstalling Playwright Chromium browser...") - try: - if use_conda: - conda_cmd = get_conda_command() - env_name = get_env_name_from_yml() - result = run_command( - [ - conda_cmd, - "run", - "-n", - env_name, - "python", - "-m", - "playwright", - "install", - "chromium", - ], - check=False, - capture=True, - show_error=False, - ) - else: - result = run_command( - [sys.executable, "-m", "playwright", "install", "chromium"], - check=False, - capture=True, - show_error=False, - ) - if result and hasattr(result, "returncode") and result.returncode == 0: - print("✓ Playwright Chromium installed") - return True - else: - print("⚠ Warning: Playwright browser installation failed") - if result and hasattr(result, "stderr") and result.stderr: - error_msg = result.stderr[:300].strip() - if error_msg: - print(f" Error details: {error_msg}") - print(" Browser-automation actions may not work") - print(" You can manually install later with: playwright install chromium") - return False - except Exception as e: - print(f"⚠ Warning: Failed to install Playwright browser: {e}") - print(" Browser-automation actions may not work") - print(" You can manually install later with: playwright install chromium") - return False - - -def _frontend_deps_stale(frontend_dir: str) -> Optional[str]: - """Why node_modules does NOT satisfy the current package.json, or None. - - "node_modules exists" only proves npm install ran *once*; it says nothing - about whether it ran for the CURRENT manifest. Pulling a branch that adds - a dependency (e.g. react-grid-layout in the dashboard revamp) left the old - check reporting "already installed" forever. Two real conditions instead: - - 1. Every package declared in dependencies/devDependencies resolves to an - installed node_modules//package.json — catches added packages. - 2. Neither manifest is newer than npm's own install receipt - (node_modules/.package-lock.json, rewritten by every npm install) — - catches version bumps, which condition 1 can't see. - - Returns a human-readable reason so the caller can say WHY it's installing. - """ - node_modules = os.path.join(frontend_dir, "node_modules") - if not os.path.isdir(node_modules): - return "node_modules is missing" - - try: - with open(os.path.join(frontend_dir, "package.json"), encoding="utf-8") as fh: - manifest = json.load(fh) - except (OSError, ValueError): - # Unreadable manifest — run npm install and let npm report the - # real problem loudly instead of silently skipping. - return "package.json could not be read" - - declared = { - **manifest.get("dependencies", {}), - **manifest.get("devDependencies", {}), - } - for name in declared: - # Scoped names ("@types/react") nest one directory deeper. - pkg_json = os.path.join(node_modules, *name.split("/"), "package.json") - if not os.path.isfile(pkg_json): - return f"declared dependency '{name}' is not installed" - - receipt = os.path.join(node_modules, ".package-lock.json") - if not os.path.isfile(receipt): - return "npm's install receipt (node_modules/.package-lock.json) is missing" - installed_at = os.path.getmtime(receipt) - for filename in ("package.json", "package-lock.json"): - path = os.path.join(frontend_dir, filename) - if os.path.isfile(path) and os.path.getmtime(path) > installed_at: - return f"{filename} changed after the last npm install" - - return None - - -def resolve_npm_cmd( - use_conda: bool = False, env_name: Optional[str] = None -) -> Optional[list]: - """Command prefix for npm, or None when no npm is reachable. - - The resolved runtime's npm first (same Node version as everything else); - in conda mode the env's npm via `conda run` comes BEFORE any stale PATH - npm (the env's Node 24 is what runs the result); plain PATH npm last.""" - rt = node_runtime.resolve() - if rt and rt.npm: - return [rt.npm] - if use_conda and env_name: - conda_cmd = get_conda_command() - probe = run_command( - [conda_cmd, "run", "-n", env_name, "npm", "--version"], - check=False, - capture=True, - quiet=True, - show_error=False, - ) - if probe and hasattr(probe, "returncode") and probe.returncode == 0: - print(" Using the conda env's npm") - return [conda_cmd, "run", "-n", env_name, "npm"] - npm = shutil.which("npm") - return [npm] if npm else None - - -def install_browser_frontend(npm_cmd: Optional[list]): - """Install npm dependencies for the browser frontend. - - npm_cmd is the command prefix from resolve_npm_cmd, or None when no npm - is reachable.""" - frontend_dir = os.path.join(BASE_DIR, "app", "ui_layer", "browser", "frontend") + """OS prerequisites for the native wheels (torch, onnxruntime, ...). - if not os.path.exists(frontend_dir): - print(f"\n⚠ Warning: Browser frontend directory not found at {frontend_dir}") - print(" Browser interface will not work") - return False - - if npm_cmd is None: - print("\n⚠ Warning: npm not found in PATH") - print(" Browser interface requires Node.js and npm.") - print("\n 📥 Install Node.js from: https://nodejs.org/") - print(f" (v{MIN_NODE_MAJOR}+ — Agent App apps need it)") - print("\n After installation:") - print(" 1. Restart your terminal") - print(" 2. Run: python install.py") - print("\n Or manually install frontend:") - print(" cd app/ui_layer/browser/frontend") - print(" npm install") - return False - - stale_reason = _frontend_deps_stale(frontend_dir) - if stale_reason is None: - print("\n✓ Browser frontend dependencies already installed") - return True - - # Try to install - print(f"\n🔧 Installing browser frontend dependencies ({stale_reason})...") - try: - result = run_command_with_progress( - npm_cmd + ["install"], - message="Installing npm packages", - cwd=frontend_dir, - check=False, - env_extras=node_runtime.path_env(), - ) - if result and hasattr(result, "returncode") and result.returncode == 0: - print("✓ Browser frontend dependencies installed") - return True - else: - print("\n⚠ Warning: npm install command failed") - print("\n Troubleshooting:") - print(" 1. Make sure Node.js is installed: node --version") - print(" 2. Check npm version: npm --version") - print(" 3. Try manually: cd app/ui_layer/browser/frontend && npm install") - print("\n If you still need help:") - print(" - Check Node.js/npm documentation: https://nodejs.org/") - print(" - Ensure internet connection is working") - return False - except Exception as e: - print(f"\n⚠ Warning: Failed to install browser frontend: {e}") - print("\n You can manually install with:") - print(" cd app/ui_layer/browser/frontend") - print(" npm install") - return False - - -def install_whatsapp_bridge(npm_cmd: Optional[list]): - """Install npm dependencies for the WhatsApp bridge (Baileys). - - The bridge is a Node subprocess speaking WhatsApp's protocol via - Baileys — no browser involved. Installing here (instead of lazily at - the first bridge start) means the first QR link isn't blocked behind - an npm download. Uses the same staleness check as the frontend, so a - pulled branch that bumps the Baileys version reinstalls automatically. + The implementation lives in app.provision.verify so the installer shares + it. It used to live here, which meant an installer-based machine never + ran it and died at first boot with WinError 126 on torch_python.dll — + exactly the failure this function was written to prevent. """ - bridge_dir = os.path.join( - BASE_DIR, "craftos_integrations", "providers", "whatsapp_web" - ) - - if not os.path.exists(os.path.join(bridge_dir, "package.json")): - print(f"\n⚠ Warning: WhatsApp bridge directory not found at {bridge_dir}") - print(" WhatsApp integration will not work") - return False + from app.provision.verify import ensure_native_runtime as _ensure - if npm_cmd is None: - # install_browser_frontend already walks the user through Node.js - # installation; keep this message short. - print("\n⚠ Warning: npm not found — WhatsApp bridge dependencies skipped") - print(" After installing Node.js, run:") - print(" cd craftos_integrations/providers/whatsapp_web && npm install") - return False + _ensure(log=print) - stale_reason = _frontend_deps_stale(bridge_dir) - if stale_reason is None: - print("\n✓ WhatsApp bridge dependencies already installed") - return True - print(f"\n🔧 Installing WhatsApp bridge dependencies ({stale_reason})...") - try: - result = run_command_with_progress( - npm_cmd + ["install"], - message="Installing WhatsApp bridge (Baileys)", - cwd=bridge_dir, - check=False, - env_extras=node_runtime.path_env(), - ) - if result and hasattr(result, "returncode") and result.returncode == 0: - print("✓ WhatsApp bridge dependencies installed") - return True - print("\n⚠ Warning: npm install for the WhatsApp bridge failed") - print(" WhatsApp integration will not work until it succeeds:") - print(" cd craftos_integrations/providers/whatsapp_web && npm install") - return False - except Exception as e: - print(f"\n⚠ Warning: Failed to install WhatsApp bridge deps: {e}") - print(" You can manually install with:") - print(" cd craftos_integrations/providers/whatsapp_web && npm install") - return False - - -def setup_pip_environment(requirements_file: str = REQUIREMENTS_FILE): - try: - if not os.path.exists(requirements_file): - print(f"Error: {requirements_file} not found.") - sys.exit(1) - - print("🔧 Installing core dependencies...") - - # Setup environment with TMPDIR for pip cache management - # This helps on systems with limited space or PEP 668 issues - my_env = os.environ.copy() - tmp_dir = os.path.expanduser("~/pip-tmp") - my_env["TMPDIR"] = tmp_dir - # Disable pip's rich/colored output so it falls back to plain text. - # This prevents pip's vendored rich library from crashing on Windows - # terminals with encoding issues (common on Python 3.14+). - my_env["NO_COLOR"] = "1" - my_env["FORCE_COLOR"] = "0" - my_env["PYTHONIOENCODING"] = "utf-8" - - # Create temp directory if it doesn't exist - os.makedirs(tmp_dir, exist_ok=True) - - # First attempt with standard pip install - # --no-color keeps output plain and avoids rich console crashes - cmd = [ - sys.executable, - "-m", - "pip", - "install", - "--no-color", - "-r", - requirements_file, - ] - result = run_command_with_progress( - cmd, - message="Installing core dependencies", - check=False, - env_extras={ - "TMPDIR": tmp_dir, - "NO_COLOR": "1", - "FORCE_COLOR": "0", - "PYTHONIOENCODING": "utf-8", - }, - ) - - if result and hasattr(result, "returncode") and result.returncode != 0: - # Check error output - error_output = "" - if hasattr(result, "stderr"): - error_output = result.stderr - elif hasattr(result, "stdout"): - error_output = result.stdout - - # Check for disk space errors - if ( - "no space left on device" in error_output.lower() - or "disk full" in error_output.lower() - ): - print("\n❌ DISK SPACE ERROR - No space left on device\n") - print( - "This is a common issue on Kali Linux when installing large packages.\n" - ) - print("Immediate fixes:\n") - print("1. Clear pip cache (usually frees 1-5 GB):") - print(" pip cache purge\n") - print("2. Clear npm cache (if installed):") - print(" npm cache clean --force\n") - print("3. Use alternate disk with more space:") - mkdir_cmd = ( - "/mnt/external/pip-tmp" if sys.platform != "win32" else "D:/pip-tmp" - ) - print(f" mkdir -p {mkdir_cmd}") - print(f" TMPDIR={mkdir_cmd} python install.py\n") - print("4. Check disk usage:") - check_cmd = "du -sh ~/*" if sys.platform != "win32" else "dir /-s C:\\" - print(f" {check_cmd}\n") - suggest_cleanup_steps() - sys.exit(1) - - # Check for PEP 668 error - if ( - "externally-managed-environment" in error_output - or "externally managed" in error_output - ): - print("\n⚠️ PEP 668 Error Detected (externally-managed-environment)\n") - print( - "This usually happens on Kali Linux or other systems with managed Python." - ) - print("\nOptions to fix:\n") - print("Option 1 (Recommended): Use a virtual environment") - print(" python3 -m venv craftbot-env") - print(" source craftbot-env/bin/activate # On Linux/macOS") - print(" .\\craftbot-env\\Scripts\\activate # On Windows") - print(" python install.py\n") - - print("Option 2: Use conda (recommended for data science projects)") - print(" python install.py --conda\n") - - print("Option 3: Break system packages (not recommended)") - print(" Retrying with --break-system-packages flag...\n") - - # Retry with --break-system-packages - cmd_with_flag = [ - sys.executable, - "-m", - "pip", - "install", - "--no-color", - "--break-system-packages", - "-r", - requirements_file, - ] - result = run_command_with_progress( - cmd_with_flag, - message="Retrying installation", - check=False, - env_extras={ - "TMPDIR": tmp_dir, - "NO_COLOR": "1", - "FORCE_COLOR": "0", - "PYTHONIOENCODING": "utf-8", - }, - ) - - if result and hasattr(result, "returncode") and result.returncode == 0: - print( - "✓ Core dependencies installed (with --break-system-packages)" - ) - else: - print("\n✗ Installation failed even with --break-system-packages") - if hasattr(result, "stderr") and result.stderr: - print(f"\nError: {result.stderr[:500]}") - print("\nPlease use Option 1 or Option 2 above.") - sys.exit(1) - else: - _pip_env = { - "TMPDIR": tmp_dir, - "NO_COLOR": "1", - "FORCE_COLOR": "0", - "PYTHONIOENCODING": "utf-8", - } - _ver = sys.version_info - - # On pre-release Python (3.14+), many packages only have wheels - # under --pre. Try that automatically before giving up. - if _ver >= (3, 14): - print( - f"\n⚠ Python {_ver.major}.{_ver.minor} detected (pre-release)." - ) - print(" Retrying with --pre to pick up pre-release wheels...") - cmd_pre = [ - sys.executable, - "-m", - "pip", - "install", - "--no-color", - "--pre", - "-r", - requirements_file, - ] - result = run_command_with_progress( - cmd_pre, - message="Retrying (--pre)", - check=False, - env_extras=_pip_env, - ) - if ( - result - and hasattr(result, "returncode") - and result.returncode == 0 - ): - print("✓ Core dependencies installed (--pre)") - return - - # Second retry: prefer binary wheels, fall back to source only when needed. - # --prefer-binary is much safer than --only-binary=:all: because it still - # allows source builds for packages that genuinely have no wheel yet. - print( - " Retrying with --prefer-binary to favour wheels over source builds..." - ) - cmd_bin = [ - sys.executable, - "-m", - "pip", - "install", - "--no-color", - "--pre", - "--prefer-binary", - "-r", - requirements_file, - ] - result = run_command_with_progress( - cmd_bin, - message="Retrying (prefer-binary)", - check=False, - env_extras=_pip_env, - ) - if ( - result - and hasattr(result, "returncode") - and result.returncode == 0 - ): - print("✓ Core dependencies installed (prefer-binary)") - return - - # Show as much context as possible then give up - print("\n✗ Error installing core dependencies:") - err_text = "" - if hasattr(result, "stderr") and result.stderr: - err_text = result.stderr.strip() - if hasattr(result, "stdout") and result.stdout and not err_text: - err_text = result.stdout.strip() - if err_text: - print(err_text[:2000]) - - if _ver >= (3, 14): - print( - f"\n Python {_ver.major}.{_ver.minor} is pre-release; some packages" - ) - print( - " may not yet ship wheels for it. The safest fix is to install" - ) - print( - " Python 3.11 or 3.12 from https://www.python.org/downloads/" - ) - print(" and re-run: python install.py") - - print("\nTroubleshooting:") - print( - " 1. Check for disk space: " - + ("df -h" if sys.platform != "win32" else "dir C:\\") - ) - print(" 2. Clear pip cache: pip cache purge") - print(" 3. Check your internet connection") - print(" 4. Try: pip install --upgrade pip") - print(" 5. Try with conda: python install.py --conda") - sys.exit(1) - else: - print("✓ Core dependencies installed") - - # Quick import smoke-test: verify that the most critical packages are - # actually importable with the current interpreter. pip can report - # returncode 0 yet leave some packages missing (e.g. version conflicts, - # wrong interpreter, PEP 668 partial installs). - _critical = ["openai", "anthropic", "requests", "aiohttp", "websockets"] - _missing = [] - for _pkg in _critical: - chk = subprocess.run( - [sys.executable, "-c", f"import {_pkg}"], - capture_output=True, - ) - if chk.returncode != 0: - _missing.append(_pkg) - if _missing: - print("\n ✗ Import check failed — these packages are not importable:") - for _m in _missing: - print(f" • {_m}") - print("\n This usually means pip installed them for a different Python") - print(f" interpreter. Current interpreter: {sys.executable}") - print("\n Fix: re-run with the correct Python:") - print(f" {sys.executable} install.py") - sys.exit(1) - print(" ✓ Import check passed") - except Exception as e: - print(f"\n✗ Exception during setup: {e}") - raise - - -# ========================================== -# OMNIPARSER SETUP (GUI Mode) -# ========================================== def setup_omniparser(force_cpu: bool, use_conda: bool): """Install OmniParser for GUI mode support.""" @@ -2475,63 +1743,79 @@ def _check_mac_python() -> None: # After user choice, setup the appropriate environment env_name = None + # Conda creates the ENV (interpreter, openssl, node, tesseract). It no + # longer installs Python packages — environment.yml carried a second, + # drifted copy of the dependency list. The lock is installed into whatever + # environment we end up with, by the python-deps stage below, so both + # modes converge on the same package set. if use_conda: env_name = get_env_name_from_yml() setup_conda_environment(env_name) print("✓ Verifying conda environment...") verify_conda_env(env_name) print("✓ Environment verified\n") - else: - setup_pip_environment() - print() # Record the interpreter the dependencies went into. craftbot.py may have # launched us under a different Python (a fresh box's only `python` is # often 3.13/3.14 — the version gate above re-execs us under 3.10); its # verify / start / auto-start must use THIS one, not the launcher's. + # Placeholder — overwritten below with whatever the python stage actually + # resolved, which may be a downloaded sidecar rather than the interpreter + # running this script. save_config_value("python_executable", sys.executable) # Native prerequisites + proof the memory stack loads in the SERVICE # interpreter. Hard stop on failure: the backend cannot boot without it, # and "INSTALLATION COMPLETE" followed by a dead port is worse than a # clear error here. - ensure_native_runtime() + ensure_native_runtime() # OS-level prerequisites (apt/brew), not packages + + # Everything else — the locked package set, the embedding stack check, + # Node, both npm trees, Playwright — is provisioned by app.provision: the + # SAME pipeline craftbot.py and the installer wizard run. Each entry point + # used to carry its own copy of these steps, and they drifted; the + # installer never ran the Node sidecar download at all, so an + # installer-only machine had no Node and Agent App could not start. + # See docs/plans/unified-install-architecture.md. + from app import provision + _service_python = ( [get_conda_command(), "run", "-n", env_name, "python"] if use_conda else [sys.executable] ) - if not verify_native_imports(_service_python): - sys.exit(1) - - # Node.js: one runtime for everything — use a suitable existing Node - # (>= MIN_NODE_MAJOR via CRAFTBOT_NODE/PATH/nvm/fnm/volta/sidecar) or - # download the sidecar; never touch the system Node. Conda mode skips - # this: environment.yml ships nodejs>=24 inside the env, which leads - # PATH when CraftBot runs and becomes that runtime. - if not use_conda: - ensure_nodejs() - npm_cmd = resolve_npm_cmd(use_conda, env_name) - - # Install Playwright browser (needed for browser-automation actions) - install_playwright_browser(use_conda=use_conda) - - # Install browser frontend dependencies — required for browser mode - frontend_ok = install_browser_frontend(npm_cmd) - - # Install the WhatsApp bridge's npm deps (Baileys) so the first QR - # link isn't blocked behind an npm download. - install_whatsapp_bridge(npm_cmd) - if not frontend_ok: - print(f"\n {RED}✗{RESET} {WHITE}Browser frontend setup failed.{RESET}") + _ctx = provision.default_context( + service_python=_service_python, + conda_env=env_name if use_conda else None, + ) + _report = provision.install(log=print, ctx=_ctx) + + # The python stage may have resolved a DIFFERENT interpreter than the one + # running install.py — a downloaded sidecar, or a matching Python found on + # PATH. Everything downstream (verify, start, auto-start) must use that + # one, so record it over the placeholder written above. + _resolved = dict(_report.results).get("python") + if _resolved is not None and _resolved.data.get("python"): + save_config_value("python_executable", _resolved.data["python"]) + + if not _report.ok: + print(f"\n {RED}[x]{RESET} {WHITE}Setup incomplete.{RESET}\n") + print(provision.format_report(_report)) print( - " Browser mode (localhost:7925) will not work until Node.js is installed" + "\n Re-run `python install.py` to retry — every stage is idempotent," + "\n so the parts that already succeeded are not redone." ) - print(" and 'npm install' succeeds in app/ui_layer/browser/frontend/") - print("\n Fix:") - print(" 1. Install Node.js LTS from https://nodejs.org/") - print(" 2. Re-run: python install.py") - sys.exit(1) + # Only a required stage is fatal. An optional one (Playwright, the + # WhatsApp bridge) degrades a feature; taking the whole install down + # for it would be worse than starting without it. + _required_failed = [ + name + for name, res in _report.results + if not res.ok + and name in ("python", "python-deps", "native-runtime", "smoke", "frontend") + ] + if _required_failed: + sys.exit(1) # Step 2: Install GUI components (optional) if install_gui: diff --git a/installer/__init__.py b/installer/__init__.py index 056ba97d..f4446239 100644 --- a/installer/__init__.py +++ b/installer/__init__.py @@ -2,10 +2,11 @@ Split out of the root namespace so the project root only contains the user-facing entry points (`craftbot.py`, `run.py`, `main.py`). Everything -in here is implementation detail of the installer/wizard flow: +in here is implementation detail of the install flow, and all of it is +pure stdlib — it ships inside the source payload and runs under the +launcher's interpreter (launcher/), which replaced the old Tk wizard: - - helpers: detached-Popen flag soup + per-platform dispatcher + - helpers: detached-Popen flag soup + per-platform dispatcher - metadata: JSON read/write for install.json - - payload: agent zip download + extract - - wizard: Tkinter UI launched when CraftBotInstaller.exe is double-clicked + - payload: agent source payload download + extract """ diff --git a/installer/api.py b/installer/api.py deleted file mode 100644 index e9f28f37..00000000 --- a/installer/api.py +++ /dev/null @@ -1,287 +0,0 @@ -"""JS-callable Python API exposed to the wizard webview. - -Each method is invoked from JS as `window.pywebview.api.(...)` and -returns a Promise. Lifecycle actions (install/start/stop/repair/uninstall) -spawn a worker thread so the bridge call returns immediately; the worker -pushes log lines and progress events back to JS via `window.evaluate_js()`. - -Why a thread per action: the JS bridge call is itself async, but blocking -the bridge thread means progress callbacks would back up. The worker keeps -the bridge thread free to handle state polls from JS while the install runs. -""" - -from __future__ import annotations - -import json -import os -import re -import sys -import threading -import time -from typing import Callable, Optional - -import craftbot - -# webview imported lazily inside `attach` so a syntax error here doesn't -# break source-mode tests that don't have pywebview installed. -_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") - - -class WizardAPI: - """Methods on this class become JS-callable. Method names map directly - to `window.pywebview.api.(...)`. - - All public methods MUST be JSON-serializable in/out — pywebview marshals - arguments via JSON, so plain dict/list/str/int only. - """ - - def __init__(self) -> None: - self._window: object = None # set via attach() once the webview exists - self._worker: Optional[threading.Thread] = None - - def attach(self, window: object) -> None: - """Called once after webview.create_window() so we can push events - back to JS via window.evaluate_js().""" - self._window = window - - # ── State queries ─────────────────────────────────────────────────────── - - def get_state(self) -> dict: - """Return the current install/run state. Polled by JS every ~1s. - - Mirrors the old Tk wizard's state machine — deliberately uses - `installed_exe_path()` (install metadata + binary on disk) rather - than `craftbot._is_installed()` which returns True for stale - Task Scheduler entries from older installs. - """ - installed_path = craftbot.installed_exe_path() - installed = bool(installed_path and os.path.isfile(installed_path)) - pid = craftbot._read_pid() - running = bool(pid and craftbot._is_running(pid)) - if installed and running: - state = "installed_running" - elif installed: - state = "installed_stopped" - elif running: - state = "running_uninstalled" - else: - state = "not_installed" - return { - "state": state, - "pid": pid if running else None, - "worker_busy": self._worker is not None and self._worker.is_alive(), - "browser_url": craftbot.BROWSER_URL, - } - - def get_default_install_location(self) -> str: - return craftbot.default_install_location() - - def pick_install_location(self) -> Optional[str]: - """Open the OS-native folder picker, return the chosen path (or None - if the user cancelled). Called from JS when Install is clicked.""" - import webview - - if not self._window: - return None - default = craftbot.default_install_location() - initial_dir = ( - os.path.dirname(default) - if os.path.isdir(os.path.dirname(default)) - else None - ) - result = self._window.create_file_dialog( - webview.FOLDER_DIALOG, directory=initial_dir or "" - ) - if not result: - return None - path = result[0] if isinstance(result, (list, tuple)) else result - # If the user picked a parent rather than a CraftBot subdir, append - # one — keeps the installed binary tidy. - if os.path.basename(path).lower() != "craftbot": - path = os.path.join(path, "CraftBot") - return path - - def open_in_browser(self) -> None: - import webbrowser - - webbrowser.open(craftbot.BROWSER_URL) - - def view_log(self) -> str: - """Return the most recent session from craftbot.log as a string. - cmd_start writes a `CraftBot service started at ...` separator on - every launch so we trim to just the last block.""" - log_path = craftbot.LOG_FILE - if not os.path.isfile(log_path): - return f"[View log] No log file at {log_path}" - try: - with open(log_path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - marker = "CraftBot service started at" - if marker in content: - idx = content.rfind(marker) - lookback = content.rfind("=" * 60, 0, idx) - start = lookback if lookback != -1 else idx - return content[start:] - lines = content.splitlines(keepends=True) - return "".join(lines[-80:]) - except OSError as e: - return f"[View log] Could not read {log_path}: {e}" - - # ── Lifecycle actions ─────────────────────────────────────────────────── - - def install(self, target_dir: str) -> dict: - return self._dispatch("Install", lambda: self._do_install(target_dir)) - - def start(self) -> dict: - return self._dispatch("Start", self._do_start) - - def stop(self) -> dict: - return self._dispatch("Stop", craftbot.cmd_stop) - - def repair(self) -> dict: - return self._dispatch( - "Repair", - lambda: craftbot.cmd_repair([], progress_cb=self._on_progress), - ) - - def uninstall(self) -> dict: - return self._dispatch("Uninstall", craftbot.cmd_uninstall) - - # ── Worker dispatch ───────────────────────────────────────────────────── - - def _dispatch(self, label: str, fn: Callable[[], None]) -> dict: - if self._worker is not None and self._worker.is_alive(): - self._push_log(f"\n[{label}] Already running, ignoring click.\n") - return {"started": False, "reason": "busy"} - - def target() -> None: - saved_stdout, saved_stderr = sys.stdout, sys.stderr - sys.stdout = _BridgeWriter(self) - sys.stderr = _BridgeWriter(self) - try: - self._push_log(f"\n━━━ {label} ━━━\n") - fn() - self._push_log(f"\n━━━ {label} done ━━━\n") - except Exception as exc: - self._push_log(f"\n[{label}] ERROR: {exc!r}\n") - finally: - sys.stdout, sys.stderr = saved_stdout, saved_stderr - self._push_event("workerDone", {"label": label}) - - self._worker = threading.Thread(target=target, daemon=True) - self._worker.start() - self._push_event("workerStarted", {"label": label}) - return {"started": True} - - def _do_install(self, target_dir: str) -> None: - start_offset = self._log_size() - craftbot._full_install_frozen(target_dir, [], progress_cb=self._on_progress) - # Spin tailing off so the worker thread completes immediately — - # otherwise worker_busy stays True for up to 90s while the tail - # waits for the ready marker, and JS keeps stop/repair/uninstall - # disabled the whole time. - self._spawn_log_tail(start_offset) - - def _do_start(self) -> None: - start_offset = self._log_size() - craftbot.cmd_start([]) - self._spawn_log_tail(start_offset) - - def _spawn_log_tail(self, start_offset: int) -> None: - """Run _tail_log on a fire-and-forget daemon thread so it doesn't - keep the worker thread alive past the action's primary work.""" - threading.Thread( - target=self._tail_log, args=(start_offset,), daemon=True - ).start() - - @staticmethod - def _log_size() -> int: - try: - return os.path.getsize(craftbot.LOG_FILE) - except OSError: - return 0 - - def _tail_log(self, start_offset: int, deadline_s: float = 90.0) -> None: - """Stream new bytes appended to craftbot.log into the JS log panel. - - Stops when the ready marker appears (run.py prints this once the - frontend + agent are both up) or after `deadline_s` seconds.""" - offset = start_offset - end_marker = craftbot.CRAFTBOT_READY_MARKER - end_time = time.monotonic() + deadline_s - announced = False - while time.monotonic() < end_time: - try: - size = os.path.getsize(craftbot.LOG_FILE) - except OSError: - time.sleep(0.3) - continue - if size > offset: - if not announced: - self._push_log("\n— agent boot —\n") - announced = True - try: - with open(craftbot.LOG_FILE, "rb") as f: - f.seek(offset) - chunk = f.read(size - offset).decode("utf-8", errors="replace") - offset = size - except OSError: - chunk = "" - if chunk: - self._push_log(chunk) - if end_marker in chunk: - return - time.sleep(0.25) - if announced: - self._push_log("\n— agent boot timed out (still running) —\n") - - # ── Progress + log push to JS ─────────────────────────────────────────── - - def _on_progress(self, read: int, total: Optional[int]) -> None: - self._push_event("progress", {"read": read, "total": total}) - - def _push_log(self, text: str) -> None: - if not self._window or not text: - return - # Strip ANSI escapes — craftbot.py captures _USE_COLOR at import time - # and may emit them even after we redirect sys.stdout. - clean = _ANSI_RE.sub("", text) - try: - self._window.evaluate_js( - f"window.appendLog && window.appendLog({json.dumps(clean)})" - ) - except Exception: - # Window may have been closed mid-write — ignore. - pass - - def _push_event(self, name: str, data: Optional[dict] = None) -> None: - if not self._window: - return - payload = json.dumps(data or {}) - try: - self._window.evaluate_js( - f"window.dispatchEvent(new CustomEvent('py:{name}', " - f"{{detail: {payload}}}))" - ) - except Exception: - pass - - -class _BridgeWriter: - """File-like that mirrors stdout/stderr writes from a worker thread into - the JS log panel via WizardAPI._push_log. Runs on the worker thread; all - cross-thread marshalling happens inside evaluate_js().""" - - def __init__(self, api: WizardAPI) -> None: - self._api = api - - def write(self, text: str) -> int: - if text: - self._api._push_log(text) - return len(text) - - def flush(self) -> None: - pass - - def isatty(self) -> bool: - return False diff --git a/installer/metadata.py b/installer/metadata.py index 2daaca1f..32c9b7d6 100644 --- a/installer/metadata.py +++ b/installer/metadata.py @@ -1,12 +1,27 @@ -"""Install metadata read/write — a small JSON file recording where the -agent was installed and which run mode the user picked. +"""Install metadata read/write — a small JSON file recording what was +installed, where, and which interpreter runs it. Written during the wizard's install flow, read by Repair (to know what to overwrite), the wizard's state probe (to display Installed/Not installed), -and `cmd_start` (to know which agent EXE to spawn). Cleared by Uninstall. +and `cmd_start` (to know what to spawn). Cleared by Uninstall. Pure functions taking the metadata file path as an argument — keeps the module decoupled from craftbot.py's path constants. + +## Schema history + +**1** — `installed_path` pointed at CraftBotAgent.exe, a PyInstaller bundle of +the whole agent. There was nothing else to record: the EXE carried its own +interpreter and dependencies. + +**2** — the agent is no longer frozen (see +docs/plans/unified-install-architecture.md). An install is now a source tree +plus a resolved interpreter, so both are recorded. `installed_path` is the +source ROOT, not an executable, and `python` is the interpreter to run it +with. + +Schema 1 metadata is still readable: `schema_of()` reports 1 for it, and the +migration in craftbot.py uses that to find and remove the old bundle. """ from __future__ import annotations @@ -14,7 +29,9 @@ import json import os from datetime import datetime -from typing import Optional +from typing import List, Optional + +SCHEMA = 2 def read(path: str) -> Optional[dict]: @@ -26,13 +43,35 @@ def read(path: str) -> Optional[dict]: return None -def write(path: str, installed_path: str, mode: str) -> None: - """Persist where the EXE was installed and which run mode the user picked.""" +def schema_of(meta: Optional[dict]) -> int: + """Schema version of a metadata dict. Absent 'schema' means the original + frozen-agent layout, which predates the field.""" + if not meta: + return 0 + try: + return int(meta.get("schema", 1)) + except (TypeError, ValueError): + return 1 + + +def write( + path: str, + installed_path: str, + mode: str, + python: Optional[str] = None, + version: Optional[str] = None, +) -> None: + """Persist the install root, run mode, and the interpreter that runs it.""" meta = { + "schema": SCHEMA, "installed_path": installed_path, "mode": mode, "installed_at": datetime.now().isoformat(timespec="seconds"), } + if python: + meta["python"] = python + if version: + meta["version"] = version with open(path, "w", encoding="utf-8") as f: json.dump(meta, f, indent=2) @@ -46,6 +85,38 @@ def clear(path: str) -> None: def installed_exe_path(path: str) -> Optional[str]: - """Convenience: read metadata and return just the installed EXE path.""" + """The install root (schema 2) or the agent EXE (schema 1). + + Name kept for compatibility with existing callers; under schema 2 it is a + directory, which is why every caller must go through launch_command() + rather than assuming it can be executed. + """ meta = read(path) return meta.get("installed_path") if meta else None + + +def launch_command(path: str, run_script: str = "run.py") -> Optional[List[str]]: + """The command that starts CraftBot for this install, or None. + + The single place that knows how an install is launched, so `cmd_start`, + the wizard and repair cannot disagree: + + schema 2 → [, /run.py] + schema 1 → [] (legacy frozen bundle, still runnable) + """ + meta = read(path) + if not meta: + return None + installed = meta.get("installed_path") + if not installed: + return None + + if schema_of(meta) >= 2: + python = meta.get("python") + script = os.path.join(installed, run_script) + if not python or not os.path.isfile(script): + return None + return [python, script] + + # Legacy: installed_path IS the executable. + return [installed] if os.path.isfile(installed) else None diff --git a/installer/payload.py b/installer/payload.py index 3f64fb82..f43494a0 100644 --- a/installer/payload.py +++ b/installer/payload.py @@ -1,13 +1,17 @@ -"""Agent payload management — downloading and extracting the agent zip. +"""Install payload management — downloading and extracting what gets installed. -The frozen installer (CraftBotInstaller.exe) is small and ships with no -agent code. At install time it downloads CraftBot-agent-.zip from -GitHub Releases (pinned to the bundled VERSION) and extracts the contained -CraftBotAgent.exe to a user-chosen install directory. +The installer (CraftBotInstaller.exe) is small and ships with no agent code. +At install time it downloads CraftBot-src.zip from GitHub Releases (pinned to +the bundled VERSION) and extracts it to a user-chosen directory; +app.provision then builds an interpreter and dependency set around it. This module owns: asset naming, version pinning, download with progress, -local-staged-zip lookup (so devs can test the installer without publishing -a release), and zip extraction with EXE discovery. +local-staged-zip lookup (so devs can test the installer without publishing a +release), and extraction. + +The CraftBot-agent-.zip helpers are the LEGACY frozen-agent path, +kept only so this code can still recognise a pre-1.5 release. See +docs/plans/unified-install-architecture.md. All functions take the dependencies they need as arguments — there is no module-level state pulled from craftbot.py, which keeps imports one-way. @@ -28,21 +32,15 @@ _PLATFORM = sys.platform -def agent_asset_name() -> str: - """Filename of the per-platform zip we expect at the GitHub release.""" - plat = ( - "windows" - if _PLATFORM == "win32" - else "macos" - if _PLATFORM == "darwin" - else "linux" - ) - return f"CraftBot-agent-{plat}.zip" - +def source_asset_name() -> str: + """The source payload every platform shares. -def agent_exe_filename() -> str: - """Filename of the agent executable produced by CraftBotAgent.spec.""" - return "CraftBotAgent.exe" if _PLATFORM == "win32" else "CraftBotAgent" + One asset, not one per platform: it is pure Python plus data files. What + used to differ per platform was the bundled interpreter and the compiled + wheels, and both are now provisioned on the machine by app.provision + (a python-build-standalone sidecar plus the per-platform lock). + """ + return "CraftBot-src.zip" def read_bundled_version(base_dir: str) -> str: @@ -64,39 +62,31 @@ def read_bundled_version(base_dir: str) -> str: return "latest" -def agent_download_url(base_dir: str) -> str: +def _asset_url(base_dir: str, asset: str) -> str: version = read_bundled_version(base_dir) - asset = agent_asset_name() if version == "latest": return f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest/download/{asset}" return f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/v{version}/{asset}" -def find_agent_exe(install_dir: str) -> Optional[str]: - """Locate the agent executable inside an extracted install directory. - Tries flat layout first, then nested CraftBotAgent/ folder.""" - candidates = [ - os.path.join(install_dir, agent_exe_filename()), - os.path.join(install_dir, "CraftBotAgent", agent_exe_filename()), - ] - for c in candidates: - if os.path.isfile(c): - return c - return None +def source_download_url(base_dir: str) -> str: + return _asset_url(base_dir, source_asset_name()) -def local_agent_zip(exe_path: Optional[str]) -> Optional[str]: - """Return path to a locally-staged agent zip, if one exists. +def local_asset(asset: str, exe_path: Optional[str], env_var: str) -> Optional[str]: + """A locally-staged copy of `asset`, if one exists. Lookup order (first match wins): - 1. $CRAFTBOT_AGENT_ZIP env var (explicit override) - 2. /CraftBot-agent-.zip - 3. /dist/CraftBot-agent-.zip (matches local build output) + 1. $ (explicit override) + 2. / + 3. /dist/ (matches local build output) + + This is the dev loop: build the payload, drop it beside the installer, and + the wizard uses it instead of fetching a published release. """ - env_path = os.environ.get("CRAFTBOT_AGENT_ZIP") + env_path = os.environ.get(env_var) if env_path and os.path.isfile(env_path): return env_path - asset = agent_asset_name() candidates: list[str] = [] if exe_path: candidates.append(os.path.join(os.path.dirname(exe_path), asset)) @@ -107,20 +97,23 @@ def local_agent_zip(exe_path: Optional[str]) -> Optional[str]: return None -def download_agent_zip( - base_dir: str, - exe_path: Optional[str], +def local_source_zip(exe_path: Optional[str]) -> Optional[str]: + return local_asset(source_asset_name(), exe_path, "CRAFTBOT_SRC_ZIP") + + +def download_asset( + url: str, + local: Optional[str], + label: str, progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, ) -> str: - """Get the agent zip — local copy if available, else download from GitHub. + """Get an asset — a locally-staged copy if given, else download it. - Returns the path to the zip on disk. If a local copy was found, the - caller MUST NOT unlink it. If the result is in tempfile.gettempdir(), - the caller is expected to clean it up. + Returns the path to the zip on disk. If a local copy was used, the caller + MUST NOT unlink it; is_temp_zip() distinguishes the two. """ - local = local_agent_zip(exe_path) if local: - print(f" Using local agent zip: {local}") + print(f" Using local {label}: {local}") if progress_cb: try: size = os.path.getsize(local) @@ -131,10 +124,9 @@ def download_agent_zip( import urllib.request - url = agent_download_url(base_dir) print(f" Downloading {url}") - fd, tmp_path = tempfile.mkstemp(prefix="CraftBot-agent-", suffix=".zip") + fd, tmp_path = tempfile.mkstemp(prefix="CraftBot-", suffix=".zip") os.close(fd) try: with urllib.request.urlopen(url, timeout=60) as resp: @@ -163,24 +155,51 @@ def download_agent_zip( raise -def extract_agent_zip(zip_path: str, target_dir: str) -> str: - """Extract zip into target_dir, return absolute path to the agent EXE.""" +def download_source_zip( + base_dir: str, + exe_path: Optional[str], + progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, +) -> str: + """The source payload the installer provisions a runtime around.""" + return download_asset( + source_download_url(base_dir), + local_source_zip(exe_path), + "source zip", + progress_cb, + ) + + +def extract_source_zip(zip_path: str, target_dir: str) -> str: + """Extract the source payload and return the directory holding run.py. + + Tolerates both shapes a zip can have: files at the root, or nested under a + single wrapper directory (what `git archive` and GitHub's own zips + produce). Getting this wrong yields an install that looks fine until + nothing can find run.py. + """ import zipfile os.makedirs(target_dir, exist_ok=True) - print(f" Extracting to {target_dir}") + print(f" Extracting source to {target_dir}") with zipfile.ZipFile(zip_path, "r") as zf: zf.extractall(target_dir) - exe = find_agent_exe(target_dir) - if not exe: - raise RuntimeError( - f"Agent EXE not found after extracting to {target_dir}. " - f"Expected {agent_exe_filename()} at the top level or under CraftBotAgent/." - ) - if _PLATFORM != "win32": - os.chmod(exe, 0o755) - return exe + if os.path.isfile(os.path.join(target_dir, "run.py")): + return target_dir + + entries = [ + os.path.join(target_dir, e) + for e in os.listdir(target_dir) + if os.path.isdir(os.path.join(target_dir, e)) + ] + for candidate in entries: + if os.path.isfile(os.path.join(candidate, "run.py")): + return candidate + + raise RuntimeError( + f"run.py not found after extracting to {target_dir}. " + "The source payload is not shaped as expected." + ) def is_temp_zip(zip_path: str) -> bool: diff --git a/installer/web/app.js b/installer/web/app.js deleted file mode 100644 index ad2f079f..00000000 --- a/installer/web/app.js +++ /dev/null @@ -1,228 +0,0 @@ -// CraftBot installer — front-end glue. -// -// Responsibilities: -// 1. Wait for the pywebview JS bridge to be ready. -// 2. Wire up button click handlers that call into Python (window.pywebview.api). -// 3. Poll get_state() every second to drive button enable/disable + status pill. -// 4. Receive log lines from Python via window.appendLog(text) and append to -// the output panel. -// 5. Receive progress events (py:progress) and update the progress bar. -// -// State machine (mirrors api.py's get_state() return values): -// -// not_installed → Install enabled, others disabled -// installed_stopped → Start/Repair/Uninstall enabled -// installed_running → Stop/Repair/Uninstall enabled -// running_uninstalled → Stop + Install enabled (rare edge case) - -(function () { - "use strict"; - - // ── DOM references ────────────────────────────────────────────────────── - const $ = (id) => document.getElementById(id); - const els = { - output: $("output"), - statusDot: $("status-dot"), - statusText: $("status-text"), - progressSection: $("progress-section"), - progressLabel: $("progress-label"), - progressFill: $("progress-fill"), - btnInstall: $("btn-install"), - btnStart: $("btn-start"), - btnStop: $("btn-stop"), - btnRepair: $("btn-repair"), - btnUninstall: $("btn-uninstall"), - btnViewLog: $("btn-view-log"), - btnOpenBrowser: $("btn-open-browser"), - }; - - // ── Output panel: append helper ───────────────────────────────────────── - // Exposed on window so Python's evaluate_js() can call it. - - window.appendLog = function (text) { - if (!text) return; - const node = document.createTextNode(text); - els.output.appendChild(node); - // Auto-scroll to bottom unless the user scrolled up (preserves their - // position if they're reading earlier output). - const distanceFromBottom = - els.output.scrollHeight - els.output.scrollTop - els.output.clientHeight; - if (distanceFromBottom < 60) { - els.output.scrollTop = els.output.scrollHeight; - } - }; - - // ── Bridge readiness ──────────────────────────────────────────────────── - // pywebview injects window.pywebview.api asynchronously. Wait for it. - - function whenBridgeReady(callback) { - if (window.pywebview && window.pywebview.api) { - callback(); - return; - } - window.addEventListener("pywebviewready", callback, { once: true }); - // Belt-and-suspenders: poll for up to 5s in case the event already fired - // before this script ran. - let waited = 0; - const tick = () => { - if (window.pywebview && window.pywebview.api) { - callback(); - } else if (waited < 5000) { - waited += 100; - setTimeout(tick, 100); - } - }; - setTimeout(tick, 100); - } - - // ── State polling ─────────────────────────────────────────────────────── - - let lastState = null; - let workerBusy = false; - - async function pollState() { - try { - const s = await window.pywebview.api.get_state(); - applyState(s); - } catch (e) { - // Window closing or bridge gone — silently stop. - } - } - - function applyState(s) { - workerBusy = !!s.worker_busy; - lastState = s.state; - - // Status pill - els.statusDot.classList.remove("is-running", "is-stopped"); - if (s.state === "installed_running" || s.state === "running_uninstalled") { - els.statusDot.classList.add("is-running"); - els.statusText.textContent = - s.pid != null ? `Running · PID ${s.pid}` : "Running"; - } else if (s.state === "installed_stopped") { - els.statusDot.classList.add("is-stopped"); - els.statusText.textContent = "Installed · stopped"; - } else { - els.statusText.textContent = "Not installed"; - } - - // Button enable/disable per state, all gated by workerBusy. - const flags = { - install: false, - start: false, - stop: false, - repair: false, - uninstall: false, - }; - if (s.state === "not_installed") { - flags.install = true; - } else if (s.state === "installed_stopped") { - flags.start = true; - flags.repair = true; - flags.uninstall = true; - } else if (s.state === "installed_running") { - flags.stop = true; - flags.repair = true; - flags.uninstall = true; - } else if (s.state === "running_uninstalled") { - flags.stop = true; - flags.install = true; - } - - setEnabled(els.btnInstall, flags.install && !workerBusy); - setEnabled(els.btnStart, flags.start && !workerBusy); - setEnabled(els.btnStop, flags.stop && !workerBusy); - setEnabled(els.btnRepair, flags.repair && !workerBusy); - setEnabled(els.btnUninstall, flags.uninstall && !workerBusy); - } - - function setEnabled(btn, on) { - btn.disabled = !on; - } - - // ── Button handlers ───────────────────────────────────────────────────── - - els.btnInstall.addEventListener("click", async () => { - if (els.btnInstall.disabled) return; - // Pop the OS-native folder picker first. - const targetDir = await window.pywebview.api.pick_install_location(); - if (!targetDir) { - window.appendLog("\nInstall cancelled (no location chosen).\n"); - return; - } - await window.pywebview.api.install(targetDir); - }); - - els.btnStart.addEventListener("click", () => callApi("start")); - els.btnStop.addEventListener("click", () => callApi("stop")); - els.btnRepair.addEventListener("click", () => callApi("repair")); - els.btnUninstall.addEventListener("click", () => callApi("uninstall")); - - els.btnViewLog.addEventListener("click", async () => { - const text = await window.pywebview.api.view_log(); - window.appendLog("\n━━━ Latest log session ━━━\n" + text + "\n"); - }); - - els.btnOpenBrowser.addEventListener("click", () => { - window.pywebview.api.open_in_browser(); - }); - - async function callApi(name) { - const btn = document.querySelector(`[data-action="${name}"]`); - if (btn && btn.disabled) return; - try { - await window.pywebview.api[name](); - } catch (e) { - window.appendLog(`\n[${name}] bridge error: ${e}\n`); - } - } - - // ── Event listeners pushed from Python ────────────────────────────────── - - window.addEventListener("py:progress", (e) => { - const { read, total } = e.detail || {}; - showProgress(read, total); - }); - - window.addEventListener("py:workerStarted", () => { - // Force a state refresh so buttons grey out immediately rather than - // waiting for the next 1s poll. - pollState(); - }); - - window.addEventListener("py:workerDone", () => { - pollState(); - // Hide progress bar once the worker finishes — extraction can complete - // before the download "100%" tick lands. - setTimeout(hideProgress, 600); - }); - - function showProgress(read, total) { - els.progressSection.hidden = false; - if (total) { - const pct = Math.max(0, Math.min(1, read / total)) * 100; - els.progressFill.style.width = pct.toFixed(1) + "%"; - const mb = (n) => (n / (1024 * 1024)).toFixed(1); - els.progressLabel.textContent = `Downloading… ${mb(read)} / ${mb(total)} MB`; - if (read >= total) { - setTimeout(hideProgress, 800); - } - } else { - const mb = (read / (1024 * 1024)).toFixed(1); - els.progressLabel.textContent = `Downloading… ${mb} MB`; - } - } - - function hideProgress() { - els.progressSection.hidden = true; - els.progressFill.style.width = "0%"; - els.progressLabel.textContent = ""; - } - - // ── Bootstrap ─────────────────────────────────────────────────────────── - - whenBridgeReady(() => { - pollState(); - setInterval(pollState, 1000); - }); -})(); diff --git a/installer/web/index.html b/installer/web/index.html deleted file mode 100644 index b852d4ff..00000000 --- a/installer/web/index.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - CraftBot - - - -
- -
-
-

CraftBot

-

Local AI agent · installer

-
-
- - Checking… -
-
- - -
- - - - - -
- - -
- - -
- - - - - -
- -
-
-
- - - - diff --git a/installer/web/style.css b/installer/web/style.css deleted file mode 100644 index 830b031f..00000000 --- a/installer/web/style.css +++ /dev/null @@ -1,344 +0,0 @@ -/* ───────────────────────────────────────────────────────────────────────── - CraftBot installer — Win11 Fluent / Apple-flavoured dark theme. - - Color philosophy: tinted near-black backgrounds, subtle 1px hairline - borders, layered shadows for depth. The accent is brand orange (#FF4F18). - Buttons use the Win11 two-stop gradient + inset top highlight pattern - that matches the OS's own buttons; on macOS the same CSS reads as - Aqua-ish because WKWebView renders gradients identically. - ───────────────────────────────────────────────────────────────────────── */ - -:root { - --bg: #161620; - --bg-elevated: #1F1F2A; - --bg-elevated-hover: #26262F; - --border: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.14); - --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.25); - - --accent: #FF4F18; - --accent-hover: #FF6B3D; - --accent-pressed: #E64115; - - --text: #ECECEF; - --text-dim: #9A9AA5; - --text-faint: #6B6B75; - - --green: #4ADE80; - --red: #F87171; - - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-pill: 999px; - - --font-ui: "Segoe UI Variable", "Segoe UI", -apple-system, BlinkMacSystemFont, - "SF Pro Text", "Inter", system-ui, sans-serif; - --font-mono: "Cascadia Mono", "Cascadia Code", "Consolas", "SF Mono", - "Menlo", monospace; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html, body { - height: 100%; - background: var(--bg); - color: var(--text); - font-family: var(--font-ui); - font-size: 14px; - line-height: 1.4; - /* Disable text selection on chrome — feels more app-like. - The output panel re-enables it. */ - user-select: none; - -webkit-user-select: none; - /* Crisp font rendering on macOS WKWebView */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - /* Subtle vertical gradient — gives the window a sense of depth without - being distracting. Win11 Mica is too OS-specific to fake here. */ - background-image: radial-gradient( - ellipse at top, - rgba(255, 79, 24, 0.04) 0%, - transparent 50% - ); - overflow: hidden; -} - -.app { - display: flex; - flex-direction: column; - height: 100vh; - padding: 28px 32px 24px 32px; - gap: 16px; -} - -/* ── Header ───────────────────────────────────────────────────────────── */ - -.header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.brand-name { - font-size: 22px; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--text); -} - -.brand-tag { - font-size: 12px; - color: var(--text-dim); - margin-top: 2px; -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 14px 6px 12px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-pill); - font-size: 13px; - color: var(--text); - white-space: nowrap; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-faint); - /* Soft halo so the dot reads as "alive" — common in modern dashboards. */ - box-shadow: 0 0 0 0 currentColor; - transition: background 0.2s, box-shadow 0.2s; -} - -.status-dot.is-stopped { - background: var(--accent); - color: var(--accent); - box-shadow: 0 0 0 4px rgba(255, 79, 24, 0.18); -} - -.status-dot.is-running { - background: var(--green); - color: var(--green); - box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.18); -} - -/* ── Action buttons ──────────────────────────────────────────────────── */ - -.actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.utility { - display: flex; - justify-content: flex-end; - gap: 6px; -} - -.btn { - /* Reset native button styles */ - appearance: none; - border: 1px solid transparent; - font-family: inherit; - font-size: 14px; - font-weight: 500; - cursor: pointer; - user-select: none; - /* Win11 button height/padding */ - min-height: 36px; - padding: 0 18px; - border-radius: var(--radius-md); - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: - background 0.12s ease, - border-color 0.12s ease, - transform 0.06s ease, - box-shadow 0.12s ease, - opacity 0.12s ease; -} - -.btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.btn:disabled { - cursor: not-allowed; - opacity: 0.42; -} - -/* Primary — solid orange. The dual gradient + inset highlight is the - Win11 accent-button look; on macOS it reads as a glossy filled button. */ -.btn-primary { - background: linear-gradient(180deg, var(--accent-hover) 0%, var(--accent) 100%); - color: #FFFFFF; - border-color: rgba(255, 255, 255, 0.18); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.22), - var(--shadow-1); -} -.btn-primary:not(:disabled):hover { - background: linear-gradient(180deg, #FF7B4D 0%, var(--accent-hover) 100%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.28), - var(--shadow-2); - transform: translateY(-1px); -} -.btn-primary:not(:disabled):active { - background: linear-gradient(180deg, var(--accent) 0%, var(--accent-pressed) 100%); - transform: translateY(0); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); -} - -/* Secondary — flat panel fill, used for Stop/Repair/Uninstall */ -.btn-secondary { - background: linear-gradient(180deg, #2A2A36 0%, #25252F 100%); - color: var(--text); - border-color: var(--border); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.04), - var(--shadow-1); -} -.btn-secondary:not(:disabled):hover { - background: linear-gradient(180deg, #34343F 0%, #2A2A35 100%); - border-color: var(--border-strong); - transform: translateY(-1px); -} -.btn-secondary:not(:disabled):active { - background: linear-gradient(180deg, #25252F 0%, #20202A 100%); - transform: translateY(0); -} - -/* Ghost — utility links with no fill until hover */ -.btn-ghost { - background: transparent; - color: var(--text-dim); - border-color: transparent; - font-size: 13px; - min-height: 30px; - padding: 0 12px; - border-radius: var(--radius-sm); -} -.btn-ghost:not(:disabled):hover { - background: var(--bg-elevated); - color: var(--text); -} -.btn-ghost:not(:disabled):active { - background: var(--bg-elevated-hover); -} - -/* ── Progress bar ────────────────────────────────────────────────────── */ - -.progress-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -.progress-label { - font-size: 12px; - color: var(--text-dim); -} - -.progress-track { - height: 6px; - background: var(--bg-elevated); - border-radius: var(--radius-pill); - overflow: hidden; - /* Subtle inner shadow so the track has depth */ - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); -} - -.progress-fill { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%); - border-radius: var(--radius-pill); - /* Smooth fill animation — matches Win11's progress bar feel */ - transition: width 0.25s ease-out; - box-shadow: 0 0 8px rgba(255, 79, 24, 0.5); -} - -/* ── Output panel ────────────────────────────────────────────────────── */ - -.output-section { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-height: 0; -} - -.section-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-faint); -} - -.output { - flex: 1; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 14px 16px; - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.55; - color: var(--text); - /* Re-enable selection here — the output is the one place users will - copy text from (sharing logs in support). */ - user-select: text; - -webkit-user-select: text; - white-space: pre-wrap; - overflow-y: auto; - /* Custom scrollbar that matches the dark theme on Edge/WebKit. */ - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.18) transparent; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); -} - -.output::-webkit-scrollbar { - width: 10px; -} -.output::-webkit-scrollbar-track { - background: transparent; -} -.output::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.14); - border-radius: 999px; - border: 2px solid var(--bg-elevated); -} -.output::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.22); -} - -/* Subtle line separator inside the output area for the ━━━ banners that - our Python side writes (e.g. "━━━ Install ━━━"). They wrap nicely on - their own line without further styling because we set white-space: pre-wrap. */ - -/* ── Reduce motion (accessibility) ───────────────────────────────────── */ - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} diff --git a/installer/wizard.py b/installer/wizard.py deleted file mode 100644 index 1f041085..00000000 --- a/installer/wizard.py +++ /dev/null @@ -1,83 +0,0 @@ -"""CraftBot installer wizard — pywebview launcher. - -The actual UI lives in installer/web/{index.html,style.css,app.js} so we -get real Win11 Fluent / macOS Aqua styling via the OS's native webview -(WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). Python -exposes lifecycle methods as a JS-callable API — see installer/api.py. - -Architecture: - craftbot.py main() - └─ launch_wizard() - ├─ creates webview window pointed at installer/web/index.html - ├─ exposes WizardAPI (install/start/stop/...) as window.pywebview.api - └─ webview.start() blocks until the user closes the window -""" - -from __future__ import annotations - -import os -import sys - -import craftbot -from installer.api import WizardAPI - - -def _web_dir() -> str: - """Locate the bundled web/ assets — works in source mode and frozen mode. - - In source mode this returns /installer/web. In frozen mode the - spec bundles `('installer', 'installer')` which extracts to - sys._MEIPASS/installer/web — the same relative layout, so __file__ - works either way. - """ - here = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(here, "web") - - -def launch_wizard() -> None: - """Open the wizard window. Blocks until the user closes the window.""" - # Blank craftbot.py's ANSI colour-code constants so the strings it prints - # to our captured stdout don't contain escape sequences. - for _name in ("ORANGE", "WHITE", "BOLD", "DIM", "GREEN", "RED", "RESET"): - if hasattr(craftbot, _name): - setattr(craftbot, _name, "") - - # Lazy import — gives a clean error message if pywebview isn't installed - # in source-mode dev builds, instead of crashing at module load. - try: - import webview - except ImportError: - sys.stderr.write( - "\nERROR: pywebview is not installed.\n" - " Install it with: pip install pywebview\n" - " (On Linux you also need: sudo apt install libwebkit2gtk-4.0-37)\n\n" - ) - sys.exit(1) - - api = WizardAPI() - index_path = os.path.join(_web_dir(), "index.html") - if not os.path.isfile(index_path): - sys.stderr.write(f"\nERROR: wizard assets not found at {index_path}\n") - sys.exit(1) - - # file:// URL — pywebview hands this to the OS webview directly. We use - # forward slashes regardless of OS because that's what file:// expects. - url = "file:///" + index_path.replace(os.sep, "/").lstrip("/") - - window = webview.create_window( - title="CraftBot", - url=url, - js_api=api, - width=760, - height=620, - min_size=(620, 520), - background_color="#161620", - # Native title bar — Win11 rounds it automatically; macOS gives us - # traffic lights. Frameless mode is harder to get right cross-OS. - ) - api.attach(window) - webview.start(debug=False) - - -if __name__ == "__main__": - launch_wizard() diff --git a/launcher/.gitignore b/launcher/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/launcher/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock new file mode 100644 index 00000000..7874c37f --- /dev/null +++ b/launcher/Cargo.lock @@ -0,0 +1,6572 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[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_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[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 = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[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-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[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.4", + "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-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-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.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +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 = "auto_enums" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3091d68264354f211516b91dce6f71046e444fab1867716035f736667243affb" +dependencies = [ + "derive_utils", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[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 = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +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-field-offset" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c" +dependencies = [ + "const-field-offset-macro", + "field-offset", +] + +[[package]] +name = "const-field-offset-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "smithay-clipboard", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "craftbot-launcher" +version = "0.1.0" +dependencies = [ + "dirs", + "flate2", + "fs4", + "libc", + "open", + "rfd", + "serde", + "serde_json", + "slint", + "slint-build", + "tar", + "ureq", + "windows-sys 0.59.0", + "winresource", + "zip", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_utils" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[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 = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "femtovg" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43d05da42e81724c16d34a150fb7fda53d3f786e5a94673ee526ff8602f0f6a" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "fnv", + "glow", + "imgref", + "itertools 0.14.0", + "log", + "rgb", + "slotmap", + "swash", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "fontique" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "274fa4f0f0a926ae182c7c076c078cce8a38471d15e61a102a02cac984be9813" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.39.2", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.13.1", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts 0.39.2", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[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.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "htmlparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" + +[[package]] +name = "i-slint-backend-linuxkms" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e76f2b13846ca54bba6b8eb3b5454b26879ad987396d0a2d8abedc31a6c469" +dependencies = [ + "bytemuck", + "calloop 0.14.4", + "cfg_aliases", + "drm", + "gbm", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "input", + "memmap2", + "nix", + "raw-window-handle", + "xkbcommon", +] + +[[package]] +name = "i-slint-backend-selector" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2247a4590a02a466b2f4916cc789dfe66247f6af826f00eb84a2685259a45c1c" +dependencies = [ + "cfg-if", + "cfg_aliases", + "i-slint-backend-linuxkms", + "i-slint-backend-testing", + "i-slint-backend-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", +] + +[[package]] +name = "i-slint-backend-testing" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786" +dependencies = [ + "cfg_aliases", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-software", + "vtable", +] + +[[package]] +name = "i-slint-backend-winit" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27c7e8f53418d3017362465367b6612b6e439a68097d627d1b91c94b885f0a6" +dependencies = [ + "accesskit", + "accesskit_winit", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "copypasta", + "derive_more", + "futures", + "glutin", + "glutin-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-skia", + "i-slint-renderer-software", + "imgref", + "lyon_path", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", + "pin-weak", + "raw-window-handle", + "rgb", + "scoped-tls-hkt", + "scopeguard", + "softbuffer", + "strum", + "vtable", + "wasm-bindgen", + "web-sys", + "webbrowser", + "windows", + "winit", + "zbus", +] + +[[package]] +name = "i-slint-common" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e63f8ea55ba21e44567ae67d2b01a4f5d09bb1fab693b1e6c79d96bef9e456" +dependencies = [ + "derive_more", + "fontique", + "htmlparser", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "pulldown-cmark", + "resvg", + "skrifa 0.42.1", +] + +[[package]] +name = "i-slint-compiler" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45ea275b15a425c7f2f77481151e1f5f8f1ea83feae580273090ef6b9e192218" +dependencies = [ + "annotate-snippets", + "by_address", + "data-url", + "derive_more", + "i-slint-common", + "icu_normalizer", + "image", + "itertools 0.14.0", + "linked_hash_set", + "lyon_extra", + "lyon_path", + "num_enum", + "proc-macro2", + "quote", + "rayon", + "resvg", + "rowan", + "rspolib", + "skrifa 0.42.1", + "smol_str 0.3.6", + "strum", + "swash", + "typed-index-collections", + "unicode-segmentation", + "url", +] + +[[package]] +name = "i-slint-core" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e407dc2bd5385dcbdd9c1cf927213d2e4bacda667545abf8839c60f8290f754" +dependencies = [ + "auto_enums", + "bitflags 2.13.1", + "cfg-if", + "chrono", + "clru", + "const-field-offset", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core-macros", + "icu_normalizer", + "image", + "lyon_algorithms", + "lyon_extra", + "lyon_geom", + "lyon_path", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "parley", + "pin-project", + "pin-weak", + "portable-atomic", + "raw-window-handle", + "resvg", + "rgb", + "scopeguard", + "skrifa 0.42.1", + "slab", + "strum", + "swash", + "sys-locale", + "taffy", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", + "vtable", + "wasm-bindgen", + "web-sys", + "web-time", + "windows", +] + +[[package]] +name = "i-slint-core-macros" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a842640da4c4d0909d25cc5a51e1f9cde02bd48f7fdfa94df10d595d4e3c80a" +dependencies = [ + "quote", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "i-slint-renderer-femtovg" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d0d99a18d66738b684758fe8460743a0aa0f64a96e05940942231a340085b1" +dependencies = [ + "cfg-if", + "const-field-offset", + "derive_more", + "femtovg", + "glow", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "imgref", + "lyon_path", + "pin-weak", + "rgb", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "i-slint-renderer-skia" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "clru", + "const-field-offset", + "derive_more", + "glow", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "lyon_path", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "pin-weak", + "raw-window-handle", + "raw-window-metal", + "read-fonts 0.39.2", + "scoped-tls-hkt", + "skia-safe", + "softbuffer", + "unicode-segmentation", + "vtable", + "windows", + "write-fonts", +] + +[[package]] +name = "i-slint-renderer-software" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73496f164f8e471877c35e0fadb996dcc674922712abd9c2be2cbe80c58859bd" +dependencies = [ + "bytemuck", + "clru", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core", + "integer-sqrt", + "lyon_path", + "num-traits", + "skrifa 0.42.1", + "swash", + "zeno", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "input" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" +dependencies = [ + "bitflags 2.13.1", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "integer-sqrt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" +dependencies = [ + "num-traits", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_extra" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" +dependencies = [ + "lyon_path", + "thiserror 2.0.20", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[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.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.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.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[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 = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "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.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "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-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 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 = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.42.1", +] + +[[package]] +name = "parley_data" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d3755e2cc12c0625b0cd2f2773c6a37dd11d1531940c945ade4aeb78f2b145" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[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 = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pin-weak" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.4", + "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 = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rspolib" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +dependencies = [ + "lazy_static", + "natord", + "snafu", + "unicode-linebreak", + "unicode-width", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scoped-tls-hkt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skia-bindings" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2d1c3ebd697c0cbded0145e9204a38fa6b268446051b7196d0a096414ea7f3" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f512ac418a64194842dd05566320805dad1c957c521039db2486fd6368865bc" +dependencies = [ + "bitflags 2.13.1", + "skia-bindings", + "windows", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slint" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce12038f57f8f3a423564b0c9301a2ab3fda4246e4cbadd6b97acf9124bdc315" +dependencies = [ + "const-field-offset", + "i-slint-backend-selector", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "num-traits", + "once_cell", + "pin-weak", + "slint-macros", + "unicode-segmentation", + "vtable", +] + +[[package]] +name = "slint-build" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76684c11441f775b10bbc7b0e6b3016a39f17efdd659ffca112f17cfd6bdaed1" +dependencies = [ + "derive_more", + "fontique", + "i-slint-compiler", + "spin_on", + "toml_edit", +] + +[[package]] +name = "slint-macros" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2a763e72e1e53c496f571d36327e777da1eb0d9e58fceb83890c2a83ee7223" +dependencies = [ + "i-slint-compiler", + "proc-macro2", + "quote", + "spin_on", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "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 = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "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 = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin_on" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" +dependencies = [ + "pin-utils", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "taffy" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtable" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea953f65593feb5287b022b83c35046d1ff49c7417b6f5851a079d8a231fe31" +dependencies = [ + "const-field-offset", + "portable-atomic", + "stable_deref_trait", + "vtable-macro", +] + +[[package]] +name = "vtable-macro" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482a37f9777480673d3a99c80ac2edc69f92382230440946855fa27ebc47cba6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "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.1", + "rustix 1.1.4", + "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.1", + "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.4", + "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.1", + "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.1", + "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.1", + "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.1", + "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.1", + "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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "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", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str 0.2.2", + "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 = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap", + "kurbo", + "log", + "read-fonts 0.39.2", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "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 = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "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.4", + "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.4", + "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 = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[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", + "url", + "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.4", + "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.4", + "winnow", +] diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml new file mode 100644 index 00000000..31f4b6fb --- /dev/null +++ b/launcher/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "craftbot-launcher" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +description = "CraftBot setup window: a native launcher that installs and runs CraftBot on Windows, macOS and Linux" +license = "MIT" +publish = false + +# The binary keeps the name every download link, workflow step and support +# article already uses. Only the technology behind it changed. +[[bin]] +name = "CraftBotInstaller" +path = "src/main.rs" + +[dependencies] +# UI. Slint draws its own widgets, so nothing needs to be present on the +# user's machine. Both renderers are enabled: femtovg (GPU) is the default +# and the software renderer is the fallback for machines with no usable +# OpenGL — Windows Sandbox, most VMs, some remote desktops. That fallback is +# the difference between "opens a window" and "does nothing when clicked". +slint = { version = "1.17", default-features = false, features = [ + "std", + "compat-1-2", + "backend-winit", + "renderer-femtovg", + "renderer-software", + "accessibility", +] } + +# HTTP, blocking. All downloads run on a worker thread, so a synchronous +# client is the simplest correct choice. rustls: no OpenSSL to find or ship. +# native-certs: trust the OS certificate store rather than a bundled list, so +# a corporate TLS-inspecting proxy whose CA is installed on the machine works. +ureq = { version = "2.12", default-features = false, features = ["tls", "native-certs", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Archives. The source payload is a zip; python-build-standalone is .tar.gz. +zip = { version = "2.4", default-features = false, features = ["deflate"] } +tar = "0.4" +flate2 = "1" + +# OS integration. +rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } +open = "5" +dirs = "6" +fs4 = "0.13" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[build-dependencies] +slint-build = "1.17" + +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 00000000..4dd6209a --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,156 @@ +# CraftBot launcher + +The setup window: one native binary per platform, written in Rust with a +[Slint](https://slint.dev) UI. It replaces the CustomTkinter window that was +frozen with PyInstaller. + +## Why it exists + +The Tk installer was fine on Windows and unusable on macOS, and the reason +turned out not to be Tk. A PyInstaller *onefile* binary inside an `.app` is +two processes — a bootloader that unpacks, then a child that runs the Python +code. LaunchServices activates the bootloader; the window belongs to the +child. macOS shows two Dock icons, the process with the window is never the +active one, and Tk discards mouse presses that arrive while its app is +inactive. Taps were dropped, hover flickered, and the per-launch unpack was +scanned by Gatekeeper on every open. + +A native binary is one process, no bootloader, no unpacking. It also needs +nothing on the user's machine: Slint draws its own widgets (no web engine, +no WebView2), and the binary is 8–15 MB. + +## What it does — and what it deliberately does not + +`install.py` and `run.py` are the source of truth for what an installation +is. The launcher never reimplements them. Its own work is limited to getting +two things onto the disk that Python cannot fetch for itself: + +1. **A Python interpreter.** It downloads + [python-build-standalone](https://github.com/astral-sh/python-build-standalone) + 3.10 — the same build `app/provision/runtimes.py` uses — into the same + location (`/runtime/python/python/`). When install.py's own + `python` stage runs later, it finds this one and accepts it. +2. **The source payload.** `CraftBot-src.zip` from the GitHub release + matching the launcher's version, extracted into the install directory and + stamped with `.craftbot-managed` (see `app/paths.py`). + +Everything after that is `craftbot.py` in the installed tree, run with +`CRAFTBOT_PYTHON` pointing at the sidecar: + +| Button | Command | +|---|---| +| Install CraftBot | `craftbot.py install --no-open-browser` → runs `install.py --no-launch`, registers auto-start, starts | +| Start CraftBot | `craftbot.py start --no-open-browser` → runs `run.py` | +| Open CraftBot | opens `http://localhost:7925` in the browser | +| Stop | `craftbot.py stop` | +| Repair | the install steps again over the existing location (also the upgrade path) | +| Uninstall | `craftbot.py uninstall`, then removes the install directory and the runtimes; user data is left alone | + +State is read the same way `craftbot.py` writes it in source mode: +`craftbot.pid` beside `craftbot.py`, and `.agent-ready` under the user data +directory for "running and actually serving" versus "still starting". + +### Files the launcher owns + +| | Windows | macOS | Linux | +|---|---|---|---| +| user data root | `%LOCALAPPDATA%\CraftBot` | `~/Library/Application Support/CraftBot` | `~/.local/share/craftbot` | +| default install dir | `%LOCALAPPDATA%\Programs\CraftBot` | `~/Applications/CraftBot` | `~/.local/share/craftbot/app` | + +Under the user data root: `launcher.json` (what was installed, where, with +which interpreter), `launcher.log` (everything, including the full output of +`craftbot.py install` — this is what "Open log" opens), and `runtime/`. + +## Building + +```bash +cd launcher +cargo build --release # target/release/CraftBotInstaller[.exe] +cargo test +``` + +Rust 1.85+ (`rustup` installs it). No system libraries are needed on Windows +or macOS; on Linux the runtime uses the system's X11/Wayland and OpenGL via +`dlopen`, with a software renderer as fallback. + +The CraftBot version is baked in at build time: `CRAFTBOT_VERSION=1.4.0 +cargo build --release`, or a `VERSION` file at the repo root, or "latest" +for a dev build (downloads the newest release). + +### macOS bundle + +```bash +packaging/macos/bundle.sh target/release/CraftBotInstaller dist 1.4.0 +``` + +writes `dist/CraftBotInstaller.app`, ad-hoc signed. Right-click → Open the +first time, as before. Replace `-s -` in the script with a Developer ID when +one exists. + +### The developer loop + +Put a `CraftBot-src.zip` beside the binary (or the `.app`), in `./dist/`, or +name it with `CRAFTBOT_SRC_ZIP=…`, and the launcher installs from it instead +of downloading. `python scripts/package_source.py` builds one. + +### Headless mode + +```bash +CraftBotInstaller --headless install [dir] +CraftBotInstaller --headless status | start | stop | repair | uninstall +``` + +Runs one job with no window and prints its events; exit code 0 on success. +This is how CI can exercise the real pipeline on a runner without a display, +and how a support case can be reproduced from a terminal. It is not a user +CLI — `craftbot.py` is that. + +### Rendering fallback + +Slint tries the GPU renderer first and falls back to its software renderer +where OpenGL is unavailable (Windows Sandbox, most VMs, some remote +desktops). To force one: `SLINT_BACKEND=winit-software` or +`SLINT_BACKEND=winit-femtovg`. + +## How it is wired into the repo + +- `.github/workflows/release.yml` is `launcher/packaging/release.yml`: the + `docker`, `source` and `release` jobs are the originals; the `launcher` + job builds the three binaries (universal on macOS) on every `v*` tag. +- `.github/workflows/launcher.yml` runs `cargo test` and `cargo build` on + all three platforms for pushes and PRs that touch `launcher/**`. +- `launcher/packaging/patch_craftbot.py` has been applied to `craftbot.py` + (idempotent; re-running it is a no-op). It adds a guard so + `_close_console_window()` does not kill the launcher on Windows, and a + shortcut so `uninstall` on a managed install does not pip-uninstall from + a sidecar the launcher is about to delete. +- `scripts/package_source.py` excludes `launcher/` from `CraftBot-src.zip` + and requires `installer/{helpers,metadata,payload}.py`, which + `craftbot.py` imports at module scope. +- The Tk installer (`installer/ui/`, `installer/wizard.py`, + `installer/api.py`, `packaging/`) and the PyInstaller hooks (`hooks/`, + `rthooks/`) are gone. `craftbot.py`'s `wizard` subcommand and its + `IS_FROZEN` branches remain and are inert under the launcher. + +## Layout + +``` +launcher/ + Cargo.toml + build.rs compiles the UI, bakes in the version, Windows icon + ui/app.slint the window — layout, palette, buttons, progress + assets/ logo mark (two blink frames) and the .ico + src/main.rs window ↔ state wiring, headless mode + src/state.rs installed / stopped / starting / running + src/install.rs the jobs behind the buttons + src/python.rs the Python sidecar + src/payload.rs CraftBot-src.zip: locate, download, extract, mark + src/craftbot.rs running craftbot.py and streaming its output + src/paths.rs every path, mirrored from app/paths.py and craftbot.py + src/record.rs launcher.json + src/download.rs HTTP with progress + src/logger.rs launcher.log + packaging/macos/bundle.sh + packaging/release.yml drop-in replacement for .github/workflows/release.yml + packaging/patch_craftbot.py the two craftbot.py edits +``` diff --git a/launcher/assets/craftbot_logo_1.ico b/launcher/assets/craftbot_logo_1.ico new file mode 100644 index 00000000..86866ccb Binary files /dev/null and b/launcher/assets/craftbot_logo_1.ico differ diff --git a/launcher/assets/craftbot_mark_192.png b/launcher/assets/craftbot_mark_192.png new file mode 100644 index 00000000..5da80dd7 Binary files /dev/null and b/launcher/assets/craftbot_mark_192.png differ diff --git a/launcher/assets/craftbot_mark_192_blink3.png b/launcher/assets/craftbot_mark_192_blink3.png new file mode 100644 index 00000000..f92e7318 Binary files /dev/null and b/launcher/assets/craftbot_mark_192_blink3.png differ diff --git a/launcher/build.rs b/launcher/build.rs new file mode 100644 index 00000000..aed0c4cc --- /dev/null +++ b/launcher/build.rs @@ -0,0 +1,64 @@ +//! Build script: compiles the Slint UI, pins the CraftBot version into the +//! binary, and (on Windows) embeds the icon. + +use std::path::{Path, PathBuf}; + +fn main() { + // ── Version ───────────────────────────────────────────────────────── + // The launcher downloads the CraftBot release that matches its own + // version, so the version has to be baked in. Resolution order: + // 1. CRAFTBOT_VERSION in the environment (what release.yml sets from + // the git tag, e.g. "1.4.0"). + // 2. A VERSION file at the repository root (a local release build). + // 3. "latest" — a dev build, which downloads the newest release. + let version = std::env::var("CRAFTBOT_VERSION") + .ok() + .map(|v| v.trim().trim_start_matches('v').to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| { + std::fs::read_to_string(repo_root().join("VERSION")) + .ok() + .map(|v| v.trim().trim_start_matches('v').to_string()) + .filter(|v| !v.is_empty()) + }) + .unwrap_or_else(|| "latest".to_string()); + println!("cargo:rustc-env=CRAFTBOT_VERSION={version}"); + println!("cargo:rerun-if-env-changed=CRAFTBOT_VERSION"); + println!( + "cargo:rerun-if-changed={}", + repo_root().join("VERSION").display() + ); + + // ── UI ────────────────────────────────────────────────────────────── + // One style everywhere. The window is drawn from primitives (rectangles, + // text, touch areas), so the style only affects the few std-widgets used + // and the default font; fluent's dark variant is closest to the design. + let config = slint_build::CompilerConfiguration::new().with_style("fluent-dark".into()); + slint_build::compile_with_config("ui/app.slint", config).expect("Slint UI failed to compile"); + + // ── Windows icon ──────────────────────────────────────────────────── + #[cfg(windows)] + { + let ico = Path::new("assets/craftbot_logo_1.ico"); + if ico.is_file() { + let mut res = winresource::WindowsResource::new(); + res.set_icon(ico.to_str().unwrap()); + res.set("ProductName", "CraftBot Setup"); + res.set("FileDescription", "CraftBot Setup"); + res.set("LegalCopyright", "CraftOS"); + if let Err(e) = res.compile() { + println!("cargo:warning=could not embed the Windows icon: {e}"); + } + } + } + let _ = Path::new("assets"); +} + +/// The CraftBot repository root: this crate lives in `/launcher/`. +fn repo_root() -> PathBuf { + let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + Path::new(&manifest) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&manifest)) +} diff --git a/launcher/packaging/macos/bundle.sh b/launcher/packaging/macos/bundle.sh new file mode 100755 index 00000000..5d5a12dd --- /dev/null +++ b/launcher/packaging/macos/bundle.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Wrap the launcher binary in a macOS application bundle. +# +# packaging/macos/bundle.sh [version] +# +# Produces /CraftBotInstaller.app. Finder will not launch a bare +# Unix executable by double-click (it opens Terminal, if it runs at all, and a +# browser download strips the execute bit), so the form a Mac user recognises +# is a bundle: a directory with a fixed layout Finder presents as one app. +# +# The bundle is ONE process. That is the whole reason the launcher exists on +# macOS: the previous PyInstaller onefile bundle was a bootloader plus a child +# process, and the child — the one with the window — was never the process +# macOS had activated, so Tk dropped its mouse presses. +# +# Signing: ad-hoc (`-s -`). Apple Silicon refuses to run an arm64 binary with +# no signature at all; an ad-hoc one runs after the user's right-click → Open. +# Replace `-s -` with a Developer ID identity (and add notarization) when one +# is available — nothing else here changes. +set -euo pipefail + +binary="${1:?path to the CraftBotInstaller binary}" +out="${2:?output directory}" +version="${3:-0.0.0}" +version="${version#v}" + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$here/../../.." && pwd)" +app="$out/CraftBotInstaller.app" + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp "$binary" "$app/Contents/MacOS/CraftBotInstaller" +chmod +x "$app/Contents/MacOS/CraftBotInstaller" + +# The icon is produced by the release workflow with sips + iconutil (both +# ship with macOS). Absent on a local build, in which case the bundle simply +# gets the default icon. +icon_key="" +if [ -f "$repo/craftbot_logo_1.icns" ]; then + cp "$repo/craftbot_logo_1.icns" "$app/Contents/Resources/craftbot_logo_1.icns" + icon_key=" CFBundleIconFile + craftbot_logo_1.icns" +fi + +cat > "$app/Contents/Info.plist" < + + + + CFBundleName + CraftBot Setup + CFBundleDisplayName + CraftBot Setup + CFBundleExecutable + CraftBotInstaller + CFBundleIdentifier + dev.craftos.craftbot.installer + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + ${version} + CFBundleVersion + ${version} + LSMinimumSystemVersion + 11.0 + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + +${icon_key} + + +PLIST + +echo 'APPL????' > "$app/Contents/PkgInfo" + +codesign --force --sign - --timestamp=none "$app" +codesign --verify --verbose=2 "$app" +echo "built $app" diff --git a/launcher/packaging/patch_craftbot.py b/launcher/packaging/patch_craftbot.py new file mode 100644 index 00000000..8a587750 --- /dev/null +++ b/launcher/packaging/patch_craftbot.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Two small edits to craftbot.py so it behaves when the launcher drives it. + +Run once from the repository root: + + python launcher/packaging/patch_craftbot.py + +Idempotent: applying it twice is a no-op. Each edit is an exact-string +replacement and the script refuses to guess — if the surrounding code has +changed, it stops and says which edit to make by hand. + +1. `_close_console_window()` kills the PARENT process on Windows ("close the + cmd.exe we were launched from"). Under the launcher the parent is the + launcher's window, so the guard skips the kill when stdout is not a + console — a script, a pipe, or the launcher. Double-clicking a .bat still + closes its window as before. + +2. Source-mode `uninstall` pip-uninstalls every requirement from the + interpreter. For a managed install the interpreter is a sidecar the + launcher deletes wholesale right afterwards, so that step is minutes of + work for nothing. Skipped when the managed marker is present. +""" + +from __future__ import annotations + +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TARGET = os.path.join(ROOT, "craftbot.py") + +EDITS = [ + ( + "console-kill guard", + '''def _close_console_window() -> None: + """Close the current console/terminal window on Windows then exit.""" + if _PLATFORM != "win32": + sys.exit(0) +''', + '''def _close_console_window() -> None: + """Close the current console/terminal window on Windows then exit. + + Only when this process owns a console. Launched from a script, a pipe or + the CraftBot launcher, the "parent" is not a cmd.exe window at all — it + is whatever started us, and killing it would take the launcher's window + down with it. + """ + if _PLATFORM != "win32" or not sys.stdout.isatty(): + sys.exit(0) +''', + ), + ( + "managed-install uninstall shortcut", + ''' # Source mode: uninstall pip packages + req_file = os.path.join(BASE_DIR, "requirements.txt") +''', + ''' # Source mode: uninstall pip packages. + # + # Not for a managed install: there the interpreter is a sidecar under the + # user data directory that the launcher removes wholesale right after + # this returns, so uninstalling packages from it one by one would only + # add minutes to the uninstall. + if paths.is_managed_install(): + print("\\n(managed install — the launcher removes the runtime)") + print("\\nUninstall complete.") + return + + req_file = os.path.join(BASE_DIR, "requirements.txt") +''', + ), +] + + +def main() -> int: + with open(TARGET, encoding="utf-8") as fh: + text = fh.read() + + changed = False + for name, old, new in EDITS: + if new in text: + print(f" already applied: {name}") + continue + if text.count(old) != 1: + print(f" cannot apply {name}: expected exactly one match in craftbot.py, " + f"found {text.count(old)}. Make this edit by hand (see this script).") + return 1 + text = text.replace(old, new) + changed = True + print(f" applied: {name}") + + if changed: + with open(TARGET, "w", encoding="utf-8") as fh: + fh.write(text) + print(f"wrote {TARGET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/launcher/packaging/release.yml b/launcher/packaging/release.yml new file mode 100644 index 00000000..758d6fc0 --- /dev/null +++ b/launcher/packaging/release.yml @@ -0,0 +1,299 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ────────────────────────────────────────────── + # Build and push Docker images to GHCR + # ────────────────────────────────────────────── + docker: + name: Docker (${{ matrix.image_suffix }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image_suffix: agent + dockerfile: Dockerfile + context: . + - image_suffix: omniparser + dockerfile: docker/omniparser/Dockerfile + context: . + # 'desktop' image removed: app/gui was deleted with GUI mode + # (901ad92e) — its Dockerfile no longer exists, and this entry + # broke the first tag build afterwards. + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ────────────────────────────────────────────── + # Build the source payload the launcher provisions around. + # ONE asset for every platform: it is pure Python plus data files. What + # used to differ per platform (the bundled interpreter, compiled wheels) + # is provisioned on the user's machine by install.py / app.provision. + # ────────────────────────────────────────────── + source: + name: Source payload + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build frontend + env: + VITE_BACKEND_PORT: "7926" + run: | + # Ships compiled into the payload: users have no Node toolchain at + # install time and must not need one to get a working UI. + cd app/ui_layer/browser/frontend + npm install + npx vite build + + - name: Write VERSION file from git tag + run: | + REF="${{ github.ref_name }}" + echo "${REF#v}" > VERSION + + - name: Create default config.json + run: | + echo '{"use_conda": false, "gui_mode_enabled": false}' > config.json + + # A missing lock does not break the BUILD — it breaks the user's + # install, minutes in and hundreds of MB down. Catch it here instead. + - name: Require a lock for every platform we ship + run: python scripts/generate_lock.py --check --require-all + + - name: Build payload + run: | + python scripts/package_source.py + + - name: Upload source payload + uses: actions/upload-artifact@v4 + with: + name: release-source + path: dist/CraftBot-src.zip + + # ────────────────────────────────────────────── + # The launcher: one native binary per platform, built from launcher/. + # + # This replaced the PyInstaller-built Tk window. A native binary is one + # process with no bootloader and no unpacking step, which is what fixed the + # macOS bundle (two Dock icons, an inactive window, dropped taps). It needs + # no Python, Node or web runtime on the user's machine: it downloads the + # source payload above plus a portable Python, then runs craftbot.py + # install (install.py) and start (run.py) in the installed tree. + # ────────────────────────────────────────────── + launcher: + name: Launcher (${{ matrix.os_label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_label: linux + - os: windows-latest + os_label: windows + - os: macos-latest + os_label: macos + + env: + # build.rs bakes this into the binary; the launcher downloads the + # matching CraftBot-src.zip from this tag's release. + CRAFTBOT_VERSION: ${{ github.ref_name }} + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + # Both macOS architectures, so the bundle is a universal binary and + # Intel Macs are not left out by macos-latest being arm64. + targets: ${{ matrix.os_label == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: launcher + + # Slint's Linux backend loads these at runtime through dlopen, and its + # build has no C dependencies — this is insurance for the few crates + # that probe for headers, not a runtime requirement for users. + - name: Linux build prerequisites + if: matrix.os_label == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev + + - name: Test + if: matrix.os_label == 'linux' + working-directory: launcher + run: cargo test --release + + - name: Build (Linux / Windows) + if: matrix.os_label != 'macos' + working-directory: launcher + run: cargo build --release + + - name: Build (macOS, universal) + if: matrix.os_label == 'macos' + working-directory: launcher + run: | + set -euo pipefail + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + mkdir -p target/universal + lipo -create \ + target/aarch64-apple-darwin/release/CraftBotInstaller \ + target/x86_64-apple-darwin/release/CraftBotInstaller \ + -output target/universal/CraftBotInstaller + lipo -info target/universal/CraftBotInstaller + + # sips and iconutil are both part of macOS; nothing to install. + - name: Build the macOS app icon + if: matrix.os_label == 'macos' + run: | + set -euo pipefail + iconset="$RUNNER_TEMP/craftbot.iconset" + mkdir -p "$iconset" + for size in 16 32 64 128 256 512; do + sips -z $size $size craftbot_logo_1.png \ + --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -z $((size * 2)) $((size * 2)) craftbot_logo_1.png \ + --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$iconset" -o craftbot_logo_1.icns + + - name: Package + shell: bash + run: | + set -euo pipefail + mkdir -p dist + case "${{ matrix.os_label }}" in + linux) + cp launcher/target/release/CraftBotInstaller dist/CraftBotInstaller-linux + chmod +x dist/CraftBotInstaller-linux + ;; + windows) + cp launcher/target/release/CraftBotInstaller.exe dist/CraftBotInstaller-windows.exe + ;; + macos) + REF="${{ github.ref_name }}" + launcher/packaging/macos/bundle.sh \ + launcher/target/universal/CraftBotInstaller dist "${REF#v}" + # ditto, not zip: it preserves the bundle's extended attributes + # and execute bits. A plain zip loses them and the app will not + # launch after the user unzips it. + ditto -c -k --keepParent dist/CraftBotInstaller.app dist/CraftBotInstaller-macos.zip + rm -rf dist/CraftBotInstaller.app + ;; + esac + ls -la dist/ + + - name: Upload launcher artifact + uses: actions/upload-artifact@v4 + with: + name: release-installer-${{ matrix.os_label }} + path: dist/CraftBotInstaller-${{ matrix.os_label }}* + + # ────────────────────────────────────────────── + # Create GitHub Release with all artifacts + # ────────────────────────────────────────────── + release: + name: Publish GitHub Release + needs: [docker, source, launcher] + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release/** + generate_release_notes: true + body: | + ### Installer (Recommended) + Download the file for your platform from the assets below and run + it. The setup window lets you choose an install location, then + downloads `CraftBot-src.zip` from this release and sets up + everything it needs — Python, Node.js and all dependencies. You do + not need Git, Python or Node installed beforehand. + + | Platform | Download | How to run | + |---|---|---| + | Windows | `CraftBotInstaller-windows.exe` | Double-click it. | + | macOS | `CraftBotInstaller-macos.zip` | Unzip, then **right-click the app and choose Open**. | + | Linux | `CraftBotInstaller-linux` | `chmod +x CraftBotInstaller-linux && ./CraftBotInstaller-linux` | + + On macOS the right-click is required the first time: the app is not + yet signed with an Apple Developer ID, so double-clicking it shows + "cannot be opened because the developer cannot be verified". + Right-click → Open gives you an Open button that double-click does + not. You only need to do this once. + + First install takes a few minutes while the runtime is prepared. + + ### Manual install + If you'd rather skip the window, download both + `CraftBotInstaller-` and `CraftBot-src.zip`, place them + in the same folder, and run the installer — it'll use the local + payload instead of fetching from GitHub. diff --git a/launcher/src/craftbot.rs b/launcher/src/craftbot.rs new file mode 100644 index 00000000..c3601cd7 --- /dev/null +++ b/launcher/src/craftbot.rs @@ -0,0 +1,145 @@ +//! Running `craftbot.py` — the one place the launcher talks to the Python +//! side. +//! +//! The launcher never reimplements what craftbot.py does. It runs +//! `python craftbot.py ` inside the installed +//! tree with `CRAFTBOT_PYTHON` pointing at the sidecar, which is how +//! `app/python_runtime.py` is told which interpreter every CraftBot process +//! must use. `install` there runs install.py; `start` runs run.py. Those two +//! files stay the source of truth for what an installation is. +//! +//! Output is streamed line by line: into launcher.log in full, and to the +//! caller so the last meaningful line can become the window's status. + +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// A Command that never flashes a console window on Windows. +pub fn quiet_command(program: &Path) -> Command { + #[allow(unused_mut)] + let mut cmd = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// Run `craftbot.py args…` and stream its output. Returns the exit code. +pub fn run( + python: &Path, + install_dir: &Path, + args: &[&str], + on_line: &mut dyn FnMut(&str), +) -> Result { + let script = install_dir.join("craftbot.py"); + if !script.is_file() { + return Err(format!( + "{} is missing — the install is incomplete", + script.display() + )); + } + crate::logger::log(&format!( + "run: {} craftbot.py {}", + python.display(), + args.join(" ") + )); + + let mut cmd = quiet_command(python); + cmd.arg(&script) + .args(args) + .current_dir(install_dir) + // The interpreter for every CraftBot process. Without this the Python + // side would go looking for a 3.10 on the machine, which is exactly + // the dependency the launcher exists to remove. + .env("CRAFTBOT_PYTHON", python) + // Line-buffered, UTF-8 output regardless of console code page, so + // progress reaches the window as it happens and box-drawing + // characters do not become mojibake. + .env("PYTHONUNBUFFERED", "1") + .env("PYTHONUTF8", "1") + .env("PYTHONIOENCODING", "utf-8") + // Nothing to answer prompts with: any input() the Python side hits + // fails immediately instead of hanging forever behind the window. + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("cannot start {}: {e}", python.display()))?; + + // stderr is drained on its own thread so neither pipe can fill up and + // block the child while we wait on the other. + let stderr = child.stderr.take(); + let err_thread = std::thread::spawn(move || { + let mut lines = Vec::new(); + if let Some(err) = stderr { + for line in BufReader::new(err).lines().map_while(Result::ok) { + crate::logger::raw(&format!("[stderr] {line}")); + lines.push(line); + } + } + lines + }); + + if let Some(out) = child.stdout.take() { + for line in BufReader::new(out).lines().map_while(Result::ok) { + let clean = crate::logger::strip_ansi(&line); + crate::logger::raw(&clean); + on_line(&clean); + } + } + + let status = child + .wait() + .map_err(|e| format!("waiting for craftbot.py: {e}"))?; + let err_lines = err_thread.join().unwrap_or_default(); + let code = status.code().unwrap_or(-1); + crate::logger::log(&format!( + "craftbot.py {} exited {code}", + args.first().unwrap_or(&"") + )); + + if code != 0 { + // Surface the last error line so the status can say something + // better than "exit 1". + let tail = err_lines + .iter() + .rev() + .map(|l| crate::logger::strip_ansi(l)) + .find(|l| !l.trim().is_empty()) + .unwrap_or_default(); + if !tail.is_empty() { + on_line(&tail); + } + } + Ok(code) +} + +/// The last line worth putting on a status line: skips blanks, rules and +/// progress noise. Same rule as the Tk window's `_last_meaningful_line`. +pub fn meaningful(line: &str) -> Option { + let text = line.trim(); + if text.chars().count() < 3 { + return None; + } + if text.chars().all(|c| "-=_─━ *#░▸║╔╗╚╝═".contains(c)) { + return None; + } + // Strip the decorative prefixes craftbot.py/install.py use. + let text = text + .trim_start_matches(|c: char| "▸░║✓✗•·".contains(c) || c.is_whitespace()) + .trim(); + if text.is_empty() { + return None; + } + let mut out: String = text.chars().take(72).collect(); + if text.chars().count() > 72 { + out.push('…'); + } + Some(out) +} diff --git a/launcher/src/download.rs b/launcher/src/download.rs new file mode 100644 index 00000000..a997661a --- /dev/null +++ b/launcher/src/download.rs @@ -0,0 +1,95 @@ +//! HTTP downloads with progress. Synchronous on purpose: every caller is +//! already on the worker thread, and a blocking read loop is the simplest +//! thing that reports bytes as they arrive. + +use std::io::{Read, Write}; +use std::path::Path; +use std::time::Duration; + +const USER_AGENT: &str = concat!("CraftBotInstaller/", env!("CARGO_PKG_VERSION")); + +pub type Progress<'a> = &'a mut dyn FnMut(u64, Option); + +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .user_agent(USER_AGENT) + .timeout_connect(Duration::from_secs(30)) + // Reads are bounded per call, not per download: a 100 MB file on a + // slow link legitimately takes minutes. + .timeout_read(Duration::from_secs(120)) + .redirects(10) + .build() +} + +/// Fetch a small JSON document (a GitHub release listing). +pub fn get_json(url: &str) -> Result { + let resp = agent() + .get(url) + .set("Accept", "application/vnd.github+json") + .call() + .map_err(|e| describe(url, e))?; + resp.into_json() + .map_err(|e| format!("{url}: bad JSON: {e}")) +} + +/// Download `url` to `dest`, writing to a `.part` file first so a partial +/// download is never mistaken for a finished one. `progress` is called with +/// (bytes so far, total if the server said). +pub fn to_file(url: &str, dest: &Path, progress: Progress) -> Result<(), String> { + let resp = agent().get(url).call().map_err(|e| describe(url, e))?; + let total = resp + .header("Content-Length") + .and_then(|v| v.trim().parse::().ok()); + + let part = dest.with_extension(match dest.extension().and_then(|e| e.to_str()) { + Some(ext) => format!("{ext}.part"), + None => "part".to_string(), + }); + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + } + let mut file = std::fs::File::create(&part) + .map_err(|e| format!("cannot write {}: {e}", part.display()))?; + + let mut reader = resp.into_reader(); + let mut buf = vec![0u8; 256 * 1024]; + let mut read: u64 = 0; + progress(0, total); + loop { + let n = reader + .read(&mut buf) + .map_err(|e| format!("download interrupted: {e}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .map_err(|e| format!("write failed: {e}"))?; + read += n as u64; + progress(read, total); + } + file.flush().map_err(|e| e.to_string())?; + drop(file); + + if let Some(t) = total { + if read != t { + let _ = std::fs::remove_file(&part); + return Err(format!("download incomplete: {read} of {t} bytes")); + } + } + std::fs::rename(&part, dest).map_err(|e| format!("cannot finish {}: {e}", dest.display())) +} + +fn describe(url: &str, e: ureq::Error) -> String { + match e { + ureq::Error::Status(code, resp) => { + let text = resp.status_text().to_string(); + format!("{url}: HTTP {code} {text}") + } + ureq::Error::Transport(t) => format!("{url}: {t}"), + } +} + +pub fn mb(bytes: u64) -> String { + format!("{:.0}", bytes as f64 / (1024.0 * 1024.0)) +} diff --git a/launcher/src/install.rs b/launcher/src/install.rs new file mode 100644 index 00000000..9f9ca3c0 --- /dev/null +++ b/launcher/src/install.rs @@ -0,0 +1,258 @@ +//! The jobs behind the buttons, run on a worker thread. +//! +//! Only `Install` and `Repair` do real work of their own — and even that is +//! limited to getting a source tree and an interpreter onto the disk. Once +//! those exist, everything is `craftbot.py `: install (which runs +//! install.py), start (which runs run.py), stop, uninstall. See craftbot.rs. + +use crate::craftbot; +use crate::download; +use crate::logger; +use crate::paths; +use crate::payload; +use crate::python; +use crate::record::InstallRecord; +use crate::state; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::Sender; + +/// install.py refuses to proceed below this (and would prompt, which no one +/// can answer behind a window), so check first and say so plainly. +const MIN_FREE_GB: u64 = 5; + +#[derive(Debug, Clone)] +pub enum Job { + Install { target: PathBuf }, + Repair, + Uninstall, + Start, + Stop, +} + +impl Job { + pub fn label(&self) -> &'static str { + match self { + Job::Install { .. } => "install", + Job::Repair => "repair", + Job::Uninstall => "uninstall", + Job::Start => "start", + Job::Stop => "stop", + } + } +} + +/// What the worker reports back to the window. +#[derive(Debug, Clone)] +pub enum Event { + /// A line for the status text. + Status(String), + /// A measured download: bytes so far, total if known. + Progress { read: u64, total: Option }, + /// Busy with nothing measurable. + Working, + /// The job ended. Err carries a one-line reason for the status line; + /// the full story is in launcher.log. + Finished(Result<(), String>), +} + +pub fn run(job: Job, tx: Sender) { + logger::log(&format!("job {} started", job.label())); + let result = match &job { + Job::Install { target } => install(target, &tx), + Job::Repair => repair(&tx), + Job::Uninstall => uninstall(&tx), + Job::Start => start(&tx), + Job::Stop => stop(&tx), + }; + match &result { + Ok(()) => logger::log(&format!("job {} finished", job.label())), + Err(e) => logger::log(&format!("job {} FAILED: {e}", job.label())), + } + let _ = tx.send(Event::Finished(result)); +} + +// ── Steps ─────────────────────────────────────────────────────────────── + +fn install(target: &Path, tx: &Sender) -> Result<(), String> { + let mut say = |s: &str| { + let _ = tx.send(Event::Status(s.to_string())); + }; + let _ = tx.send(Event::Working); + + if let Some(free) = python::free_space(target) { + let need = MIN_FREE_GB * 1024 * 1024 * 1024; + if free < need { + return Err(format!( + "Not enough disk space: {} GB free, {MIN_FREE_GB} GB needed", + free / (1024 * 1024 * 1024) + )); + } + } + + // Anything running from an earlier install would hold files open — on + // Windows that makes extraction fail halfway and leaves a broken tree. + stop_if_running(tx)?; + + // 1. An interpreter. Same build, same place as install.py's own python + // stage, which will find it and not download another. + let python = { + let mut progress = |read, total| { + let _ = tx.send(Event::Progress { read, total }); + }; + python::ensure(&mut say, &mut progress)? + }; + let _ = tx.send(Event::Working); + + // 2. The source tree. + let (zip, owned) = { + let mut progress = |read, total| { + let _ = tx.send(Event::Progress { read, total }); + }; + payload::obtain(&mut say, &mut progress)? + }; + let _ = tx.send(Event::Working); + say("Unpacking CraftBot…"); + let extracted = payload::extract(&zip, target); + if owned { + let _ = std::fs::remove_file(&zip); + } + let src_root = extracted?; + payload::mark_managed(&src_root)?; + logger::log(&format!("source at {}", src_root.display())); + + // 3. Remember it before running install.py, so a failure partway still + // leaves Repair pointing at the right place. + InstallRecord::new(src_root.clone(), python.clone(), payload::VERSION).save()?; + + // 4. Everything else is install.py's business, via craftbot.py install: + // the locked dependency set, Node, the npm trees, Playwright, the + // auto-start registration, and the first start. + say("Setting up the runtime — this takes a few minutes on first install"); + let code = run_craftbot(&python, &src_root, &["install", "--no-open-browser"], tx)?; + if code != 0 { + return Err(format!( + "Setup did not complete (craftbot.py install exited {code}). See the log." + )); + } + say("Installed"); + Ok(()) +} + +fn repair(tx: &Sender) -> Result<(), String> { + // Repair is an install over the existing location. It re-downloads the + // payload for this launcher's version, which is also the upgrade path. + let rec = InstallRecord::load_any().ok_or("Nothing to repair: CraftBot is not installed")?; + install(rec.dir(), tx) +} + +fn start(tx: &Sender) -> Result<(), String> { + let rec = InstallRecord::load().ok_or("CraftBot is not installed")?; + let _ = tx.send(Event::Working); + let _ = tx.send(Event::Status("Starting CraftBot…".into())); + // --no-open-browser: craftbot.py would otherwise block until the agent is + // ready and then open a tab. The window watches readiness itself and + // offers "Open CraftBot" when it is real. + let code = run_craftbot(&rec.python, rec.dir(), &["start", "--no-open-browser"], tx)?; + if code != 0 { + return Err(format!( + "CraftBot failed to start (exit {code}). See the log." + )); + } + Ok(()) +} + +fn stop(tx: &Sender) -> Result<(), String> { + let rec = InstallRecord::load().ok_or("CraftBot is not installed")?; + let _ = tx.send(Event::Working); + let _ = tx.send(Event::Status("Stopping CraftBot…".into())); + let code = run_craftbot(&rec.python, rec.dir(), &["stop"], tx)?; + if code != 0 { + return Err(format!("Stop failed (exit {code}). See the log.")); + } + Ok(()) +} + +fn uninstall(tx: &Sender) -> Result<(), String> { + let rec = InstallRecord::load_any().ok_or("CraftBot is not installed")?; + let _ = tx.send(Event::Working); + let _ = tx.send(Event::Status("Uninstalling…".into())); + + // craftbot.py uninstall stops the agent, removes auto-start and the + // desktop shortcut. Only if the tree is still there to run it from. + if rec.craftbot_py().is_file() && rec.python.is_file() { + let code = run_craftbot(&rec.python, rec.dir(), &["uninstall"], tx)?; + if code != 0 { + logger::log(&format!( + "craftbot.py uninstall exited {code}; removing files anyway" + )); + } + } + + // The source tree and the runtimes are the launcher's to remove. The + // user's own data (agent_file_system, databases, the vector store) lives + // elsewhere under the data root and is deliberately left alone. + let _ = tx.send(Event::Status("Removing files…".into())); + remove_tree(rec.dir())?; + remove_tree(&paths::user_data_root().join("runtime"))?; + let _ = std::fs::remove_file(paths::agent_ready_file()); + InstallRecord::clear(); + let _ = tx.send(Event::Status("Uninstalled".into())); + Ok(()) +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn run_craftbot( + python: &Path, + dir: &Path, + args: &[&str], + tx: &Sender, +) -> Result { + let mut on_line = |line: &str| { + if let Some(text) = craftbot::meaningful(line) { + let _ = tx.send(Event::Status(text)); + } + }; + craftbot::run(python, dir, args, &mut on_line) +} + +fn stop_if_running(tx: &Sender) -> Result<(), String> { + let snap = state::poll(); + if !matches!( + snap.phase, + state::Phase::InstalledRunning | state::Phase::InstalledStarting + ) { + return Ok(()); + } + if let Some(rec) = snap.record { + let _ = tx.send(Event::Status("Stopping the running CraftBot first…".into())); + let _ = run_craftbot(&rec.python, rec.dir(), &["stop"], tx)?; + } + Ok(()) +} + +fn remove_tree(dir: &Path) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + // Refuse anything that is not clearly ours. Deleting the user's home + // because a record was hand-edited would be unforgivable. + let is_root_like = + dir.parent().is_none() || dirs::home_dir().map(|h| h == dir).unwrap_or(false); + if is_root_like { + return Err(format!("refusing to remove {}", dir.display())); + } + std::fs::remove_dir_all(dir).map_err(|e| format!("could not remove {}: {e}", dir.display())) +} + +#[allow(dead_code)] +pub fn describe_progress(read: u64, total: Option) -> String { + match total { + Some(t) => format!( + "Downloading… {} of {} MB", + download::mb(read), + download::mb(t) + ), + None => format!("Downloading… {} MB", download::mb(read)), + } +} diff --git a/launcher/src/logger.rs b/launcher/src/logger.rs new file mode 100644 index 00000000..4e6fde72 --- /dev/null +++ b/launcher/src/logger.rs @@ -0,0 +1,93 @@ +//! The launcher's log file. +//! +//! The launcher is a windowed program: before its window is up, and for +//! everything a subprocess prints, a file is the only channel that survives. +//! Everything goes here — launcher events, the full output of `craftbot.py +//! install`, errors — so "Open log" always has something useful to show. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static LOG: OnceLock>> = OnceLock::new(); + +fn handle() -> &'static Mutex> { + LOG.get_or_init(|| { + let path = crate::paths::launcher_log(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok(); + Mutex::new(file) + }) +} + +/// Append one line, timestamped. Never fails; a log that cannot be written +/// must not stop an install. +pub fn log(line: &str) { + if let Ok(mut guard) = handle().lock() { + if let Some(file) = guard.as_mut() { + let _ = writeln!(file, "{} {}", stamp(), line); + } + } +} + +/// Append raw subprocess output (already a complete line). +pub fn raw(line: &str) { + if let Ok(mut guard) = handle().lock() { + if let Some(file) = guard.as_mut() { + let _ = writeln!(file, " {line}"); + } + } +} + +fn stamp() -> String { + // Wall-clock seconds since the epoch, rendered as HH:MM:SS UTC. Enough to + // correlate with craftbot.log without pulling in a date crate. + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (h, m, s) = ((secs / 3600) % 24, (secs / 60) % 60, secs % 60); + let days = secs / 86_400; + // Civil date from days since epoch (Howard Hinnant's algorithm). + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02}Z") +} + +/// Strip ANSI colour codes: the Python side colours its output for a +/// terminal, and the status line has nowhere to put escape sequences. +pub fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + // CSI: ESC [ ... final byte in 0x40..=0x7E + if chars.peek() == Some(&'[') { + chars.next(); + for n in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&n) { + break; + } + } + } + continue; + } + out.push(c); + } + out +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs new file mode 100644 index 00000000..23d2ec0c --- /dev/null +++ b/launcher/src/main.rs @@ -0,0 +1,588 @@ +//! CraftBot setup window. +//! +//! A native launcher: one binary per platform, no runtime to install first. +//! It downloads the CraftBot source payload and a portable Python, then +//! hands everything else to `craftbot.py` in the installed tree — `install` +//! (which runs install.py) and `start` (which runs run.py). Those two +//! scripts are the source of truth for what an installation is; this window +//! is a way to press the buttons without a terminal. +//! +//! Threads: +//! * UI thread — Slint's event loop. Owns the window, applies state. +//! * poll thread — asks `state::poll()` once a second, sends snapshots. +//! * job thread — one at a time, runs an `install::Job`, sends events. +//! +//! Both channels are drained by a 200 ms Slint timer on the UI thread, so +//! nothing but the UI thread ever touches a widget. + +#![cfg_attr(windows, windows_subsystem = "windows")] + +mod craftbot; +mod download; +mod install; +mod logger; +mod paths; +mod payload; +mod python; +mod record; +mod state; + +use install::{Event, Job}; +use record::InstallRecord; +use slint::{ComponentHandle, SharedString}; +use state::{Phase, Snapshot}; +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::{Duration, Instant}; + +slint::include_modules!(); + +/// How long a measured byte count keeps the bar determinate before the +/// once-a-second state poll is allowed to take it back to indeterminate. +const MEASURED_GRACE: Duration = Duration::from_secs(3); +/// A second click on Uninstall within this window confirms it. +const CONFIRM_WINDOW: Duration = Duration::from_secs(6); + +struct App { + ui: slint::Weak, + target_dir: RefCell, + busy: Cell, + snapshot: RefCell>, + last_measured: Cell>, + /// Until when the status line is spoken for — a job's own lines, a + /// confirmation prompt, or an error that must stay until the user acts — + /// so the once-a-second state poll does not write over it. + status_hold: Cell>, + uninstall_armed: Cell>, + close_armed: Cell>, + events_tx: Sender, + events_rx: Receiver, + states_rx: Receiver, +} + +fn main() { + logger::log("──────────────────────────────────────────────"); + logger::log(&format!( + "launcher {} for CraftBot {} starting ({} {})", + env!("CARGO_PKG_VERSION"), + payload::VERSION, + std::env::consts::OS, + std::env::consts::ARCH + )); + + // `CraftBotInstaller --headless [dir]` runs one job with no window + // and prints its events. This is how CI exercises the real pipeline on a + // runner with no display, and how a support case can be reproduced from + // a terminal. It is not a user-facing CLI: craftbot.py is that. + let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("--headless") { + std::process::exit(headless(&args[1..])); + } + + let ui = match InstallerWindow::new() { + Ok(ui) => ui, + Err(e) => { + logger::log(&format!("could not create the window: {e}")); + eprintln!("CraftBot Setup could not open a window: {e}"); + std::process::exit(1); + } + }; + + let (events_tx, events_rx) = mpsc::channel::(); + let (states_tx, states_rx) = mpsc::channel::(); + + // State poll, off the UI thread: on Windows a liveness check can stall, + // and a stalled UI thread is a frozen window. + std::thread::Builder::new() + .name("state-poll".into()) + .spawn(move || loop { + if states_tx.send(state::poll()).is_err() { + break; + } + std::thread::sleep(Duration::from_secs(1)); + }) + .expect("spawn poll thread"); + + let initial_target = InstallRecord::load_any() + .map(|r| r.install_dir) + .unwrap_or_else(paths::default_install_dir); + + let app = Rc::new(App { + ui: ui.as_weak(), + target_dir: RefCell::new(initial_target), + busy: Cell::new(false), + snapshot: RefCell::new(None), + last_measured: Cell::new(None), + status_hold: Cell::new(None), + uninstall_armed: Cell::new(None), + close_armed: Cell::new(None), + events_tx, + events_rx, + states_rx, + }); + + // ── Static bits ───────────────────────────────────────────────────── + let version = payload::VERSION; + ui.set_version_label(if matches!(version, "" | "latest" | "dev" | "unknown") { + SharedString::new() + } else { + format!("Version {version}").into() + }); + app.show_target(); + + // ── Callbacks ─────────────────────────────────────────────────────── + { + let app = app.clone(); + ui.on_primary_clicked(move || app.on_primary()); + } + { + let app = app.clone(); + ui.on_stop_clicked(move || app.start_job(Job::Stop)); + } + { + let app = app.clone(); + ui.on_repair_clicked(move || app.start_job(Job::Repair)); + } + { + let app = app.clone(); + ui.on_uninstall_clicked(move || app.on_uninstall()); + } + { + let app = app.clone(); + ui.on_change_location_clicked(move || app.on_change_location()); + } + ui.on_open_log_clicked(|| { + let path = paths::launcher_log(); + if let Err(e) = open::that(&path) { + logger::log(&format!("could not open the log: {e}")); + } + }); + { + let app = app.clone(); + ui.window() + .on_close_requested(move || app.on_close_requested()); + } + + // ── Tick: drain both channels on the UI thread ────────────────────── + let tick = slint::Timer::default(); + { + let app = app.clone(); + tick.start( + slint::TimerMode::Repeated, + Duration::from_millis(200), + move || app.tick(), + ); + } + + logger::log("entering event loop"); + if let Err(e) = ui.run() { + logger::log(&format!("event loop ended with error: {e}")); + } + logger::log("window closed"); +} + +/// Run one job without a window; see main(). +fn headless(args: &[String]) -> i32 { + let job = match args.first().map(String::as_str) { + Some("install") => Job::Install { + target: args + .get(1) + .map(PathBuf::from) + .unwrap_or_else(paths::default_install_dir), + }, + Some("repair") => Job::Repair, + Some("uninstall") => Job::Uninstall, + Some("start") => Job::Start, + Some("stop") => Job::Stop, + Some("status") => { + let snap = state::poll(); + println!("{:?}", snap.phase); + if let Some(rec) = snap.record { + println!("install_dir: {}", rec.install_dir.display()); + println!("python: {}", rec.python.display()); + println!("version: {}", rec.version); + } + return 0; + } + _ => { + eprintln!("usage: CraftBotInstaller --headless "); + return 2; + } + }; + let (tx, rx) = mpsc::channel::(); + let worker = std::thread::spawn(move || install::run(job, tx)); + let mut code = 1; + for event in rx { + match event { + Event::Status(text) => println!("{text}"), + Event::Progress { read, total } => { + if let Some(t) = total { + if read == t || read % (8 * 1024 * 1024) < 256 * 1024 { + println!(" {} / {} MB", download::mb(read), download::mb(t)); + } + } + } + Event::Working => {} + Event::Finished(Ok(())) => code = 0, + Event::Finished(Err(e)) => { + eprintln!("FAILED: {e}"); + code = 1; + } + } + } + let _ = worker.join(); + code +} + +impl App { + fn ui(&self) -> InstallerWindow { + self.ui.unwrap() + } + + // ── Actions ───────────────────────────────────────────────────────── + + fn on_primary(&self) { + if self.busy.get() { + return; + } + let phase = self.snapshot.borrow().as_ref().map(|s| s.phase); + match phase { + Some(Phase::InstalledRunning) => { + if let Err(e) = open::that(paths::BROWSER_URL) { + logger::log(&format!("could not open the browser: {e}")); + self.set_status( + &format!("Open {} in your browser", paths::BROWSER_URL), + Tone::Dim, + ); + self.hold_status(CONFIRM_WINDOW); + } + } + Some(Phase::InstalledStopped) => self.start_job(Job::Start), + Some(Phase::InstalledStarting) | None => {} + Some(Phase::NotInstalled) => { + let target = self.target_dir.borrow().clone(); + self.start_job(Job::Install { target }); + } + } + } + + fn on_uninstall(&self) { + if self.busy.get() { + return; + } + let armed = self.uninstall_armed.get(); + if armed.map(|t| t.elapsed() < CONFIRM_WINDOW).unwrap_or(false) { + self.uninstall_armed.set(None); + self.start_job(Job::Uninstall); + } else { + self.uninstall_armed.set(Some(Instant::now())); + self.set_status("Press Uninstall again to remove CraftBot", Tone::Amber); + self.hold_status(CONFIRM_WINDOW); + } + } + + fn on_change_location(&self) { + if self.busy.get() { + return; + } + let current = self.target_dir.borrow().clone(); + let start_in = current + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or(current.clone()); + let picked = rfd::FileDialog::new() + .set_title("Choose where to install CraftBot") + .set_directory(start_in) + .pick_folder(); + let Some(mut chosen) = picked else { return }; + // If they picked a parent rather than a CraftBot folder, append one + // so the install does not scatter itself through e.g. Documents. + let is_craftbot = chosen + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.eq_ignore_ascii_case("craftbot")) + .unwrap_or(false); + if !is_craftbot { + chosen = chosen.join("CraftBot"); + } + *self.target_dir.borrow_mut() = chosen; + self.show_target(); + } + + fn on_close_requested(&self) -> slint::CloseRequestResponse { + if !self.busy.get() { + return slint::CloseRequestResponse::HideWindow; + } + let armed = self.close_armed.get(); + if armed.map(|t| t.elapsed() < CONFIRM_WINDOW).unwrap_or(false) { + logger::log("closed while a job was running"); + return slint::CloseRequestResponse::HideWindow; + } + self.close_armed.set(Some(Instant::now())); + self.set_status( + "Setup is still working — close again to quit anyway", + Tone::Amber, + ); + self.hold_status(CONFIRM_WINDOW); + slint::CloseRequestResponse::KeepWindowShown + } + + fn start_job(&self, job: Job) { + if self.busy.get() { + return; + } + self.busy.set(true); + self.status_hold.set(None); + self.uninstall_armed.set(None); + let ui = self.ui(); + ui.set_busy(true); + ui.set_progress_visible(true); + ui.set_progress_indeterminate(true); + let tx = self.events_tx.clone(); + std::thread::Builder::new() + .name(format!("job-{}", job.label())) + .spawn(move || install::run(job, tx)) + .expect("spawn job thread"); + } + + // ── Tick ──────────────────────────────────────────────────────────── + + fn tick(&self) { + while let Ok(event) = self.events_rx.try_recv() { + self.apply_event(event); + } + let mut latest = None; + while let Ok(snap) = self.states_rx.try_recv() { + latest = Some(snap); + } + if let Some(snap) = latest { + self.apply_snapshot(snap); + } + } + + fn apply_event(&self, event: Event) { + let ui = self.ui(); + match event { + Event::Status(text) => self.set_status(&text, Tone::Dim), + Event::Progress { read, total } => { + ui.set_progress_visible(true); + match total { + Some(t) if t > 0 => { + self.last_measured.set(Some(Instant::now())); + ui.set_progress_indeterminate(false); + ui.set_progress((read as f64 / t as f64).clamp(0.0, 1.0) as f32); + self.set_status( + &format!( + "Downloading… {} of {} MB", + download::mb(read), + download::mb(t) + ), + Tone::Dim, + ); + } + _ => { + ui.set_progress_indeterminate(true); + self.set_status( + &format!("Downloading… {} MB", download::mb(read)), + Tone::Dim, + ); + } + } + } + Event::Working => { + ui.set_progress_visible(true); + ui.set_progress_indeterminate(true); + } + Event::Finished(result) => { + self.busy.set(false); + ui.set_busy(false); + ui.set_progress_visible(false); + ui.set_progress(0.0); + match result { + Ok(()) => { + // The next snapshot writes the real state. + self.status_hold.set(None); + } + Err(e) => { + // Keep the error on screen until the user does + // something else; the state poll must not replace + // it with a cheerful "Installed · not running". + let first = e.lines().next().unwrap_or("Something went wrong"); + self.set_status(first, Tone::Red); + self.hold_status(Duration::from_secs(24 * 3600)); + } + } + } + } + } + + fn apply_snapshot(&self, snap: Snapshot) { + let ui = self.ui(); + let busy = self.busy.get(); + let phase = snap.phase; + + // Once an install is recorded, the location is no longer a choice. + if let Some(rec) = &snap.record { + if *self.target_dir.borrow() != rec.install_dir { + *self.target_dir.borrow_mut() = rec.install_dir.clone(); + self.show_target(); + } + } + + // While a job is running the status line belongs to it; a held + // status (a prompt, or the last job's error) stays put as well. + let held = self + .status_hold + .get() + .map(|t| Instant::now() < t) + .unwrap_or(false); + if !busy && !held { + match phase { + Phase::InstalledStarting => self.set_status("Starting CraftBot…", Tone::Amber), + Phase::InstalledRunning => { + let text = match snap.pid { + Some(pid) => format!("Running · PID {pid}"), + None => "Running".to_string(), + }; + self.set_status(&text, Tone::Green); + } + Phase::InstalledStopped => self.set_status("Installed · not running", Tone::Amber), + Phase::NotInstalled => self.set_status("Not installed", Tone::Dim), + } + } + + let (label, enabled) = match phase { + Phase::InstalledStarting => ("Starting…", false), + Phase::InstalledRunning => ("Open CraftBot", true), + Phase::InstalledStopped => ("Start CraftBot", true), + Phase::NotInstalled => ("Install CraftBot", true), + }; + ui.set_primary_label(label.into()); + ui.set_primary_enabled(enabled && !busy); + + let installed = !matches!(phase, Phase::NotInstalled); + let running = matches!(phase, Phase::InstalledRunning | Phase::InstalledStarting); + ui.set_stop_enabled(running && !busy); + ui.set_repair_enabled(installed && !busy); + ui.set_uninstall_enabled(installed && !busy); + ui.set_change_enabled(!installed && !busy); + + // Any busy stage gets a bar; a measured download upgrades it to a + // real percentage. Never over the top of a live download, though. + let starting = matches!(phase, Phase::InstalledStarting); + if busy || starting { + let measured_recently = self + .last_measured + .get() + .map(|t| t.elapsed() < MEASURED_GRACE) + .unwrap_or(false); + if !measured_recently { + ui.set_progress_visible(true); + ui.set_progress_indeterminate(true); + } + } else if ui.get_progress_visible() { + ui.set_progress_visible(false); + ui.set_progress(0.0); + } + + *self.snapshot.borrow_mut() = Some(snap); + } + + // ── Small helpers ─────────────────────────────────────────────────── + + fn show_target(&self) { + let text = self.target_dir.borrow().display().to_string(); + self.ui().set_install_path(paths::elide(&text, 46).into()); + } + + fn hold_status(&self, for_how_long: Duration) { + self.status_hold.set(Some(Instant::now() + for_how_long)); + } + + fn set_status(&self, text: &str, tone: Tone) { + let ui = self.ui(); + let palette = ui.global::(); + let color = match tone { + Tone::Dim => palette.get_text_dim(), + Tone::Green => palette.get_green(), + Tone::Amber => palette.get_amber(), + Tone::Red => palette.get_red(), + }; + ui.set_status(text.into()); + ui.set_status_color(color); + } +} + +#[derive(Clone, Copy)] +enum Tone { + Dim, + Green, + Amber, + Red, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elide_keeps_both_ends() { + let p = "C:\\Users\\someone\\AppData\\Local\\Programs\\CraftBot"; + let e = paths::elide(p, 20); + assert!(e.starts_with("C:\\Users")); + assert!(e.ends_with("CraftBot")); + assert!(e.contains("...")); + assert_eq!(paths::elide("short", 20), "short"); + } + + #[test] + fn ansi_is_stripped() { + assert_eq!( + logger::strip_ansi("\x1b[38;2;255;79;24m▸\x1b[0m hi"), + "▸ hi" + ); + } + + #[test] + fn meaningful_lines() { + assert_eq!(craftbot::meaningful("═══════════"), None); + assert_eq!(craftbot::meaningful(" "), None); + assert_eq!( + craftbot::meaningful(" ▸ STEP 1/3 Installing dependencies"), + Some("STEP 1/3 Installing dependencies".into()) + ); + } + + #[test] + fn extract_handles_wrapper_dir() { + let tmp = + std::env::temp_dir().join(format!("craftbot-launcher-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let zip_path = tmp.join("src.zip"); + { + let f = std::fs::File::create(&zip_path).unwrap(); + let mut w = zip::ZipWriter::new(f); + let opts = zip::write::SimpleFileOptions::default(); + w.start_file("CraftBot-1.0/run.py", opts).unwrap(); + std::io::Write::write_all(&mut w, b"print('hi')\n").unwrap(); + w.start_file("CraftBot-1.0/app/__init__.py", opts).unwrap(); + w.finish().unwrap(); + } + let target = tmp.join("install"); + let root = payload::extract(&zip_path, &target).unwrap(); + assert!(root.join("run.py").is_file()); + assert!(root.ends_with("CraftBot-1.0")); + payload::mark_managed(&root).unwrap(); + assert!(root.join(paths::MANAGED_MARKER).is_file()); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn version_is_baked_in() { + assert!(!payload::VERSION.is_empty()); + assert!(payload::download_url().contains("CraftBot-src.zip")); + } +} diff --git a/launcher/src/paths.rs b/launcher/src/paths.rs new file mode 100644 index 00000000..8e5e5ff6 --- /dev/null +++ b/launcher/src/paths.rs @@ -0,0 +1,159 @@ +//! Where things live. Every path here mirrors one in the Python side +//! (`app/paths.py`, `craftbot.py`), because the launcher and the agent must +//! agree on them without talking to each other: +//! +//! * the per-user data root is `app.paths._user_data_root()` — the agent +//! keeps its state there once the install is marked managed; +//! * the Python sidecar location is `PythonStage._sidecar_exe()` in +//! `app/provision/runtimes.py` — the launcher puts the interpreter exactly +//! where install.py's provisioning would, so that stage finds it and does +//! not download a second one; +//! * the pid and log files are `craftbot.py`'s `PID_FILE`/`LOG_FILE` in +//! source mode, which is beside `craftbot.py` in the install directory. + +use std::path::{Path, PathBuf}; + +/// Per-user writable directory for launcher state, the Python sidecar and +/// the agent's own data. Matches `app.paths._user_data_root()`. +pub fn user_data_root() -> PathBuf { + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| home().join("AppData").join("Local")); + root.join("CraftBot") + } + #[cfg(target_os = "macos")] + { + home() + .join("Library") + .join("Application Support") + .join("CraftBot") + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let root = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| home().join(".local").join("share")); + root.join("craftbot") + } +} + +/// Where CraftBot is installed when the user does not choose. Per-user, so +/// no elevation is needed. Matches `craftbot.default_install_location()` +/// except on Linux, where the Python default coincides with the data root; +/// a subdirectory keeps the source tree (replaced on upgrade) apart from the +/// user's data (never replaced). +pub fn default_install_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home().join("AppData").join("Local")); + root.join("Programs").join("CraftBot") + } + #[cfg(target_os = "macos")] + { + home().join("Applications").join("CraftBot") + } + #[cfg(all(unix, not(target_os = "macos")))] + { + user_data_root().join("app") + } +} + +/// The launcher's own record of what it installed: `launcher.json`. +pub fn install_record() -> PathBuf { + user_data_root().join("launcher.json") +} + +/// Everything the launcher and the subprocesses it runs print. +pub fn launcher_log() -> PathBuf { + user_data_root().join("launcher.log") +} + +/// Root of the Python sidecar tree, matching `PythonStage`. +pub fn python_runtime_dir() -> PathBuf { + user_data_root().join("runtime").join("python") +} + +/// The sidecar interpreter, matching `PythonStage._sidecar_exe()`. +pub fn python_exe() -> PathBuf { + let root = python_runtime_dir().join("python"); + if cfg!(windows) { + root.join("python.exe") + } else { + root.join("bin").join("python3") + } +} + +/// `app.paths.AGENT_READY_FILE`: written by the agent once boot() has +/// finished, deleted by run.py just before each launch. +pub fn agent_ready_file() -> PathBuf { + user_data_root().join(".agent-ready") +} + +/// `app.paths.MANAGED_MARKER`, stamped into an install root. +pub const MANAGED_MARKER: &str = ".craftbot-managed"; + +/// `craftbot.py`'s PID file in source mode: beside craftbot.py. (Its log, +/// craftbot.log, is in the same place.) +pub fn pid_file(install_dir: &Path) -> PathBuf { + install_dir.join("craftbot.pid") +} + +/// The URL the browser UI is served at (`craftbot.BROWSER_URL`). +pub const BROWSER_URL: &str = "http://localhost:7925"; + +/// Directory holding the running launcher binary. On macOS that is +/// `CraftBotInstaller.app/Contents/MacOS/`; callers that look for files +/// "beside the app" want the directory the bundle sits in, see +/// [`beside_app_dirs`]. +pub fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) +} + +/// Directories a locally staged file (the dev-loop `CraftBot-src.zip`) may +/// sit in, most specific first. +pub fn beside_app_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(dir) = exe_dir() { + dirs.push(dir.clone()); + // Walk out of a macOS bundle: MacOS/ -> Contents/ -> Foo.app -> dir. + if cfg!(target_os = "macos") { + if let Some(outside) = dir + .parent() + .and_then(|c| c.parent()) + .and_then(|a| a.parent()) + { + dirs.push(outside.to_path_buf()); + } + } + } + if let Ok(cwd) = std::env::current_dir() { + dirs.push(cwd.join("dist")); + dirs.push(cwd); + } + dirs +} + +fn home() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) +} + +/// Shorten a path from the middle so both the drive and the final folder +/// stay readable. Same rule as the Tk window's `elide()`. +pub fn elide(path: &str, limit: usize) -> String { + let chars: Vec = path.chars().collect(); + if chars.len() <= limit { + return path.to_string(); + } + let keep = (limit.saturating_sub(3)) / 2; + let head: String = chars[..keep].iter().collect(); + let tail: String = chars[chars.len() - keep..].iter().collect(); + format!("{head}...{tail}") +} diff --git a/launcher/src/payload.rs b/launcher/src/payload.rs new file mode 100644 index 00000000..39d1fdad --- /dev/null +++ b/launcher/src/payload.rs @@ -0,0 +1,164 @@ +//! The source payload: `CraftBot-src.zip`, one asset for every platform. +//! +//! Mirrors `installer/payload.py`. The launcher is pinned to the CraftBot +//! version it was built for (see build.rs), so it downloads that release's +//! asset; a dev build ("latest") takes the newest release. A zip staged +//! beside the launcher, in `./dist/`, or named by `CRAFTBOT_SRC_ZIP` is used +//! instead of downloading — that is the developer loop. + +use crate::download; +use crate::logger; +use crate::paths; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub const GITHUB_OWNER: &str = "CraftOS-dev"; +pub const GITHUB_REPO: &str = "CraftBot"; +pub const ASSET: &str = "CraftBot-src.zip"; + +/// The CraftBot version this launcher installs. Baked in by build.rs. +pub const VERSION: &str = env!("CRAFTBOT_VERSION"); + +pub fn download_url() -> String { + if VERSION == "latest" { + format!("https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest/download/{ASSET}") + } else { + format!( + "https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/v{VERSION}/{ASSET}" + ) + } +} + +/// A locally staged payload, if any. Explicit override first, then beside +/// the app, then the local build output. +pub fn local_zip() -> Option { + if let Some(p) = std::env::var_os("CRAFTBOT_SRC_ZIP").map(PathBuf::from) { + if p.is_file() { + return Some(p); + } + } + paths::beside_app_dirs() + .into_iter() + .map(|d| d.join(ASSET)) + .find(|p| p.is_file()) +} + +/// Where a downloaded payload is kept until it has been extracted. +fn temp_zip() -> PathBuf { + paths::user_data_root().join("downloads").join(ASSET) +} + +/// Obtain the payload. Returns the zip path and whether the launcher owns it +/// (downloaded, so delete after extraction) or not (staged by a developer, +/// so leave it alone). +pub fn obtain( + say: &mut dyn FnMut(&str), + progress: &mut dyn FnMut(u64, Option), +) -> Result<(PathBuf, bool), String> { + if let Some(local) = local_zip() { + logger::log(&format!("payload: using local {}", local.display())); + say("Using local CraftBot package"); + return Ok((local, false)); + } + let url = download_url(); + logger::log(&format!("payload: {url}")); + say(&format!( + "Downloading CraftBot {}…", + if VERSION == "latest" { "" } else { VERSION } + )); + let dest = temp_zip(); + download::to_file(&url, &dest, progress).map_err(|e| { + if e.contains("HTTP 404") { + format!("{e}\nNo CraftBot {VERSION} release has a {ASSET} asset yet.") + } else { + e + } + })?; + Ok((dest, true)) +} + +/// Extract the payload into `target` and return the directory holding +/// run.py. Tolerates both shapes a zip can have — files at the root, or +/// everything under one wrapper directory (what `git archive` and GitHub's +/// own zips produce) — because getting this wrong yields an install that +/// looks fine until nothing can find run.py. +pub fn extract(zip_path: &Path, target: &Path) -> Result { + std::fs::create_dir_all(target) + .map_err(|e| format!("cannot create {}: {e}", target.display()))?; + let file = std::fs::File::open(zip_path) + .map_err(|e| format!("cannot open {}: {e}", zip_path.display()))?; + let mut archive = zip::ZipArchive::new(std::io::BufReader::new(file)) + .map_err(|e| format!("{} is not a valid zip: {e}", zip_path.display()))?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("zip entry {i}: {e}"))?; + // enclosed_name() refuses "../" and absolute paths: a payload must not + // be able to write outside the install directory. + let Some(rel) = entry.enclosed_name() else { + logger::log(&format!( + "payload: skipping unsafe entry {:?}", + entry.name() + )); + continue; + }; + let out = target.join(rel); + if entry.is_dir() { + std::fs::create_dir_all(&out) + .map_err(|e| format!("cannot create {}: {e}", out.display()))?; + continue; + } + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; + } + let mut data = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut data) + .map_err(|e| format!("cannot read {}: {e}", entry.name()))?; + std::fs::write(&out, &data).map_err(|e| format!("cannot write {}: {e}", out.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + let _ = std::fs::set_permissions(&out, std::fs::Permissions::from_mode(mode)); + } + } + } + + if target.join("run.py").is_file() { + return Ok(target.to_path_buf()); + } + let mut dirs: Vec = std::fs::read_dir(target) + .map_err(|e| e.to_string())? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + for candidate in dirs { + if candidate.join("run.py").is_file() { + return Ok(candidate); + } + } + Err(format!( + "run.py not found after extracting to {}. The source payload is not shaped as expected.", + target.display() + )) +} + +/// Stamp an install root as managed — `app.paths.mark_managed_install()`. +/// Must happen before anything in that tree imports `app.paths`, because +/// the marker decides where the user's data goes: without it, the installed +/// copy looks exactly like a developer checkout and would put databases and +/// logs inside the install directory, which the next upgrade replaces. +pub fn mark_managed(root: &Path) -> Result<(), String> { + const TEXT: &str = "This file marks a managed CraftBot install.\n\n\ +It tells app/paths.py to keep user data (agent_file_system, databases, logs,\n\ +the vector store) in the per-user data directory rather than in this folder,\n\ +which an upgrade replaces wholesale.\n\n\ +Delete it only if you are converting this directory into a dev checkout.\n"; + let path = root.join(paths::MANAGED_MARKER); + std::fs::write(&path, TEXT).map_err(|e| format!("cannot write {}: {e}", path.display())) +} diff --git a/launcher/src/python.rs b/launcher/src/python.rs new file mode 100644 index 00000000..33511602 --- /dev/null +++ b/launcher/src/python.rs @@ -0,0 +1,176 @@ +//! The Python sidecar. +//! +//! install.py and run.py are the source of truth for what an install is, +//! and both are Python — so before the launcher can run either it needs an +//! interpreter, and it must not depend on one being on the machine. It +//! downloads python-build-standalone (a relocatable CPython with pip), the +//! same build `app/provision/runtimes.py` uses, into the same place that +//! module puts it. When install.py later runs its own `python` stage it +//! finds this one already there and accepts it; nothing is downloaded twice. +//! +//! The release tag, patch version and platform triple are resolved from the +//! GitHub API rather than hardcoded, for the reason recorded in runtimes.py: +//! a hardcoded asset name went stale and produced a silent 404. + +use crate::download; +use crate::logger; +use crate::paths; +use std::path::PathBuf; + +/// Same as `TARGET_PYTHON` in runtimes.py — the version the dependency locks +/// are generated for, not a minimum. +pub const TARGET: (u32, u32) = (3, 10); + +const PBS_API: &str = + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"; + +/// The interpreter to run CraftBot with, downloading it if needed. +pub fn ensure( + say: &mut dyn FnMut(&str), + progress: &mut dyn FnMut(u64, Option), +) -> Result { + let exe = paths::python_exe(); + if exe.is_file() { + if let Some(v) = probe(&exe) { + if v == TARGET { + say(&format!("Python {}.{} ready", v.0, v.1)); + return Ok(exe); + } + logger::log(&format!( + "sidecar at {} is {}.{}, replacing", + exe.display(), + v.0, + v.1 + )); + } else { + logger::log(&format!( + "sidecar at {} does not run, replacing", + exe.display() + )); + } + // A wrong or broken sidecar is removed wholesale; a partial tree is + // worse than none. + let _ = std::fs::remove_dir_all(paths::python_runtime_dir().join("python")); + } + + let triple = triple().ok_or_else(|| { + format!( + "no portable Python {}.{} is published for this machine", + TARGET.0, TARGET.1 + ) + })?; + say(&format!("Downloading Python {}.{}…", TARGET.0, TARGET.1)); + let url = resolve_url(&triple)?; + logger::log(&format!("python: {url}")); + + let dest_dir = paths::python_runtime_dir(); + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("cannot create {}: {e}", dest_dir.display()))?; + let archive = dest_dir.join("_download.tar.gz"); + download::to_file(&url, &archive, progress)?; + + say("Unpacking Python…"); + let result = extract_tar_gz(&archive, &dest_dir); + let _ = std::fs::remove_file(&archive); + result?; + + if !exe.is_file() { + return Err(format!("Python unpacked, but {} is missing", exe.display())); + } + match probe(&exe) { + Some(v) if v == TARGET => { + say(&format!("Python {}.{} ready", v.0, v.1)); + Ok(exe) + } + Some(v) => Err(format!( + "downloaded Python reports {}.{}, expected {}.{}", + v.0, v.1, TARGET.0, TARGET.1 + )), + None => Err("downloaded Python will not run on this machine".to_string()), + } +} + +/// Run the interpreter and read back its (major, minor). +pub fn probe(exe: &std::path::Path) -> Option<(u32, u32)> { + let out = crate::craftbot::quiet_command(exe) + .args([ + "-c", + "import sys; print(sys.version_info[0], sys.version_info[1])", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut parts = text.split_whitespace(); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some((major, minor)) +} + +/// python-build-standalone's platform triple for this machine. Same table +/// as `_pbs_triple()` in runtimes.py, including "arm64 Windows runs the x64 +/// build under emulation" because no win-arm64 build is published. +fn triple() -> Option { + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + if cfg!(target_os = "windows") { + Some("x86_64-pc-windows-msvc".to_string()) + } else if cfg!(target_os = "macos") { + Some(format!("{arch}-apple-darwin")) + } else if cfg!(target_os = "linux") { + Some(format!("{arch}-unknown-linux-gnu")) + } else { + None + } +} + +fn resolve_url(triple: &str) -> Result { + let data = download::get_json(PBS_API) + .map_err(|e| format!("could not reach the Python download index: {e}"))?; + let prefix = format!("cpython-{}.{}.", TARGET.0, TARGET.1); + let suffix = format!("-{triple}-install_only.tar.gz"); + let assets = data + .get("assets") + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + for asset in &assets { + let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name.starts_with(&prefix) && name.ends_with(&suffix) { + if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { + return Ok(url.to_string()); + } + } + } + let tag = data.get("tag_name").and_then(|t| t.as_str()).unwrap_or("?"); + Err(format!( + "no {prefix}*{suffix} in python-build-standalone release {tag}" + )) +} + +/// Extract a .tar.gz, preserving permissions — the executable bit on +/// bin/python3 is the whole point on macOS and Linux. +fn extract_tar_gz(archive: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { + let file = std::fs::File::open(archive) + .map_err(|e| format!("cannot open {}: {e}", archive.display()))?; + let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file)); + let mut tar = tar::Archive::new(gz); + tar.set_preserve_permissions(true); + tar.set_overwrite(true); + tar.unpack(dest) + .map_err(|e| format!("cannot unpack Python: {e}")) +} + +/// Free space available at (the nearest existing ancestor of) `path`. +pub fn free_space(path: &std::path::Path) -> Option { + let mut probe = path.to_path_buf(); + while !probe.exists() { + probe = probe.parent()?.to_path_buf(); + } + fs4::available_space(&probe).ok() +} diff --git a/launcher/src/record.rs b/launcher/src/record.rs new file mode 100644 index 00000000..1849a3e9 --- /dev/null +++ b/launcher/src/record.rs @@ -0,0 +1,82 @@ +//! `launcher.json`: what the launcher installed, where, and with which +//! interpreter. This is the launcher's equivalent of the old frozen +//! installer's `install.json`; `craftbot.py` in source mode does not write +//! one, because a source install has always known where it is (beside +//! craftbot.py) — the launcher is the one that needs reminding. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const SCHEMA: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallRecord { + pub schema: u32, + /// Directory holding run.py / craftbot.py / install.py. + pub install_dir: PathBuf, + /// The interpreter every CraftBot process runs under. + pub python: PathBuf, + /// CraftBot version the payload came from ("latest" for a dev build). + pub version: String, + /// Seconds since the Unix epoch. + pub installed_at: u64, +} + +impl InstallRecord { + pub fn new(install_dir: PathBuf, python: PathBuf, version: &str) -> Self { + let installed_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + Self { + schema: SCHEMA, + install_dir, + python, + version: version.to_string(), + installed_at, + } + } + + /// The record, if one exists and still describes a real install: the + /// source tree and the interpreter must both be present. A record whose + /// tree has been deleted by hand is treated as "not installed" rather + /// than offering Start on nothing. + pub fn load() -> Option { + let text = std::fs::read_to_string(crate::paths::install_record()).ok()?; + let rec: Self = serde_json::from_str(&text).ok()?; + if rec.install_dir.join("run.py").is_file() && rec.python.is_file() { + Some(rec) + } else { + None + } + } + + /// The recorded install directory even when the tree is gone — what + /// "Change location" should default to, and what Repair reinstalls into. + pub fn load_any() -> Option { + let text = std::fs::read_to_string(crate::paths::install_record()).ok()?; + serde_json::from_str(&text).ok() + } + + pub fn save(&self) -> Result<(), String> { + let path = crate::paths::install_record(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + } + let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(&path, text).map_err(|e| format!("cannot write {}: {e}", path.display())) + } + + pub fn clear() { + let _ = std::fs::remove_file(crate::paths::install_record()); + } + + pub fn craftbot_py(&self) -> PathBuf { + self.install_dir.join("craftbot.py") + } + + pub fn dir(&self) -> &Path { + &self.install_dir + } +} diff --git a/launcher/src/state.rs b/launcher/src/state.rs new file mode 100644 index 00000000..5d10da3c --- /dev/null +++ b/launcher/src/state.rs @@ -0,0 +1,95 @@ +//! What is CraftBot doing right now? Polled about once a second from a +//! worker thread (see main.rs); the window only ever renders the latest +//! snapshot. +//! +//! The rules are the ones the Tk window used, which came from the frozen +//! installer's state machine: +//! +//! * installed — a launcher record exists AND its tree and interpreter do; +//! * running — `craftbot.pid` names a live process; +//! * ready — the agent has written `.agent-ready` for this run. A live +//! PID is not a usable CraftBot: run.py spends a while initialising, and +//! offering "Open CraftBot" before the marker exists sends the user to a +//! tab that cannot serve yet. + +use crate::paths; +use crate::record::InstallRecord; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + NotInstalled, + InstalledStopped, + InstalledStarting, + InstalledRunning, +} + +#[derive(Debug, Clone)] +pub struct Snapshot { + pub phase: Phase, + pub pid: Option, + pub record: Option, +} + +pub fn poll() -> Snapshot { + let record = InstallRecord::load(); + let Some(rec) = record.as_ref() else { + return Snapshot { + phase: Phase::NotInstalled, + pid: None, + record: None, + }; + }; + let pid = read_pid(&paths::pid_file(rec.dir())); + let running = pid.map(pid_alive).unwrap_or(false); + if !running { + return Snapshot { + phase: Phase::InstalledStopped, + pid: None, + record, + }; + } + let ready = paths::agent_ready_file().is_file(); + Snapshot { + phase: if ready { + Phase::InstalledRunning + } else { + Phase::InstalledStarting + }, + pid, + record, + } +} + +fn read_pid(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() +} + +#[cfg(unix)] +pub fn pid_alive(pid: u32) -> bool { + // kill(pid, 0): no signal is sent, but the permission and existence + // checks still run. ESRCH means gone; EPERM means alive but not ours. + let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if rc == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +pub fn pid_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return false; + } + let mut code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut code); + CloseHandle(handle); + ok != 0 && code == STILL_ACTIVE as u32 + } +} diff --git a/launcher/ui/app.slint b/launcher/ui/app.slint new file mode 100644 index 00000000..fece9c3c --- /dev/null +++ b/launcher/ui/app.slint @@ -0,0 +1,268 @@ +// CraftBot setup window. +// +// The layout is the one the Tk window used — one mark, one line of status, +// one primary button, a quiet row of secondary actions — drawn from +// primitives so it looks identical on all three platforms. Every colour is a +// Palette token; the values are the ones from installer/ui/theme.py. +// +// Everything here is presentation. State lives in Rust (src/main.rs), which +// sets the `in` properties below and receives the callbacks. + +export global Palette { + out property base: #14141F; + // Raised surfaces are a white film over the base. Slint composites for + // real, so these are the films themselves rather than pre-blended hexes. + out property surface: #FFFFFF0E; // 5.5% + out property surface-raised: #FFFFFF16; // 8.5% + out property hairline: #FFFFFF1A; // 10% + out property track: #FFFFFF1F; // 12% + out property text: #F2F2F6; + out property text-dim: #9C9CAB; + out property text-faint: #63636F; + out property accent: #FF4F18; + out property accent-hover: #FF6A3B; + out property accent-text: #FFFFFF; + out property green: #4ADE80; + out property amber: #FBBF24; + out property red: #F87171; +} + +// A pill. `primary` is the one accent-filled control in the window; the +// rest are glass outlines that only brighten on hover. +component PillButton inherits Rectangle { + in property label; + in property enabled: true; + in property primary: false; + // The primary button while work is in progress: no accent, no hover, + // reads as "not now" rather than "press me". + in property busy: false; + in property font-size: 12px; + callback clicked; + + border-radius: self.height / 2; + border-width: primary ? 0px : 1px; + border-color: Palette.hairline; + background: primary + ? (busy ? Palette.surface-raised : (touch.has-hover && enabled ? Palette.accent-hover : Palette.accent)) + : (touch.has-hover && enabled ? Palette.surface-raised : Palette.surface); + animate background { duration: 120ms; } + + touch := TouchArea { + enabled: root.enabled; + mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default; + clicked => { root.clicked(); } + } + + Text { + text: root.label; + font-size: root.font-size; + font-weight: root.primary ? 700 : 400; + color: root.primary + ? (root.enabled ? Palette.accent-text : Palette.text-dim) + : (root.enabled ? Palette.text-dim : Palette.text-faint); + horizontal-alignment: center; + vertical-alignment: center; + } +} + +// Clickable text with no surface of its own. +component Link inherits Text { + in property enabled: true; + callback clicked; + font-size: 11px; + color: enabled ? (area.has-hover ? Palette.text-dim : Palette.text-faint) : Palette.text-faint; + opacity: enabled ? 1.0 : 0.55; + animate color { duration: 120ms; } + area := TouchArea { + enabled: root.enabled; + mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default; + clicked => { root.clicked(); } + } +} + +export component InstallerWindow inherits Window { + title: "CraftBot Setup"; + icon: @image-url("../assets/craftbot_mark_192.png"); + // Fixed size: an installer is a fixed-size dialog on every OS. + width: 440px; + height: 540px; + background: Palette.base; + default-font-size: 12px; + + // ── State set from Rust ───────────────────────────────────────────── + in property status: "Checking…"; + in property status-color: Palette.text-dim; + in property install-path: ""; + in property change-enabled: true; + + in property primary-label: "Install CraftBot"; + in property primary-enabled: false; + in property busy: false; + + in property stop-enabled: false; + in property repair-enabled: false; + in property uninstall-enabled: false; + + in property progress-visible: false; + in property progress-indeterminate: true; + in property progress: 0.0; // 0..1 when determinate + + in property version-label: ""; + + // ── Actions ───────────────────────────────────────────────────────── + callback primary-clicked(); + callback stop-clicked(); + callback repair-clicked(); + callback uninstall-clicked(); + callback change-location-clicked(); + callback open-log-clicked(); + + // ── The mark, which blinks ────────────────────────────────────────── + property eyes-closed: false; + Timer { + interval: 4200ms; + running: true; + triggered => { root.eyes-closed = true; } + } + Timer { + interval: 140ms; + running: root.eyes-closed; + triggered => { root.eyes-closed = false; } + } + Image { + source: @image-url("../assets/craftbot_mark_192.png"); + width: 96px; + height: 96px; + x: (root.width - self.width) / 2; + y: 112px - self.height / 2; + visible: !root.eyes-closed; + } + Image { + source: @image-url("../assets/craftbot_mark_192_blink3.png"); + width: 96px; + height: 96px; + x: (root.width - self.width) / 2; + y: 112px - self.height / 2; + visible: root.eyes-closed; + } + + // ── Identity + status ─────────────────────────────────────────────── + Text { + text: "CraftBot"; + font-size: 23px; + font-weight: 700; + color: Palette.text; + horizontal-alignment: center; + x: 0; width: root.width; + y: 204px - self.height / 2; + } + Text { + text: root.status; + font-size: 11px; + color: root.status-color; + horizontal-alignment: center; + overflow: elide; + x: 28px; width: root.width - 56px; + y: 228px - self.height / 2; + animate color { duration: 200ms; } + } + + // ── Progress ──────────────────────────────────────────────────────── + // Sits under the status line, shown only while something is happening: + // a bar at zero for two minutes reads as "stuck". + property sweep: root.progress-indeterminate && root.progress-visible ? 1.0 : 0.0; + animate sweep { duration: 1400ms; iteration-count: -1; easing: ease-in-out; } + Rectangle { + visible: root.progress-visible; + x: (root.width - 240px) / 2; + y: 252px; + width: 240px; + height: 5px; + border-radius: 2.5px; + background: Palette.track; + clip: true; + Rectangle { + border-radius: 2.5px; + background: Palette.accent; + height: parent.height; + width: root.progress-indeterminate ? 80px : parent.width * max(0.0, min(1.0, root.progress)); + x: root.progress-indeterminate ? (parent.width + 80px) * root.sweep - 80px : 0px; + animate width { duration: 150ms; } + } + } + + // ── Install location ──────────────────────────────────────────────── + Text { + text: root.install-path; + font-size: 11px; + color: Palette.text-faint; + horizontal-alignment: center; + overflow: elide; + x: 28px; width: root.width - 56px; + y: 296px - self.height / 2; + } + Link { + text: "Change location"; + enabled: root.change-enabled; + horizontal-alignment: center; + x: 0; width: root.width; + y: 318px - self.height / 2; + clicked => { root.change-location-clicked(); } + } + + // ── Actions ───────────────────────────────────────────────────────── + PillButton { + label: root.busy ? "Working…" : root.primary-label; + primary: true; + busy: root.busy; + enabled: root.primary-enabled && !root.busy; + font-size: 14px; + width: 240px; + height: 46px; + x: (root.width - self.width) / 2; + y: 378px; + clicked => { root.primary-clicked(); } + } + + HorizontalLayout { + x: (root.width - 302px) / 2; + y: 440px; + width: 302px; + height: 30px; + spacing: 10px; + PillButton { + label: "Stop"; + enabled: root.stop-enabled && !root.busy; + font-size: 11px; + clicked => { root.stop-clicked(); } + } + PillButton { + label: "Repair"; + enabled: root.repair-enabled && !root.busy; + font-size: 11px; + clicked => { root.repair-clicked(); } + } + PillButton { + label: "Uninstall"; + enabled: root.uninstall-enabled && !root.busy; + font-size: 11px; + clicked => { root.uninstall-clicked(); } + } + } + + // ── Footer ────────────────────────────────────────────────────────── + Text { + text: root.version-label; + font-size: 11px; + color: Palette.text-faint; + x: 28px; + y: root.height - 24px - self.height / 2; + } + Link { + text: "Open log"; + horizontal-alignment: right; + x: root.width - 28px - self.width; + y: root.height - 24px - self.height / 2; + clicked => { root.open-log-clicked(); } + } +} diff --git a/packaging/CraftBotAgent.spec b/packaging/CraftBotAgent.spec deleted file mode 100644 index f687a70c..00000000 --- a/packaging/CraftBotAgent.spec +++ /dev/null @@ -1,153 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -""" -PyInstaller spec for CraftBotAgent — the actual agent runtime. - -Built as --onedir (a folder containing CraftBotAgent.exe + _internal/) so it -launches fast (no per-run extraction) and so the installer can copy/extract -it anywhere. Console=False so it runs detached without a terminal window. - -Output: dist/CraftBotAgent/CraftBotAgent(.exe) + dist/CraftBotAgent/_internal/. -The release workflow zips this folder into CraftBot-agent-{platform}.zip. - -Build from the repo root: `python -m PyInstaller packaging/CraftBotAgent.spec`. -All paths below are derived from SPECPATH (the absolute directory of this -spec file), so the build works regardless of the current working directory. -""" -import glob as _glob -import os as _os - -from PyInstaller.utils.hooks import collect_data_files -from PyInstaller.utils.hooks import collect_submodules -from PyInstaller.utils.hooks import collect_all - -# SPECPATH is set by PyInstaller to this spec file's directory -# (/packaging/). ROOT walks one level up so all data/source paths -# resolve relative to the project root. -ROOT = _os.path.dirname(SPECPATH) - - -def _root(*parts: str) -> str: - return _os.path.join(ROOT, *parts) - - -# VERSION file is generated by the release workflow (echo of the git tag, -# minus leading 'v'). app.config.get_app_version() reads it from _MEIPASS -# at runtime so the installed agent reports its actual version — not the -# stale "0.0.0" that used to live in settings.json. -_datas_extra = [] -_version_path = _root('VERSION') -if _os.path.isfile(_version_path): - _datas_extra.append((_version_path, '.')) - -# Locale catalogs loaded at runtime by app.i18n via Path(__file__).parent — -# globbed so dropping in a new errors..json / ui_messages..json needs -# no spec edit. Fail loudly if none are found: shipping without them degrades -# every provider error message (errors.*) or browser-UI message (ui_messages.*) -# to its raw catalog key. -_i18n_catalogs = _glob.glob(_root('app', 'i18n', 'errors.*.json')) -if not _i18n_catalogs: - raise RuntimeError( - "No app/i18n/errors.*.json catalogs found — the packaged agent would " - "show raw error keys instead of messages." - ) -_ui_message_catalogs = _glob.glob(_root('app', 'i18n', 'ui_messages.*.json')) -if not _ui_message_catalogs: - raise RuntimeError( - "No app/i18n/ui_messages.*.json catalogs found — the packaged agent " - "would show raw UI-message keys instead of localized text." - ) -_datas_extra += [(_f, 'app/i18n') for _f in _i18n_catalogs] -_datas_extra += [(_f, 'app/i18n') for _f in _ui_message_catalogs] - -datas = [ - *_datas_extra, - (_root('assets'), 'assets'), - (_root('main.py'), '.'), - (_root('config.json'), '.'), - (_root('.env.example'), '.'), - (_root('requirements.txt'), '.'), - (_root('environment.yml'), '.'), - (_root('app/config/mcp_config.json'), 'app/config'), - (_root('app/config/connection_test_models.json'), 'app/config'), - (_root('app/config/scheduler_config.json'), 'app/config'), - (_root('app/config/skills_config.json'), 'app/config'), - (_root('app/data'), 'app/data'), - (_root('app/ui_layer/browser/frontend/dist'), 'app/ui_layer/browser/frontend/dist'), - # app/gui was deleted with GUI mode (901ad92e); its docker files broke - # the first tag build afterwards — PyInstaller fails hard on missing datas. - (_root('agents'), 'agents'), - (_root('skills'), 'skills'), -] - -hiddenimports = ['onnxruntime', 'tokenizers'] -datas += collect_data_files('tiktoken_ext') -hiddenimports += collect_submodules('app') -hiddenimports += collect_submodules('agent_core') -hiddenimports += collect_submodules('agents') -hiddenimports += collect_submodules('decorators') -hiddenimports += collect_submodules('chromadb') -hiddenimports += collect_submodules('tiktoken') - -# Third-party SDKs the agent loads at runtime via factories. Required: if -# any of these are missing the resulting EXE will crash at runtime with -# ModuleNotFoundError. Fail the build loudly instead. -binaries = [] -for _pkg in ('openai', 'anthropic'): - _datas, _binaries, _hidden = collect_all(_pkg) - if not _hidden: - raise RuntimeError( - f"Required package {_pkg!r} not installed in the build env. " - f"Run `pip install -r requirements.txt` before building." - ) - datas += _datas - binaries += _binaries - hiddenimports += _hidden - - -a = Analysis( - [_root('run.py')], - pathex=[ROOT], - binaries=binaries, - datas=datas, - hiddenimports=hiddenimports, - hookspath=[_root('hooks')], - hooksconfig={}, - runtime_hooks=[ - _root('rthooks/rthook-utf8-stdio.py'), # Must run first — UTF-8 stdout - _root('rthooks/rthook-windows-noflash.py'), # Suppress per-subprocess console windows - _root('rthooks/rthook-rich-unicode.py'), - ], - excludes=['torch', 'torchvision', 'torchaudio', 'triton', 'nvidia', 'transformers', 'cv2', 'matplotlib', 'tensorflow'], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, # onedir: COLLECT() places binaries alongside the EXE - name='CraftBotAgent', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, # No terminal when launched by the installer / auto-start - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=_root('craftbot_logo_1.ico'), -) - -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='CraftBotAgent', -) diff --git a/packaging/CraftBotInstaller.spec b/packaging/CraftBotInstaller.spec deleted file mode 100644 index 5896ad61..00000000 --- a/packaging/CraftBotInstaller.spec +++ /dev/null @@ -1,116 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -""" -PyInstaller spec for CraftBotInstaller.exe — the small wizard/installer. - -Bundles ONLY: craftbot.py, the installer/ package (api/helpers/metadata/ -payload/wizard + the web/ assets), pywebview's runtime bindings, the icon -files, and a VERSION file. No agent code, no openai/anthropic/chromadb — -those live in the agent zip that this installer downloads from GitHub -Releases at install time. - -Wizard UI runs in the OS-native webview (WebView2 on Windows, WKWebView -on macOS, WebKitGTK on Linux) styled via installer/web/{html,css,js}. -We bundle pywebview itself but rely on the OS-provided webview engine — -no Chromium ships inside the EXE. - -Result: ~15–25 MB onefile EXE. Double-click → wizard. CLI subcommands -(install / start / stop / status / uninstall / repair) still work. - -Build from the repo root: `python -m PyInstaller packaging/CraftBotInstaller.spec`. -All paths below are derived from SPECPATH (the absolute directory of this -spec file), so the build works regardless of the current working directory. -""" - -import os as _os - -from PyInstaller.utils.hooks import collect_all as _collect_all - -# SPECPATH is set by PyInstaller to the directory containing this spec file -# (i.e. /packaging/). ROOT walks one level up to the project root so -# we can reference craftbot.py, installer/, assets/, rthooks/ etc. via -# absolute paths. -ROOT = _os.path.dirname(SPECPATH) - -# Pull in pywebview's bundled JS bridge files + platform backend (edgechromium -# on Windows, cocoa on macOS, gtk on Linux). collect_all returns a tuple -# (datas, binaries, hiddenimports) — splatted into the Analysis() call below. -_webview_datas, _webview_binaries, _webview_hiddenimports = _collect_all('webview') - -datas = [ - # The installer/ package — wizard + helpers + metadata + payload modules. - # Top-level imports in craftbot.py mean PyInstaller's analyser already - # bundles these as bytecode, but we keep the source files alongside so - # tracebacks and debugging show real lines instead of . - (_os.path.join(ROOT, 'installer'), 'installer'), - (_os.path.join(ROOT, 'craftbot_logo_1.ico'), '.'), - (_os.path.join(ROOT, 'craftbot_logo_1.png'), '.'), - # Brand assets used by the wizard header. We only ship the few PNGs we - # actually display — not the whole assets/ folder, which is megabytes - # of marketing screenshots that bloat the installer. - (_os.path.join(ROOT, 'assets', 'craftbot_logo_text_no_border_dark.png'), 'assets'), - # pywebview's own bundled assets (the JS bridge file it injects into - # every webview, plus its config). collect_all already gathers them but - # they go in datas, not binaries. - *_webview_datas, -] - -# VERSION file is generated by the release workflow (echo of the git tag) -# at the project root. Bundle it if present so -# craftbot._read_bundled_version() pins the installer to the matching agent -# version. -_version_path = _os.path.join(ROOT, 'VERSION') -if _os.path.isfile(_version_path): - datas.append((_version_path, '.')) - - -a = Analysis( - [_os.path.join(ROOT, 'craftbot.py')], - pathex=[ROOT], # so `import craftbot` and `from installer import ...` resolve - binaries=list(_webview_binaries), - datas=datas, - hiddenimports=list(_webview_hiddenimports), - hookspath=[], - hooksconfig={}, - runtime_hooks=[ - # Same hooks the agent uses. The installer is also console=False so - # any subprocess it spawns (powershell for shortcut, schtasks for - # auto-start, taskkill for stop) would otherwise flash a console. - _os.path.join(ROOT, 'rthooks', 'rthook-utf8-stdio.py'), - _os.path.join(ROOT, 'rthooks', 'rthook-windows-noflash.py'), - ], - excludes=[ - # Be aggressive — none of this belongs in the installer EXE. - 'torch', 'torchvision', 'torchaudio', 'triton', 'nvidia', - 'transformers', 'cv2', 'matplotlib', 'tensorflow', - 'openai', 'anthropic', 'chromadb', 'tiktoken', 'tiktoken_ext', - 'fastapi', 'starlette', 'uvicorn', 'pydantic', - 'numpy', 'scipy', 'pandas', 'sklearn', - # The agent's own modules — installer never imports them - 'app', 'agent_core', 'agents', 'decorators', 'skills', - ], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='CraftBotInstaller', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, # No terminal flash on double-click; wizard owns the UX - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=_os.path.join(ROOT, 'craftbot_logo_1.ico'), -) diff --git a/packaging/requirements-installer.txt b/packaging/requirements-installer.txt deleted file mode 100644 index e22b72cc..00000000 --- a/packaging/requirements-installer.txt +++ /dev/null @@ -1,40 +0,0 @@ -# Build-time dependencies for CraftBotInstaller.exe. -# -# Install with: pip install -r packaging/requirements-installer.txt -# These are kept separate from requirements.txt (the agent's deps) so the -# installer's build env stays small. The agent EXE has no use for pywebview. - -# Wizard UI runs in the OS-native webview. pywebview's setup.py does NOT -# auto-install the per-OS backend bindings, so we list them here with -# platform markers. End users still need the OS-level webview engine -# installed: -# - Windows: WebView2 runtime (pre-installed on Win10 22H2+ / Win11) -# - macOS: WKWebView (always present, macOS 10.10+) -# - Linux: libwebkit2gtk-4.1-0 + gir1.2-webkit2-4.1 (apt/dnf) -# On older distros (Ubuntu 22.04 etc.) the 4.0 versions -# (libwebkit2gtk-4.0-37 + gir1.2-webkit2-4.0) work too — -# pywebview's WebKit2 namespace is identical across both. -pywebview>=5.0 - -# Windows backend: WebView2 via .NET interop. pywebview's metadata is -# unpinned and pip falls back to pythonnet 2.5.2 (source-only, builds .NET -# extensions via NuGet, breaks on modern systems). Pin to 3.x: -# - 3.0.x supports Python <3.14 and has prebuilt wheels. -# - 3.1.0rc0 supports Python 3.14+. Pinning the rc explicitly so pip -# installs it without --pre — exact-version specifiers bypass pip's -# pre-release filter. -pythonnet==3.1.0rc0; sys_platform == 'win32' and python_version >= '3.14' -pythonnet>=3.0,<3.1; sys_platform == 'win32' and python_version < '3.14' - -# macOS backend: WKWebView via PyObjC. PyInstaller's collect_all('webview') -# only bundles the cocoa backend correctly when these are importable in -# the build env, so they're build-time required (not just runtime). -pyobjc-core; sys_platform == 'darwin' -pyobjc-framework-Cocoa; sys_platform == 'darwin' -pyobjc-framework-WebKit; sys_platform == 'darwin' - -# Linux backend: WebKitGTK via PyGObject. PyGObject is pip-installable but -# bridges to system GTK libs (apt: libwebkit2gtk-4.0-37 gir1.2-webkit2-4.0 -# libgirepository1.0-dev python3-gi). Without those system pkgs, pywebview -# imports but webview.start() raises at runtime. -PyGObject; sys_platform == 'linux' diff --git a/requirements/lock-linux_x86_64-py310.txt b/requirements/lock-linux_x86_64-py310.txt new file mode 100644 index 00000000..c11eaa3e --- /dev/null +++ b/requirements/lock-linux_x86_64-py310.txt @@ -0,0 +1,524 @@ +# GENERATED by scripts/generate_lock.py — do not edit by hand. +# Regenerate with: python scripts/generate_lock.py +# +# Valid ONLY for: linux_x86_64-py310 +# Locks are per platform+python: torch means CPU wheels on Windows and +# CUDA libraries on Linux, so one lock cannot serve every runner. +# +# Source: requirements.txt (sha256:bf6bd04ea6a2588c) +# Packages: 256 + +--require-hashes + +accelerate==1.14.0 \ + --hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 +aiohappyeyeballs==2.7.1 \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 +aiohttp==3.14.3 \ + --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 +annotated-types==0.8.0 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +anthropic==1.2.0 \ + --hash=sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6 +antlr4-python3-runtime==4.9.3 \ + --hash=sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 +async-timeout==5.0.1 \ + --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +babel==2.18.0 \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +bcrypt==5.0.0 \ + --hash=sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a +beautifulsoup4==4.15.0 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 +boto3==1.43.83 \ + --hash=sha256:73a3564f737d4516625964eee709a498fa98ccee6aca929febad2b0b5fbeae1e +botocore==1.43.83 \ + --hash=sha256:bf75a6cf587c22d968e43e79fe122c39f82deafbe9c3422bc5d3e80b6210fc98 +build==1.6.0 \ + --hash=sha256:f7aaf1ebbb79178a02ba248bb524f2176b256017e17e8e4bd4289c7b38cc2bad +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +cffi==2.1.1 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 +chardet==7.6.0 \ + --hash=sha256:4b81d3f7d7914442d5f7d515b8c6d79cee6b794bc208971fb6902f176671166a +charset-normalizer==3.5.1 \ + --hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 +chromadb==1.5.9 \ + --hash=sha256:cc09b3df76e5a5cb386aed2715a2eea152e3949f9e1ba93c7119505377749929 +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +cloudpickle==3.1.2 \ + --hash=sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a +coloredlogs==15.0.1 \ + --hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 +colorlog==6.12.0 \ + --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e +courlan==1.4.0 \ + --hash=sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e +croniter==6.2.4 \ + --hash=sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d +cryptography==50.0.1 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 +cuda-bindings==13.3.1 \ + --hash=sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0 +cuda-pathfinder==1.8.0 \ + --hash=sha256:c44e574dc997fae2814721d1ae97d0fd6db76db82decbe9b753bf75de53f515e +cuda-toolkit==13.0.3.0 \ + --hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f +dateparser==1.4.2 \ + --hash=sha256:752f3d49d477cf7f60a7a9c8bcb19c882496ede0e377d5a3d80014cdfeca7050 +defusedxml==0.7.1 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 +dill==0.4.1 \ + --hash=sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d +distro==1.9.0 \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af +doclang==0.7.3 \ + --hash=sha256:9440c4ca9f7e061a7b8d33bdf15b1029be69a4c13cd8952dd6ce541884e4c685 +docling-core==2.92.0 \ + --hash=sha256:726d89c23197e53be2f7192bb4c00108ff545830cf9ab7b981fb014fa92b5c02 +docling-ibm-models==4.0.0 \ + --hash=sha256:b4dc321ca9203bfa7cda6092e6b996845fd592457dfe49dd69247fa75d3770aa +docling-parse==7.16.0 \ + --hash=sha256:ea261b705ffd1cac97b0ca2eab656b2fe4434ff4876be38eddf0b2c9bb582133 +docling-slim==2.124.0 \ + --hash=sha256:2c7c667394d3eae9080bc15143ed6f1d39a4838090d9e3f29be212606e6676bd +docling==2.124.0 \ + --hash=sha256:bff775aea776429e3bc1d20972dc173e80cd748b1202ebadc4c3ab64c156cdb5 +docstring_parser==0.18.0 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b +durationpy==0.11 \ + --hash=sha256:a739fe2b8972c250ff72f8e2c488d18cf25f7b852f49ee76048775d5171df30c +et_xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa +exceptiongroup==1.3.1 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 +fake-useragent==2.2.0 \ + --hash=sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24 +Faker==40.37.0 \ + --hash=sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490 +filelock==3.32.4 \ + --hash=sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd +filetype==1.2.0 \ + --hash=sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25 +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +fonttools==4.64.0 \ + --hash=sha256:d16102cbcd4615b09c64e6022733faccc93200785f1ab0d4493afb8b0261edde +fpdf2==2.8.8 \ + --hash=sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17 +frozenlist==1.8.0 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 +google-auth==2.57.0 \ + --hash=sha256:180dafe015cfb62193bea26b677500fab5b9fd51a1e825ebf3ad9b182047ae59 +google-genai==2.20.0 \ + --hash=sha256:49bddeccd29a4e6bf1706c5de67735f7115f537f08b6c36a70b8023c99399095 +googleapis-common-protos==1.75.2 \ + --hash=sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e +googlesearch-python==1.3.0 \ + --hash=sha256:808c4dd390dc4c6a1cfba2f5151f5ef16dceb0a200d9770b388dcd39162b4e19 +gradio_client==2.6.1 \ + --hash=sha256:df3752925fbaaa56f7bfeeb0064ae23591ec3976432443748dd663a9729ef23b +greenlet==3.5.5 \ + --hash=sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56 +grpcio==1.83.1 \ + --hash=sha256:b59eaaeeb03dde0a2708095fb50f1afa94f11dc1b459bb7790b53bfb8cf95153 +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +hf-xet==1.6.0 \ + --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f +htmldate==1.10.0 \ + --hash=sha256:9211dae35ab94147c8ed9e5fc2c9287a5cf31d2394cb7857e7f5dd814eb2aad6 +httpcore2==2.12.0 \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +httptools==0.8.0 \ + --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc +httpx2==2.12.0 \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +huggingface_hub==1.29.0 \ + --hash=sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd +humanfriendly==10.0 \ + --hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 +idna==3.19 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 +importlib_resources==7.1.0 \ + --hash=sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1 +Jinja2==3.1.6 \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +jiter==0.16.0 \ + --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de +jmespath==1.1.0 \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 +joblib==1.6.0 \ + --hash=sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba +jsonpatch==1.33 \ + --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade +jsonpointer==3.1.1 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca +jsonref==1.1.0 \ + --hash=sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9 +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +jsonschema==4.26.0 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +jusText==3.0.2 \ + --hash=sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7 +kubernetes==36.0.3 \ + --hash=sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f +langchain-core==1.6.1 \ + --hash=sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a +langchain-protocol==0.0.19 \ + --hash=sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f +langgraph-checkpoint==4.2.0 \ + --hash=sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942 +langgraph-prebuilt==1.1.0 \ + --hash=sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9 +langgraph-sdk==0.4.4 \ + --hash=sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64 +langgraph==1.2.11 \ + --hash=sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b +langsmith==0.11.2 \ + --hash=sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f +lark-oapi==1.7.3 \ + --hash=sha256:c91f00087b7977dc9059ab492e8fe435e1a873863dca1d4e660d2be5b801e4cd +latex2mathml==3.81.0 \ + --hash=sha256:d317710393fe20579aea39cfe8928fa2ad9b8780896e585326c75e89c1d1d1a4 +loguru==0.7.3 \ + --hash=sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c +lxml==6.1.2 \ + --hash=sha256:83e7510a6dda8df41d1b68b783de2953b3feb55a11dcebf693201ebaa5cc0c4a +lxml_html_clean==0.4.5 \ + --hash=sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746 +mail-parser==4.6.4 \ + --hash=sha256:bc6e437b3afe38091893e7b6ea49c7f2188ad7616abdd4fa3059e85f1b69efce +markdown-it-py==4.2.0 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +markdown2==2.5.5 \ + --hash=sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941 +marko==2.2.4 \ + --hash=sha256:d80510506edba096ec49d4720a09645fa0bb78e7b7b88697f20032fc19730aa9 +MarkupSafe==3.0.3 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 +mmh3==5.3.0 \ + --hash=sha256:bd044cff158529364124210044fec4f1b0a13219fb6e8b9e393384458bb753fc +MouseInfo==0.1.3 \ + --hash=sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7 +mpire==2.10.2 \ + --hash=sha256:d627707f7a8d02aa4c7f7d59de399dec5290945ddf7fbd36cbb1d6ebb37a51fb +mpmath==1.3.0 \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c +mss==10.2.0 \ + --hash=sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197 +multidict==6.7.1 \ + --hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 +multiprocess==0.70.19 \ + --hash=sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87 +neo4j==6.3.0 \ + --hash=sha256:d243f9c8adf882ae7205a76eb2419b0a632d5f3c89383d4a0044d737a2223991 +nest-asyncio==1.6.0 \ + --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c +networkx==3.4.2 \ + --hash=sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f +numpy==2.2.6 \ + --hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 +nvidia-cublas==13.1.1.3 \ + --hash=sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436 +nvidia-cuda-cupti==13.0.85 \ + --hash=sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 +nvidia-cuda-nvrtc==13.0.88 \ + --hash=sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 +nvidia-cuda-runtime==13.0.96 \ + --hash=sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 +nvidia-cudnn-cu13==9.20.0.48 \ + --hash=sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304 +nvidia-cufft==12.0.0.61 \ + --hash=sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 +nvidia-cufile==1.15.1.6 \ + --hash=sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 +nvidia-curand==10.4.0.35 \ + --hash=sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc +nvidia-cusolver==12.0.4.66 \ + --hash=sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 +nvidia-cusparse==12.6.3.3 \ + --hash=sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b +nvidia-cusparselt-cu13==0.8.1 \ + --hash=sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0 +nvidia-nccl-cu13==2.29.7 \ + --hash=sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d +nvidia-nvjitlink==13.3.33 \ + --hash=sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5 +nvidia-nvshmem-cu13==3.4.5 \ + --hash=sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 +nvidia-nvtx==13.0.85 \ + --hash=sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 +oauthlib==3.3.1 \ + --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 +olefile==0.47 \ + --hash=sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f +omegaconf==2.3.1 \ + --hash=sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0 +onnxruntime==1.23.2 \ + --hash=sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7 +openai==3.6.0 \ + --hash=sha256:508e2158bf971687f953b62e44b02f207792c815aac306816386d7ba34d37f5f +opencv-python-headless==5.0.0.93 \ + --hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37 +opencv-python==5.0.0.93 \ + --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 +opentelemetry-api==1.44.0 \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef +opentelemetry-exporter-otlp-proto-common==1.44.0 \ + --hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 +opentelemetry-exporter-otlp-proto-grpc==1.44.0 \ + --hash=sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e +opentelemetry-proto==1.44.0 \ + --hash=sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56 +opentelemetry-sdk==1.44.0 \ + --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad +opentelemetry-semantic-conventions==0.65b0 \ + --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb +orjson==3.12.0 \ + --hash=sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef +ormsgpack==1.12.2 \ + --hash=sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2 +overrides==7.7.0 \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pandas==2.3.3 \ + --hash=sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838 +pdfminer.six==20260107 \ + --hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 +pdfplumber==0.11.10 \ + --hash=sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580 +pillow==12.3.0 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec +playwright==1.62.0 \ + --hash=sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3 +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +polyfactory==3.3.0 \ + --hash=sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e +propcache==0.5.2 \ + --hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d +protobuf==7.36.0 \ + --hash=sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b +psutil==7.2.2 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 +pyaes==1.6.1 \ + --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f +pyasn1==0.6.4 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b +pyasn1_modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a +PyAutoGUI==0.9.54 \ + --hash=sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2 +pybase64==1.5.0 \ + --hash=sha256:dd4abc5f83ea43fe977caa7111af763e0f2ad5f4143a55abaef8bc4efe4fe30c +pyclipper==1.4.0 \ + --hash=sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +pycryptodome==3.23.0 \ + --hash=sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575 +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 +pydantic_core==2.46.5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b +pyee==13.0.1 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 +PyGetWindow==0.0.9 \ + --hash=sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688 +Pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 +pylatexenc==2.11 \ + --hash=sha256:e78e7391d6c104f1ed150e21cfaa58016cdb50aa54406a2eecb793649ffdfdd0 +pymongo==4.17.0 \ + --hash=sha256:77aa4bc164b4de60d5db193b322f0f5b6ead716e831031bfdef8e8bd92205556 +PyMsgBox==2.0.1 \ + --hash=sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b +pymupdf==1.28.2 \ + --hash=sha256:397d6715c1f0df7548a92d0afd8ce370fc48fa47aeefac16be2bc04a16a8227f +pypdf==6.16.2 \ + --hash=sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604 +pypdfium2==5.13.0 \ + --hash=sha256:81df25c1ab4c13ff773102d3cbea1967511d079123b067fc077bd0c4d57d91d8 +pyperclip==1.11.0 \ + --hash=sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273 +PyPika==0.51.1 \ + --hash=sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46 +pyproject_hooks==1.2.0 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +PyRect==0.2.0 \ + --hash=sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78 +PyScreeze==1.0.1 \ + --hash=sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be +pytesseract==0.3.13 \ + --hash=sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34 +python-dateutil==2.9.0.post0 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +python-docx==1.2.0 \ + --hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 +python-oxmsg==0.0.2 \ + --hash=sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355 +python-pptx==1.0.2 \ + --hash=sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba +python3-xlib==0.15 \ + --hash=sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8 +pytweening==1.2.0 \ + --hash=sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b +pytz==2026.3.post1 \ + --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815 +PyYAML==6.0.3 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b +qrcode==8.2 \ + --hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f +rank-bm25==0.2.2 \ + --hash=sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae +rapidocr==3.9.2 \ + --hash=sha256:04d6b8d151f823d930bd91910555f57bea897c0c44fa6794267b94cf9c1ef9a0 +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +regex==2026.8.31 \ + --hash=sha256:453e9ffb310eede3f35303d7fb2e891382c98888d54f162e5a2e0174d1b75331 +requests-oauthlib==2.0.0 \ + --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 +requests-toolbelt==1.0.0 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb +rpds-py==0.30.0 \ + --hash=sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3 +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 +rtree==1.4.1 \ + --hash=sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c +s3transfer==0.19.2 \ + --hash=sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 +safetensors==0.8.0 \ + --hash=sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774 +scikit-learn==1.7.2 \ + --hash=sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8 +scipy==1.15.3 \ + --hash=sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40 +semchunk==3.2.5 \ + --hash=sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076 +sentence-transformers==6.0.1 \ + --hash=sha256:b8888d72c707ba33c63aa30845850702dd5acadf1dd0d051436380bcebe4fd0f +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +shapely==2.1.2 \ + --hash=sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142 +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 +soupsieve==2.9.2 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 +sympy==1.14.0 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 +tabulate==0.10.0 \ + --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 +Telethon==1.44.0 \ + --hash=sha256:52fc49efb67a4916c2aedcb295ad286f4afa2aba9bf15d83ed2acdc64af0c718 +tenacity==9.1.4 \ + --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 +threadpoolctl==3.6.0 \ + --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb +tiktoken==0.14.0 \ + --hash=sha256:18a1b651c4b032004bf7b4f1713391a54b2a341a52c6e8a2b59acae9d16e13c7 +tld==0.13.2 \ + --hash=sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c +tokenizers==0.23.1 \ + --hash=sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4 +tomli==2.4.1 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe +torch==2.13.0 \ + --hash=sha256:c78b7b4d04461855a764cf01bae9a462bb88bc93defcfa11235cbc8fdf3e12c4 +torchvision==0.28.0 \ + --hash=sha256:6dfb0f45e2b4ceb4e76f158c3fbb5f44387099f3c466e3423a09ab665a194aba +tqdm==4.70.0 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 +trafilatura==2.2.0 \ + --hash=sha256:ac43592a6201264dfc4f9c361cbe3eb3fea96e54437010a159d5e7365360ed98 +transformers==5.16.1 \ + --hash=sha256:2f2d5b98a5ad3718713653734298fa620754ed683702a635ebb587df3ed29c7e +tree-sitter-c==0.24.2 \ + --hash=sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede +tree-sitter-javascript==0.25.0 \ + --hash=sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75 +tree-sitter-python==0.25.0 \ + --hash=sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5 +tree-sitter-typescript==0.23.2 \ + --hash=sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c +tree-sitter==0.26.0 \ + --hash=sha256:e9e46b664887d8c1014f1fb33e09454bbdd9ec1fe29b7fd02dde7b46bc1bb81a +triton==3.7.1 \ + --hash=sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e +truststore==0.10.4 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 +typer==0.26.8 \ + --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c +typing-inspection==0.4.4 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 +typing_extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +tzdata==2026.3 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 +tzlocal==5.4.4 \ + --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15 +urllib3==2.7.0 \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +uuid_utils==0.17.0 \ + --hash=sha256:52db0e471d3d2632d35445af352591f40a8f32959a412981d9f51e068bb9514b +uvicorn==0.52.4 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 +uvloop==0.22.1 \ + --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd +watchdog==6.0.0 \ + --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 +watchfiles==1.2.0 \ + --hash=sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07 +websocket-client==1.9.2 \ + --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce +websockets==15.0.1 \ + --hash=sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb +xlsxwriter==3.2.9 \ + --hash=sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3 +xxhash==4.0.1 \ + --hash=sha256:7e27dbed5c4ba033919e4b4ed8dc14e029e91d14a93cd9f920d25277c7df6781 +yarl==1.24.5 \ + --hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba +zstandard==0.25.0 \ + --hash=sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0 diff --git a/requirements/lock-macosx_11_0_arm64-py310.txt b/requirements/lock-macosx_11_0_arm64-py310.txt new file mode 100644 index 00000000..0df2fbad --- /dev/null +++ b/requirements/lock-macosx_11_0_arm64-py310.txt @@ -0,0 +1,494 @@ +# GENERATED by scripts/generate_lock.py — do not edit by hand. +# Regenerate with: python scripts/generate_lock.py +# +# Valid ONLY for: macosx_11_0_arm64-py310 +# Locks are per platform+python: torch means CPU wheels on Windows and +# CUDA libraries on Linux, so one lock cannot serve every runner. +# +# Source: requirements.txt (sha256:bf6bd04ea6a2588c) +# Packages: 241 + +--require-hashes + +accelerate==1.14.0 \ + --hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 +aiohappyeyeballs==2.7.1 \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 +aiohttp==3.14.3 \ + --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 +annotated-types==0.8.0 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +anthropic==1.2.0 \ + --hash=sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6 +antlr4-python3-runtime==4.9.3 \ + --hash=sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 +async-timeout==5.0.1 \ + --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +babel==2.18.0 \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +bcrypt==5.0.0 \ + --hash=sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a +beautifulsoup4==4.15.0 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 +boto3==1.43.85 \ + --hash=sha256:f11bdaca18e59f53ec0529f4d6203dd1f0bb7ff165e51559d62fd863024abc9b +botocore==1.43.85 \ + --hash=sha256:685510e5f4c0f321806c815a60f121a176c0969665f053c4a336209cbe62b1d5 +build==1.6.0 \ + --hash=sha256:f7aaf1ebbb79178a02ba248bb524f2176b256017e17e8e4bd4289c7b38cc2bad +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +cffi==2.1.1 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b +chardet==7.6.0 \ + --hash=sha256:55a4c31adc7c7e83ad412f2f66b6b7358d0d4fe67505e7f58e18f68f75d341bb +charset-normalizer==3.5.1 \ + --hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa +chromadb==1.5.9 \ + --hash=sha256:814b9c95617377f6501e5757d63dfddb554a283a7739c87b9fa573850174e6f3 +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +cloudpickle==3.1.2 \ + --hash=sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a +coloredlogs==15.0.1 \ + --hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 +colorlog==6.12.0 \ + --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e +courlan==1.4.0 \ + --hash=sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e +croniter==6.2.4 \ + --hash=sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d +cryptography==50.0.1 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 +dateparser==1.4.2 \ + --hash=sha256:752f3d49d477cf7f60a7a9c8bcb19c882496ede0e377d5a3d80014cdfeca7050 +defusedxml==0.7.1 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 +dill==0.4.1 \ + --hash=sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d +distro==1.9.0 \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af +doclang==0.7.3 \ + --hash=sha256:9440c4ca9f7e061a7b8d33bdf15b1029be69a4c13cd8952dd6ce541884e4c685 +docling-core==2.92.0 \ + --hash=sha256:726d89c23197e53be2f7192bb4c00108ff545830cf9ab7b981fb014fa92b5c02 +docling-ibm-models==4.0.0 \ + --hash=sha256:b4dc321ca9203bfa7cda6092e6b996845fd592457dfe49dd69247fa75d3770aa +docling-parse==7.16.0 \ + --hash=sha256:2eec0297713e85076a9856ba455b546ec108b7d1d0c485a4a0260e820e722ca9 +docling-slim==2.124.0 \ + --hash=sha256:2c7c667394d3eae9080bc15143ed6f1d39a4838090d9e3f29be212606e6676bd +docling==2.124.0 \ + --hash=sha256:bff775aea776429e3bc1d20972dc173e80cd748b1202ebadc4c3ab64c156cdb5 +docstring_parser==0.18.0 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b +durationpy==0.11 \ + --hash=sha256:a739fe2b8972c250ff72f8e2c488d18cf25f7b852f49ee76048775d5171df30c +et_xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa +exceptiongroup==1.3.1 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 +fake-useragent==2.2.0 \ + --hash=sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24 +Faker==40.37.0 \ + --hash=sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490 +filelock==3.32.5 \ + --hash=sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2 +filetype==1.2.0 \ + --hash=sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25 +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +fonttools==4.64.0 \ + --hash=sha256:50e52b6f479ddb1fe32423c2ec860811f36584cf6eabf279fb9a4f98b859a8b4 +fpdf2==2.8.8 \ + --hash=sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17 +frozenlist==1.8.0 \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 +google-auth==2.57.0 \ + --hash=sha256:180dafe015cfb62193bea26b677500fab5b9fd51a1e825ebf3ad9b182047ae59 +google-genai==2.21.0 \ + --hash=sha256:36b575034be46a03acd603a852e22a6359f2cdd6b26bb1d65d9b7e0cc7ab3648 +googleapis-common-protos==1.75.2 \ + --hash=sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e +googlesearch-python==1.3.0 \ + --hash=sha256:808c4dd390dc4c6a1cfba2f5151f5ef16dceb0a200d9770b388dcd39162b4e19 +gradio_client==2.6.1 \ + --hash=sha256:df3752925fbaaa56f7bfeeb0064ae23591ec3976432443748dd663a9729ef23b +greenlet==3.5.5 \ + --hash=sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667 +grpcio==1.83.1 \ + --hash=sha256:b7ace1f740b36fcd451a1bb96f71ee7650e60b308822baeb66a023965bc27f4b +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +hf-xet==1.6.0 \ + --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a +htmldate==1.10.0 \ + --hash=sha256:9211dae35ab94147c8ed9e5fc2c9287a5cf31d2394cb7857e7f5dd814eb2aad6 +httpcore2==2.12.0 \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +httptools==0.8.0 \ + --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc +httpx2==2.12.0 \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +huggingface_hub==1.29.0 \ + --hash=sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd +humanfriendly==10.0 \ + --hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 +idna==3.19 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 +importlib_resources==7.1.0 \ + --hash=sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1 +Jinja2==3.1.6 \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +jiter==0.16.0 \ + --hash=sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b +jmespath==1.1.0 \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 +joblib==1.6.0 \ + --hash=sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba +jsonpatch==1.33 \ + --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade +jsonpointer==3.1.1 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca +jsonref==1.1.0 \ + --hash=sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9 +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +jsonschema==4.26.0 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +jusText==3.0.2 \ + --hash=sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7 +kubernetes==36.0.3 \ + --hash=sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f +langchain-core==1.6.1 \ + --hash=sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a +langchain-protocol==0.0.19 \ + --hash=sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f +langgraph-checkpoint==4.2.0 \ + --hash=sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942 +langgraph-prebuilt==1.1.0 \ + --hash=sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9 +langgraph-sdk==0.4.4 \ + --hash=sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64 +langgraph==1.2.11 \ + --hash=sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b +langsmith==0.11.2 \ + --hash=sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f +lark-oapi==1.7.3 \ + --hash=sha256:c91f00087b7977dc9059ab492e8fe435e1a873863dca1d4e660d2be5b801e4cd +latex2mathml==3.81.0 \ + --hash=sha256:d317710393fe20579aea39cfe8928fa2ad9b8780896e585326c75e89c1d1d1a4 +loguru==0.7.3 \ + --hash=sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c +lxml==6.1.2 \ + --hash=sha256:522387e05cd015a81d1dc621fb167fb42b8f629ccd2e8b39de583828f165aae6 +lxml_html_clean==0.4.5 \ + --hash=sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746 +mail-parser==4.6.4 \ + --hash=sha256:bc6e437b3afe38091893e7b6ea49c7f2188ad7616abdd4fa3059e85f1b69efce +markdown-it-py==4.2.0 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +markdown2==2.5.5 \ + --hash=sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941 +marko==2.2.4 \ + --hash=sha256:d80510506edba096ec49d4720a09645fa0bb78e7b7b88697f20032fc19730aa9 +MarkupSafe==3.0.3 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 +mmh3==5.3.0 \ + --hash=sha256:6be479b31ba2f4f86f886060cff1e640facc7f22d266d960b0ca41a9bf2690ef +MouseInfo==0.1.3 \ + --hash=sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7 +mpire==2.10.2 \ + --hash=sha256:d627707f7a8d02aa4c7f7d59de399dec5290945ddf7fbd36cbb1d6ebb37a51fb +mpmath==1.3.0 \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c +mss==10.2.0 \ + --hash=sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197 +multidict==6.7.1 \ + --hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 +multiprocess==0.70.19 \ + --hash=sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87 +neo4j==6.3.0 \ + --hash=sha256:d243f9c8adf882ae7205a76eb2419b0a632d5f3c89383d4a0044d737a2223991 +nest-asyncio==1.6.0 \ + --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c +networkx==3.4.2 \ + --hash=sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f +numpy==2.2.6 \ + --hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 +oauthlib==3.3.1 \ + --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 +olefile==0.47 \ + --hash=sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f +omegaconf==2.3.1 \ + --hash=sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0 +onnxruntime==1.23.2 \ + --hash=sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3 +openai==3.6.0 \ + --hash=sha256:508e2158bf971687f953b62e44b02f207792c815aac306816386d7ba34d37f5f +opencv-python-headless==5.0.0.93 \ + --hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f +opencv-python==5.0.0.93 \ + --hash=sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 +opentelemetry-api==1.44.0 \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef +opentelemetry-exporter-otlp-proto-common==1.44.0 \ + --hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 +opentelemetry-exporter-otlp-proto-grpc==1.44.0 \ + --hash=sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e +opentelemetry-proto==1.44.0 \ + --hash=sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56 +opentelemetry-sdk==1.44.0 \ + --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad +opentelemetry-semantic-conventions==0.65b0 \ + --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb +orjson==3.12.0 \ + --hash=sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796 +ormsgpack==1.12.2 \ + --hash=sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657 +overrides==7.7.0 \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pandas==2.3.3 \ + --hash=sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a +pdfminer.six==20260107 \ + --hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 +pdfplumber==0.11.10 \ + --hash=sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580 +pillow==12.3.0 \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 +playwright==1.62.0 \ + --hash=sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034 +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +polyfactory==3.3.0 \ + --hash=sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e +propcache==0.5.2 \ + --hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb +protobuf==7.36.1 \ + --hash=sha256:3cf2ee25d006cee57294a1196ea43b37feb78e0dcd1e8af5c1aeddb777655aca +psutil==7.2.2 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 +pyaes==1.6.1 \ + --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f +pyasn1==0.6.4 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b +pyasn1_modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a +PyAutoGUI==0.9.54 \ + --hash=sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2 +pybase64==1.5.0 \ + --hash=sha256:43885294c9e7c79c4a43c42fe759a82e92d8822fe3e7f2f8b23af90e5dbc4269 +pyclipper==1.4.0 \ + --hash=sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73 +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +pycryptodome==3.23.0 \ + --hash=sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27 +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 +pydantic_core==2.46.5 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 +pyee==13.0.1 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 +PyGetWindow==0.0.9 \ + --hash=sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688 +Pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 +pylatexenc==2.11 \ + --hash=sha256:e78e7391d6c104f1ed150e21cfaa58016cdb50aa54406a2eecb793649ffdfdd0 +pymongo==4.17.0 \ + --hash=sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca +PyMsgBox==2.0.1 \ + --hash=sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b +pymupdf==1.28.2 \ + --hash=sha256:7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae +pyobjc-core==12.2.2 \ + --hash=sha256:56c6c39f1de059fcbb174ebca5525505fc8feaa89be2a28c329bf09b6b25ee75 +pyobjc-framework-Cocoa==12.2.2 \ + --hash=sha256:5a751c8033a3b51f7996f0327e0675eb44dcfdfe7920fae01e3d78b662723fff +pyobjc-framework-Quartz==12.2.2 \ + --hash=sha256:d89a5f47c079b5c340d2b1cbb83eb6c4c92d4bb17cd4daf7d8c02c91a49f5399 +pypdf==6.16.2 \ + --hash=sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604 +pypdfium2==5.13.0 \ + --hash=sha256:da5c7b74eebf40b5c1fbe1de01aa1edc8827a79fb1efd999616bc20dcaf77ba4 +pyperclip==1.11.0 \ + --hash=sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273 +PyPika==0.51.1 \ + --hash=sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46 +pyproject_hooks==1.2.0 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +PyRect==0.2.0 \ + --hash=sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78 +PyScreeze==1.0.1 \ + --hash=sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be +pytesseract==0.3.13 \ + --hash=sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34 +python-dateutil==2.9.0.post0 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +python-docx==1.2.0 \ + --hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 +python-oxmsg==0.0.2 \ + --hash=sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355 +python-pptx==1.0.2 \ + --hash=sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba +python3-xlib==0.15 \ + --hash=sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8 +pytweening==1.2.0 \ + --hash=sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b +pytz==2026.3.post1 \ + --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815 +PyYAML==6.0.3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 +qrcode==8.2 \ + --hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f +rank-bm25==0.2.2 \ + --hash=sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae +rapidocr==3.9.2 \ + --hash=sha256:04d6b8d151f823d930bd91910555f57bea897c0c44fa6794267b94cf9c1ef9a0 +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +regex==2026.9.3 \ + --hash=sha256:05e9f7d16b42686fb38b1702071a7359469ba89e9d516e2ba5228e077dcac524 +requests-oauthlib==2.0.0 \ + --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 +requests-toolbelt==1.0.0 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb +rpds-py==0.30.0 \ + --hash=sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00 +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 +rtree==1.4.1 \ + --hash=sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d +rubicon-objc==0.5.6 \ + --hash=sha256:b53f6fc458d78ddf2ce82365c34e234ae85cd8217c983438ae4bf9e1bd1252b3 +s3transfer==0.19.2 \ + --hash=sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 +safetensors==0.8.0 \ + --hash=sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 +scikit-learn==1.7.2 \ + --hash=sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c +scipy==1.15.3 \ + --hash=sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f +semchunk==3.2.5 \ + --hash=sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076 +sentence-transformers==6.0.1 \ + --hash=sha256:b8888d72c707ba33c63aa30845850702dd5acadf1dd0d051436380bcebe4fd0f +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +shapely==2.1.2 \ + --hash=sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 +soupsieve==2.9.2 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 +sympy==1.14.0 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 +tabulate==0.10.0 \ + --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 +Telethon==1.44.0 \ + --hash=sha256:52fc49efb67a4916c2aedcb295ad286f4afa2aba9bf15d83ed2acdc64af0c718 +tenacity==9.1.4 \ + --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 +threadpoolctl==3.6.0 \ + --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb +tiktoken==0.14.0 \ + --hash=sha256:94f77b60a8ab23580db19ae822744c9716c1720020d2179ca5605112d12326f1 +tld==0.13.2 \ + --hash=sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c +tokenizers==0.22.2 \ + --hash=sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 +tomli==2.4.1 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe +torch==2.13.0 \ + --hash=sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d +torchvision==0.28.0 \ + --hash=sha256:2a1ef4b6f4bf5828b48cfad97372c8982db906830884b2868ba5c3df937a7d81 +tqdm==4.70.0 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 +trafilatura==2.2.0 \ + --hash=sha256:ac43592a6201264dfc4f9c361cbe3eb3fea96e54437010a159d5e7365360ed98 +transformers==5.8.1 \ + --hash=sha256:5340fb95962162cdfdae5cc91d7f8fedd92ed75216c1154c5e1f590fcf56dd0e +tree-sitter-c==0.24.2 \ + --hash=sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd +tree-sitter-javascript==0.25.0 \ + --hash=sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1 +tree-sitter-python==0.25.0 \ + --hash=sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762 +tree-sitter-typescript==0.23.2 \ + --hash=sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8 +tree-sitter==0.26.0 \ + --hash=sha256:7bcbadfa614326debef581957d5c780a9d7f66065c13deea61aa21d1dd36263f +truststore==0.10.4 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 +typer==0.26.8 \ + --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c +typing-inspection==0.4.4 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 +typing_extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +tzdata==2026.3 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 +tzlocal==5.4.4 \ + --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15 +urllib3==2.7.0 \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +uuid_utils==0.17.0 \ + --hash=sha256:d2d9a63a9e6f2416ace8c109043a9280d6b34f34bb2e5421903e149403db40a6 +uvicorn==0.52.4 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 +uvloop==0.22.1 \ + --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c +watchdog==6.0.0 \ + --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 +watchfiles==1.2.0 \ + --hash=sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4 +websocket-client==1.9.2 \ + --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce +websockets==15.0.1 \ + --hash=sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a +xlsxwriter==3.2.9 \ + --hash=sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3 +xxhash==4.0.1 \ + --hash=sha256:9b1dddc257279417d93c9e59420d49ef90aece90d7a01996db3aade74b0281b1 +yarl==1.24.5 \ + --hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 +zstandard==0.25.0 \ + --hash=sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7 diff --git a/requirements/lock-win_amd64-py310.txt b/requirements/lock-win_amd64-py310.txt new file mode 100644 index 00000000..4d8e4d7f --- /dev/null +++ b/requirements/lock-win_amd64-py310.txt @@ -0,0 +1,490 @@ +# GENERATED by scripts/generate_lock.py — do not edit by hand. +# Regenerate with: python scripts/generate_lock.py +# +# Valid ONLY for: win_amd64-py310 +# Locks are per platform+python: torch means CPU wheels on Windows and +# CUDA libraries on Linux, so one lock cannot serve every runner. +# +# Source: requirements.txt (sha256:bf6bd04ea6a2588c) +# Packages: 239 + +--require-hashes + +accelerate==1.14.0 \ + --hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 +aiohappyeyeballs==2.7.1 \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 +aiohttp==3.14.3 \ + --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 +annotated-types==0.8.0 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +anthropic==1.2.0 \ + --hash=sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6 +antlr4-python3-runtime==4.9.3 \ + --hash=sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 +async-timeout==5.0.1 \ + --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +babel==2.18.0 \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +bcrypt==5.0.0 \ + --hash=sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2 +beautifulsoup4==4.15.0 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 +boto3==1.43.83 \ + --hash=sha256:73a3564f737d4516625964eee709a498fa98ccee6aca929febad2b0b5fbeae1e +botocore==1.43.83 \ + --hash=sha256:bf75a6cf587c22d968e43e79fe122c39f82deafbe9c3422bc5d3e80b6210fc98 +build==1.6.0 \ + --hash=sha256:f7aaf1ebbb79178a02ba248bb524f2176b256017e17e8e4bd4289c7b38cc2bad +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +cffi==2.1.1 \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 +chardet==7.6.0 \ + --hash=sha256:d6030886e7da2740bf299b6a8cc75b4dcc2c90db0ca8fe0a6e4fd0bfd071dabd +charset-normalizer==3.5.1 \ + --hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d +chromadb==1.5.9 \ + --hash=sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +colorama==0.4.6 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +coloredlogs==15.0.1 \ + --hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 +colorlog==6.12.0 \ + --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e +courlan==1.4.0 \ + --hash=sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e +croniter==6.2.4 \ + --hash=sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d +cryptography==50.0.1 \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 +dateparser==1.4.2 \ + --hash=sha256:752f3d49d477cf7f60a7a9c8bcb19c882496ede0e377d5a3d80014cdfeca7050 +defusedxml==0.7.1 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 +dill==0.4.1 \ + --hash=sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d +distro==1.9.0 \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af +doclang==0.7.3 \ + --hash=sha256:9440c4ca9f7e061a7b8d33bdf15b1029be69a4c13cd8952dd6ce541884e4c685 +docling-core==2.92.0 \ + --hash=sha256:726d89c23197e53be2f7192bb4c00108ff545830cf9ab7b981fb014fa92b5c02 +docling-ibm-models==4.0.0 \ + --hash=sha256:b4dc321ca9203bfa7cda6092e6b996845fd592457dfe49dd69247fa75d3770aa +docling-parse==7.16.0 \ + --hash=sha256:8d383646bd8f11969b1e45c0107ead107354e4ff20661943642091e92cffcc99 +docling-slim==2.123.1 \ + --hash=sha256:bdb99f2ed3bed1e1ad5b18b8e139cbe16ef729c93945f6b0a7f99f63a8fc9770 +docling==2.123.1 \ + --hash=sha256:ec012ac0cd6bfc97b357f6aedd33aadbbc9a23ba08e5cee598081baff756030e +docstring_parser==0.18.0 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b +durationpy==0.11 \ + --hash=sha256:a739fe2b8972c250ff72f8e2c488d18cf25f7b852f49ee76048775d5171df30c +et_xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa +exceptiongroup==1.3.1 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 +fake-useragent==2.2.0 \ + --hash=sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24 +Faker==40.37.0 \ + --hash=sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490 +filelock==3.32.4 \ + --hash=sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd +filetype==1.2.0 \ + --hash=sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25 +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +fonttools==4.63.0 \ + --hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac +fpdf2==2.8.8 \ + --hash=sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17 +frozenlist==1.8.0 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 +google-auth==2.57.0 \ + --hash=sha256:180dafe015cfb62193bea26b677500fab5b9fd51a1e825ebf3ad9b182047ae59 +google-genai==2.20.0 \ + --hash=sha256:49bddeccd29a4e6bf1706c5de67735f7115f537f08b6c36a70b8023c99399095 +googleapis-common-protos==1.75.2 \ + --hash=sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e +googlesearch-python==1.3.0 \ + --hash=sha256:808c4dd390dc4c6a1cfba2f5151f5ef16dceb0a200d9770b388dcd39162b4e19 +gradio_client==2.6.1 \ + --hash=sha256:df3752925fbaaa56f7bfeeb0064ae23591ec3976432443748dd663a9729ef23b +greenlet==3.5.5 \ + --hash=sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc +grpcio==1.83.1 \ + --hash=sha256:7d43e3bd2b7d749c2dbd41c2cc83d550c3343d299a19acbbba9e37ad8c11fa8e +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +hf-xet==1.6.0 \ + --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b +htmldate==1.10.0 \ + --hash=sha256:9211dae35ab94147c8ed9e5fc2c9287a5cf31d2394cb7857e7f5dd814eb2aad6 +httpcore2==2.12.0 \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +httptools==0.8.0 \ + --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc +httpx2==2.12.0 \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +huggingface_hub==1.29.0 \ + --hash=sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd +humanfriendly==10.0 \ + --hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 +idna==3.19 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 +importlib_resources==7.1.0 \ + --hash=sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1 +Jinja2==3.1.6 \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +jiter==0.16.0 \ + --hash=sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26 +jmespath==1.1.0 \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 +joblib==1.5.3 \ + --hash=sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 +jsonpatch==1.33 \ + --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade +jsonpointer==3.1.1 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca +jsonref==1.1.0 \ + --hash=sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9 +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +jsonschema==4.26.0 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +jusText==3.0.2 \ + --hash=sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7 +kubernetes==36.0.3 \ + --hash=sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f +langchain-core==1.6.1 \ + --hash=sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a +langchain-protocol==0.0.19 \ + --hash=sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f +langgraph-checkpoint==4.2.0 \ + --hash=sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942 +langgraph-prebuilt==1.1.0 \ + --hash=sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9 +langgraph-sdk==0.4.4 \ + --hash=sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64 +langgraph==1.2.11 \ + --hash=sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b +langsmith==0.11.2 \ + --hash=sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f +lark-oapi==1.7.3 \ + --hash=sha256:c91f00087b7977dc9059ab492e8fe435e1a873863dca1d4e660d2be5b801e4cd +latex2mathml==3.81.0 \ + --hash=sha256:d317710393fe20579aea39cfe8928fa2ad9b8780896e585326c75e89c1d1d1a4 +loguru==0.7.3 \ + --hash=sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c +lxml==6.1.2 \ + --hash=sha256:1c0173595dc1c25768f42681a1517dcfc74bb18a34695f127931cbd05f4dead6 +lxml_html_clean==0.4.5 \ + --hash=sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746 +mail-parser==4.6.4 \ + --hash=sha256:bc6e437b3afe38091893e7b6ea49c7f2188ad7616abdd4fa3059e85f1b69efce +markdown-it-py==4.2.0 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +markdown2==2.5.5 \ + --hash=sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941 +marko==2.2.4 \ + --hash=sha256:d80510506edba096ec49d4720a09645fa0bb78e7b7b88697f20032fc19730aa9 +MarkupSafe==3.0.3 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 +mmh3==5.3.0 \ + --hash=sha256:4770ee0d719be9edc3849a231fc29fde75bfab6cf234b79733ed35cb7cf901f7 +MouseInfo==0.1.3 \ + --hash=sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7 +mpire==2.10.2 \ + --hash=sha256:d627707f7a8d02aa4c7f7d59de399dec5290945ddf7fbd36cbb1d6ebb37a51fb +mpmath==1.3.0 \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c +mss==10.2.0 \ + --hash=sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197 +multidict==6.7.1 \ + --hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df +multiprocess==0.70.19 \ + --hash=sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87 +neo4j==6.3.0 \ + --hash=sha256:d243f9c8adf882ae7205a76eb2419b0a632d5f3c89383d4a0044d737a2223991 +nest-asyncio==1.6.0 \ + --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c +networkx==3.4.2 \ + --hash=sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f +numpy==2.2.6 \ + --hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 +oauthlib==3.3.1 \ + --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 +olefile==0.47 \ + --hash=sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f +omegaconf==2.3.1 \ + --hash=sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0 +onnxruntime==1.23.2 \ + --hash=sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc +openai==3.6.0 \ + --hash=sha256:508e2158bf971687f953b62e44b02f207792c815aac306816386d7ba34d37f5f +opencv-python-headless==5.0.0.93 \ + --hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e +opencv-python==5.0.0.93 \ + --hash=sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2 +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 +opentelemetry-api==1.44.0 \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef +opentelemetry-exporter-otlp-proto-common==1.44.0 \ + --hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 +opentelemetry-exporter-otlp-proto-grpc==1.44.0 \ + --hash=sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e +opentelemetry-proto==1.44.0 \ + --hash=sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56 +opentelemetry-sdk==1.44.0 \ + --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad +opentelemetry-semantic-conventions==0.65b0 \ + --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb +orjson==3.12.0 \ + --hash=sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df +ormsgpack==1.12.2 \ + --hash=sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f +overrides==7.7.0 \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pandas==2.3.3 \ + --hash=sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826 +pdfminer.six==20260107 \ + --hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 +pdfplumber==0.11.10 \ + --hash=sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580 +pillow==12.3.0 \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb +playwright==1.62.0 \ + --hash=sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1 +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +polyfactory==3.3.0 \ + --hash=sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e +propcache==0.5.2 \ + --hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d +protobuf==7.36.0 \ + --hash=sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488 +psutil==7.2.2 \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 +pyaes==1.6.1 \ + --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f +pyasn1==0.6.4 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b +pyasn1_modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a +PyAutoGUI==0.9.54 \ + --hash=sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2 +pybase64==1.5.0 \ + --hash=sha256:283d2fabf23e356e72b4fb8a59f5e319202c0328c748f6596f14459b0650bfbb +pyclipper==1.4.0 \ + --hash=sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4 +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +pycryptodome==3.23.0 \ + --hash=sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2 +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 +pydantic_core==2.46.5 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d +pyee==13.0.1 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 +PyGetWindow==0.0.9 \ + --hash=sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688 +Pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 +pylatexenc==2.11 \ + --hash=sha256:e78e7391d6c104f1ed150e21cfaa58016cdb50aa54406a2eecb793649ffdfdd0 +pymongo==4.17.0 \ + --hash=sha256:e46767f28dea610e02edf6c5d956ce615c3c7790ea396660b9b1efd5c5ead2e0 +PyMsgBox==2.0.1 \ + --hash=sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b +pymupdf==1.28.2 \ + --hash=sha256:ebd244918798502d7b4504c90410d1711a4d7675a32584ca30f1bab419ecbffe +pypdf==6.16.2 \ + --hash=sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604 +pypdfium2==5.13.0 \ + --hash=sha256:47dcca2a8d507b5fd24f94c3c9d48fb379430f097bc20f01beff6c963ffbcedb +pyperclip==1.11.0 \ + --hash=sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273 +PyPika==0.51.1 \ + --hash=sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46 +pyproject_hooks==1.2.0 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +pyreadline3==3.5.6 \ + --hash=sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d +PyRect==0.2.0 \ + --hash=sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78 +PyScreeze==1.0.1 \ + --hash=sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be +pytesseract==0.3.13 \ + --hash=sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34 +python-dateutil==2.9.0.post0 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +python-docx==1.2.0 \ + --hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 +python-oxmsg==0.0.2 \ + --hash=sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355 +python-pptx==1.0.2 \ + --hash=sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba +python3-xlib==0.15 \ + --hash=sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8 +pytweening==1.2.0 \ + --hash=sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b +pytz==2026.3.post1 \ + --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815 +pywin32==312 \ + --hash=sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db +PyYAML==6.0.3 \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c +qrcode==8.2 \ + --hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f +rank-bm25==0.2.2 \ + --hash=sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae +rapidocr==3.9.2 \ + --hash=sha256:04d6b8d151f823d930bd91910555f57bea897c0c44fa6794267b94cf9c1ef9a0 +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +regex==2026.8.31 \ + --hash=sha256:f59d36c5356ca6ff79b1a91ef39845c0dd71eeee6b98d71cd0972307eba77260 +requests-oauthlib==2.0.0 \ + --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 +requests-toolbelt==1.0.0 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb +rpds-py==0.30.0 \ + --hash=sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169 +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 +rtree==1.4.1 \ + --hash=sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489 +s3transfer==0.19.2 \ + --hash=sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 +safetensors==0.8.0 \ + --hash=sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f +scikit-learn==1.7.2 \ + --hash=sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5 +scipy==1.15.3 \ + --hash=sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13 +semchunk==3.2.5 \ + --hash=sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076 +sentence-transformers==6.0.1 \ + --hash=sha256:b8888d72c707ba33c63aa30845850702dd5acadf1dd0d051436380bcebe4fd0f +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +shapely==2.1.2 \ + --hash=sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 +soupsieve==2.9.2 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 +sympy==1.14.0 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 +tabulate==0.10.0 \ + --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 +Telethon==1.44.0 \ + --hash=sha256:52fc49efb67a4916c2aedcb295ad286f4afa2aba9bf15d83ed2acdc64af0c718 +tenacity==9.1.4 \ + --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 +threadpoolctl==3.6.0 \ + --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb +tiktoken==0.14.0 \ + --hash=sha256:c6cb9896a82b9ee44e15ba0b5c8044072f2e4d48acaa704c8d3feeef5ad9487c +tld==0.13.2 \ + --hash=sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c +tokenizers==0.23.1 \ + --hash=sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7 +tomli==2.4.1 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe +torch==2.13.0 \ + --hash=sha256:2bd30b6b730d987fa386ce3898933762c5cb8cc82eb0535211d787cc3ce2dfeb +torchvision==0.28.0 \ + --hash=sha256:7fad44dc9582570c7d92c4487d36ac46998f40cc39b438e8b8f5111a935ce4e8 +tqdm==4.70.0 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 +trafilatura==2.2.0 \ + --hash=sha256:ac43592a6201264dfc4f9c361cbe3eb3fea96e54437010a159d5e7365360ed98 +transformers==5.16.1 \ + --hash=sha256:2f2d5b98a5ad3718713653734298fa620754ed683702a635ebb587df3ed29c7e +tree-sitter-c==0.24.2 \ + --hash=sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1 +tree-sitter-javascript==0.25.0 \ + --hash=sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c +tree-sitter-python==0.25.0 \ + --hash=sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76 +tree-sitter-typescript==0.23.2 \ + --hash=sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9 +tree-sitter==0.26.0 \ + --hash=sha256:f289be0225ba2ace8e87d6c9639b2bc9ff2b5271afb7c5d39282a4a00e248682 +truststore==0.10.4 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 +typer==0.26.8 \ + --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c +typing-inspection==0.4.4 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 +typing_extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +tzdata==2026.3 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 +tzlocal==5.4.4 \ + --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15 +urllib3==2.7.0 \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +uuid_utils==0.17.0 \ + --hash=sha256:981cc10163988defea96e8d6c507df151eab8f483e7df9ae543d5a41a4be073b +uvicorn==0.52.4 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 +watchdog==6.0.0 \ + --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 +watchfiles==1.2.0 \ + --hash=sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d +websocket-client==1.9.1 \ + --hash=sha256:19a871b81d0022589dd9e8e1b1891e0516d32113837dfb987d7691b6a425a1e2 +websockets==15.0.1 \ + --hash=sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41 +win32_setctime==1.2.0 \ + --hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 +xlsxwriter==3.2.9 \ + --hash=sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3 +xxhash==4.0.1 \ + --hash=sha256:8ec4777d92fd61a5c8fdeddab894fd65bea301a8092fb5419ec6472aa4d458d7 +yarl==1.24.5 \ + --hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 +zstandard==0.25.0 \ + --hash=sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e diff --git a/rthooks/rthook-rich-unicode.py b/rthooks/rthook-rich-unicode.py deleted file mode 100644 index 29b9d3db..00000000 --- a/rthooks/rthook-rich-unicode.py +++ /dev/null @@ -1,45 +0,0 @@ -"""PyInstaller runtime hook: custom finder for rich._unicode_data.unicodeX-X-X. - -rich._unicode_data.__init__ uses importlib.import_module() to load modules -with hyphenated names (e.g. 'unicode17-0-0'). PyInstaller's FrozenImporter -cannot handle these, so we install a meta-path finder that loads them from -the filesystem (they are included via our companion hook as data files). -""" - -import sys -import os -import importlib -import importlib.abc -import importlib.util - - -class _RichUnicodeDataFinder(importlib.abc.MetaPathFinder): - def find_module(self, fullname, path=None): - if not fullname.startswith("rich._unicode_data.unicode"): - return None - base = getattr(sys, "_MEIPASS", None) - if base is None: - return None - filepath = os.path.join( - base, "rich", "_unicode_data", fullname.rsplit(".", 1)[-1] + ".py" - ) - if os.path.isfile(filepath): - return self - return None - - def load_module(self, fullname): - if fullname in sys.modules: - return sys.modules[fullname] - base = sys._MEIPASS - filepath = os.path.join( - base, "rich", "_unicode_data", fullname.rsplit(".", 1)[-1] + ".py" - ) - spec = importlib.util.spec_from_file_location(fullname, filepath) - mod = importlib.util.module_from_spec(spec) - sys.modules[fullname] = mod - spec.loader.exec_module(mod) - return mod - - -if getattr(sys, "frozen", False): - sys.meta_path.insert(0, _RichUnicodeDataFinder()) diff --git a/rthooks/rthook-utf8-stdio.py b/rthooks/rthook-utf8-stdio.py deleted file mode 100644 index 3d317593..00000000 --- a/rthooks/rthook-utf8-stdio.py +++ /dev/null @@ -1,159 +0,0 @@ -"""PyInstaller runtime hook: force UTF-8 on stdout/stderr. - -In frozen builds — especially `console=False` windowed builds on Windows — -Python's stdout/stderr default to the system locale codec (cp1252 in most -locales). Any non-Latin-1 character (the box-drawing glyphs in run.py's -banner, emoji in messages, etc.) crashes with UnicodeEncodeError. - -PYTHONIOENCODING is ignored by frozen PyInstaller bootloaders, so we -reconfigure here at startup, before any user code runs. - -Strategy: - 0. PROBE the stream first by attempting a no-op write+flush. In a - `console=False` windowed build on Windows, `sys.stdout` exists as a - valid Python TextIOWrapper but its underlying Windows file handle - is invalid — every real write raises `OSError: [Errno 22] Invalid - argument`. The previous version of this hook trusted - `stream.encoding == 'utf-8'` as proof the stream worked, which let - these broken streams through unchanged and crashed later (e.g. on - `print(..., flush=True)` in run.py's print_step). Probe = the only - reliable test. - 1. If the stream supports `.reconfigure()` (Python 3.7+ TextIOWrapper), - try it. Cheap and non-destructive. - 2. Otherwise rebuild a fresh TextIOWrapper around the underlying FD - with UTF-8 encoding. We have to grab the FD before replacing. - 3. After every path we re-probe; if writes still fail, install a - NullIO sink so unconditional `print()` calls don't crash. -""" - -import io -import os -import sys - - -class _NullIO(io.TextIOBase): - def isatty(self) -> bool: - return False - - def write(self, s: str) -> int: - return len(s) - - def flush(self) -> None: - pass - - @property - def encoding(self) -> str: - return "utf-8" - - -class _SafeIO(io.TextIOBase): - """Wraps a real stream and swallows OSError/ValueError on every write - or flush. The probe in `_force_utf8` catches streams that are broken - AT STARTUP, but in a frozen Windows build a stream can be valid when - probed and start failing later (e.g. when a child process inherits - the parent's stdout fd through a chain of subprocess.Popen calls and - the eventual write to that fd raises errno 22). Wrapping defensively - means a one-off write error doesn't crash the program — it just - drops the message.""" - - def __init__(self, inner) -> None: - self._inner = inner - - def write(self, s: str) -> int: - try: - return self._inner.write(s) - except (OSError, ValueError): - return len(s) if isinstance(s, str) else 0 - - def flush(self) -> None: - try: - self._inner.flush() - except (OSError, ValueError): - pass - - def isatty(self) -> bool: - try: - return self._inner.isatty() - except Exception: - return False - - def fileno(self): - return self._inner.fileno() - - @property - def encoding(self) -> str: - return getattr(self._inner, "encoding", "utf-8") or "utf-8" - - @property - def buffer(self): - return getattr(self._inner, "buffer", None) - - -def _is_broken(stream) -> bool: - """Return True if a no-op write+flush raises. Detects PyInstaller - `console=False` Windows builds where stdout is a TextIOWrapper around - an invalid HANDLE — encoding looks fine but real I/O explodes.""" - try: - stream.write("") - stream.flush() - except (OSError, ValueError, AttributeError): - return True - return False - - -def _force_utf8(name: str) -> None: - stream = getattr(sys, name, None) - - # No stream at all, or stream is already broken — install NullIO. - if stream is None or _is_broken(stream): - setattr(sys, name, _NullIO()) - return - - # Already UTF-8 and the probe passed — wrap in SafeIO and return. - enc = getattr(stream, "encoding", "") or "" - if enc.lower().replace("-", "") == "utf8": - setattr(sys, name, _SafeIO(stream)) - return - - # Path 1: reconfigure() — preserves the stream object identity. - if hasattr(stream, "reconfigure"): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - if not _is_broken(getattr(sys, name)): - setattr(sys, name, _SafeIO(getattr(sys, name))) - return - except Exception: - pass - - # Path 2: rebuild a TextIOWrapper around the same FD with UTF-8. - fd = -1 - try: - fd = stream.fileno() - except Exception: - pass - if fd != -1: - try: - try: - stream.flush() - except Exception: - pass - new = io.TextIOWrapper( - os.fdopen(fd, "wb", buffering=0, closefd=False), - encoding="utf-8", - errors="replace", - line_buffering=True, - write_through=True, - ) - setattr(sys, name, new) - if not _is_broken(new): - setattr(sys, name, _SafeIO(new)) - return - except Exception: - pass - - # Path 3: nothing worked — replace with a sink so prints don't crash. - setattr(sys, name, _NullIO()) - - -for _name in ("stdout", "stderr"): - _force_utf8(_name) diff --git a/rthooks/rthook-windows-noflash.py b/rthooks/rthook-windows-noflash.py deleted file mode 100644 index 7205de14..00000000 --- a/rthooks/rthook-windows-noflash.py +++ /dev/null @@ -1,90 +0,0 @@ -"""PyInstaller runtime hook: suppress per-subprocess console windows on Windows. - -The frozen agent and installer EXEs are built with `console=False` (Windows -GUI subsystem, no console attached). When a no-console Windows process spawns -a CLI child without `creationflags=CREATE_NO_WINDOW`, Windows allocates a -fresh console for that child — visible as a brief terminal flash. - -Patching every subprocess call site individually doesn't scale (the agent has -spawns spread across MCP servers, action executor/registry, Agent App manager, -scheduler, GUI handler, npm bridge, etc.). Instead we patch the choke points -once here, before any user code runs: - - - subprocess.Popen.__init__ covers subprocess.run / call / check_output / - asyncio.create_subprocess_exec / _shell - - _winapi.CreateProcess used by asyncio's ProactorEventLoop directly - - os.system spawns cmd.exe with its own console; replace - with subprocess.run which is now patched - -`flags | CREATE_NO_WINDOW` is idempotent, so callers that already set the flag -explicitly keep working unchanged. - -Linux/macOS: no-op. -""" - -import sys - -if sys.platform == "win32": - import os - import subprocess - - _CREATE_NO_WINDOW = 0x08000000 - - # ── subprocess.Popen ───────────────────────────────────────────────────── - _original_popen_init = subprocess.Popen.__init__ - - def _patched_popen_init(self, *args, **kwargs): - flags = kwargs.get("creationflags", 0) or 0 - kwargs["creationflags"] = flags | _CREATE_NO_WINDOW - return _original_popen_init(self, *args, **kwargs) - - subprocess.Popen.__init__ = _patched_popen_init - - # ── _winapi.CreateProcess (asyncio Proactor path) ──────────────────────── - try: - import _winapi - - _original_create_process = _winapi.CreateProcess - - def _patched_create_process( - executable, - command_line, - proc_attrs, - thread_attrs, - inherit_handles, - creation_flags, - env, - current_directory, - startup_info, - ): - return _original_create_process( - executable, - command_line, - proc_attrs, - thread_attrs, - inherit_handles, - creation_flags | _CREATE_NO_WINDOW, - env, - current_directory, - startup_info, - ) - - _winapi.CreateProcess = _patched_create_process - except Exception: - pass - - # ── os.system ──────────────────────────────────────────────────────────── - _original_os_system = os.system - - def _patched_os_system(command): - try: - return subprocess.run( - command, - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode - except Exception: - return _original_os_system(command) - - os.system = _patched_os_system diff --git a/run.py b/run.py index af8fe453..867063ce 100644 --- a/run.py +++ b/run.py @@ -48,50 +48,41 @@ # No .env file is used - all settings come from app/config/settings.json # --- Base directory --- -# In a PyInstaller --onefile binary, bundled data is extracted to sys._MEIPASS -if getattr(sys, "frozen", False): - BASE_DIR = sys._MEIPASS -else: - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def _bootstrap_frozen(): - """Copy bundled config/data from _MEIPASS to the user data dir on first run. - - PyInstaller extracts bundled files into a temp directory (sys._MEIPASS) - which is read-only and deleted on exit. The app expects mutable config - and data directories that persist between runs. We target a per-user - data dir (NOT the install dir, NOT cwd) so: - - User data lives outside Program Files / install location - - Uninstall + reinstall preserves history - - Direct double-click of CraftBotAgent.exe doesn't dump runtime files - next to the binary +# app.paths is the single answer to code-vs-state (see app/paths.py). It is +# stdlib-only, so importing it here — before dependencies exist — is safe. +from app import paths as _paths # noqa: E402 + +BASE_DIR = str(_paths.CODE_ROOT) + + +def _bootstrap_state(): + """Seed the per-user state directory from the shipped defaults. + + A managed install keeps CODE in the install directory (replaced wholesale + by the next upgrade, and on Windows not reliably writable) and STATE in + the per-user data dir. The app expects mutable app/config, app/data, + agents, assets and skills trees, so on first run they are copied across. + + Only ever copies what is ABSENT — a user's edited settings.json or their + customised skills must survive every upgrade. + + A dev checkout is skipped: there, code and state are the same tree, which + is what makes a checkout convenient to work in. """ - if not getattr(sys, "frozen", False): + if _paths.is_dev_checkout(): return import shutil as _shutil - # Per-user data root — same convention as the installer wizard - # (craftbot._user_data_dir() / app.config._frozen_user_data_root()). - if sys.platform == "win32": - _root = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") - user_data = os.path.join(_root, "CraftBot") - elif sys.platform == "darwin": - user_data = os.path.expanduser("~/Library/Application Support/CraftBot") - else: - _root = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") - user_data = os.path.join(_root, "craftbot") + user_data = str(_paths.STATE_ROOT) os.makedirs(user_data, exist_ok=True) - # Switch CWD so any code that still uses os.getcwd() / relative paths - # ends up writing into user_data instead of the install dir. + # Switch CWD so any code still using relative paths writes into the state + # dir rather than the install dir. os.chdir(user_data) - meipass = sys._MEIPASS - cwd = user_data + src_root = str(_paths.CODE_ROOT) - # Directories to bootstrap (source relative to _MEIPASS) dirs_to_copy = [ "app/config", "app/data", @@ -99,29 +90,28 @@ def _bootstrap_frozen(): "assets", "skills", ] - # Individual files to bootstrap files_to_copy = [ "config.json", ".env.example", ] for rel_dir in dirs_to_copy: - src = os.path.join(meipass, rel_dir) - dst = os.path.join(cwd, rel_dir) + src = os.path.join(src_root, rel_dir) + dst = os.path.join(user_data, rel_dir) if os.path.isdir(src) and not os.path.isdir(dst): print(f" Bootstrapping {rel_dir}/...") os.makedirs(os.path.dirname(dst), exist_ok=True) _shutil.copytree(src, dst) for rel_file in files_to_copy: - src = os.path.join(meipass, rel_file) - dst = os.path.join(cwd, rel_file) + src = os.path.join(src_root, rel_file) + dst = os.path.join(user_data, rel_file) if os.path.isfile(src) and not os.path.isfile(dst): print(f" Bootstrapping {rel_file}...") _shutil.copy2(src, dst) -_bootstrap_frozen() +_bootstrap_state() # --- Configuration --- CONFIG_FILE = os.path.join(BASE_DIR, "config.json") @@ -517,7 +507,8 @@ def handle_error(self, request, client_address): httpd = _QuietHTTPServer(("localhost", FRONTEND_PORT), FrontendHandler) except OSError as e: if not silent: - print(f"Error: Could not start static frontend server: {e}") + print(f"Error: could not serve the UI on port {FRONTEND_PORT}: {e}") + print(" Another CraftBot instance may already hold that port.") return None thread = threading.Thread(target=httpd.serve_forever, daemon=True) @@ -600,22 +591,32 @@ def _ensure_frontend_deps_fresh(npm_cmd: str, silent: bool = False) -> bool: def launch_frontend(silent: bool = False) -> Optional[subprocess.Popen]: - """Launch the frontend dev server for browser mode.""" - # If running as a PyInstaller binary, serve pre-built static files - # instead of launching npm dev server (node/npm won't be available) + """Serve the browser UI: prebuilt files for an install, Vite for a checkout. + + The choice used to be "am I a PyInstaller binary?". That question no + longer means anything — the agent is never frozen — and getting it wrong + was expensive: a managed install fell through to the Vite dev-server + path, which demands node_modules the install has no reason to have. The + install payload already ships a COMPILED dist/, so it needs neither npm + nor a build step to show a working UI. + + The real question is what this tree is: + * managed install → serve the prebuilt dist statically. No Node, no + npm, no network. + * dev checkout → run Vite, so hot reload works while editing. + """ dist_dir = os.path.join(FRONTEND_DIR, "dist") - is_frozen = getattr(sys, "frozen", False) + prebuilt = os.path.isfile(os.path.join(dist_dir, "index.html")) - if is_frozen: - if os.path.exists(dist_dir): + if not _paths.is_dev_checkout(): + if prebuilt: return _launch_static_frontend(silent) - else: - # Binary mode but no dist folder bundled — can't start frontend - if not silent: - print(f"Error: Frontend dist not found at {dist_dir}") - print(f" BASE_DIR: {BASE_DIR}") - print(f" FRONTEND_DIR: {FRONTEND_DIR}") - return None + if not silent: + print(f"Error: Frontend dist not found at {dist_dir}") + print(f" BASE_DIR: {BASE_DIR}") + print(f" FRONTEND_DIR: {FRONTEND_DIR}") + print(" The install payload should contain a prebuilt frontend.") + return None if not os.path.exists(FRONTEND_DIR): if not silent: @@ -800,6 +801,11 @@ def print_ready_banner(url: str): print(f"{ORANGE}║{RESET}{ORANGE}{_r2.ljust(W)}{RESET}{ORANGE}║{RESET}") print(f"{ORANGE}║{' ' * W}║{RESET}") print(f"{ORANGE}╚{'═' * W}╝{RESET}\n") + # MUST flush. When craftbot.py starts us as a service our stdout is a log + # FILE, not a terminal, so Python block-buffers it — and this banner is + # only ~400 bytes into an 8 KB buffer. Anything watching the log for the + # ready marker would wait forever while the text sat in memory. + sys.stdout.flush() def wait_for_backend_silent(timeout: int = 60) -> bool: @@ -1197,7 +1203,109 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: # ========================================== # MAIN # ========================================== +#: How long to wait for the agent to finish booting before giving up and +#: showing the UI anyway. Generous on purpose: a first run downloads the +#: embedding model, which on a slow connection is genuinely minutes. Timing +#: out is not an error — it just means we stop waiting to open the browser. +AGENT_READY_TIMEOUT_S = 900 + + +def _clear_agent_ready() -> None: + """Remove a previous run's readiness marker.""" + try: + from app import paths + + paths.AGENT_READY_FILE.unlink(missing_ok=True) + except Exception: + pass + + +def _wait_for_agent_ready(process=None, timeout: float = AGENT_READY_TIMEOUT_S) -> bool: + """Block until the agent says boot() finished. See app/paths.py. + + Returns True if the marker appeared, False if the agent died or the + timeout expired — the caller proceeds either way, because refusing to + show the UI just because the agent was slow would be worse than showing + it early. + + Watching `process` matters: the marker is only written on a *successful* + boot, so an agent that crashes part-way through would otherwise leave us + sitting here for the full timeout with nothing to show for it. + """ + try: + from app import paths + + marker = paths.AGENT_READY_FILE + except Exception: + return True # cannot check; do not block the boot on it + + deadline = time.time() + timeout + while time.time() < deadline: + try: + if marker.is_file(): + return True + except OSError: + pass + if process is not None and process.poll() is not None: + print( + f"\n Agent exited during startup (code {process.returncode}).", + flush=True, + ) + return False + time.sleep(0.4) + print( + f"\n Agent still starting after {int(timeout)}s — continuing anyway.", + flush=True, + ) + return False + + +def _suppress_child_consoles() -> None: + """Stop console children opening their own terminal windows. + + craftbot.py spawns run.py detached, so it has no console of its own. On + Windows a *console* application launched from a process with no console + gets a brand new console window — so npm, node, conda and the agent + process each popped up a terminal during an installed start. + + The frozen agent never showed this: PyInstaller ran + rthooks/rthook-windows-noflash.py, which patched subprocess for exactly + this reason. The agent is no longer a frozen bundle, so that hook now + applies only to the installer EXE and nothing covered run.py any more. + This restores the behaviour at the same choke point. + + Only patch when we genuinely have no console. A developer running + `python run.py` in a terminal has one, and there the children *should* + inherit it — that is where the output is meant to go. + """ + if sys.platform != "win32": + return + try: + import ctypes + + if ctypes.windll.kernel32.GetConsoleWindow(): + return # we have a console; children should inherit it + except Exception: + return + + CREATE_NO_WINDOW = 0x08000000 + _original_init = subprocess.Popen.__init__ + + def _patched_init(self, *args, **kwargs): + flags = kwargs.get("creationflags", 0) or 0 + # Idempotent: Windows ignores CREATE_NO_WINDOW when DETACHED_PROCESS + # is already set, and the call sites that set it stay correct. + kwargs["creationflags"] = flags | CREATE_NO_WINDOW + return _original_init(self, *args, **kwargs) + + subprocess.Popen.__init__ = _patched_init + + if __name__ == "__main__": + # Before anything spawns a child. See the docstring for why this is not + # simply always-on. + _suppress_child_consoles() + # Whatever `python` launched us is a trampoline: hop onto the project's # interpreter (the one the dependencies live in) before doing anything. python_runtime.reexec_if_needed() @@ -1303,7 +1411,12 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: # Step 1: Start frontend server (0% -> 10%) # Step 1: Start frontend server print_step(1, 8, "Starting frontend server") - frontend_process = launch_frontend(silent=not getattr(sys, "frozen", False)) + # Not silent. This used to be `silent=not sys.frozen`, which made + # sense while the agent shipped as a PyInstaller binary; nothing is + # frozen since that was retired, so the flag was always True and every + # specific reason this can fail — a busy port, a missing dist/ — was + # swallowed in favour of the generic "install Node.js" advice below. + frontend_process = launch_frontend() if not frontend_process: print(" ✗") print("\nError: Failed to start browser frontend.") @@ -1326,6 +1439,9 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: # Step 2: Start agent backend print_step(2, 8, "Starting agent backend") + # Clear last run's marker first, or we would read it as this run's + # readiness and open the browser instantly. + _clear_agent_ready() agent_process = launch_agent_background(env_name, use_conda, silent=True) if not agent_process: print(" ✗") @@ -1369,6 +1485,14 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: pass time.sleep(0.5) + # The backend port answering is NOT the agent being ready: it binds + # early, while steps 3-7 (model download, MCP servers, skills, + # integrations, scheduler) are still running. Treating the port as + # readiness is what printed the ready banner — and opened the browser + # — at step 2 of 8, onto a backend that could not serve yet. + if backend_ready: + backend_ready = _wait_for_agent_ready(agent_process) + # Small delay to ensure agent's stdout is flushed before we print # The agent prints steps 3-8, and we want them to appear before the ready banner time.sleep(0.3) diff --git a/scripts/build_wheelhouse.py b/scripts/build_wheelhouse.py new file mode 100644 index 00000000..77d67eb6 --- /dev/null +++ b/scripts/build_wheelhouse.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Download every locked wheel into a folder, for fast or offline installs. + +Two uses: + +1. **Testing.** A clean-machine test spends almost all its time downloading + ~2 GB of packages. In Windows Sandbox, over a NAT'd connection, that is + slow enough to look like a hang and slow enough to discourage re-testing — + which is how install bugs survive. Map a wheelhouse in and the same + install runs from local files in a couple of minutes. + +2. **Offline installs.** Same mechanism serves an air-gapped machine: ship + the wheelhouse alongside the payload and nothing needs PyPI. + +The provisioning pipeline picks it up automatically: + * $CRAFTBOT_WHEELHOUSE, or + * /wheelhouse +and then installs with --no-index --find-links, so pip cannot silently reach +the network and mask a missing wheel. + +Wheels are per platform AND per Python version, exactly like the lock they +come from. A wheelhouse built on Windows cannot serve a Linux install. + +Usage: + python scripts/build_wheelhouse.py + python scripts/build_wheelhouse.py --output D:\\craftbot-wheels +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + + +def main() -> int: + from app.provision.deps import _lock_tag, find_lock + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--output", + default=str(REPO_ROOT / "wheelhouse"), + help="where to put the wheels (default: /wheelhouse)", + ) + ap.add_argument( + "--python", + default=sys.executable, + help="interpreter whose platform/version the wheels must match", + ) + args = ap.parse_args() + + lock = find_lock(str(REPO_ROOT), [args.python]) + if lock is None: + print( + f"error: no lock for {_lock_tag([args.python])}.\n" + "Generate it first: python scripts/generate_lock.py", + file=sys.stderr, + ) + return 1 + + out = Path(args.output) + out.mkdir(parents=True, exist_ok=True) + print(f"lock : {lock.name}") + print(f"target: {out}") + print("Downloading — this is the whole dependency set, so expect GBs.\n") + + # `pip download` resolves and fetches without installing. --require-hashes + # is implied by the lock's own directives, so a tampered wheel fails here + # rather than on the user's machine. + proc = subprocess.run( + [ + args.python, + "-u", + "-m", + "pip", + "download", + "--no-color", + "--progress-bar", + "off", + "--dest", + str(out), + "-r", + str(lock), + ], + cwd=str(REPO_ROOT), + ) + if proc.returncode != 0: + print("\npip download failed", file=sys.stderr) + return proc.returncode + + # The lock is not the whole story. Nine of its entries have no wheel and + # are built from sdist at install time, and an isolated PEP 517 build + # fetches its OWN dependencies — setuptools and wheel — from the index. + # With --no-index that fetch fails, so an otherwise complete wheelhouse + # dies on the first sdist with: + # ERROR: No matching distribution found for wheel + # setuptools usually arrives as somebody's transitive dependency; wheel + # does not, which is why this failed on exactly one package. + print("\nAdding build backends (needed to build the sdists offline)...") + build_deps = subprocess.run( + [ + args.python, + "-u", + "-m", + "pip", + "download", + "--no-color", + "--progress-bar", + "off", + "--dest", + str(out), + "setuptools", + "wheel", + ], + cwd=str(REPO_ROOT), + ) + if build_deps.returncode != 0: + print("\nfailed to fetch build backends", file=sys.stderr) + return build_deps.returncode + + files = list(out.glob("*")) + size = sum(f.stat().st_size for f in files if f.is_file()) / (1024 * 1024) + print(f"\n{len(files)} files, {size:.0f} MB in {out}") + print("\nTo use it, either:") + print(f" set CRAFTBOT_WHEELHOUSE={out}") + print(" or map it into the test machine and set the variable there") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_lock.py b/scripts/generate_lock.py new file mode 100644 index 00000000..d9ba7d13 --- /dev/null +++ b/scripts/generate_lock.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Generate a hash-pinned lock file from requirements.txt. + +Every install path — pip, conda, and the installer — must install the same +set, and that is only possible if the set is written down. requirements.txt +declares 56 packages; the real closure is 239. + +Uses pip's own resolver via `--dry-run --report`, so it needs no lock tool +(uv / pip-tools) in the build environment. + +Locks are PER PLATFORM AND PYTHON VERSION and are not interchangeable: +`torch==X` means CPU wheels on Windows and CUDA libraries on Linux, so each +one has to be generated on the platform it describes. + +Usage: + python scripts/generate_lock.py # write this platform's lock + python scripts/generate_lock.py --check # verify committed locks + +## What --check means + +That every committed lock was generated from the CURRENT requirements.txt — +compared by the source digest in each lock's header, not by re-resolving. + +Re-resolving would be wrong. requirements.txt is unpinned, so a fresh resolve +picks up whatever upstream published since, and the check would report STALE +because `fonttools` shipped a patch release overnight. That makes the check +permanently red and, if CI acted on it, would bump all 239 packages on every +run — the opposite of what a lock is for. + +Stale therefore means "someone edited requirements.txt without regenerating", +which is the thing worth catching. Upgrading dependencies is a deliberate act: +run this script without --check. +""" + +from __future__ import annotations + +import argparse +import glob +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import sysconfig +import tempfile +from typing import Dict, List + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REQUIREMENTS = os.path.join(REPO_ROOT, "requirements.txt") +LOCK_DIR = os.path.join(REPO_ROOT, "requirements") + +#: Platforms a release ships an installer for, as lock-filename prefixes. +#: Matched by prefix because the macOS tag carries an OS version and arch +#: (macosx_11_0_arm64-py310), so the exact filename is not fixed. +#: +#: Keep in step with the launcher matrix in .github/workflows/release.yml. +SHIPPED_PLATFORMS = ("win_amd64", "linux_x86_64", "macosx") + + +def lock_tag() -> str: + """Identify the (platform, python) this lock is valid for. + + sysconfig's platform tag rather than sys.platform: it distinguishes + macosx arm64 from x86_64, which matters because the wheels differ. + """ + plat = sysconfig.get_platform().replace(".", "_").replace("-", "_") + py = f"py{sys.version_info.major}{sys.version_info.minor}" + return f"{plat}-{py}" + + +def lock_path() -> str: + return os.path.join(LOCK_DIR, f"lock-{lock_tag()}.txt") + + +def resolve() -> List[dict]: + """Ask pip to resolve requirements.txt without installing anything.""" + fd, report = tempfile.mkstemp(suffix=".json", prefix="craftbot-lock-") + os.close(fd) + try: + proc = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--dry-run", + "--ignore-installed", + "--quiet", + "--report", + report, + "-r", + REQUIREMENTS, + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + sys.stderr.write(proc.stdout + proc.stderr) + raise SystemExit(f"pip resolution failed ({proc.returncode})") + with io.open(report, encoding="utf-8") as fh: + return json.load(fh).get("install", []) + finally: + try: + os.unlink(report) + except OSError: + pass + + +def render(entries: List[dict]) -> str: + """Render a pip --require-hashes compatible lock.""" + rows: Dict[str, List[str]] = {} + for item in entries: + meta = item.get("metadata", {}) + name = (meta.get("name") or "").strip() + version = (meta.get("version") or "").strip() + if not name or not version: + continue + hashes = item.get("download_info", {}).get("archive_info", {}).get("hashes", {}) + sha = hashes.get("sha256") + key = f"{name}=={version}" + rows.setdefault(key, []) + if sha: + rows[key].append(f"sha256:{sha}") + + unhashed = [k for k, v in rows.items() if not v] + if unhashed: + raise SystemExit( + "Cannot lock — no sha256 for: " + + ", ".join(sorted(unhashed)) + + "\n--require-hashes needs every entry hashed." + ) + + out = [ + "# GENERATED by scripts/generate_lock.py — do not edit by hand.", + "# Regenerate with: python scripts/generate_lock.py", + "#", + f"# Valid ONLY for: {lock_tag()}", + "# Locks are per platform+python: torch means CPU wheels on Windows and", + "# CUDA libraries on Linux, so one lock cannot serve every runner.", + "#", + f"# Source: requirements.txt ({_source_digest()})", + f"# Packages: {len(rows)}", + "", + "--require-hashes", + "", + ] + for key in sorted(rows, key=str.lower): + joined = " \\\n ".join(f"--hash={h}" for h in sorted(rows[key])) + out.append(f"{key} \\\n {joined}") + return "\n".join(out) + "\n" + + +def _source_digest() -> str: + with io.open(REQUIREMENTS, encoding="utf-8") as fh: + body = "".join( + line.strip() + for line in fh + if line.strip() and not line.strip().startswith("#") + ) + return "sha256:" + hashlib.sha256(body.encode()).hexdigest()[:16] + + +def _check_committed_locks(require_all: bool = False) -> int: + """Verify every committed lock came from the current requirements.txt. + + Checks all of them, not just this platform's: a Windows machine should + still be told that the Linux lock is out of date, because someone has to + regenerate it somewhere. Needs no pip resolve, so it is instant. + + A platform with no lock at all is reported either way, but only fails the + run under `require_all`. On a PR that would be noise — you cannot fix a + missing macOS lock from the branch you are reviewing — while at release + time it is fatal, because that installer would download hundreds of MB + and then stop at the dependency step. + """ + digest = _source_digest() + locks = sorted(glob.glob(os.path.join(LOCK_DIR, "lock-*.txt"))) + if not locks: + print("MISSING: no lock files at all — run this script without --check") + return 1 + + stale = [] + for path in locks: + name = os.path.relpath(path, REPO_ROOT) + with io.open(path, encoding="utf-8") as fh: + header = fh.read(2048) + match = re.search(r"^# Source: requirements\.txt \((sha256:[0-9a-f]+)\)", header, re.M) + if match is None: + print(f"UNREADABLE: {name} has no source digest — regenerate it") + stale.append(name) + elif match.group(1) != digest: + print(f"STALE: {name} was generated from a different requirements.txt") + stale.append(name) + else: + print(f"OK: {name}") + + missing = [ + prefix + for prefix in SHIPPED_PLATFORMS + if not any( + os.path.basename(p).startswith(f"lock-{prefix}") for p in locks + ) + ] + for prefix in missing: + # ::warning/::error:: renders on the GitHub summary rather than being + # buried in the log. + level = "error" if require_all else "warning" + print(f"::{level}::no lock for {prefix} - that platform cannot install") + + if stale: + print() + print("requirements.txt changed without regenerating these locks.") + print("Run `python scripts/generate_lock.py` on each affected platform.") + return 1 + if missing and require_all: + print() + print("Generate the missing lock(s) on the platform each describes.") + return 1 + if missing: + print() + print(f"{len(missing)} platform(s) have no lock: {', '.join(missing)}") + print("Not fatal here; release.yml will refuse to build without them.") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--check", + action="store_true", + help="verify every committed lock matches requirements.txt (for CI)", + ) + ap.add_argument( + "--require-all", + action="store_true", + help="with --check, also fail when a shipped platform has no lock", + ) + args = ap.parse_args() + + if args.check: + return _check_committed_locks(require_all=args.require_all) + + target = lock_path() + rendered = render(resolve()) + + os.makedirs(LOCK_DIR, exist_ok=True) + with io.open(target, "w", encoding="utf-8", newline="\n") as fh: + fh.write(rendered) + print(f"wrote {os.path.relpath(target, REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/package_source.py b/scripts/package_source.py new file mode 100644 index 00000000..807f2b9f --- /dev/null +++ b/scripts/package_source.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Build CraftBot-src.zip — the payload the installer provisions around. + +This replaces the per-platform frozen agent (CraftBotAgent.spec). One asset +serves every platform: it is pure Python plus data files, and what used to +differ per platform — the bundled interpreter and the compiled wheels — is +now provisioned on the machine by app.provision. + +## What goes in, and why it is defined this way + +The file list is `git ls-files`, plus a short allow-list of build outputs +that are gitignored but required at runtime (the compiled frontend). + +That is deliberate. The obvious alternative — walk the tree and skip an +exclude list — is what the old agent spec did with `datas`, and it shipped +1.1 GB of the *builder's own* runtime state: app/data/.file_index (the memory +index) and app/data/.usage (containing chat.db and integrations.db, i.e. the +builder's conversation history and integration credentials). It only escaped +notice because CI checks out clean, so those directories were absent there. +A local release build would have published them. + +Taking the file list from git makes that impossible rather than unlikely: +untracked state is not in `git ls-files`, so it cannot be included by +forgetting an exclusion. + +Usage: + python scripts/package_source.py [--output dist/CraftBot-src.zip] +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import List + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Gitignored build outputs that ARE required at runtime. Kept short and +# explicit: every entry here is a hole in the "git decides" guarantee. +BUILD_OUTPUTS = [ + # The compiled browser UI. Users have no Node toolchain at install time + # and must not need one to see a working interface. + "app/ui_layer/browser/frontend/dist", +] + +# Tracked paths that are pure development weight. Excluded to keep the +# download small; none is reachable at runtime. +EXCLUDE_PREFIXES = ( + ".github/", + "docs/", + "tests/", + "diagnostic/", + "launcher/", # the native launcher is built and shipped separately +) + +EXCLUDE_SUFFIXES = (".md",) + +# Kept despite the rules above — README is the one doc worth shipping, and +# the i18n README variants are not. +KEEP_EXACT = {"README.md", "LICENSE"} + + +def tracked_files() -> List[str]: + out = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + return [p for p in out.stdout.split("\0") if p] + + +def wanted(rel: str) -> bool: + if rel in KEEP_EXACT: + return True + if rel.startswith(EXCLUDE_PREFIXES): + return False + if rel.endswith(EXCLUDE_SUFFIXES): + # Keep per-package docs the agent reads at runtime: integration + # guidance is loaded from craftos_integrations/providers/*/*.md, and + # skills are markdown by definition. + return rel.startswith(("craftos_integrations/", "skills/", "agents/")) + return True + + +def build(output: Path) -> int: + files = [f for f in tracked_files() if wanted(f)] + if not files: + print("error: git ls-files returned nothing — not a checkout?", file=sys.stderr) + return 1 + + missing_outputs = [b for b in BUILD_OUTPUTS if not (REPO_ROOT / b).exists()] + if missing_outputs: + print( + "error: required build output missing: " + + ", ".join(missing_outputs) + + "\nBuild the frontend first:\n" + " cd app/ui_layer/browser/frontend && npx vite build", + file=sys.stderr, + ) + return 1 + + output.parent.mkdir(parents=True, exist_ok=True) + if output.exists(): + output.unlink() + + written = 0 + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: + for rel in files: + src = REPO_ROOT / rel + if not src.is_file(): + continue # submodule entry or a deleted-but-staged path + zf.write(src, rel) + written += 1 + + for base in BUILD_OUTPUTS: + for path in (REPO_ROOT / base).rglob("*"): + if path.is_file(): + zf.write(path, str(path.relative_to(REPO_ROOT)).replace("\\", "/")) + written += 1 + + size_mb = output.stat().st_size / (1024 * 1024) + print(f"wrote {output} — {written} files, {size_mb:.1f} MB") + + # A payload without these is installable and then broken at runtime, in + # ways that surface far from the cause. Fail the build instead. + required = [ + "run.py", + "main.py", + "requirements.txt", + "app/paths.py", + "app/provision/__init__.py", + "app/ui_layer/browser/frontend/dist/index.html", + "craftos_integrations/providers/whatsapp_web/bridge.js", + # craftbot.py imports these at module scope; without them every + # command fails before parsing its arguments. + "installer/helpers.py", + "installer/metadata.py", + "installer/payload.py", + ] + with zipfile.ZipFile(output) as zf: + names = set(zf.namelist()) + absent = [r for r in required if r not in names] + if absent: + print("error: payload is missing " + ", ".join(absent), file=sys.stderr) + return 1 + + if not any(n.startswith("requirements/lock-") for n in names): + print( + "error: no requirements/lock-*.txt in the payload — the installer " + "cannot provision dependencies without a lock.", + file=sys.stderr, + ) + return 1 + + print(f"verified: {len(required)} required paths present, lock included") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--output", + default=str(REPO_ROOT / "dist" / "CraftBot-src.zip"), + help="where to write the payload", + ) + args = ap.parse_args() + return build(Path(args.output)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/parity_check.py b/scripts/parity_check.py new file mode 100644 index 00000000..63a5c39f --- /dev/null +++ b/scripts/parity_check.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Emit a fingerprint of a CraftBot install, so the three install paths can be +compared mechanically instead of by inspection. + +The point is not to pass today — it will not. The point is to make the +divergence a *number* that CI tracks, so later work can drive it to zero and +keep it there. + +Usage: + python scripts/parity_check.py --label pip > pip.json + python scripts/parity_check.py --label conda > conda.json + python scripts/parity_check.py --compare pip.json conda.json + +Deliberately stdlib-only and importing nothing from app/: it must run in a +half-installed environment (that is exactly the case it exists to detect) +without the import itself failing. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import platform +import subprocess +import sys +from typing import Any, Dict, List + +SCHEMA = 1 +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Modules the app imports directly and cannot degrade without. Chosen because +# each is genuinely referenced in the codebase, not because it is declared — +# a declared-but-unused package drifting is harmless, a used one is not. +CRITICAL_IMPORTS = [ + "chromadb", + "openai", + "anthropic", + "tiktoken", + "rank_bm25", + "pdfplumber", + "pypdf", + "pypdfium2", + "fitz", + "pyperclip", + "websockets", + "boto3", + "requests", + "bs4", + "trafilatura", + "ddgs", + "docling", + "onnxruntime", + "playwright", + "cv2", +] + +# Non-Python files resolved at runtime by path. PyInstaller does not bundle +# these from module analysis, so they are the frozen build's blind spot. +CRITICAL_DATA_FILES = [ + "craftos_integrations/providers/whatsapp_web/bridge.js", + "craftos_integrations/providers/gmail/INTEGRATION.md", + "app/data/action/read_pdf.py", + "app/i18n/errors.en.json", +] + + +#: Packaging plumbing that whoever built the environment supplies, not +#: requirements.txt. A venv ships one pip version; conda ships another plus +#: wheel. That is a difference between venv and conda bootstrap, not in what +#: CraftBot installs — recorded for the record, excluded from the comparison. +BOOTSTRAP_PACKAGES = frozenset( + {"pip", "setuptools", "wheel", "distribute", "pkg-resources"} +) + + +def _packages() -> Dict[str, Any]: + """Installed distributions as name==version, plus a digest of the set.""" + try: + from importlib.metadata import distributions + except ImportError: # pragma: no cover - py<3.8 + return {"error": "importlib.metadata unavailable"} + + found: Dict[str, str] = {} + for dist in distributions(): + name = (dist.metadata["Name"] or "").strip().lower().replace("_", "-") + if name: + found[name] = dist.version or "?" + + bootstrap = {k: v for k, v in found.items() if k in BOOTSTRAP_PACKAGES} + app = {k: v for k, v in found.items() if k not in BOOTSTRAP_PACKAGES} + joined = "\n".join(f"{k}=={app[k]}" for k in sorted(app)) + return { + "count": len(app), + "digest": hashlib.sha256(joined.encode()).hexdigest()[:16], + "list": dict(sorted(app.items())), + "bootstrap": dict(sorted(bootstrap.items())), + } + + +def _imports() -> Dict[str, bool]: + """find_spec rather than import: cheap, and importing torch here would + dominate the runtime of the check for no added signal.""" + out: Dict[str, bool] = {} + for name in CRITICAL_IMPORTS: + try: + out[name] = importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + out[name] = False + return out + + +def _data_files(root: str) -> Dict[str, bool]: + return {rel: os.path.isfile(os.path.join(root, rel)) for rel in CRITICAL_DATA_FILES} + + +def _node() -> Dict[str, Any]: + """Resolve Node the way the app does, falling back to a bare PATH probe so + this still reports something when app/ cannot be imported.""" + info: Dict[str, Any] = {"resolved": None, "version": None, "source": None} + try: + sys.path.insert(0, REPO_ROOT) + from app import node_runtime # type: ignore + + rt = node_runtime.resolve() + if rt is not None: + info.update(resolved=rt.node, version=rt.version, source=rt.source) + return info + info["source"] = "unresolved" + except Exception as e: + info["source"] = f"probe-failed: {type(e).__name__}" + + import shutil + + path_node = shutil.which("node") + if path_node and not info["resolved"]: + try: + out = subprocess.run( + [path_node, "--version"], capture_output=True, text=True, timeout=15 + ).stdout.strip() + info.update(resolved=path_node, version=out or None, source="path-fallback") + except Exception: + pass + return info + + +def _embedding_model() -> Dict[str, Any]: + """Which embedding model this install would actually use — the difference + between bge-small and ChromaDB's bundled MiniLM is invisible at rest but + changes every retrieval score.""" + configured = os.environ.get("MEMORY_EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5") + loadable = False + try: + loadable = importlib.util.find_spec("sentence_transformers") is not None and ( + importlib.util.find_spec("transformers") is not None + ) + except (ImportError, ValueError): + loadable = False + return { + "configured": configured, + "sentence_transformers_loadable": loadable, + "effective": configured if (loadable or configured == "default") else "default", + } + + +def fingerprint(label: str) -> Dict[str, Any]: + return { + "schema": SCHEMA, + "label": label, + "platform": { + "system": platform.system(), + "machine": platform.machine(), + "frozen": bool(getattr(sys, "frozen", False)), + }, + "python": { + "version": platform.python_version(), + "executable": sys.executable, + }, + "packages": _packages(), + "imports": _imports(), + "data_files": _data_files( + getattr(sys, "_MEIPASS", REPO_ROOT) # frozen builds resolve here + ), + "node": _node(), + "embedding": _embedding_model(), + } + + +def compare(paths: List[str]) -> int: + """Diff two or more fingerprints. Exit 1 on any divergence that matters.""" + prints = [] + for p in paths: + with open(p, encoding="utf-8") as fh: + prints.append(json.load(fh)) + + base, *rest = prints + problems = 0 + + def note(msg: str) -> None: + nonlocal problems + problems += 1 + print(f" DIVERGENCE {msg}") + + for other in rest: + a, b = base["label"], other["label"] + print(f"\n=== {a} vs {b} ===") + + if base["packages"]["digest"] != other["packages"]["digest"]: + note( + f"package set differs " + f"({base['packages']['count']} vs {other['packages']['count']})" + ) + only_a = sorted( + set(base["packages"]["list"]) - set(other["packages"]["list"]) + ) + only_b = sorted( + set(other["packages"]["list"]) - set(base["packages"]["list"]) + ) + if only_a: + print(f" only in {a}: {', '.join(only_a[:15])}") + if only_b: + print(f" only in {b}: {', '.join(only_b[:15])}") + shared = set(base["packages"]["list"]) & set(other["packages"]["list"]) + vers = [ + f"{n} ({base['packages']['list'][n]} vs {other['packages']['list'][n]})" + for n in sorted(shared) + if base["packages"]["list"][n] != other["packages"]["list"][n] + ] + if vers: + print(f" version mismatch: {', '.join(vers[:15])}") + + for key, human in (("imports", "import"), ("data_files", "data file")): + for name, present in base[key].items(): + if other[key].get(name) != present: + note(f"{human} {name}: {a}={present} {b}={other[key].get(name)}") + + if base["embedding"]["effective"] != other["embedding"]["effective"]: + note( + f"embedding model: {a}={base['embedding']['effective']} " + f"{b}={other['embedding']['effective']}" + ) + + # Minor version, not patch. Locks are keyed to py310 because the minor + # is what decides wheel compatibility; setup-python resolves "3.10" to + # the newest patch while environment.yml pins an exact one, so the + # patch differs by construction and means nothing here. + def _minor(fp: Dict[str, Any]) -> str: + return ".".join(fp["python"]["version"].split(".")[:2]) + + if _minor(base) != _minor(other): + note( + f"python: {a}={base['python']['version']} " + f"{b}={other['python']['version']}" + ) + + # Node is reported, never a divergence. This job installs a lock into + # two PYTHON environments; it never runs app.provision's node stage. + # So one side reports whatever the runner happened to have on PATH and + # the other reports what environment.yml's `nodejs` pulled in — a + # difference between the two bootstraps, not between install paths. + # installer-e2e is what exercises node provisioning. + bn, on = base["node"]["version"], other["node"]["version"] + if bn != on: + print(f" note: node {a}={bn} {b}={on} (not compared here)") + + print(f"\n{problems} divergence(s)") + return 1 if problems else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--label", default="unnamed", help="name for this install path") + ap.add_argument("--compare", nargs="+", metavar="FILE", help="diff fingerprints") + args = ap.parse_args() + + if args.compare: + return compare(args.compare) + + json.dump(fingerprint(args.label), sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/prefetch_runtimes.py b/scripts/prefetch_runtimes.py new file mode 100644 index 00000000..70c1bc4e --- /dev/null +++ b/scripts/prefetch_runtimes.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Fetch everything an install downloads, so test runs do not re-fetch it. + +A clean-machine install pulls roughly 300 MB before it is usable: + + Python (python-build-standalone) ~38 MB + Node.js ~36 MB + Visual C++ redistributable ~24 MB + npm packages (Baileys and friends) ~50 MB + Playwright's Chromium ~150 MB + bge-small model weights ~130 MB (downloaded on FIRST RUN, + after the installer finishes) + +plus ~2 GB of Python packages, which scripts/build_wheelhouse.py handles. + +On a fast connection that is a few minutes. Inside Windows Sandbox, over +NAT, it was the better part of an hour PER RUN — and that cost is what stops +people re-testing, which is how install bugs survive. + +This fetches everything on the machine with the good connection. The sandbox +script maps the results in automatically: + + downloads-cache/ CRAFTBOT_DOWNLOAD_CACHE runtime archives + npm-cache/ CRAFTBOT_NPM_CACHE npm's package cache + playwright-browsers/ PLAYWRIGHT_BROWSERS_PATH Chromium + hf-cache/ HF_HOME embedding model weights + +Usage: + python scripts/prefetch_runtimes.py + python scripts/prefetch_runtimes.py --output D:\\craftbot-cache +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + + +def _fetch_archives(out: Path) -> int: + """Runtime archives: Python, Node, and (on Windows) the VC++ runtime.""" + from app import downloads, node_runtime + from app.provision import runtimes + from app.provision.verify import _vcredist_url + + targets = [] + + py_url = runtimes._pbs_download_url(print) + if py_url: + targets.append(("Python runtime", py_url)) + else: + print("warning: could not resolve the Python runtime URL", file=sys.stderr) + + node_url = node_runtime.latest_download_url(log=print) + if node_url: + targets.append(("Node.js", node_url)) + else: + print("warning: could not resolve the Node URL", file=sys.stderr) + + if sys.platform == "win32": + targets.append(("Visual C++ runtime", _vcredist_url())) + + failures = 0 + for label, url in targets: + dest = out / downloads.cache_name(url) + if dest.is_file(): + print(f"\n{label}: cached already ({dest.stat().st_size / 1048576:.1f} MB)") + continue + print(f"\n{label}:") + try: + downloads.download(url, str(dest), log=print, label=label) + except Exception as e: + failures += 1 + print(f" failed: {str(e)[:200]}", file=sys.stderr) + return failures + + +def _warm_npm_cache(cache: Path) -> int: + """Populate npm's cache with the WhatsApp bridge's dependency tree.""" + from app import node_runtime + + bridge = REPO_ROOT / "craftos_integrations" / "providers" / "whatsapp_web" + if not bridge.is_dir(): + return 0 + + npm = node_runtime.npm_cmd() + if not npm: + print("\nnpm cache: skipped, no npm found", file=sys.stderr) + return 0 + + print(f"\nnpm packages -> {cache}") + cache.mkdir(parents=True, exist_ok=True) + + # Install into a THROWAWAY copy of the manifests, not the real tree. + # Running it in place on a machine that already has node_modules is a + # no-op: npm downloads nothing, so it caches nothing, and the cache ends + # up empty exactly when you thought you had warmed it. + import shutil + import tempfile + + staging = Path(tempfile.mkdtemp(prefix="craftbot-npmwarm-")) + try: + copied = False + for name in ("package.json", "package-lock.json"): + src = bridge / name + if src.is_file(): + shutil.copy2(src, staging / name) + copied = copied or name == "package.json" + if not copied: + print(" no package.json to warm from", file=sys.stderr) + return 0 + + proc = subprocess.run( + [ + npm, + "install", + "--no-audit", + "--no-fund", + # Only fetch and cache; the lifecycle scripts are what need a + # real Node and they are irrelevant to warming a cache. + "--ignore-scripts", + "--cache", + str(cache), + ], + cwd=str(staging), + # Lifecycle scripts spawn bare `node`, which must resolve to the + # sidecar rather than failing or picking up a different Node. + env=node_runtime.child_env(), + ) + if proc.returncode != 0: + print(" npm cache warm-up failed", file=sys.stderr) + return 1 + finally: + shutil.rmtree(staging, ignore_errors=True) + return 0 + + +def _fetch_playwright(browsers: Path) -> int: + """Download Chromium into a relocatable directory. + + PLAYWRIGHT_BROWSERS_PATH is Playwright's own mechanism for this, so the + directory can be mapped in read-only and found without a download. + """ + try: + import playwright # noqa: F401 + except ImportError: + print("\nPlaywright: skipped, not installed in this environment") + return 0 + + print(f"\nPlaywright browser -> {browsers}") + browsers.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers) + proc = subprocess.run( + [sys.executable, "-m", "playwright", "install", "chromium"], env=env + ) + if proc.returncode != 0: + print(" playwright download failed", file=sys.stderr) + return 1 + return 0 + + +def _fetch_embedding_model(hf_home: Path) -> int: + """Download the memory embedding model's weights. + + The last uncached download in an install, and the only one that happens + AFTER the installer finishes: the agent fetches ~130 MB from HuggingFace + the first time it builds its memory index, so a "finished" install still + sits there downloading before it can answer anything. + + HF_HOME relocates the whole HuggingFace cache, so this can be mapped in + the same way as everything else. + """ + model = os.environ.get("MEMORY_EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5") + if model == "default": + print("\nEmbedding model: skipped (MEMORY_EMBEDDING_MODEL=default)") + return 0 + + try: + import sentence_transformers # noqa: F401 + except ImportError: + print("\nEmbedding model: skipped, sentence-transformers not installed") + return 0 + + print(f"\nEmbedding model {model} -> {hf_home}") + hf_home.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["HF_HOME"] = str(hf_home) + # A subprocess so HF_HOME is read at import time, which is when the + # library decides where its cache lives. + code = ( + "from sentence_transformers import SentenceTransformer;" + f"SentenceTransformer({model!r});" + "print('model cached')" + ) + proc = subprocess.run([sys.executable, "-c", code], env=env) + if proc.returncode != 0: + print(" embedding model download failed", file=sys.stderr) + return 1 + return 0 + + +def _dir_size_mb(path: Path) -> float: + if not path.is_dir(): + return 0.0 + total = 0 + for root, _dirs, files in os.walk(path): + for name in files: + try: + total += os.path.getsize(os.path.join(root, name)) + except OSError: + pass + return total / (1024 * 1024) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--output", + default=str(REPO_ROOT / "downloads-cache"), + help="where to keep the runtime archives (default: /downloads-cache)", + ) + ap.add_argument( + "--skip-npm", action="store_true", help="don't warm npm's cache" + ) + ap.add_argument( + "--skip-playwright", action="store_true", help="don't fetch Chromium" + ) + ap.add_argument( + "--skip-model", action="store_true", help="don't fetch the embedding model" + ) + args = ap.parse_args() + + out = Path(args.output) + out.mkdir(parents=True, exist_ok=True) + # download() reads this to decide where to cache. + os.environ["CRAFTBOT_DOWNLOAD_CACHE"] = str(out) + + npm_cache = REPO_ROOT / "npm-cache" + browsers = REPO_ROOT / "playwright-browsers" + hf_home = REPO_ROOT / "hf-cache" + + failures = _fetch_archives(out) + if not args.skip_npm: + failures += _warm_npm_cache(npm_cache) + if not args.skip_playwright: + failures += _fetch_playwright(browsers) + if not args.skip_model: + failures += _fetch_embedding_model(hf_home) + + print("\n" + "=" * 58) + print(f" runtime archives {_dir_size_mb(out):7.0f} MB {out}") + if npm_cache.is_dir(): + print(f" npm cache {_dir_size_mb(npm_cache):7.0f} MB {npm_cache}") + if browsers.is_dir(): + print(f" playwright {_dir_size_mb(browsers):7.0f} MB {browsers}") + if hf_home.is_dir(): + print(f" embedding model {_dir_size_mb(hf_home):7.0f} MB {hf_home}") + print("=" * 58) + + if failures: + print(f"\n{failures} step(s) failed", file=sys.stderr) + return 1 + + print("\nThe sandbox script maps all of these automatically when present.") + print("To use them elsewhere:") + print(f" set CRAFTBOT_DOWNLOAD_CACHE={out}") + if npm_cache.is_dir(): + print(f" set CRAFTBOT_NPM_CACHE={npm_cache}") + if browsers.is_dir(): + print(f" set PLAYWRIGHT_BROWSERS_PATH={browsers}") + if hf_home.is_dir(): + print(f" set HF_HOME={hf_home}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_install_e2e.py b/scripts/test_install_e2e.py new file mode 100644 index 00000000..3e85165f --- /dev/null +++ b/scripts/test_install_e2e.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""End-to-end test of the installer path, without the GUI. + +The acceptance test for docs/plans/unified-install-architecture.md: an +installed CraftBot must be the SAME THING as a source checkout. This performs +a real install into a throwaway directory and then compares it against the +developer's environment, so "same thing" is measured rather than asserted. + +What it does: + 1. Extracts dist/CraftBot-src.zip into a temp directory (what the wizard + downloads and unpacks). + 2. Marks it a managed install, so state goes to CRAFTBOT_HOME rather than + into the install directory. + 3. Runs the SAME app.provision pipeline install.py and the wizard run. + 4. Fingerprints the result with scripts/parity_check.py and diffs it + against a fingerprint of this checkout. + +Usage: + python scripts/package_source.py # build the payload first + python scripts/test_install_e2e.py + python scripts/test_install_e2e.py --keep # leave the temp install +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +PAYLOAD = REPO_ROOT / "dist" / "CraftBot-src.zip" + + +def _run(cmd, cwd=None, env=None, timeout=5400): + print(f" $ {' '.join(str(c) for c in cmd)}") + return subprocess.run( + cmd, cwd=cwd, env=env, timeout=timeout, text=True, capture_output=True + ) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--keep", action="store_true", help="don't delete the temp install") + ap.add_argument( + "--skip-deps", + action="store_true", + help="skip the dependency install (fast structural check only)", + ) + args = ap.parse_args() + + if not PAYLOAD.is_file(): + print(f"error: {PAYLOAD} not found — run scripts/package_source.py first") + return 1 + + workdir = Path(tempfile.mkdtemp(prefix="craftbot-e2e-")) + install_dir = workdir / "install" + state_dir = workdir / "state" + state_dir.mkdir(parents=True) + ok = True + + try: + print(f"\n== 1. Extract payload -> {install_dir}") + install_dir.mkdir(parents=True) + with zipfile.ZipFile(PAYLOAD) as zf: + zf.extractall(install_dir) + src_root = install_dir + if not (src_root / "run.py").is_file(): + subdirs = [d for d in src_root.iterdir() if d.is_dir()] + for d in subdirs: + if (d / "run.py").is_file(): + src_root = d + break + print(f" source root: {src_root}") + + print("\n== 2. Mark as a managed install") + # Same call the wizard makes. Without it the extracted tree looks + # like a dev checkout (it contains install.py and requirements.txt) + # and would put user state inside the install directory. + sys.path.insert(0, str(src_root)) + marker = src_root / ".craftbot-managed" + marker.write_text("managed\n", encoding="utf-8") + + env = dict(os.environ) + env["CRAFTBOT_HOME"] = str(state_dir) + env["PYTHONPATH"] = str(src_root) + + print("\n== 3. Verify path resolution inside the install") + res = _run([sys.executable, "-m", "app.paths"], cwd=src_root, env=env) + if res.returncode != 0: + print(res.stdout, res.stderr) + return 1 + described = json.loads(res.stdout) + print(json.dumps(described, indent=2)) + if described["dev_checkout"]: + print(" FAIL: install is being treated as a dev checkout") + ok = False + if not described.get("managed_install"): + print(" FAIL: managed marker not detected") + ok = False + # Compare CANONICAL paths. app.paths resolves what it is given, and on + # two of the three platforms the resolved form differs from the string + # we passed in: + # + # Windows C:\Users\RUNNER~1\... resolves to C:\Users\runneradmin\... + # (8.3 short name, which is what TEMP holds on CI) + # macOS /var/folders/... resolves to /private/var/folders/... + # (/var is a symlink to /private/var) + # + # Linux has neither, which is why a raw string compare passed there + # and failed on the other two. Resolving is correct behaviour in + # app.paths — the expectation here was the wrong thing. + if Path(described["state_root"]).resolve() != state_dir.resolve(): + print(f" FAIL: state_root is {described['state_root']}, want {state_dir}") + ok = False + if Path(described["code_root"]).resolve() != src_root.resolve(): + print(f" FAIL: code_root is {described['code_root']}, want {src_root}") + ok = False + + print("\n== 4. Payload completeness") + # Every file the frozen build used to get wrong. Each was a real + # shipped bug: missing bridge.js broke WhatsApp entirely, missing + # INTEGRATION.md broke the integration guidance layer, and actions + # are exec'd from source so their absence is silent until called. + required = [ + "run.py", + "app/provision/__init__.py", + "app/data/action/read_pdf.py", + "craftos_integrations/providers/whatsapp_web/bridge.js", + "craftos_integrations/providers/gmail/INTEGRATION.md", + "app/ui_layer/browser/frontend/dist/index.html", + "app/i18n/errors.en.json", + ] + for rel in required: + if not (src_root / rel).exists(): + print(f" FAIL: missing {rel}") + ok = False + if ok: + print(f" {len(required)} required paths present") + + lock_dir = src_root / "requirements" + locks = list(lock_dir.glob("lock-*.txt")) if lock_dir.is_dir() else [] + print(f" locks shipped: {[p.name for p in locks]}") + if not locks: + print(" FAIL: no lock in the payload") + ok = False + + if args.skip_deps: + print("\n== 5. Provisioning SKIPPED (--skip-deps)") + else: + # A fresh venv, not this interpreter. Provisioning into the + # developer's own site-packages would report success because + # everything is already there — it would test nothing. The whole + # question is whether a machine with none of this ends up correct. + print("\n== 5a. Create an empty environment") + venv_dir = workdir / "venv" + r = _run([sys.executable, "-m", "venv", str(venv_dir)]) + if r.returncode != 0: + print(r.stdout, r.stderr) + return 1 + venv_py = ( + venv_dir / "Scripts" / "python.exe" + if os.name == "nt" + else venv_dir / "bin" / "python" + ) + print(f" {venv_py}") + + print("\n== 5b. Provision into it (installs 239 packages — slow)") + res = _run( + [ + sys.executable, + "-c", + "import sys;from app import provision;" + f"ctx=provision.default_context(service_python=[r'{venv_py}']);" + "r=provision.install(log=print, ctx=ctx);" + "print('PIPELINE_OK' if r.ok else 'PIPELINE_FAIL');" + "print(provision.format_report(r))", + ], + cwd=src_root, + env=env, + ) + print(res.stdout[-4000:]) + if res.stderr.strip(): + print("stderr:", res.stderr[-2000:]) + if "PIPELINE_OK" not in res.stdout: + print(" FAIL: provisioning did not complete") + ok = False + + print("\n== 6. Compare install against this checkout") + fp_install = workdir / "fp-install.json" + fp_dev = workdir / "fp-dev.json" + # Fingerprint the INSTALL through its own interpreter (the venv), + # and the checkout through this one. Anything the two disagree on + # is a real difference between how a user gets CraftBot and how a + # developer does — which is the thing this whole plan removes. + for out, py, cwd_, env_, label in ( + (fp_install, str(venv_py), src_root, env, "install"), + (fp_dev, sys.executable, REPO_ROOT, dict(os.environ), "dev"), + ): + r = _run( + [ + py, + str(REPO_ROOT / "scripts" / "parity_check.py"), + "--label", + label, + ], + cwd=cwd_, + env=env_, + ) + out.write_text(r.stdout, encoding="utf-8") + r = _run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "parity_check.py"), + "--compare", + str(fp_dev), + str(fp_install), + ] + ) + print(r.stdout) + if r.returncode != 0: + print(" NOTE: divergences above (see detail)") + + print("\n" + "=" * 60) + print(" E2E RESULT:", "PASS" if ok else "FAIL") + print("=" * 60) + return 0 if ok else 1 + finally: + if args.keep: + print(f"\nkept: {workdir}") + else: + shutil.rmtree(workdir, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main())