diff --git a/.github/workflows/build-bonsaiviewer-autodesk.yml b/.github/workflows/build-bonsaiviewer-autodesk.yml
index 1e51eb724a..f91a163088 100644
--- a/.github/workflows/build-bonsaiviewer-autodesk.yml
+++ b/.github/workflows/build-bonsaiviewer-autodesk.yml
@@ -13,14 +13,8 @@ on:
jobs:
test:
- name: test-py${{ matrix.python-version }}
+ name: cargo-test
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- # Exercise the floor and a current version of the supported range
- # (pyproject requires-python = ">=3.11").
- python-version: ['3.11', '3.13']
defaults:
run:
@@ -29,20 +23,22 @@ jobs:
steps:
- uses: actions/checkout@v6
- - uses: actions/setup-python@v6
+ - uses: dtolnay/rust-toolchain@stable
+
+ # cargo-target reuse across runs. Massive cold-build speedup,
+ # cheap on the GitHub Actions cache budget.
+ - uses: Swatinem/rust-cache@v2
with:
- python-version: ${{ matrix.python-version }}
+ workspaces: src/bonsaiviewer-autodesk
- # The connector imports tkinter/customtkinter (via the test suite's
- # connector coverage), so fail loudly here if Tk is missing.
- - name: Verify tkinter is available
- run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
+ - name: cargo fmt --check
+ run: cargo fmt --all -- --check
- - name: Install package and test deps
- run: python -m pip install ".[test]"
+ - name: cargo clippy
+ run: cargo clippy --all-targets --all-features -- -D warnings
- - name: Run pytest
- run: python -m pytest -q
+ - name: cargo test
+ run: cargo test --all-features
build:
name: ${{ matrix.os_label }}-${{ matrix.arch }}
@@ -77,21 +73,17 @@ jobs:
steps:
- uses: actions/checkout@v6
- - uses: actions/setup-python@v6
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
with:
- python-version: '3.12'
-
- # actions/setup-python ships Python with tkinter on all three OSes via
- # python-build-standalone, but verify so a missing-tk regression fails
- # the build loudly instead of inside PyInstaller.
- - name: Verify tkinter is available
- run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
-
- - name: Install package and build deps
- run: python -m pip install ".[build]"
+ workspaces: src/bonsaiviewer-autodesk
+ # python3 is the runner default on all four hosts; packaging/build.py
+ # only uses stdlib (subprocess, shutil, zipfile, pathlib, platform),
+ # no pip-installable deps.
- name: Build connector bundle
- run: python packaging/build.py
+ run: python3 packaging/build.py
- name: Upload connector zip
uses: actions/upload-artifact@v7
diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml
index 8e2bdd3d34..b1a24e6212 100644
--- a/.github/workflows/build_rocky.yml
+++ b/.github/workflows/build_rocky.yml
@@ -89,7 +89,11 @@ jobs:
shell: bash
run: |
VERSION=v`cat VERSION`
- python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
+ # bonsaiviewer-autodesk is now a Rust connector. packaging/build.py
+ # invokes `cargo build --release` and stages the binary +
+ # connector.json into dist/autodesk/. Same on-disk shape as the
+ # old PyInstaller flow so the symlink + zip steps below
+ # continue to work unchanged.
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
test -d "$autodesk_connector_dir"
diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml
index 55b18672b2..a513f953da 100644
--- a/.github/workflows/build_rocky_arm.yml
+++ b/.github/workflows/build_rocky_arm.yml
@@ -89,7 +89,11 @@ jobs:
shell: bash
run: |
VERSION=v`cat VERSION`
- python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
+ # bonsaiviewer-autodesk is now a Rust connector. packaging/build.py
+ # invokes `cargo build --release` and stages the binary +
+ # connector.json into dist/autodesk/. Same on-disk shape as the
+ # old PyInstaller flow so the symlink + zip steps below
+ # continue to work unchanged.
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
test -d "$autodesk_connector_dir"
diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml
index 695f726ee3..cccf497dde 100644
--- a/.github/workflows/build_win.yml
+++ b/.github/workflows/build_win.yml
@@ -75,11 +75,12 @@ jobs:
# Build the Autodesk connector before the C++ build: build-all-win.py
# bundles it next to BonsaiViewer.exe while archiving the executables.
+ # bonsaiviewer-autodesk is a Rust connector; packaging/build.py runs
+ # `cargo build --release` and stages the binary + connector.json into
+ # dist/autodesk/ — same layout the old PyInstaller flow produced.
- name: Build Autodesk connector
working-directory: src/bonsaiviewer-autodesk
- run: |
- python -m pip install ".[build]"
- python packaging/build.py
+ run: python packaging/build.py
- name: Run Build Script And Pack .zip Archives
shell: cmd
diff --git a/src/bonsaiviewer-autodesk/.gitignore b/src/bonsaiviewer-autodesk/.gitignore
new file mode 100644
index 0000000000..4f96631dab
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/.gitignore
@@ -0,0 +1,2 @@
+/target
+/dist
diff --git a/src/bonsaiviewer-autodesk/Cargo.lock b/src/bonsaiviewer-autodesk/Cargo.lock
new file mode 100644
index 0000000000..e8f475cfe4
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/Cargo.lock
@@ -0,0 +1,1959 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bonsaiviewer-autodesk"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "chrono",
+ "dirs",
+ "fltk",
+ "fltk-theme",
+ "keyring",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "ureq",
+ "url",
+ "webbrowser",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmk"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd5de2a10e31b3ec3e8d75e7ccf8281ab3ee55de68f7ab6ffa9e21be8d82f22"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[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"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dbus-secret-service"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
+dependencies = [
+ "dbus",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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 = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fltk"
+version = "1.5.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78e4a0494d4c6c020514ae1d0b6cc44beddd5f37f0b81bc8e84334652ee15291"
+dependencies = [
+ "bitflags",
+ "crossbeam-channel",
+ "fltk-sys",
+ "minipaste",
+ "once_cell",
+ "ttf-parser",
+]
+
+[[package]]
+name = "fltk-sys"
+version = "1.5.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9c99e29458923abe3f07b7f8a3bc5b5de3a2c04ad82273fd96402b3666ed06"
+dependencies = [
+ "cmake",
+ "cmk",
+]
+
+[[package]]
+name = "fltk-theme"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eac14d47a555db231b4ce9729118a7c57c8a21714af008b8e9ae627998edb7a"
+dependencies = [
+ "fltk",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "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",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "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",
+]
+
+[[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",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "dbus-secret-service",
+ "log",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "log"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "minipaste"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bbcafc6544efab7decec8e8e63d6e7cbaac0a699a4adead308a80f9c302d6ae"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[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.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags",
+ "objc2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[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 = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "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",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[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 = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+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 2.11.1",
+]
+
+[[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.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[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 = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "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.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[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.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[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",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[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",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[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.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
+dependencies = [
+ "core-foundation 0.10.1",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2",
+ "objc2-foundation",
+ "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.7",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[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-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-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-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.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[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 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.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_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[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_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.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_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.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_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.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_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.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_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.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 = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+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",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/src/bonsaiviewer-autodesk/Cargo.toml b/src/bonsaiviewer-autodesk/Cargo.toml
new file mode 100644
index 0000000000..8425fc480b
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/Cargo.toml
@@ -0,0 +1,38 @@
+[package]
+name = "bonsaiviewer-autodesk"
+version = "0.1.0"
+edition = "2021"
+description = "Autodesk cloud connector for Bonsai Viewer"
+license = "LGPL-3.0-or-later"
+
+[lib]
+name = "bonsaiviewer_autodesk"
+path = "src/lib.rs"
+
+[[bin]]
+name = "bonsaiviewer-autodesk"
+path = "src/main.rs"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+ureq = { version = "2.10", features = ["json", "tls", "native-certs"], default-features = false }
+keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
+dirs = "5"
+sha2 = "0.10"
+base64 = "0.22"
+rand = "0.8"
+url = "2"
+webbrowser = "1"
+chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
+fltk = { version = "1.5", features = ["fltk-bundled"] }
+fltk-theme = "0.7"
+
+[dev-dependencies]
+tempfile = "3"
+
+[profile.release]
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/__init__.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/__main__.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/__main__.py
deleted file mode 100644
index d45b9a078e..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/__main__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from __future__ import annotations
-
-import sys
-
-from bonsaiviewer_autodesk.connector import AutodeskConnector
-from bonsaiviewer_autodesk.rpc import JsonRpcHost
-from bonsaiviewer_autodesk.ui import ensure_tk_app
-
-
-def main() -> int:
- ensure_tk_app()
- connector = AutodeskConnector()
- host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
- return host.run()
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py
deleted file mode 100644
index 38a609b134..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py
+++ /dev/null
@@ -1,739 +0,0 @@
-from __future__ import annotations
-
-import base64
-import datetime as dt
-import hashlib
-import json
-import math
-import secrets
-import urllib.parse
-import webbrowser
-from dataclasses import asdict, dataclass
-from http.server import BaseHTTPRequestHandler, HTTPServer
-from pathlib import Path
-from typing import Any, Callable
-
-import httpx
-import keyring
-import keyring.errors
-
-from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
-
-
-Progress = Callable[[str, str, "int | None"], None]
-
-# (host, port, path, expected_state) -> authorization code
-CallbackWaiter = Callable[[str, int, str, str], str]
-
-
-def _utcnow() -> dt.datetime:
- return dt.datetime.now(dt.timezone.utc)
-
-
-def _no_keyring_error() -> RpcError:
- return RpcError(
- JSONRPC_INTERNAL_ERROR,
- "No secure keyring backend is available. On macOS, use Keychain; "
- "on Windows, use Credential Manager; on Linux, install a Secret Service "
- "backend such as gnome-keyring or KWallet.",
- )
-
-
-class KeyringTokenStore:
- def __init__(self, *, service_name: str, username: str) -> None:
- self.service_name = service_name
- self.username = username
-
- def load(self) -> dict[str, Any] | None:
- try:
- raw = keyring.get_password(self.service_name, self.username)
- except keyring.errors.NoKeyringError as exc:
- raise _no_keyring_error() from exc
- return json.loads(raw) if raw else None
-
- def save(self, value: dict[str, Any]) -> None:
- try:
- keyring.set_password(self.service_name, self.username, json.dumps(value))
- except keyring.errors.NoKeyringError as exc:
- raise _no_keyring_error() from exc
-
- def delete(self) -> None:
- try:
- keyring.delete_password(self.service_name, self.username)
- except keyring.errors.PasswordDeleteError:
- pass
- except keyring.errors.NoKeyringError as exc:
- raise _no_keyring_error() from exc
-
-
-def _base64url(value: bytes) -> str:
- return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
-
-
-def generate_code_verifier() -> str:
- return _base64url(secrets.token_bytes(48))
-
-
-def generate_code_challenge(verifier: str) -> str:
- return _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
-
-
-@dataclass
-class StoredToken:
- client_id: str
- access_token: str
- refresh_token: str
- access_token_expires_at_utc: str
- refresh_token_expires_at_utc: str
- scope: str
-
- @property
- def access_token_expires_at(self) -> dt.datetime:
- return dt.datetime.fromisoformat(self.access_token_expires_at_utc)
-
- @property
- def refresh_token_expires_at(self) -> dt.datetime:
- return dt.datetime.fromisoformat(self.refresh_token_expires_at_utc)
-
-
-def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> None:
- return
-
-
-def wait_for_oauth_callback(host: str, port: int, path: str, expected_state: str) -> str:
- """Block on a single OAuth redirect to ``http://host:port/path`` and return
- the authorization code. Raises ``RpcError`` on an OAuth error, a ``state``
- mismatch, or a missing code. This is the default ``callback_waiter`` for
- :class:`AuthSessionService`; tests inject a stub instead."""
- result: dict[str, str] = {}
-
- class Handler(BaseHTTPRequestHandler):
- def do_GET(self) -> None:
- parsed = urllib.parse.urlparse(self.path)
- if parsed.path != path:
- self.send_response(404)
- self.end_headers()
- return
- query = urllib.parse.parse_qs(parsed.query)
- result["state"] = query.get("state", [""])[0]
- result["code"] = query.get("code", [""])[0]
- result["error"] = query.get("error", [""])[0]
- body = b"
Authentication complete. You can close this window.
"
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def log_message(self, format: str, *args: object) -> None:
- return
-
- server = HTTPServer((host, port), Handler)
- server.handle_request()
- server.server_close()
-
- if result.get("error"):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
- if result.get("state") != expected_state:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
- code = result.get("code", "")
- if not code:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
- return code
-
-
-class AuthSessionService:
- authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
- token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
-
- def __init__(
- self,
- *,
- client_id: str,
- callback_url: str,
- scope: str,
- token_store: KeyringTokenStore,
- transport: httpx.BaseTransport | None = None,
- now: Callable[[], dt.datetime] | None = None,
- callback_waiter: CallbackWaiter | None = None,
- ) -> None:
- self.client_id = client_id
- self.callback_url = callback_url
- self.scope = scope
- self.token_store = token_store
- self.http = httpx.Client(timeout=60, transport=transport)
- self._now = now or _utcnow
- self._callback_waiter = callback_waiter or wait_for_oauth_callback
-
- def get_token(self) -> StoredToken | None:
- raw = self.token_store.load()
- return StoredToken(**raw) if raw else None
-
- def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
- token = self.get_token()
- now = self._now()
- if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
- return token.access_token
- if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
- return self._refresh(token, progress).access_token
- return self.login_interactive(progress).access_token
-
- def login_interactive(self, progress: Progress = _noop_progress) -> StoredToken:
- progress("auth", "Preparing Autodesk sign-in", None)
- verifier = generate_code_verifier()
- challenge = generate_code_challenge(verifier)
- state = secrets.token_hex(16)
- callback = urllib.parse.urlparse(self.callback_url)
- if callback.scheme != "http" or callback.hostname not in {"127.0.0.1", "localhost"}:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "Callback URL must be http://localhost or http://127.0.0.1.")
-
- query = urllib.parse.urlencode(
- {
- "response_type": "code",
- "client_id": self.client_id,
- "redirect_uri": self.callback_url,
- "scope": self.scope,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "state": state,
- }
- )
- authorize_url = f"{self.authorize_endpoint}?{query}"
-
- progress("auth", "Opening browser for Autodesk sign-in", None)
- webbrowser.open(authorize_url)
- code = self._callback_waiter(
- callback.hostname or "127.0.0.1",
- callback.port or 80,
- callback.path or "/",
- state,
- )
-
- progress("auth", "Exchanging authorization code for token", None)
- response = self.http.post(
- self.token_endpoint,
- data={
- "client_id": self.client_id,
- "grant_type": "authorization_code",
- "code": code,
- "code_verifier": verifier,
- "redirect_uri": self.callback_url,
- },
- )
- if response.is_error:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token exchange failed: {response.text}")
- token = self._token_from_payload(response.json())
- self.token_store.save(asdict(token))
- progress("auth", "Signed in to Autodesk", 100)
- return token
-
- def _refresh(self, token: StoredToken, progress: Progress) -> StoredToken:
- progress("auth", "Refreshing Autodesk session", None)
- response = self.http.post(
- self.token_endpoint,
- data={
- "client_id": self.client_id,
- "grant_type": "refresh_token",
- "refresh_token": token.refresh_token,
- "scope": self.scope,
- },
- )
- if response.is_error:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token refresh failed: {response.text}")
- refreshed = self._token_from_payload(response.json())
- self.token_store.save(asdict(refreshed))
- progress("auth", "Session refreshed", 100)
- return refreshed
-
- def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
- now = self._now()
- refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
- return StoredToken(
- client_id=self.client_id,
- access_token=payload["access_token"],
- refresh_token=payload["refresh_token"],
- access_token_expires_at_utc=(now + dt.timedelta(seconds=int(payload["expires_in"]) - 30)).isoformat(),
- refresh_token_expires_at_utc=(now + dt.timedelta(seconds=refresh_ttl - 30)).isoformat(),
- scope=self.scope,
- )
-
-class ApsClient:
- def __init__(self, auth: AuthSessionService, *, transport: httpx.BaseTransport | None = None) -> None:
- self.auth = auth
- self.http = httpx.Client(timeout=120, transport=transport)
-
- # Browsing -----------------------------------------------------------------
-
- def list_hubs(self) -> list[dict[str, Any]]:
- payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
- hubs = [
- {
- "id": item["id"],
- "name": item["attributes"]["name"],
- "extension_type": item["attributes"]["extension"]["type"],
- }
- for item in payload.get("data", [])
- ]
- hubs.sort(key=lambda h: (h["name"] or "").casefold())
- return hubs
-
- def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
- url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
- projects: list[dict[str, Any]] = []
- while url:
- payload = self._get_json(url)
- for item in payload.get("data", []):
- projects.append(
- {
- "id": item["id"],
- "name": item["attributes"]["name"],
- "extension_type": item["attributes"]["extension"]["type"],
- "root_folder_id": item["relationships"]["rootFolder"]["data"]["id"],
- }
- )
- url = payload.get("links", {}).get("next", {}).get("href", "") or ""
- projects.sort(key=lambda p: (p["name"] or "").casefold())
- return projects
-
- def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
- payload = self._get_json(
- f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
- )
- folders = [self._entry(item) for item in payload.get("data", [])]
- folders.sort(key=lambda e: (e.get("display_name") or "").casefold())
- return folders
-
- def list_folder_contents(
- self,
- project_id: str,
- folder_id: str,
- *,
- object_types: list[str] | None = None,
- extension_filter: Callable[[dict[str, Any]], bool] | None = None,
- ) -> list[dict[str, Any]]:
- url = f"https://developer.api.autodesk.com/data/v1/projects/{project_id}/folders/{folder_id}/contents"
- if object_types:
- query = [("filter[type]", value) for value in object_types]
- url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
- entries: list[dict[str, Any]] = []
- while url:
- payload = self._get_json(url)
- for item in payload.get("data", []):
- entry = self._entry(item)
- if extension_filter and entry["type"] == "items" and not extension_filter(entry):
- continue
- entries.append(entry)
- url = payload.get("links", {}).get("next", {}).get("href", "") or ""
- entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
- return entries
-
- def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
- """Return the item plus its current tip in a single request.
-
- ``hidden`` reflects the item's soft-delete state (BIM 360 / ACC mark
- deleted items as ``hidden: true``; the storage URL may still resolve
- to a stale copy, so callers must check this before downloading).
- """
- payload = self._get_json(
- f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}"
- f"/items/{urllib.parse.quote(item_id, safe='')}?include=tip"
- )
- item = payload["data"]
- item_attributes = item.get("attributes", {})
- parent_folder_id = self._relationship_id(item, "parent")
- tip_id = self._relationship_id(item, "tip")
- tip: dict[str, Any] | None = None
- for included in payload.get("included", []):
- if included.get("type") == "versions" and included.get("id") == tip_id:
- tip = included
- break
-
- if tip is None:
- return {
- "id": item["id"],
- "display_name": item_attributes.get("displayName")
- or item_attributes.get("name")
- or item["id"],
- "hidden": True,
- "version_id": None,
- "storage_id": None,
- "version_number": None,
- "last_modified_time_utc": None,
- "last_modified_user_name": None,
- "parent_folder_id": parent_folder_id,
- }
-
- tip_attributes = tip.get("attributes", {})
- return {
- "id": item["id"],
- "display_name": tip_attributes.get("displayName")
- or tip_attributes.get("name")
- or item_attributes.get("displayName")
- or item["id"],
- "hidden": bool(item_attributes.get("hidden", False)),
- "version_id": tip["id"],
- "storage_id": self._relationship_id(tip, "storage"),
- "version_number": tip_attributes.get("versionNumber"),
- "last_modified_time_utc": tip_attributes.get("lastModifiedTime"),
- "last_modified_user_name": tip_attributes.get("lastModifiedUserName"),
- "parent_folder_id": parent_folder_id,
- }
-
- # Download / upload --------------------------------------------------------
-
- def download_storage_to_file(
- self,
- storage_id: str,
- destination_path: Path,
- *,
- progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
- ) -> None:
- bucket_key, object_key = self._parse_storage_id(storage_id)
- signed_url = self._get_signed_download_url(bucket_key, object_key)
- self._download_to_file(signed_url, destination_path, progress)
-
- def upload_file_to_folder(
- self,
- project_id: str,
- folder_id: str,
- local_path: Path,
- *,
- display_name: str | None = None,
- progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
- ) -> dict[str, Any]:
- if not local_path.exists():
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
- file_name = display_name or local_path.name
- storage_id = self._create_storage(project_id, folder_id, file_name)
- bucket_key, object_key = self._parse_storage_id(storage_id)
- self._upload_local_file_to_oss(bucket_key, object_key, local_path, progress)
- existing_item = self._find_item_in_folder(project_id, folder_id, file_name)
- if existing_item is not None:
- return self._create_version(project_id, existing_item["id"], file_name, storage_id)
- return self._create_item(project_id, folder_id, file_name, storage_id)
-
- # HTTP helpers -------------------------------------------------------------
-
- def _get_json(self, url: str) -> dict[str, Any]:
- token = self.auth.ensure_access_token()
- try:
- response = self.http.get(url, headers={"Authorization": f"Bearer {token}"})
- response.raise_for_status()
- return response.json()
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
- token = self.auth.ensure_access_token()
- try:
- response = self.http.post(
- url,
- headers={
- "Authorization": f"Bearer {token}",
- "Content-Type": "application/vnd.api+json",
- "Accept": "application/vnd.api+json",
- },
- json=payload,
- )
- response.raise_for_status()
- return response.json()
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _get_signed_download_url(self, bucket_key: str, object_key: str) -> str:
- payload = self._get_json(
- "https://developer.api.autodesk.com/oss/v2/buckets/"
- f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
- f"{urllib.parse.quote(object_key, safe='')}/signeds3download"
- )
- url = payload.get("url")
- if not isinstance(url, str) or not url:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "Signed download URL response did not contain a URL.")
- return url
-
- def _download_to_file(
- self,
- url: str,
- destination_path: Path,
- progress: Callable[[str, int | None, int | None, int | None], None] | None,
- ) -> None:
- try:
- with self.http.stream("GET", url) as response:
- response.raise_for_status()
- total_bytes: int | None = None
- header_value = response.headers.get("Content-Length")
- if header_value and header_value.isdigit():
- total_bytes = int(header_value)
- downloaded_bytes = 0
- with open(destination_path, "wb") as handle:
- for chunk in response.iter_bytes():
- handle.write(chunk)
- downloaded_bytes += len(chunk)
- if progress and total_bytes:
- percent = min(100, int((downloaded_bytes / total_bytes) * 100))
- progress(destination_path.name, percent, downloaded_bytes, total_bytes)
- elif progress:
- progress(destination_path.name, None, downloaded_bytes, total_bytes)
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _create_storage(self, project_id: str, folder_id: str, file_name: str) -> str:
- payload = self._post_json(
- f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/storage",
- {
- "jsonapi": {"version": "1.0"},
- "data": {
- "type": "objects",
- "attributes": {"name": file_name},
- "relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
- },
- },
- )
- storage_id = payload.get("data", {}).get("id")
- if not isinstance(storage_id, str) or not storage_id:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "Storage creation did not return an object id.")
- return storage_id
-
- def _upload_local_file_to_oss(
- self,
- bucket_key: str,
- object_key: str,
- local_path: Path,
- progress: Callable[[str, int | None, int | None, int | None], None] | None,
- ) -> None:
- file_size = local_path.stat().st_size
- chunk_size = 5 * 1024 * 1024
- total_parts = max(1, math.ceil(file_size / chunk_size))
- upload_key: str | None = None
- parts_uploaded = 0
- bytes_uploaded = 0
-
- with open(local_path, "rb") as handle:
- while parts_uploaded < total_parts:
- parts_to_request = min(total_parts - parts_uploaded, 5)
- first_part = parts_uploaded + 1
- signed = self._get_signed_upload_urls(
- bucket_key,
- object_key,
- upload_key=upload_key,
- first_part=first_part,
- parts=parts_to_request,
- )
- if upload_key is None:
- upload_key = signed.get("uploadKey")
- urls = signed.get("urls", [])
- if not isinstance(urls, list) or not urls:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload URL response did not contain upload URLs.")
- for url in urls:
- if parts_uploaded >= total_parts:
- break
- chunk = handle.read(chunk_size)
- if not chunk:
- break
- self._put_bytes(str(url), chunk)
- parts_uploaded += 1
- bytes_uploaded += len(chunk)
- if progress:
- percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
- progress(local_path.name, percent, bytes_uploaded, file_size)
-
- if not upload_key:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
- self._complete_signed_upload(bucket_key, object_key, upload_key)
-
- def _get_signed_upload_urls(
- self,
- bucket_key: str,
- object_key: str,
- *,
- upload_key: str | None,
- first_part: int,
- parts: int,
- ) -> dict[str, Any]:
- token = self.auth.ensure_access_token()
- params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts}
- if upload_key:
- params["uploadKey"] = upload_key
- try:
- response = self.http.get(
- "https://developer.api.autodesk.com/oss/v2/buckets/"
- f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
- f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
- headers={"Authorization": f"Bearer {token}"},
- params=params,
- )
- response.raise_for_status()
- return response.json()
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None:
- token = self.auth.ensure_access_token()
- try:
- response = self.http.post(
- "https://developer.api.autodesk.com/oss/v2/buckets/"
- f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
- f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
- headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
- json={"uploadKey": upload_key},
- )
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _put_bytes(self, url: str, content: bytes) -> None:
- try:
- response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"})
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- body = exc.response.text.strip()
- raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
- except httpx.HTTPError as exc:
- raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
-
- def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None:
- children = self.list_folder_contents(project_id, folder_id, object_types=["items"])
- return next(
- (
- child for child in children
- if self._entry_name_matches(child, file_name)
- ),
- None,
- )
-
- def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
- payload = self._post_json(
- f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions",
- {
- "jsonapi": {"version": "1.0"},
- "data": {
- "type": "versions",
- "attributes": {
- "name": file_name,
- "extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
- },
- "relationships": {
- "item": {"data": {"type": "items", "id": item_id}},
- "storage": {"data": {"type": "objects", "id": storage_id}},
- },
- },
- },
- )
- version = payload["data"]
- attributes = version.get("attributes", {})
- return {
- "item_id": item_id,
- "version_id": version["id"],
- "display_name": file_name,
- "version_number": attributes.get("versionNumber"),
- "last_modified_time_utc": attributes.get("lastModifiedTime"),
- "last_modified_user_name": attributes.get("lastModifiedUserName"),
- }
-
- def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
- payload = self._post_json(
- f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items",
- {
- "jsonapi": {"version": "1.0"},
- "data": {
- "type": "items",
- "attributes": {
- "displayName": file_name,
- "extension": {"type": "items:autodesk.bim360:File", "version": "1.0"},
- },
- "relationships": {
- "tip": {"data": {"type": "versions", "id": "1"}},
- "parent": {"data": {"type": "folders", "id": folder_id}},
- },
- },
- "included": [
- {
- "type": "versions",
- "id": "1",
- "attributes": {
- "name": file_name,
- "extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
- },
- "relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}},
- }
- ],
- },
- )
- item = payload["data"]
- version_id = "1"
- version_number: Any = 1
- last_modified_time: Any = None
- last_modified_user: Any = None
- for included in payload.get("included", []):
- if included.get("type") == "versions":
- version_id = included.get("id") or version_id
- attributes = included.get("attributes", {})
- version_number = attributes.get("versionNumber", version_number)
- last_modified_time = attributes.get("lastModifiedTime")
- last_modified_user = attributes.get("lastModifiedUserName")
- break
- return {
- "item_id": item["id"],
- "version_id": version_id,
- "display_name": file_name,
- "version_number": version_number,
- "last_modified_time_utc": last_modified_time,
- "last_modified_user_name": last_modified_user,
- }
-
- # Static helpers -----------------------------------------------------------
-
- @staticmethod
- def _parse_storage_id(storage_id: str) -> tuple[str, str]:
- marker = "urn:adsk.objects:os.object:"
- if not storage_id.startswith(marker):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.")
- path = storage_id[len(marker):]
- slash = path.find("/")
- if slash <= 0 or slash == len(path) - 1:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.")
- return path[:slash], path[slash + 1 :]
-
- @staticmethod
- def _entry(item: dict[str, Any]) -> dict[str, Any]:
- attributes = item["attributes"]
- return {
- "id": item["id"],
- "type": item["type"],
- "display_name": attributes.get("displayName") or attributes.get("name") or "",
- "name": attributes.get("name"),
- "extension_type": attributes.get("extension", {}).get("type", ""),
- }
-
- @staticmethod
- def _relationship_id(data: dict[str, Any], name: str) -> str | None:
- rel_data = data.get("relationships", {}).get(name, {}).get("data")
- if isinstance(rel_data, dict):
- rel_id = rel_data.get("id")
- return rel_id if isinstance(rel_id, str) and rel_id else None
- if isinstance(rel_data, list) and rel_data:
- rel_id = rel_data[0].get("id")
- return rel_id if isinstance(rel_id, str) and rel_id else None
- return None
-
- @staticmethod
- def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool:
- display_name = str(entry.get("display_name") or "").lower()
- raw_name = str(entry.get("name") or "").lower()
- expected = expected_name.lower()
- return display_name == expected or raw_name == expected
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/cache.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/cache.py
deleted file mode 100644
index 1a9a98e20f..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/cache.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import platform
-import shutil
-from pathlib import Path
-from typing import Any
-
-
-def cache_root() -> Path:
- system = platform.system()
- if system == "Windows":
- base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
- root = Path(base) / "bonsaiviewer-autodesk" / "Cache"
- elif system == "Darwin":
- root = Path.home() / "Library" / "Caches" / "bonsaiviewer-autodesk"
- else:
- base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
- root = Path(base) / "bonsaiviewer-autodesk"
- root.mkdir(parents=True, exist_ok=True)
- return root
-
-
-def _short_hash(*parts: str) -> str:
- joined = "\x1f".join(parts)
- return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
-
-
-def ifcfed_dir(project_id: str, item_id: str) -> Path:
- """Stable directory for an .ifcfed. Re-downloads overwrite in place so the
- viewer's open path remains valid across sync operations."""
- return cache_root() / "ifcfeds" / _short_hash(project_id, item_id)
-
-
-def model_dir(project_id: str, item_id: str, version_id: str) -> Path:
- """Per-version directory for a model. A new resolved version → a new
- directory, satisfying the spec's invariant that sidecars regenerate when
- the model file changes."""
- return cache_root() / "models" / _short_hash(project_id, item_id, version_id)
-
-
-def prepare_sole_child_dir(directory: Path) -> Path:
- """Clear the directory so the file we write is the only child."""
- if directory.exists():
- shutil.rmtree(directory)
- directory.mkdir(parents=True, exist_ok=True)
- return directory
-
-
-def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path:
- manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
- manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
- return manifest_path
-
-
-def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None:
- manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
- if not manifest_path.exists():
- return None
- return json.loads(manifest_path.read_text(encoding="utf-8"))
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py
deleted file mode 100644
index 2e785df620..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py
+++ /dev/null
@@ -1,563 +0,0 @@
-from __future__ import annotations
-
-import sys
-import traceback
-from pathlib import Path
-from typing import Any, Callable
-
-from bonsaiviewer_autodesk import cache, settings
-from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
-from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError
-from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, prompt_for_filename, run_with_progress
-
-
-ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
-Report = Callable[[str, str, "int | None", "str | None"], None]
-
-
-def _format_bytes(value: int) -> str:
- """Render a byte count as a short human-readable string (e.g. '3.4 MB')."""
- if value < 1024:
- return f"{value} B"
- scaled = float(value)
- for unit in ("KB", "MB", "GB", "TB"):
- scaled /= 1024.0
- if scaled < 1024 or unit == "TB":
- return f"{scaled:.1f} {unit}"
- return f"{value} B"
-
-
-def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str:
- """Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'."""
- parts: list[str] = []
- if percent is not None:
- parts.append(f"{percent}%")
- if done is not None and total:
- parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}")
- elif done is not None:
- parts.append(_format_bytes(done))
- return ", ".join(parts)
-
-
-def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
- """Adapt ProgressDialog.report to the APS download callback.
-
- index/total render "(i/N)" suffix when batching; pass 0 (the default) for
- single-file downloads to omit the suffix.
- """
- def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
- suffix = f" ({index}/{total})" if total else ""
- detail = _progress_detail(percent, bytes_done, bytes_total)
- report("download", f"Downloading {name}{suffix}", percent, detail)
- return cb
-
-
-def _upload_callback(report: Report) -> ApsProgress:
- def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
- detail = _progress_detail(percent, bytes_done, bytes_total)
- report("upload", f"Uploading {name}", percent, detail)
- return cb
-
-
-CONNECTOR_ID = "autodesk"
-KEYRING_SERVICE = "bonsaiviewer-autodesk"
-DEFAULT_SCOPE = "data:read data:write data:create"
-
-
-class AutodeskConnector:
- def __init__(self) -> None:
- self.auth: AuthSessionService | None = None
- self.aps: ApsClient | None = None
- self.reload_credentials()
-
- def reload_credentials(self) -> None:
- """Rebuild auth + APS from current settings. Safe to call any time."""
- client_id = settings.load_client_id()
- if not client_id:
- self.auth = None
- self.aps = None
- return
- token_store = KeyringTokenStore(service_name=KEYRING_SERVICE, username=client_id)
- callback_url = f"http://localhost:{settings.stored_callback_port()}/"
- self.auth = AuthSessionService(
- client_id=client_id,
- callback_url=callback_url,
- scope=DEFAULT_SCOPE,
- token_store=token_store,
- )
- self.aps = ApsClient(self.auth)
-
- def _require_aps(self) -> tuple[AuthSessionService, ApsClient]:
- if self.auth is None or self.aps is None:
- raise RpcError(
- JSONRPC_INTERNAL_ERROR,
- "Autodesk client id is not configured. Open the connector settings to set it.",
- )
- return self.auth, self.aps
-
- def handlers(self) -> dict[str, Any]:
- return {
- "pull_ifcfed_interactive": self.pull_ifcfed_interactive,
- "pull_ifcfed": self.pull_ifcfed,
- "pull_models": self.pull_models,
- "pull_models_interactive": self.pull_models_interactive,
- "push_ifcfed_interactive": self.push_ifcfed_interactive,
- "push_ifcfed": self.push_ifcfed,
- "push_model_interactive": self.push_model_interactive,
- "push_model": self.push_model,
- "open_settings": self.open_settings,
- }
-
- # ---- open_settings ------------------------------------------------------
-
- def open_settings(self, _params: Any) -> dict[str, Any]:
- SettingsDialog(connector=self).run()
- return {}
-
- # ---- pull_ifcfed_interactive --------------------------------------------
-
- def pull_ifcfed_interactive(self, _params: Any) -> dict[str, Any]:
- auth, aps = self._require_aps()
- chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
- hub = chosen["hub"]
- project = chosen["project"]
- entry = chosen["entries"][0]
- path = run_with_progress(
- "Downloading project",
- lambda report: self._download_ifcfed(
- aps=aps,
- hub_id=hub["id"],
- project_id=project["id"],
- item_id=entry["id"],
- display_name=entry["display_name"],
- progress=_download_callback(report),
- ),
- )
- return {"path": str(path)}
-
- # ---- pull_ifcfed --------------------------------------------------------
-
- def pull_ifcfed(self, params: Any) -> dict[str, Any]:
- _, aps = self._require_aps()
- manifest = _require_object(params, "params")
- hub_id = _require_string(manifest, "hub_id")
- project_id = _require_string(manifest, "project_id")
- item_id = _require_string(manifest, "item_id")
- display_name = manifest.get("display_name") or item_id
- path = run_with_progress(
- "Downloading project",
- lambda report: self._download_ifcfed(
- aps=aps,
- hub_id=hub_id,
- project_id=project_id,
- item_id=item_id,
- display_name=display_name,
- progress=_download_callback(report),
- ),
- )
- return {"path": str(path)}
-
- def _download_ifcfed(
- self,
- *,
- aps: ApsClient,
- hub_id: str,
- project_id: str,
- item_id: str,
- display_name: str,
- progress: ApsProgress | None = None,
- ) -> Path:
- item = aps.get_item(project_id, item_id)
- if item["hidden"]:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
- storage_id = item["storage_id"]
- if not isinstance(storage_id, str):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
- file_name = item["display_name"] or display_name or item_id
- if not file_name.lower().endswith(".ifcfed"):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Item '{file_name}' is not an .ifcfed file.")
-
- directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, item_id))
- ifcfed_path = directory / file_name
- aps.download_storage_to_file(storage_id, ifcfed_path, progress=progress)
- cache.write_manifest(
- ifcfed_path,
- {
- "connector": CONNECTOR_ID,
- "hub_id": hub_id,
- "project_id": project_id,
- "item_id": item_id,
- "display_name": file_name,
- },
- )
- return ifcfed_path
-
- # ---- pull_models --------------------------------------------------------
-
- def pull_models(self, params: Any) -> list[dict[str, Any] | None]:
- _, aps = self._require_aps()
- models = _require_array(params, "params")
- total = len(models)
-
- def work(report: Report) -> list[dict[str, Any] | None]:
- results: list[dict[str, Any] | None] = []
- for index, model in enumerate(models):
- callback = _download_callback(report, index=index + 1, total=total)
- try:
- results.append(self._resolve_model(aps, model, progress=callback))
- except RpcError as exc:
- print(f"pull_models[{index}] skipped: {exc.message}", file=sys.stderr)
- results.append(None)
- except Exception as exc:
- print(f"pull_models[{index}] skipped: {exc}", file=sys.stderr)
- traceback.print_exc(file=sys.stderr)
- results.append(None)
- return results
-
- return run_with_progress("Downloading models", work)
-
- def _resolve_model(
- self,
- aps: ApsClient,
- model: Any,
- *,
- progress: ApsProgress | None = None,
- ) -> dict[str, Any] | None:
- if not isinstance(model, dict):
- raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must be an object.")
- source = model.get("source")
- if not isinstance(source, dict):
- raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must have a 'source' object.")
- if source.get("connector") != CONNECTOR_ID:
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
- project_id = _require_string(source, "project_id")
- item_id = _require_string(source, "item_id")
- display_name_hint = model.get("display_name") or item_id
-
- item = aps.get_item(project_id, item_id)
- if item["hidden"]:
- print(f"Autodesk item '{item_id}' is hidden/deleted; returning null.", file=sys.stderr)
- return None
-
- storage_id = item["storage_id"]
- if not isinstance(storage_id, str):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
- file_name = item["display_name"] or display_name_hint
- version_id = item["version_id"]
-
- directory = cache.model_dir(project_id, item_id, version_id)
- model_path = directory / file_name
- if not model_path.exists():
- cache.prepare_sole_child_dir(directory)
- aps.download_storage_to_file(storage_id, model_path, progress=progress)
-
- return {
- "path": str(model_path),
- "metadata": _build_metadata(item),
- }
-
- # ---- pull_models_interactive --------------------------------------------
-
- def pull_models_interactive(self, _params: Any) -> list[dict[str, Any]]:
- auth, aps = self._require_aps()
- chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
- hub = chosen["hub"]
- project = chosen["project"]
- entries = chosen["entries"]
- total = len(entries)
-
- def work(report: Report) -> list[dict[str, Any]]:
- results: list[dict[str, Any]] = []
- for index, entry in enumerate(entries):
- callback = _download_callback(report, index=index + 1, total=total)
- try:
- result = self._download_picked_model(aps, hub, project, entry, callback)
- except RpcError as exc:
- print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr)
- continue
- except Exception as exc:
- print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr)
- traceback.print_exc(file=sys.stderr)
- continue
- if result is not None:
- results.append(result)
- return results
-
- return run_with_progress("Downloading models", work)
-
- def _download_picked_model(
- self,
- aps: ApsClient,
- hub: dict[str, Any],
- project: dict[str, Any],
- entry: dict[str, Any],
- progress: ApsProgress,
- ) -> dict[str, Any] | None:
- item = aps.get_item(project["id"], entry["id"])
- if item["hidden"]:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.")
- storage_id = item["storage_id"]
- if not isinstance(storage_id, str):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
-
- file_name = item["display_name"] or entry["display_name"] or entry["id"]
- version_id = item["version_id"]
-
- directory = cache.model_dir(project["id"], entry["id"], version_id)
- model_path = directory / file_name
- if not model_path.exists():
- cache.prepare_sole_child_dir(directory)
- aps.download_storage_to_file(storage_id, model_path, progress=progress)
-
- return {
- "display_name": file_name,
- "source": {
- "connector": CONNECTOR_ID,
- "hub_id": hub["id"],
- "project_id": project["id"],
- "item_id": entry["id"],
- },
- "path": str(model_path),
- "metadata": _build_metadata(item),
- }
-
- # ---- push_ifcfed_interactive --------------------------------------------
-
- def push_ifcfed_interactive(self, params: Any) -> dict[str, Any]:
- auth, aps = self._require_aps()
- params_obj = _require_object(params, "params")
- local_path = Path(_require_string(params_obj, "path"))
- if not local_path.exists():
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
- if not local_path.name.lower().endswith(".ifcfed"):
- raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed_interactive expects an .ifcfed file.")
-
- chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
- hub = chosen["hub"]
- project = chosen["project"]
- folder = chosen["entries"][0]
-
- file_name = prompt_for_filename(
- title="Save Project",
- label="Save .ifcfed as:",
- default=local_path.name,
- )
- if not file_name:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
- if not file_name.lower().endswith(".ifcfed"):
- file_name = file_name + ".ifcfed"
-
- uploaded = run_with_progress(
- "Uploading project",
- lambda report: aps.upload_file_to_folder(
- project["id"],
- folder["id"],
- local_path,
- display_name=file_name,
- progress=_upload_callback(report),
- ),
- )
-
- directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project["id"], uploaded["item_id"]))
- cached_path = directory / file_name
- cached_path.write_bytes(local_path.read_bytes())
- cache.write_manifest(
- cached_path,
- {
- "connector": CONNECTOR_ID,
- "hub_id": hub["id"],
- "project_id": project["id"],
- "item_id": uploaded["item_id"],
- "display_name": file_name,
- },
- )
- return {"path": str(cached_path)}
-
- # ---- push_ifcfed --------------------------------------------------------
-
- def push_ifcfed(self, params: Any) -> dict[str, Any]:
- _, aps = self._require_aps()
- params_obj = _require_object(params, "params")
- local_path = Path(_require_string(params_obj, "path"))
- if not local_path.exists():
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
- if not local_path.name.lower().endswith(".ifcfed"):
- raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed expects an .ifcfed file.")
-
- manifest = params_obj.get("manifest")
- if not isinstance(manifest, dict):
- raise RpcError(JSONRPC_INVALID_PARAMS, "'manifest' must be a JSON object.")
- if manifest.get("connector") != CONNECTOR_ID:
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Manifest connector is not '{CONNECTOR_ID}'.")
- hub_id = _require_string(manifest, "hub_id")
- project_id = _require_string(manifest, "project_id")
- item_id = _require_string(manifest, "item_id")
-
- item = aps.get_item(project_id, item_id)
- if item["hidden"]:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
- folder_id = item.get("parent_folder_id")
- if not isinstance(folder_id, str) or not folder_id:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
- file_name = manifest.get("display_name") or item.get("display_name") or local_path.name
-
- uploaded = run_with_progress(
- "Uploading project",
- lambda report: aps.upload_file_to_folder(
- project_id,
- folder_id,
- local_path,
- display_name=file_name,
- progress=_upload_callback(report),
- ),
- )
-
- directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, uploaded["item_id"]))
- cached_path = directory / file_name
- cached_path.write_bytes(local_path.read_bytes())
- cache.write_manifest(
- cached_path,
- {
- "connector": CONNECTOR_ID,
- "hub_id": hub_id,
- "project_id": project_id,
- "item_id": uploaded["item_id"],
- "display_name": file_name,
- },
- )
- return {"path": str(cached_path)}
-
- # ---- push_model_interactive ---------------------------------------------
-
- def push_model_interactive(self, params: Any) -> dict[str, Any]:
- auth, aps = self._require_aps()
- params_obj = _require_object(params, "params")
- local_path = Path(_require_string(params_obj, "path"))
- if not local_path.exists():
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
-
- chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
- hub = chosen["hub"]
- project = chosen["project"]
- folder = chosen["entries"][0]
-
- file_name = prompt_for_filename(
- title="Save Model",
- label="Save model as:",
- default=local_path.name,
- )
- if not file_name:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
-
- uploaded = run_with_progress(
- "Uploading model",
- lambda report: aps.upload_file_to_folder(
- project["id"],
- folder["id"],
- local_path,
- display_name=file_name,
- progress=_upload_callback(report),
- ),
- )
-
- directory = cache.model_dir(project["id"], uploaded["item_id"], uploaded["version_id"])
- cache.prepare_sole_child_dir(directory)
- cached_path = directory / file_name
- cached_path.write_bytes(local_path.read_bytes())
-
- return {
- "display_name": file_name,
- "path": str(cached_path),
- "source": {
- "connector": CONNECTOR_ID,
- "hub_id": hub["id"],
- "project_id": project["id"],
- "item_id": uploaded["item_id"],
- },
- "metadata": _build_metadata(uploaded),
- }
-
- # ---- push_model ---------------------------------------------------------
-
- def push_model(self, params: Any) -> dict[str, Any]:
- _, aps = self._require_aps()
- params_obj = _require_object(params, "params")
- local_path = Path(_require_string(params_obj, "path"))
- if not local_path.exists():
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
-
- source = params_obj.get("source")
- if not isinstance(source, dict):
- raise RpcError(JSONRPC_INVALID_PARAMS, "'source' must be a JSON object.")
- if source.get("connector") != CONNECTOR_ID:
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
- hub_id = _require_string(source, "hub_id")
- project_id = _require_string(source, "project_id")
- item_id = _require_string(source, "item_id")
-
- item = aps.get_item(project_id, item_id)
- if item["hidden"]:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
- folder_id = item.get("parent_folder_id")
- if not isinstance(folder_id, str) or not folder_id:
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
- file_name = item.get("display_name") or local_path.name
-
- uploaded = run_with_progress(
- "Uploading model",
- lambda report: aps.upload_file_to_folder(
- project_id,
- folder_id,
- local_path,
- display_name=file_name,
- progress=_upload_callback(report),
- ),
- )
-
- directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"])
- cache.prepare_sole_child_dir(directory)
- cached_path = directory / file_name
- cached_path.write_bytes(local_path.read_bytes())
-
- return {
- "source": {
- "connector": CONNECTOR_ID,
- "hub_id": hub_id,
- "project_id": project_id,
- "item_id": uploaded["item_id"],
- },
- "metadata": _build_metadata(uploaded),
- }
-
-
-def _require_object(params: Any, name: str) -> dict[str, Any]:
- if not isinstance(params, dict):
- raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.")
- return params
-
-
-def _require_array(params: Any, name: str) -> list[Any]:
- if not isinstance(params, list):
- raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.")
- return params
-
-
-def _require_string(obj: dict[str, Any], key: str) -> str:
- value = obj.get(key)
- if not isinstance(value, str) or not value.strip():
- raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.")
- return value
-
-
-def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]:
- metadata: dict[str, Any] = {}
- version_number = version_info.get("version_number")
- if version_number is not None:
- metadata["revision"] = f"v{version_number}"
- last_modified = version_info.get("last_modified_time_utc")
- if isinstance(last_modified, str) and last_modified:
- metadata["date"] = last_modified
- author = version_info.get("last_modified_user_name")
- if isinstance(author, str) and author:
- metadata["author"] = author
- return metadata
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/rpc.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/rpc.py
deleted file mode 100644
index a91ccbe0ef..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/rpc.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from __future__ import annotations
-
-import json
-import sys
-import traceback
-from typing import Any, Callable, TextIO
-
-
-JSONRPC_PARSE_ERROR = -32700
-JSONRPC_INVALID_REQUEST = -32600
-JSONRPC_METHOD_NOT_FOUND = -32601
-JSONRPC_INVALID_PARAMS = -32602
-JSONRPC_INTERNAL_ERROR = -32603
-
-
-class RpcError(Exception):
- def __init__(self, code: int, message: str, data: Any | None = None) -> None:
- super().__init__(message)
- self.code = code
- self.message = message
- self.data = data
-
-
-Handler = Callable[[Any], Any]
-
-
-class JsonRpcHost:
- def __init__(
- self,
- handlers: dict[str, Handler],
- *,
- stdin: TextIO = sys.stdin,
- stdout: TextIO = sys.stdout,
- stderr: TextIO = sys.stderr,
- ) -> None:
- self.handlers = handlers
- self.stdin = stdin
- self.stdout = stdout
- self.stderr = stderr
-
- def run(self) -> int:
- for line in self.stdin:
- line = line.strip()
- if not line:
- continue
- self._handle_line(line)
- return 0
-
- def _handle_line(self, line: str) -> None:
- message_id: Any = None
- try:
- try:
- message = json.loads(line)
- except json.JSONDecodeError as exc:
- self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}")
- return
-
- if not isinstance(message, dict):
- self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object")
- return
- if message.get("jsonrpc") != "2.0":
- self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version")
- return
-
- message_id = message.get("id")
- method = message.get("method")
- if not isinstance(method, str):
- self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string")
- return
-
- params = message.get("params", None)
- if params is not None and not isinstance(params, (dict, list)):
- self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array")
- return
-
- handler = self.handlers.get(method)
- if handler is None:
- self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'")
- return
-
- try:
- result = handler(params)
- except RpcError as exc:
- self._respond_error(message_id, exc.code, exc.message, exc.data)
- return
- except Exception as exc:
- print(f"Handler '{method}' raised: {exc}", file=self.stderr)
- traceback.print_exc(file=self.stderr)
- self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
- return
-
- if message_id is not None:
- self._respond_result(message_id, result)
- except Exception as exc:
- print(f"Unhandled host error: {exc}", file=self.stderr)
- traceback.print_exc(file=self.stderr)
- self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
-
- def _respond_result(self, message_id: Any, result: Any) -> None:
- self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
-
- def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None:
- error: dict[str, Any] = {"code": code, "message": message}
- if data is not None:
- error["data"] = data
- self._write({"jsonrpc": "2.0", "id": message_id, "error": error})
-
- def _write(self, payload: dict[str, Any]) -> None:
- line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
- self.stdout.write(line + "\n")
- self.stdout.flush()
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py
deleted file mode 100644
index 276fa02aa8..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import platform
-from pathlib import Path
-from typing import Any
-
-
-DEFAULT_CALLBACK_PORT = 8080
-
-
-def config_root() -> Path:
- system = platform.system()
- if system == "Windows":
- base = os.environ.get("APPDATA") or os.path.expanduser("~")
- root = Path(base) / "bonsaiviewer-autodesk"
- elif system == "Darwin":
- root = Path.home() / "Library" / "Application Support" / "bonsaiviewer-autodesk"
- else:
- base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
- root = Path(base) / "bonsaiviewer-autodesk"
- root.mkdir(parents=True, exist_ok=True)
- return root
-
-
-def _settings_path() -> Path:
- return config_root() / "settings.json"
-
-
-def _read() -> dict[str, Any]:
- path = _settings_path()
- if not path.exists():
- return {}
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError):
- return {}
- return data if isinstance(data, dict) else {}
-
-
-def _write(data: dict[str, Any]) -> None:
- _settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
-
-
-def load_client_id() -> str:
- """The client id persisted in settings.json, or "" if none is set."""
- return str(_read().get("client_id", "")).strip()
-
-
-def save_client_id(client_id: str) -> None:
- data = _read()
- data["client_id"] = client_id.strip()
- _write(data)
-
-
-def stored_callback_port() -> int:
- value = _read().get("callback_port", DEFAULT_CALLBACK_PORT)
- try:
- port = int(value)
- except (TypeError, ValueError):
- return DEFAULT_CALLBACK_PORT
- return port if 1 <= port <= 65535 else DEFAULT_CALLBACK_PORT
-
-
-def save_callback_port(port: int) -> None:
- if not 1 <= port <= 65535:
- raise ValueError("Callback port must be between 1 and 65535.")
- data = _read()
- data["callback_port"] = port
- _write(data)
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py
deleted file mode 100644
index 3981c2e514..0000000000
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py
+++ /dev/null
@@ -1,810 +0,0 @@
-from __future__ import annotations
-
-import threading
-import time
-import tkinter as tk
-from tkinter import ttk
-from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
-
-import customtkinter as ctk
-
-from bonsaiviewer_autodesk import settings
-from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
-from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
-
-if TYPE_CHECKING:
- from bonsaiviewer_autodesk.connector import AutodeskConnector
-
-
-MODEL_EXTENSIONS = (".ifc", ".ifcview", ".rdb", ".rdbview")
-Mode = Literal["ifcfed", "model", "destination"]
-
-
-# --- root + Treeview style ---------------------------------------------------
-# Tk's default ttk.Treeview looks like Windows 95 in any theme; force-style it
-# to match the surrounding CTk dark theme. Every other widget uses CTk defaults.
-
-_root: ctk.CTk | None = None
-
-
-def ensure_tk_app() -> ctk.CTk:
- global _root
- if _root is None:
- ctk.set_appearance_mode("Dark")
- ctk.set_default_color_theme("blue")
- _root = ctk.CTk()
- _root.withdraw()
- _apply_treeview_style()
- return _root
-
-
-def _apply_treeview_style() -> None:
- style = ttk.Style()
- try:
- style.theme_use("clam")
- except tk.TclError:
- pass
- style.configure(
- "Treeview",
- background="#2b2b2b",
- foreground="#dce4ee",
- fieldbackground="#2b2b2b",
- borderwidth=0,
- rowheight=26,
- )
- style.map(
- "Treeview",
- background=[("selected", "#1f6aa5")],
- foreground=[("selected", "#ffffff")],
- )
- style.layout("Treeview", [("Treeview.treearea", {"sticky": "nswe"})])
-
-
-# --- base modal --------------------------------------------------------------
-
-
-class _BaseDialog(ctk.CTkToplevel):
- def __init__(self, title: str, *, size: tuple[int, int], resizable: bool = True) -> None:
- super().__init__(ensure_tk_app())
- self.title(title)
- self.geometry(f"{size[0]}x{size[1]}")
- if not resizable:
- self.resizable(False, False)
- self.protocol("WM_DELETE_WINDOW", self._on_close)
- self.result: Any = None
- self.withdraw()
-
- def _on_close(self) -> None:
- try:
- self.grab_release()
- except tk.TclError:
- pass
- self.destroy()
-
- def _center_on_screen(self) -> None:
- self.update_idletasks()
- w = self.winfo_width()
- h = self.winfo_height()
- x = (self.winfo_screenwidth() - w) // 2
- y = (self.winfo_screenheight() - h) // 2
- self.geometry(f"+{x}+{y}")
-
- def run(self) -> Any:
- root = ensure_tk_app()
- self._center_on_screen()
- self.deiconify()
- self.lift()
- self.focus_force()
- try:
- self.grab_set()
- except tk.TclError:
- pass
- self.wait_window()
- try:
- root.update()
- root.update_idletasks()
- except tk.TclError:
- pass
- return self.result
-
-
-# --- progress ----------------------------------------------------------------
-
-
-class ProgressDialog(_BaseDialog):
- """Fixed-size progress dialog: a title line, a stats line, and a bar.
-
- Both text lines are single-line and middle-elided with '…' so a long
- filename can never reflow the layout or resize the window.
- """
-
- _WIDTH = 560
-
- def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
- super().__init__(title, size=(self._WIDTH, 160), resizable=False)
-
- self._text_font = ctk.CTkFont()
-
- body = ctk.CTkFrame(self)
- body.pack(fill="both", expand=True, padx=20, pady=20)
-
- self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
- self.title_label.pack(fill="x", padx=16, pady=(16, 0))
-
- # Initialised with a space so the line reserves its height before the
- # first report(); a single-line label never grows taller than this.
- self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font)
- self.detail_label.pack(fill="x", padx=16, pady=(2, 0))
-
- self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
- self.bar.pack(fill="x", padx=16, pady=(14, 16))
- self.bar.start()
- self._determinate = False
-
- # Derive the height from the laid-out content (font-driven) rather than
- # hardcoding it, then lock it. Single-line labels keep it stable no
- # matter how long the text is.
- self.update_idletasks()
- self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}")
-
- self._center_on_screen()
- self.deiconify()
- self.lift()
- self.update()
-
- def report(
- self,
- _phase: str,
- message: str,
- percent: int | None = None,
- detail: str | None = None,
- ) -> None:
- try:
- width = self._text_area_width()
- self.title_label.configure(text=self._elide_middle(message, width))
- self.detail_label.configure(text=self._elide_middle(detail or " ", width))
- if percent is None:
- if self._determinate:
- self.bar.configure(mode="indeterminate")
- self.bar.start()
- self._determinate = False
- else:
- if not self._determinate:
- self.bar.stop()
- self.bar.configure(mode="determinate")
- self._determinate = True
- self.bar.set(max(0.0, min(1.0, percent / 100.0)))
- self.update()
- except tk.TclError:
- pass
-
- def _text_area_width(self) -> int:
- """Pixels available for label text, in the same scaled space as the font."""
- width = self.title_label.winfo_width()
- if width <= 1: # not laid out yet
- return self._WIDTH - 2 * 20 - 2 * 16
- return max(40, width - 6)
-
- def _elide_middle(self, text: str, max_width: int) -> str:
- """Middle-truncate text with '…' so it fits max_width without wrapping."""
- font = self._text_font
- if font.measure(text) <= max_width:
- return text
- ellipsis = "…"
- keep = len(text) - 1
- while keep > 0:
- head = (keep + 1) // 2
- tail = keep - head
- candidate = text[:head] + ellipsis + (text[-tail:] if tail else "")
- if font.measure(candidate) <= max_width:
- return candidate
- keep -= 1
- return ellipsis
-
-
-class _ProgressContext:
- def __init__(self, parent: tk.Misc | None, message: str) -> None:
- self.parent = parent
- self.message = message
- self.dialog: ProgressDialog | None = None
-
- def __enter__(self) -> Callable[..., None]:
- self.dialog = ProgressDialog(self.message, self.parent)
- return self.dialog.report
-
- def __exit__(self, *_exc: object) -> None:
- if self.dialog is not None:
- try:
- self.dialog.withdraw()
- self.dialog.destroy()
- except tk.TclError:
- pass
- self.dialog = None
- try:
- root = ensure_tk_app()
- root.update_idletasks()
- root.update()
- except tk.TclError:
- pass
-
-
-T = TypeVar("T")
-
-# A progress sink: (phase, message, percent, detail) -> None.
-Report = Callable[[str, str, "int | None", "str | None"], None]
-
-
-class _ProgressBridge:
- """Thread-safe hand-off of the latest progress report to the UI thread.
-
- ``work`` runs on a worker thread and must never touch Tk; it calls
- :meth:`report`, which only stashes the most recent update. The main thread
- drains it via :meth:`take` and applies it to the dialog. Intermediate
- updates are coalesced — only the latest matters for a progress bar.
- """
-
- def __init__(self) -> None:
- self._lock = threading.Lock()
- self._pending: tuple[str, str, int | None, str | None] | None = None
-
- def report(
- self,
- phase: str,
- message: str,
- percent: int | None = None,
- detail: str | None = None,
- ) -> None:
- with self._lock:
- self._pending = (phase, message, percent, detail)
-
- def take(self) -> tuple[str, str, int | None, str | None] | None:
- with self._lock:
- pending, self._pending = self._pending, None
- return pending
-
-
-def run_with_progress(
- message: str,
- work: Callable[[Report], T],
- *,
- parent: tk.Misc | None = None,
-) -> T:
- """Show a ProgressDialog and run ``work`` on a worker thread.
-
- Tkinter is single-threaded and only repaints while its event loop runs, so
- a long upload/download executed inline would freeze the dialog. Worse, on
- Windows ``CTkToplevel`` withdraws itself at construction and re-shows via a
- delayed ``after()`` callback — without a running loop that callback never
- fires and the window stays invisible for the whole transfer.
-
- So the blocking ``work`` runs on a background thread while the main thread
- pumps the Tk event loop here. ``work`` receives a thread-safe ``report``
- callback; its updates are marshalled back to the UI thread. Returns work's
- result, or re-raises (on the main thread) whatever exception it raised.
- """
- root = ensure_tk_app()
- dialog = ProgressDialog(message, parent)
- bridge = _ProgressBridge()
- outcome: dict[str, Any] = {}
-
- def runner() -> None:
- try:
- outcome["value"] = work(bridge.report)
- except BaseException as exc: # noqa: BLE001 - re-raised on the main thread
- outcome["error"] = exc
-
- thread = threading.Thread(target=runner, name="autodesk-progress", daemon=True)
- thread.start()
-
- try:
- while thread.is_alive():
- pending = bridge.take()
- try:
- if pending is not None:
- dialog.report(*pending)
- else:
- root.update()
- except tk.TclError:
- break
- time.sleep(0.03)
- thread.join()
- final = bridge.take()
- if final is not None:
- try:
- dialog.report(*final)
- except tk.TclError:
- pass
- finally:
- try:
- dialog.withdraw()
- dialog.destroy()
- except tk.TclError:
- pass
- try:
- root.update_idletasks()
- root.update()
- except tk.TclError:
- pass
-
- if "error" in outcome:
- raise outcome["error"]
- return outcome["value"]
-
-
-# --- browse ------------------------------------------------------------------
-
-
-class BrowseDialog(_BaseDialog):
- """Hub → project → folder tree → file/folder picker."""
-
- def __init__(self, *, auth: AuthSessionService, aps: ApsClient, mode: Mode) -> None:
- titles = {
- "ifcfed": ("Open Project From Autodesk", "Open"),
- "model": ("Add Model From Autodesk", "Add"),
- "destination": ("Choose Autodesk Destination", "Select"),
- }
- title, action_label = titles[mode]
- super().__init__(title, size=(920, 620))
-
- self.auth = auth
- self.aps = aps
- self.mode: Mode = mode
- self.multi_select = mode == "model"
- self.selected_hub: dict[str, Any] | None = None
- self.selected_project: dict[str, Any] | None = None
- self.selected_entries: list[dict[str, Any]] = []
- self._tree_entries: dict[str, dict[str, Any]] = {}
- self._project_entries: dict[str, dict[str, Any]] = {}
-
- self._build_ui(action_label)
-
- def _build_ui(self, action_label: str) -> None:
- root = ctk.CTkFrame(self, fg_color="transparent")
- root.pack(fill="both", expand=True, padx=16, pady=16)
- root.grid_rowconfigure(1, weight=1)
- root.grid_columnconfigure(0, weight=1)
-
- top = ctk.CTkFrame(root, fg_color="transparent")
- top.grid(row=0, column=0, sticky="ew", pady=(0, 12))
- top.grid_columnconfigure(1, weight=1)
-
- self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in)
- self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w")
-
- self.hub_combo = ctk.CTkOptionMenu(
- top,
- values=["Select hub"],
- command=self._hub_changed,
- anchor="w",
- )
- self.hub_combo.grid(row=0, column=1, sticky="ew")
- self.hub_combo.configure(state="disabled")
-
- split = ctk.CTkFrame(root, fg_color="transparent")
- split.grid(row=1, column=0, sticky="nsew")
- split.grid_rowconfigure(0, weight=1)
- split.grid_columnconfigure(0, weight=3, uniform="col")
- split.grid_columnconfigure(1, weight=7, uniform="col")
-
- self.projects_frame = ctk.CTkFrame(split)
- self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
- self.tree_frame = ctk.CTkFrame(split)
- self.tree_frame.grid(row=0, column=1, sticky="nsew")
-
- self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
- self.projects.bind("<>", lambda _e: self._project_changed())
-
- tree_selectmode = "extended" if self.multi_select else "browse"
- self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode)
- self.tree.bind("<>", lambda _e: self._tree_selection_changed())
- self.tree.bind("<>", self._on_tree_open)
-
- self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
- self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
-
- actions = ctk.CTkFrame(root, fg_color="transparent")
- actions.grid(row=3, column=0, sticky="ew")
- actions.grid_columnconfigure(0, weight=1)
- self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
- self.cancel_button.grid(row=0, column=1, padx=(0, 8))
- self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
- self.action_button.grid(row=0, column=2)
- self.action_button.configure(state="disabled")
-
- def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview:
- ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
- body = ctk.CTkFrame(parent, fg_color="transparent")
- body.pack(fill="both", expand=True, padx=8, pady=8)
- body.grid_rowconfigure(0, weight=1)
- body.grid_columnconfigure(0, weight=1)
-
- tree = ttk.Treeview(body, show="tree", selectmode=selectmode)
- tree.grid(row=0, column=0, sticky="nsew")
- scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
- scrollbar.grid(row=0, column=1, sticky="ns")
- tree.configure(yscrollcommand=scrollbar.set)
- return tree
-
- # --- sign-in & population ------------------------------------------------
-
- def run(self) -> dict[str, Any]:
- if self.auth.get_token() is not None:
- self._populate_hubs_silently()
- outcome = super().run()
- if outcome is None:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled the Autodesk picker.")
- return outcome
-
- def _populate_hubs_silently(self) -> None:
- try:
- hubs = self.aps.list_hubs()
- self._fill_hubs(hubs)
- self.status.configure(text="Signed in. Select a hub.")
- except Exception:
- pass
-
- def _sign_in(self) -> None:
- with self._with_progress("Signing in to Autodesk") as report:
- try:
- self.auth.login_interactive(report)
- hubs = self.aps.list_hubs()
- self._fill_hubs(hubs)
- self.status.configure(text="Signed in. Select a hub.")
- except Exception as exc:
- show_error(title="Sign In Failed", message=str(exc))
-
- def _fill_hubs(self, hubs: list[dict[str, Any]]) -> None:
- self._hubs_by_name = {hub["name"]: hub for hub in hubs}
- values = ["Select hub"] + list(self._hubs_by_name.keys())
- self.hub_combo.configure(values=values, state="normal")
- self.hub_combo.set("Select hub")
-
- def _hub_changed(self, label: str) -> None:
- if label == "Select hub":
- return
- hub = getattr(self, "_hubs_by_name", {}).get(label)
- if not isinstance(hub, dict):
- return
- self.selected_hub = hub
- self.selected_project = None
- self.selected_entry = None
- self._clear_projects()
- self._clear_tree()
- self._refresh_action_button()
- with self._with_progress("Loading Autodesk projects"):
- try:
- projects = self.aps.list_projects(hub["id"])
- self._project_entries = {}
- for project in projects:
- iid = self.projects.insert("", "end", text=project["name"])
- self._project_entries[iid] = project
- self.status.configure(text=f"Hub: {hub['name']}. Select a project.")
- except Exception as exc:
- show_error(title="Load Projects Failed", message=str(exc))
-
- def _project_changed(self) -> None:
- selection = self.projects.selection()
- if not selection or self.selected_hub is None:
- return
- project = self._project_entries.get(selection[0])
- if not isinstance(project, dict):
- return
- self.selected_project = project
- self.selected_entries = []
- self._refresh_action_button()
- self._clear_tree()
- with self._with_progress("Loading top folders"):
- try:
- top_folders = self.aps.list_top_folders(self.selected_hub["id"], project["id"])
- for entry in top_folders:
- self._insert_tree_entry("", entry)
- if self.mode == "destination":
- self.status.configure(text=f"Project: {project['name']}. Browse folders and choose a destination.")
- else:
- self.status.configure(text=f"Project: {project['name']}. Browse folders and pick a file.")
- except Exception as exc:
- show_error(title="Load Project Failed", message=str(exc))
-
- def _insert_tree_entry(self, parent_iid: str, entry: dict[str, Any]) -> str:
- label = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
- iid = self.tree.insert(parent_iid, "end", text=label)
- self._tree_entries[iid] = entry
- if entry.get("type") == "folders":
- placeholder = self.tree.insert(iid, "end", text="Loading…")
- self._tree_entries[placeholder] = {"__placeholder__": True}
- return iid
-
- def _on_tree_open(self, _event: tk.Event) -> None:
- selection = self.tree.focus()
- if not selection:
- return
- entry = self._tree_entries.get(selection)
- if not isinstance(entry, dict) or entry.get("type") != "folders" or self.selected_project is None:
- return
- children = self.tree.get_children(selection)
- if len(children) != 1:
- return
- only = self._tree_entries.get(children[0])
- if not (isinstance(only, dict) and only.get("__placeholder__")):
- return
-
- self.tree.delete(children[0])
- self._tree_entries.pop(children[0], None)
-
- with self._with_progress("Loading folder contents"):
- try:
- object_types = ["folders"] if self.mode == "destination" else ["folders", "items"]
- contents = self.aps.list_folder_contents(
- self.selected_project["id"],
- entry["id"],
- object_types=object_types,
- extension_filter=self._extension_filter(),
- )
- for child in contents:
- self._insert_tree_entry(selection, child)
- except Exception as exc:
- show_error(title="Load Folder Failed", message=str(exc))
-
- def _extension_filter(self) -> Callable[[dict[str, Any]], bool] | None:
- if self.mode == "ifcfed":
- return lambda entry: (entry.get("display_name") or "").lower().endswith(".ifcfed")
- if self.mode == "model":
- return lambda entry: (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
- return None
-
- def _tree_selection_changed(self) -> None:
- selection = self.tree.selection()
- entries: list[dict[str, Any]] = []
- for iid in selection:
- entry = self._tree_entries.get(iid)
- if isinstance(entry, dict) and not entry.get("__placeholder__"):
- entries.append(entry)
- self.selected_entries = entries
-
- if not entries:
- pass
- elif len(entries) == 1:
- entry = entries[0]
- name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
- kind = entry.get("type", "entry")
- self.status.configure(text=f"Selected {kind}: {name}")
- else:
- valid_count = sum(1 for e in entries if self._is_valid_selection(e))
- self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.")
- self._refresh_action_button()
-
- def _is_valid_selection(self, entry: dict[str, Any]) -> bool:
- if self.mode == "destination":
- return entry.get("type") == "folders"
- if self.mode == "ifcfed":
- return (
- entry.get("type") == "items"
- and (entry.get("display_name") or "").lower().endswith(".ifcfed")
- )
- return (
- entry.get("type") == "items"
- and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
- )
-
- def _valid_entries(self) -> list[dict[str, Any]]:
- return [e for e in self.selected_entries if self._is_valid_selection(e)]
-
- def _refresh_action_button(self) -> None:
- enabled = self.selected_project is not None and bool(self._valid_entries())
- self.action_button.configure(state="normal" if enabled else "disabled")
-
- def _confirm(self) -> None:
- valid = self._valid_entries()
- if not self.selected_hub or not self.selected_project or not valid:
- return
- self.result = {
- "hub": self.selected_hub,
- "project": self.selected_project,
- "entries": valid,
- }
- self._on_close()
-
- def _clear_projects(self) -> None:
- for iid in self.projects.get_children():
- self.projects.delete(iid)
- self._project_entries.clear()
-
- def _clear_tree(self) -> None:
- for iid in self.tree.get_children():
- self.tree.delete(iid)
- self._tree_entries.clear()
-
- def _with_progress(self, message: str) -> _ProgressContext:
- return _ProgressContext(self, message)
-
-
-# --- filename prompt ---------------------------------------------------------
-
-
-class _FilenamePrompt(_BaseDialog):
- def __init__(self, *, title: str, label: str, default: str) -> None:
- super().__init__(title, size=(440, 170), resizable=False)
- body = ctk.CTkFrame(self, fg_color="transparent")
- body.pack(fill="both", expand=True, padx=20, pady=20)
-
- ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x")
- self.entry = ctk.CTkEntry(body)
- self.entry.pack(fill="x", pady=(8, 16))
- self.entry.insert(0, default)
- self.entry.select_range(0, "end")
- self.entry.focus_set()
-
- buttons = ctk.CTkFrame(body, fg_color="transparent")
- buttons.pack(fill="x")
- buttons.grid_columnconfigure(0, weight=1)
- ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
- ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2)
-
- self.bind("", lambda _e: self._confirm())
- self.bind("", lambda _e: self._on_close())
-
- def _confirm(self) -> None:
- value = self.entry.get().strip()
- self.result = value or None
- self._on_close()
-
-
-def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
- return _FilenamePrompt(title=title, label=label, default=default).run()
-
-
-# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
-
-
-class _ConfirmDialog(_BaseDialog):
- def __init__(self, *, title: str, message: str) -> None:
- super().__init__(title, size=(440, 180), resizable=False)
- body = ctk.CTkFrame(self, fg_color="transparent")
- body.pack(fill="both", expand=True, padx=20, pady=20)
-
- ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
-
- buttons = ctk.CTkFrame(body, fg_color="transparent")
- buttons.pack(fill="x")
- buttons.grid_columnconfigure(0, weight=1)
- ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
- ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
-
- self.bind("", lambda _e: self._confirm())
- self.bind("", lambda _e: self._on_close())
-
- def _confirm(self) -> None:
- self.result = True
- self._on_close()
-
-
-class _AlertDialog(_BaseDialog):
- def __init__(self, *, title: str, message: str) -> None:
- super().__init__(title, size=(440, 180), resizable=False)
- body = ctk.CTkFrame(self, fg_color="transparent")
- body.pack(fill="both", expand=True, padx=20, pady=20)
-
- ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
-
- buttons = ctk.CTkFrame(body, fg_color="transparent")
- buttons.pack(fill="x")
- buttons.grid_columnconfigure(0, weight=1)
- ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
-
- self.bind("", lambda _e: self._on_close())
- self.bind("", lambda _e: self._on_close())
-
-
-def confirm(*, title: str, message: str) -> bool:
- return bool(_ConfirmDialog(title=title, message=message).run())
-
-
-def show_error(*, title: str, message: str) -> None:
- _AlertDialog(title=title, message=message).run()
-
-
-# --- settings ----------------------------------------------------------------
-
-
-class SettingsDialog(_BaseDialog):
- """Edit the APS client id and sign out."""
-
- def __init__(self, *, connector: "AutodeskConnector") -> None:
- super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
- self.connector = connector
-
- client_id = settings.load_client_id()
- callback_port = settings.stored_callback_port()
-
- body = ctk.CTkFrame(self, fg_color="transparent")
- body.pack(fill="both", expand=True, padx=24, pady=24)
-
- ctk.CTkLabel(
- body,
- text="Autodesk Platform Services",
- anchor="w",
- font=ctk.CTkFont(size=14, weight="bold"),
- ).pack(fill="x")
- ctk.CTkLabel(
- body,
- text="The connector signs in to Autodesk using a PKCE flow. The client id below comes from your APS application.",
- anchor="w",
- wraplength=460,
- justify="left",
- ).pack(fill="x", pady=(2, 16))
-
- ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
- self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
- self.client_id_entry.pack(fill="x", pady=(6, 8))
- self.client_id_entry.insert(0, client_id)
-
- ctk.CTkLabel(body, text="OAuth callback port", anchor="w").pack(fill="x")
- self.callback_port_entry = ctk.CTkEntry(body, placeholder_text=str(settings.DEFAULT_CALLBACK_PORT))
- self.callback_port_entry.pack(fill="x", pady=(6, 8))
- self.callback_port_entry.insert(0, str(callback_port))
-
- self.status_label = ctk.CTkLabel(
- body,
- text=f"Signed in as {client_id}" if client_id else "No client id configured.",
- anchor="w",
- wraplength=460,
- justify="left",
- )
- self.status_label.pack(fill="x", pady=(0, 16))
-
- buttons = ctk.CTkFrame(body, fg_color="transparent")
- buttons.pack(fill="x")
- buttons.grid_columnconfigure(1, weight=1)
-
- self.signout_button = ctk.CTkButton(
- buttons,
- text="Sign Out",
- command=self._sign_out,
- fg_color="transparent",
- border_width=1,
- )
- self.signout_button.grid(row=0, column=0, sticky="w")
- if not client_id:
- self.signout_button.configure(state="disabled")
-
- ctk.CTkButton(
- buttons,
- text="Close",
- command=self._on_close,
- fg_color="transparent",
- border_width=1,
- ).grid(row=0, column=2, padx=(0, 8))
- ctk.CTkButton(buttons, text="Save", command=self._save).grid(row=0, column=3)
-
- def _save(self) -> None:
- new_id = self.client_id_entry.get().strip()
- try:
- callback_port = int(self.callback_port_entry.get().strip())
- settings.save_callback_port(callback_port)
- except ValueError:
- show_error(title="Invalid Callback Port", message="Callback port must be a number between 1 and 65535.")
- return
- settings.save_client_id(new_id)
- try:
- self.connector.reload_credentials()
- except Exception as exc:
- show_error(title="Reload Failed", message=str(exc))
- return
- self._on_close()
-
- def _sign_out(self) -> None:
- client_id = settings.load_client_id()
- if not client_id:
- return
- if not confirm(
- title="Sign Out",
- message=f"Forget the stored Autodesk session for {client_id}?",
- ):
- return
- try:
- KeyringTokenStore(service_name="bonsaiviewer-autodesk", username=client_id).delete()
- except RpcError as exc:
- show_error(title="Sign Out Failed", message=exc.message)
- return
- self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.")
- self.signout_button.configure(state="disabled")
diff --git a/src/bonsaiviewer-autodesk/examples/test_close.rs b/src/bonsaiviewer-autodesk/examples/test_close.rs
new file mode 100644
index 0000000000..b20ca14248
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/examples/test_close.rs
@@ -0,0 +1,45 @@
+//! Minimal close-button test. Run with:
+//! cargo run --example test_close
+//!
+//! Expected: a small window opens; clicking "Close" closes it and the
+//! program exits with stderr lines tracing what happened. If the window
+//! refuses to close, FLTK's hide() is misbehaving on this system and the
+//! problem is below the dialog layer.
+
+use fltk::{app, button::Button, prelude::*, window::Window};
+
+fn main() {
+ eprintln!("[1] initialising FLTK");
+ let app = app::App::default();
+
+ eprintln!("[2] building window");
+ let mut win = Window::default()
+ .with_size(300, 120)
+ .with_label("Close test");
+ let mut close_btn = Button::new(100, 40, 100, 40, "Close");
+ win.end();
+ win.make_modal(true);
+
+ close_btn.set_callback({
+ let mut win = win.clone();
+ move |_| {
+ eprintln!("[5] close button callback fired");
+ win.hide();
+ eprintln!("[6] after win.hide(), shown() = {}", win.shown());
+ }
+ });
+
+ eprintln!("[3] win.show()");
+ win.show();
+ eprintln!("[4] entering app::wait() loop; shown() = {}", win.shown());
+
+ let mut ticks: u32 = 0;
+ while win.shown() {
+ app.wait();
+ ticks += 1;
+ if ticks % 100 == 0 {
+ eprintln!(" ... still in loop after {ticks} wait() calls, shown() = {}", win.shown());
+ }
+ }
+ eprintln!("[7] loop exited cleanly after {ticks} wait() calls");
+}
diff --git a/src/bonsaiviewer-autodesk/examples/test_settings.rs b/src/bonsaiviewer-autodesk/examples/test_settings.rs
new file mode 100644
index 0000000000..766939ec6f
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/examples/test_settings.rs
@@ -0,0 +1,25 @@
+//! Open the real SettingsDialog with a no-op on_reload. Run with:
+//! cargo run --release --example test_settings
+//!
+//! Click "Close" and watch stderr. The settings dialog is the actual code
+//! path the host invokes for the `open_settings` RPC — so if Close fails
+//! here, it's not a connector-vs-host issue.
+
+use std::sync::Arc;
+
+use bonsaiviewer_autodesk::ui::{ensure_app, SettingsDialog};
+
+fn main() {
+ eprintln!("[A] ensure_app()");
+ let _ = ensure_app();
+
+ eprintln!("[B] constructing SettingsDialog");
+ let dialog = SettingsDialog::new(Arc::new(|| {
+ eprintln!("[*] on_reload called");
+ Ok(())
+ }));
+
+ eprintln!("[C] dialog.run() — click Close (or Save, or Sign Out) now");
+ dialog.run();
+ eprintln!("[D] dialog.run() returned");
+}
diff --git a/src/bonsaiviewer-autodesk/examples/theme_picker.rs b/src/bonsaiviewer-autodesk/examples/theme_picker.rs
new file mode 100644
index 0000000000..5627df30aa
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/examples/theme_picker.rs
@@ -0,0 +1,965 @@
+//! Interactive theme picker. Run with:
+//! cargo run --release --example theme_picker
+//!
+//! The launcher lists curated theme combinations. Clicking a row starts a
+//! separate preview process, applies the chosen theme before any demo
+//! widgets are created, and then opens a clean preview window. This avoids
+//! stale global FLTK theme state and cached widget colors from previous
+//! selections.
+//!
+//! Once you've found one you like, copy its `apply` call into
+//! `src/ui/mod.rs::ensure_app` and rebuild.
+
+use std::cell::Cell;
+use std::env;
+use std::process::Command;
+
+use fltk::{
+ app,
+ browser::HoldBrowser,
+ button::Button,
+ draw,
+ enums::{Align, Color, Event, FrameType},
+ frame::Frame,
+ group::Flex,
+ input::Input,
+ misc::Progress,
+ prelude::*,
+ tree::{Tree, TreeSelect},
+ window::Window,
+};
+use fltk_theme::{
+ color_themes::{self, fleet},
+ ColorMap, ColorTheme, SchemeType, ThemeType, WidgetScheme, WidgetTheme,
+};
+
+/// One row in the picker. Any of the three knobs can be `None`.
+struct Preset {
+ id: &'static str,
+ name: &'static str,
+ /// FLTK built-in scheme name passed to `app::set_scheme` (e.g. "gtk+",
+ /// "gleam", "plastic", "oxy", "base"). `None` keeps whatever the
+ /// previous run set.
+ fltk_scheme: Option<&'static str>,
+ /// fltk-theme `WidgetScheme` overlay (drawing style).
+ scheme: Option,
+ /// fltk-theme `WidgetTheme` overlay (default colors).
+ theme: Option,
+ /// fltk-theme `ColorTheme` constant (palette).
+ color: Option<&'static [ColorMap]>,
+}
+
+fn presets() -> Vec {
+ vec![
+ Preset {
+ id: "black-aqua",
+ name: "Black + Aqua",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Aqua),
+ theme: None,
+ color: Some(color_themes::BLACK_THEME),
+ },
+ Preset {
+ id: "dark-aqua",
+ name: "Dark + Aqua",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Aqua),
+ theme: None,
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "shake-aqua",
+ name: "Shake + Aqua",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Aqua),
+ theme: None,
+ color: Some(color_themes::SHAKE_THEME),
+ },
+ Preset {
+ id: "monokai-aqua",
+ name: "Monokai + Aqua",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Aqua),
+ theme: None,
+ color: Some(&fleet::MONOKAI),
+ },
+ Preset {
+ id: "material-dark-aqua",
+ name: "Material dark + Aqua",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Aqua),
+ theme: None,
+ color: Some(&fleet::MATERIAL_DARK),
+ },
+ Preset {
+ id: "black-sweet",
+ name: "Black + Sweet",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: None,
+ color: Some(color_themes::BLACK_THEME),
+ },
+ Preset {
+ id: "dark-sweet",
+ name: "Dark + Sweet",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: None,
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "shake-sweet",
+ name: "Shake + Sweet",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: None,
+ color: Some(color_themes::SHAKE_THEME),
+ },
+ Preset {
+ id: "monokai-sweet",
+ name: "Monokai + Sweet",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: None,
+ color: Some(&fleet::MONOKAI),
+ },
+ Preset {
+ id: "material-dark-sweet",
+ name: "Material dark + Sweet",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: None,
+ color: Some(&fleet::MATERIAL_DARK),
+ },
+ Preset {
+ id: "widget-theme-dark",
+ name: "Widget theme Dark",
+ fltk_scheme: None,
+ scheme: None,
+ theme: Some(ThemeType::Dark),
+ color: None,
+ },
+ Preset {
+ id: "gleam-dark",
+ name: "Gleam + Dark + dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Gleam),
+ theme: Some(ThemeType::Dark),
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "gleam-greybird",
+ name: "Gleam + Greybird + gray",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Gleam),
+ theme: Some(ThemeType::Greybird),
+ color: Some(color_themes::GRAY_THEME),
+ },
+ Preset {
+ id: "crystal-dark",
+ name: "Crystal + Dark + dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Crystal),
+ theme: Some(ThemeType::Dark),
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "fluent-dark",
+ name: "Fluent + Dark + dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Fluent),
+ theme: Some(ThemeType::Dark),
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "sweet-dark",
+ name: "Sweet + Dark + dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Sweet),
+ theme: Some(ThemeType::Dark),
+ color: Some(color_themes::DARK_THEME),
+ },
+ Preset {
+ id: "fleet1-nord",
+ name: "Fleet1 + Nord palette",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Fleet1),
+ theme: None,
+ color: Some(&fleet::NORD),
+ },
+ Preset {
+ id: "fleet2-gruvbox-dark",
+ name: "Fleet2 + Gruvbox dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Fleet2),
+ theme: None,
+ color: Some(&fleet::GRUVBOX_DARK),
+ },
+ Preset {
+ id: "fleet1-monokai",
+ name: "Fleet1 + Monokai",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Fleet1),
+ theme: None,
+ color: Some(&fleet::MONOKAI),
+ },
+ Preset {
+ id: "fleet1-solarized-light",
+ name: "Fleet1 + Solarized Light",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Fleet1),
+ theme: None,
+ color: Some(&fleet::SOLARIZED_LIGHT),
+ },
+ Preset {
+ id: "clean-greybird",
+ name: "Clean + Greybird + gray",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Clean),
+ theme: Some(ThemeType::Greybird),
+ color: Some(color_themes::GRAY_THEME),
+ },
+ Preset {
+ id: "clean-modern-light",
+ name: "Clean + modern light",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Clean),
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "clean-modern-dark",
+ name: "Clean + modern dark",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Clean),
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "clean-modern-light-bordered",
+ name: "Clean + modern light + borders",
+ fltk_scheme: None,
+ scheme: Some(SchemeType::Clean),
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "fltk-gtk",
+ name: "FLTK gtk+ scheme, no overrides",
+ fltk_scheme: Some("gtk+"),
+ scheme: None,
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "fltk-plastic",
+ name: "FLTK plastic scheme, no overrides",
+ fltk_scheme: Some("plastic"),
+ scheme: None,
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "fltk-oxy-greybird",
+ name: "FLTK oxy scheme + Greybird",
+ fltk_scheme: Some("oxy"),
+ scheme: None,
+ theme: Some(ThemeType::Greybird),
+ color: Some(color_themes::GRAY_THEME),
+ },
+ Preset {
+ id: "fltk-oxy-bonsai-dark",
+ name: "FLTK oxy scheme + Bonsai dark",
+ fltk_scheme: Some("oxy"),
+ scheme: None,
+ theme: Some(ThemeType::Greybird),
+ color: None,
+ },
+ Preset {
+ id: "fltk-oxy-neutral-dark",
+ name: "FLTK oxy scheme + neutral dark",
+ fltk_scheme: Some("oxy"),
+ scheme: None,
+ theme: Some(ThemeType::Greybird),
+ color: None,
+ },
+ Preset {
+ id: "flat-neutral-dark",
+ name: "Flat neutral dark",
+ fltk_scheme: Some("base"),
+ scheme: None,
+ theme: None,
+ color: None,
+ },
+ Preset {
+ id: "fltk-default",
+ name: "FLTK defaults (no theme)",
+ fltk_scheme: None,
+ scheme: None,
+ theme: None,
+ color: None,
+ },
+ ]
+}
+
+fn apply(preset: &Preset) {
+ if let Some(s) = preset.fltk_scheme {
+ app::set_scheme(match s {
+ "gtk+" => app::Scheme::Gtk,
+ "plastic" => app::Scheme::Plastic,
+ "gleam" => app::Scheme::Gleam,
+ "oxy" => app::Scheme::Oxy,
+ "base" => app::Scheme::Base,
+ _ => app::Scheme::Base,
+ });
+ }
+ if let Some(s) = preset.scheme {
+ WidgetScheme::new(s).apply();
+ }
+ if let Some(t) = preset.theme {
+ WidgetTheme::new(t).apply();
+ }
+ if let Some(c) = preset.color {
+ ColorTheme::new(c).apply();
+ }
+ if preset.id == "fltk-oxy-bonsai-dark" {
+ apply_bonsai_dark_palette();
+ } else if preset.id == "fltk-oxy-neutral-dark" {
+ apply_neutral_dark_palette();
+ } else if preset.id == "flat-neutral-dark" {
+ apply_flat_neutral_dark_palette();
+ } else if preset.id == "clean-modern-light" {
+ apply_modern_light_palette();
+ } else if preset.id == "clean-modern-dark" {
+ apply_modern_dark_palette();
+ } else if preset.id == "clean-modern-light-bordered" {
+ apply_modern_light_palette();
+ }
+ eprintln!(
+ "[theme] applied: {} (fltk_scheme={:?}, scheme={:?}, theme={:?}, color={})",
+ preset.name,
+ preset.fltk_scheme,
+ preset.scheme,
+ preset.theme,
+ preset.color.is_some(),
+ );
+}
+
+fn apply_bonsai_dark_palette() {
+ app::background(38, 41, 47); // #26292f app_background
+ app::background2(49, 53, 61); // #31353d control_background
+ app::foreground(208, 213, 221); // #d0d5dd primary_text
+ app::set_selection_color(83, 199, 99); // #53c763 icon_accent_active_color
+ app::set_color(Color::Background, 38, 41, 47);
+ app::set_color(Color::BackGround2, 49, 53, 61);
+ app::set_color(Color::Foreground, 208, 213, 221);
+ app::set_color(Color::Selection, 83, 199, 99);
+}
+
+fn apply_neutral_dark_palette() {
+ app::background(31, 34, 40);
+ app::background2(42, 46, 54);
+ app::foreground(222, 226, 232);
+ app::set_selection_color(92, 170, 255);
+ app::set_color(Color::Background, 31, 34, 40);
+ app::set_color(Color::BackGround2, 42, 46, 54);
+ app::set_color(Color::Foreground, 222, 226, 232);
+ app::set_color(Color::Selection, 92, 170, 255);
+}
+
+fn apply_flat_neutral_dark_palette() {
+ app::background(24, 26, 31);
+ app::background2(34, 37, 44);
+ app::foreground(226, 231, 238);
+ app::set_selection_color(102, 187, 255);
+ app::set_color(Color::Background, 24, 26, 31);
+ app::set_color(Color::BackGround2, 34, 37, 44);
+ app::set_color(Color::Foreground, 226, 231, 238);
+ app::set_color(Color::Selection, 102, 187, 255);
+ app::set_frame_type_cb(FrameType::FreeBoxType, draw_flat_box, 6, 3, -10, -6);
+}
+
+fn apply_modern_light_palette() {
+ app::background(244, 246, 248);
+ app::background2(255, 255, 255);
+ app::foreground(32, 37, 46);
+ app::set_selection_color(34, 132, 245);
+ app::set_color(Color::Background, 244, 246, 248);
+ app::set_color(Color::BackGround2, 255, 255, 255);
+ app::set_color(Color::Foreground, 32, 37, 46);
+ app::set_color(Color::Selection, 34, 132, 245);
+}
+
+fn apply_modern_dark_palette() {
+ app::background(28, 31, 36);
+ app::background2(39, 43, 50);
+ app::foreground(224, 228, 235);
+ app::set_selection_color(82, 164, 255);
+ app::set_color(Color::Background, 28, 31, 36);
+ app::set_color(Color::BackGround2, 39, 43, 50);
+ app::set_color(Color::Foreground, 224, 228, 235);
+ app::set_color(Color::Selection, 82, 164, 255);
+}
+
+fn find_preset<'a>(presets: &'a [Preset], id: &str) -> Option<&'a Preset> {
+ presets.iter().find(|p| p.id == id)
+}
+
+/// Brightens a button's fill on Enter, restores on Leave. FLTK widgets are
+/// flat by default; this is the tiny per-button helper that gets you a
+/// proper hover indication regardless of theme.
+fn add_hover(btn: &mut Button) {
+ let base = btn.color().lighter();
+ btn.set_color(base);
+ let stash = Cell::new(btn.color());
+ btn.handle(move |b, ev| match ev {
+ Event::Enter => {
+ stash.set(b.color());
+ b.set_color(brighten(b.color(), 24));
+ b.redraw();
+ true
+ }
+ Event::Leave => {
+ b.set_color(stash.get());
+ b.redraw();
+ true
+ }
+ _ => false,
+ });
+}
+
+fn style_theme_progress(bar: &mut Progress) {
+ let fill = bar.selection_color();
+ let track = bar.color();
+ if fill == track {
+ bar.set_selection_color(Color::Selection);
+ }
+}
+
+fn brighten(c: Color, delta: i32) -> Color {
+ let (r, g, b) = c.to_rgb();
+ let clamp = |v: i32| v.clamp(0, 255) as u8;
+ Color::from_rgb(
+ clamp(r as i32 + delta),
+ clamp(g as i32 + delta),
+ clamp(b as i32 + delta),
+ )
+}
+
+fn is_flat_neutral_dark(preset: &Preset) -> bool {
+ preset.id == "flat-neutral-dark"
+}
+
+fn is_clean_modern(preset: &Preset) -> bool {
+ matches!(
+ preset.id,
+ "clean-modern-light" | "clean-modern-dark" | "clean-modern-light-bordered"
+ )
+}
+
+fn is_clean_modern_dark(preset: &Preset) -> bool {
+ preset.id == "clean-modern-dark"
+}
+
+fn is_clean_modern_bordered(preset: &Preset) -> bool {
+ preset.id == "clean-modern-light-bordered"
+}
+
+fn modern_button_color(preset: &Preset) -> Color {
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(48, 53, 62)
+ } else {
+ Color::from_rgb(255, 255, 255)
+ }
+}
+
+fn modern_button_hover(preset: &Preset) -> Color {
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(58, 64, 75)
+ } else {
+ Color::from_rgb(232, 240, 252)
+ }
+}
+
+fn modern_button_pressed(preset: &Preset) -> Color {
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(35, 39, 46)
+ } else {
+ Color::from_rgb(218, 229, 246)
+ }
+}
+
+fn modern_text_color(preset: &Preset) -> Color {
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(224, 228, 235)
+ } else {
+ Color::from_rgb(32, 37, 46)
+ }
+}
+
+fn modern_accent(preset: &Preset) -> Color {
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(82, 164, 255)
+ } else {
+ Color::from_rgb(34, 132, 245)
+ }
+}
+
+fn style_modern_button(btn: &mut Button, preset: &Preset) {
+ btn.set_frame(if is_clean_modern_bordered(preset) {
+ FrameType::BorderBox
+ } else {
+ FrameType::FlatBox
+ });
+ btn.set_down_frame(FrameType::FlatBox);
+ btn.set_color(modern_button_color(preset));
+ btn.set_selection_color(modern_button_pressed(preset));
+ btn.set_label_color(modern_text_color(preset));
+ let base = modern_button_color(preset);
+ let hover = modern_button_hover(preset);
+ let pressed = modern_button_pressed(preset);
+ btn.handle(move |b, ev| match ev {
+ Event::Enter => {
+ b.set_color(hover);
+ b.redraw();
+ true
+ }
+ Event::Leave => {
+ b.set_color(base);
+ b.redraw();
+ true
+ }
+ Event::Push => {
+ b.set_color(pressed);
+ b.redraw();
+ false
+ }
+ Event::Released => {
+ b.set_color(hover);
+ b.redraw();
+ false
+ }
+ _ => false,
+ });
+}
+
+fn style_modern_input(input: &mut Input, preset: &Preset) {
+ if is_clean_modern_bordered(preset) {
+ input.set_frame(FrameType::BorderBox);
+ }
+ input.set_color(modern_button_color(preset));
+ input.set_text_color(modern_text_color(preset));
+ input.set_selection_color(modern_accent(preset));
+}
+
+fn style_modern_tree(tree: &mut Tree, preset: &Preset) {
+ if is_clean_modern_bordered(preset) {
+ tree.set_frame(FrameType::BorderBox);
+ }
+ tree.set_color(modern_button_color(preset));
+ tree.set_selection_color(modern_accent(preset));
+ tree.set_item_label_fgcolor(modern_text_color(preset));
+ tree.set_connector_color(if is_clean_modern_dark(preset) {
+ Color::from_rgb(84, 92, 106)
+ } else {
+ Color::from_rgb(190, 199, 212)
+ });
+}
+
+fn flat_bg() -> Color {
+ Color::from_rgb(24, 26, 31)
+}
+
+fn flat_panel() -> Color {
+ Color::from_rgb(30, 33, 39)
+}
+
+fn flat_control() -> Color {
+ Color::from_rgb(34, 37, 44)
+}
+
+fn flat_control_hover() -> Color {
+ Color::from_rgb(45, 50, 60)
+}
+
+fn flat_control_pressed() -> Color {
+ Color::from_rgb(26, 29, 35)
+}
+
+fn flat_border() -> Color {
+ Color::from_rgb(69, 76, 88)
+}
+
+fn flat_text() -> Color {
+ Color::from_rgb(226, 231, 238)
+}
+
+fn flat_accent() -> Color {
+ Color::from_rgb(102, 187, 255)
+}
+
+fn draw_flat_box(x: i32, y: i32, w: i32, h: i32, color: Color) {
+ draw::set_draw_color(color);
+ draw::draw_rounded_rectf(x, y, w, h, 3);
+ draw::set_draw_color(flat_border());
+ draw::draw_rounded_rect(x, y, w, h, 3);
+}
+
+fn style_flat_button(btn: &mut Button) {
+ btn.set_frame(FrameType::FreeBoxType);
+ btn.set_down_frame(FrameType::FreeBoxType);
+ btn.set_color(flat_control());
+ btn.set_selection_color(flat_control_pressed());
+ btn.set_label_color(flat_text());
+ btn.handle(move |b, ev| match ev {
+ Event::Enter => {
+ b.set_color(flat_control_hover());
+ b.redraw();
+ true
+ }
+ Event::Leave => {
+ b.set_color(flat_control());
+ b.redraw();
+ true
+ }
+ Event::Push => {
+ b.set_color(flat_control_pressed());
+ b.redraw();
+ false
+ }
+ Event::Released => {
+ b.set_color(flat_control_hover());
+ b.redraw();
+ false
+ }
+ _ => false,
+ });
+}
+
+fn style_flat_input(input: &mut Input) {
+ input.set_frame(FrameType::FreeBoxType);
+ input.set_color(flat_control());
+ input.set_text_color(flat_text());
+ input.set_selection_color(flat_accent());
+}
+
+fn style_flat_frame(frame: &mut Frame) {
+ frame.set_label_color(flat_text());
+}
+
+fn style_flat_tree(tree: &mut Tree) {
+ tree.set_frame(FrameType::FreeBoxType);
+ tree.set_color(flat_panel());
+ tree.set_selection_color(flat_accent());
+ tree.set_item_label_fgcolor(flat_text());
+ tree.set_item_label_bgcolor(flat_panel());
+ tree.set_connector_color(flat_border());
+}
+
+fn swatch(label: &str, color: Color) -> Frame {
+ let mut frame = Frame::default().with_label(label);
+ frame.set_frame(FrameType::FlatBox);
+ frame.set_color(color);
+ frame.set_label_color(contrast_label(color));
+ frame
+}
+
+fn contrast_label(color: Color) -> Color {
+ let (r, g, b) = color.to_rgb();
+ let luminance = (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000;
+ if luminance > 140 {
+ Color::Black
+ } else {
+ Color::White
+ }
+}
+
+fn connector_progress_colors(preset: &Preset) -> (Color, Color) {
+ if preset.id == "fltk-oxy-bonsai-dark" {
+ (
+ Color::from_rgb(38, 41, 47), // #26292f app_background
+ Color::from_rgb(83, 199, 99), // #53c763 active accent
+ )
+ } else if preset.id == "fltk-oxy-neutral-dark" {
+ (
+ Color::from_rgb(31, 34, 40),
+ Color::from_rgb(92, 170, 255),
+ )
+ } else if is_flat_neutral_dark(preset) {
+ (flat_bg(), flat_accent())
+ } else if is_clean_modern(preset) {
+ (
+ if is_clean_modern_dark(preset) {
+ Color::from_rgb(39, 43, 50)
+ } else {
+ Color::from_rgb(230, 234, 240)
+ },
+ modern_accent(preset),
+ )
+ } else {
+ (
+ Color::from_rgb(45, 45, 50),
+ Color::from_rgb(31, 106, 165),
+ )
+ }
+}
+
+fn run_launcher(presets: Vec) {
+ let app = app::App::default();
+
+ let mut win = Window::default()
+ .with_size(520, 520)
+ .with_label("FLTK theme launcher");
+
+ let mut root = Flex::default_fill().column();
+ root.set_margins(16, 16, 16, 16);
+ root.set_spacing(8);
+
+ let mut header = Frame::default().with_label("Select a preset to open a clean preview process");
+ header.set_align(Align::Left | Align::Inside);
+ root.fixed(&header, 24);
+
+ let mut list = HoldBrowser::default();
+ for p in &presets {
+ list.add(p.name);
+ }
+
+ root.end();
+ win.end();
+
+ list.set_callback(move |b| {
+ let line = b.value();
+ if line <= 0 {
+ return;
+ }
+ let idx = (line - 1) as usize;
+ let Some(preset) = presets.get(idx) else {
+ return;
+ };
+ match env::current_exe() {
+ Ok(exe) => {
+ if let Err(e) = Command::new(exe).arg("--preview").arg(preset.id).spawn() {
+ eprintln!("[theme] failed to launch preview '{}': {e}", preset.id);
+ }
+ }
+ Err(e) => eprintln!("[theme] could not resolve current executable: {e}"),
+ }
+ });
+
+ win.show();
+ eprintln!("[theme] launcher open - click a preset to launch an isolated preview");
+ while win.shown() {
+ app.wait();
+ }
+}
+
+fn run_preview(preset: &Preset) {
+ let app = app::App::default();
+ apply(preset);
+
+ let mut win = Window::default()
+ .with_size(900, 560)
+ .with_label(&format!("FLTK theme preview - {}", preset.name));
+ if preset.id == "fltk-oxy-bonsai-dark" {
+ win.set_color(Color::from_rgb(38, 41, 47));
+ } else if preset.id == "fltk-oxy-neutral-dark" {
+ win.set_color(Color::from_rgb(31, 34, 40));
+ } else if is_flat_neutral_dark(preset) {
+ win.set_color(flat_bg());
+ } else if is_clean_modern_dark(preset) {
+ win.set_color(Color::from_rgb(28, 31, 36));
+ } else if is_clean_modern(preset) {
+ win.set_color(Color::from_rgb(244, 246, 248));
+ }
+
+ let mut root = Flex::default_fill().row();
+ root.set_margins(16, 16, 16, 16);
+ root.set_spacing(12);
+
+ // ---- Left: selected preset -------------------------------------------
+ let mut left = Flex::default().column();
+ left.set_spacing(6);
+ let mut header = Frame::default().with_label("PRESET");
+ header.set_align(Align::Left | Align::Inside);
+ if is_flat_neutral_dark(preset) {
+ style_flat_frame(&mut header);
+ }
+ left.fixed(&header, 18);
+ let mut selected = Frame::default().with_label(preset.name);
+ selected.set_align(Align::Left | Align::Inside | Align::Wrap);
+ if is_flat_neutral_dark(preset) {
+ style_flat_frame(&mut selected);
+ }
+ left.fixed(&selected, 60);
+ let mut detail = Frame::default().with_label("This preview was launched in a separate process.");
+ detail.set_align(Align::Left | Align::Inside | Align::Wrap);
+ if is_flat_neutral_dark(preset) {
+ style_flat_frame(&mut detail);
+ }
+ left.fixed(&detail, 48);
+ Frame::default();
+ left.end();
+ root.fixed(&left, 360);
+
+ // ---- Right: demo widgets ---------------------------------------------
+ let mut right = Flex::default().column();
+ right.set_spacing(8);
+
+ let mut sample_label = Frame::default().with_label("Demo widgets");
+ sample_label.set_align(Align::Left | Align::Inside);
+ if is_flat_neutral_dark(preset) {
+ style_flat_frame(&mut sample_label);
+ }
+ right.fixed(&sample_label, 22);
+
+ let mut input_row = Flex::default().row();
+ input_row.set_spacing(8);
+ let mut lab = Frame::default().with_label("Input:");
+ lab.set_align(Align::Left | Align::Inside);
+ if is_flat_neutral_dark(preset) {
+ style_flat_frame(&mut lab);
+ }
+ input_row.fixed(&lab, 60);
+ let mut input = Input::default();
+ input.set_value("Some text — try selecting it");
+ if is_flat_neutral_dark(preset) {
+ style_flat_input(&mut input);
+ } else if is_clean_modern(preset) {
+ style_modern_input(&mut input, preset);
+ }
+ input_row.end();
+ right.fixed(&input_row, 34);
+
+ let mut button_row = Flex::default().row();
+ button_row.set_spacing(8);
+ let mut b1 = Button::default().with_label("Primary");
+ let mut b2 = Button::default().with_label("Cancel");
+ let mut b3 = Button::default().with_label("Sign Out");
+ if is_flat_neutral_dark(preset) {
+ style_flat_button(&mut b1);
+ style_flat_button(&mut b2);
+ style_flat_button(&mut b3);
+ } else if is_clean_modern(preset) {
+ style_modern_button(&mut b1, preset);
+ style_modern_button(&mut b2, preset);
+ style_modern_button(&mut b3, preset);
+ } else {
+ add_hover(&mut b1);
+ add_hover(&mut b2);
+ add_hover(&mut b3);
+ }
+ button_row.fixed(&b1, 110);
+ button_row.fixed(&b2, 110);
+ button_row.fixed(&b3, 110);
+ Frame::default(); // spacer
+ button_row.end();
+ right.fixed(&button_row, 34);
+
+ let mut swatch_row = Flex::default().row();
+ swatch_row.set_spacing(8);
+ let background = swatch("bg", Color::Background);
+ let background2 = swatch("bg2", Color::BackGround2);
+ let foreground = swatch("fg", Color::Foreground);
+ let selection = swatch("sel", Color::Selection);
+ swatch_row.fixed(&background, 80);
+ swatch_row.fixed(&background2, 80);
+ swatch_row.fixed(&foreground, 80);
+ swatch_row.fixed(&selection, 80);
+ Frame::default();
+ swatch_row.end();
+ right.fixed(&swatch_row, 34);
+
+ let mut box_row = Flex::default().row();
+ box_row.set_spacing(8);
+ let mut up = Frame::default().with_label("UpBox");
+ up.set_frame(FrameType::UpBox);
+ let mut down = Frame::default().with_label("DownBox");
+ down.set_frame(FrameType::DownBox);
+ let mut thin = Frame::default().with_label("ThinUpBox");
+ thin.set_frame(FrameType::ThinUpBox);
+ let mut border = Frame::default().with_label("BorderBox");
+ border.set_frame(FrameType::BorderBox);
+ if is_flat_neutral_dark(preset) {
+ for frame in [&mut up, &mut down, &mut thin, &mut border] {
+ frame.set_frame(FrameType::FreeBoxType);
+ frame.set_color(flat_control());
+ frame.set_label_color(flat_text());
+ }
+ }
+ box_row.fixed(&up, 110);
+ box_row.fixed(&down, 110);
+ box_row.fixed(&thin, 110);
+ box_row.fixed(&border, 110);
+ Frame::default();
+ box_row.end();
+ right.fixed(&box_row, 34);
+
+ let mut tree = Tree::default();
+ tree.set_show_root(false);
+ tree.set_root_label("");
+ tree.set_select_mode(TreeSelect::Single);
+ if is_flat_neutral_dark(preset) {
+ style_flat_tree(&mut tree);
+ } else if is_clean_modern(preset) {
+ style_modern_tree(&mut tree, preset);
+ }
+ let _ = tree.add("Project Files/00_General");
+ let _ = tree.add("Project Files/01_WIP");
+ let _ = tree.add("Project Files/11_IFC/CenterConference.ifc");
+ let _ = tree.add("Project Files/11_IFC/Tower.ifc");
+ let _ = tree.add("Project Files/04_Archive");
+ right.fixed(&tree, 250);
+
+ let mut theme_bar = Progress::default();
+ theme_bar.set_minimum(0.0);
+ theme_bar.set_maximum(100.0);
+ theme_bar.set_value(45.0);
+ if is_flat_neutral_dark(preset) {
+ theme_bar.set_frame(FrameType::NoBox);
+ theme_bar.set_color(flat_bg());
+ theme_bar.set_selection_color(flat_accent());
+ } else {
+ style_theme_progress(&mut theme_bar);
+ }
+ right.fixed(&theme_bar, 14);
+
+ let mut connector_bar = Progress::default();
+ connector_bar.set_minimum(0.0);
+ connector_bar.set_maximum(100.0);
+ connector_bar.set_value(45.0);
+ let (progress_background, progress_fill) = connector_progress_colors(preset);
+ if is_flat_neutral_dark(preset) {
+ connector_bar.set_frame(FrameType::NoBox);
+ }
+ connector_bar.set_color(progress_background);
+ connector_bar.set_selection_color(progress_fill);
+ right.fixed(&connector_bar, 14);
+
+ right.end();
+ root.end();
+ win.end();
+
+ win.show();
+ eprintln!("[theme] preview open: {}", preset.name);
+ while win.shown() {
+ app.wait();
+ }
+}
+
+fn main() {
+ let presets = presets();
+ let args: Vec = env::args().collect();
+ if args.get(1).map(String::as_str) == Some("--preview") {
+ let Some(id) = args.get(2) else {
+ eprintln!("[theme] missing preset id");
+ std::process::exit(2);
+ };
+ let Some(preset) = find_preset(&presets, id) else {
+ eprintln!("[theme] unknown preset id: {id}");
+ std::process::exit(2);
+ };
+ run_preview(preset);
+ } else {
+ run_launcher(presets);
+ }
+}
diff --git a/src/bonsaiviewer-autodesk/packaging/bonsaiviewer-autodesk.spec b/src/bonsaiviewer-autodesk/packaging/bonsaiviewer-autodesk.spec
deleted file mode 100644
index 3f88ebf8b0..0000000000
--- a/src/bonsaiviewer-autodesk/packaging/bonsaiviewer-autodesk.spec
+++ /dev/null
@@ -1,81 +0,0 @@
-# PyInstaller spec for the Bonsai Viewer Autodesk connector (Tk + CustomTkinter).
-#
-# The connector talks JSON-RPC over stdio, so `console=True` is required to
-# attach stdin/stdout on Windows. The Bonsai Viewer is expected to spawn the
-# connector with the OS's "hide console window" flag on Windows
-# (CREATE_NO_WINDOW) so end users never see a console pop up.
-
-from pathlib import Path
-
-PROJECT_ROOT = Path(SPECPATH).resolve().parent
-
-# keyring uses entry points for backends — PyInstaller can't trace them
-# without hints. Bundle every backend; the right one is picked at runtime
-# per OS.
-HIDDEN_IMPORTS = [
- "keyring.backends.SecretService",
- "keyring.backends.macOS",
- "keyring.backends.Windows",
- "keyring.backends.fail",
- "keyring.backends.chainer",
-]
-
-
-a = Analysis(
- [str(PROJECT_ROOT / "bonsaiviewer_autodesk" / "__main__.py")],
- pathex=[str(PROJECT_ROOT)],
- binaries=[],
- datas=[],
- hiddenimports=HIDDEN_IMPORTS,
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=[
- # Test / docs.
- "test", "unittest", "pydoc_data",
- # Protocols / formats we never touch. (email, html, http.cookies and
- # http.cookiejar are required by http.server / httpx and must stay.)
- "xmlrpc", "sqlite3", "ftplib", "imaplib", "poplib", "nntplib",
- "smtplib", "telnetlib", "wsgiref",
- # Concurrency we never use (asyncio is needed by httpx → anyio).
- "multiprocessing", "concurrent.futures.process",
- # Build / packaging tools.
- "setuptools", "pip", "distutils", "ensurepip", "lib2to3",
- # Heavy stdlib bits with no callers.
- "decimal", "_decimal",
- # tkinter test modules.
- "tkinter.test", "test.test_tk",
- ],
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data)
-
-exe = EXE(
- pyz,
- a.scripts,
- [],
- exclude_binaries=True,
- name="bonsaiviewer-autodesk",
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=False,
- console=True,
- disable_windowed_traceback=False,
- argv_emulation=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
-)
-
-coll = COLLECT(
- exe,
- a.binaries,
- a.zipfiles,
- a.datas,
- strip=False,
- upx=False,
- upx_exclude=[],
- name="bonsaiviewer-autodesk",
-)
diff --git a/src/bonsaiviewer-autodesk/packaging/build.py b/src/bonsaiviewer-autodesk/packaging/build.py
index 3b2b2457aa..0f461ff038 100644
--- a/src/bonsaiviewer-autodesk/packaging/build.py
+++ b/src/bonsaiviewer-autodesk/packaging/build.py
@@ -1,38 +1,40 @@
"""Build the Autodesk connector bundle for the current OS.
-Each OS builds on itself (PyInstaller does not cross-compile). The output is a
-single zip ready to drop into the Bonsai Viewer connectors directory:
+Runs `cargo build --release` and packages the resulting binary +
+connector.json into the same layout the old PyInstaller flow produced,
+so build_viewer.sh and the CI workflows that already expected
+`dist/autodesk/` and `dist/autodesk--.zip` keep working
+unchanged:
dist/autodesk--.zip
autodesk/
connector.json
bonsaiviewer-autodesk[.exe]
- _internal/...
+
+The Rust binary statically links its deps, so unlike the PyInstaller
+output there is no `_internal/` directory — single executable inside
+the autodesk/ folder.
Usage:
- pip install -e ".[build]"
python packaging/build.py
"""
from __future__ import annotations
-import json
import platform
import shutil
import subprocess
-import sys
+import zipfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
-PACKAGING_DIR = PROJECT_ROOT / "packaging"
-SPEC_FILE = PACKAGING_DIR / "bonsaiviewer-autodesk.spec"
-DIST_DIR = PROJECT_ROOT / "dist"
-BUILD_DIR = PROJECT_ROOT / "build"
+DIST_DIR = PROJECT_ROOT / "dist"
+TARGET_DIR = PROJECT_ROOT / "target" / "release"
CONNECTOR_FOLDER_NAME = "autodesk"
-PYINSTALLER_OUTPUT_NAME = "bonsaiviewer-autodesk"
+BINARY_NAME = "bonsaiviewer-autodesk"
def _platform_tag() -> str:
@@ -55,66 +57,40 @@ def _platform_tag() -> str:
return f"{os_name}-{arch}"
-def _clean() -> None:
- for path in (DIST_DIR, BUILD_DIR):
- if path.exists():
- shutil.rmtree(path)
+def _binary_name_for_host() -> str:
+ return f"{BINARY_NAME}.exe" if platform.system() == "Windows" else BINARY_NAME
-def _run_pyinstaller() -> Path:
- subprocess.check_call(
- [
- sys.executable,
- "-m",
- "PyInstaller",
- str(SPEC_FILE),
- "--noconfirm",
- "--distpath",
- str(DIST_DIR),
- "--workpath",
- str(BUILD_DIR),
- ],
+def main() -> None:
+ if DIST_DIR.exists():
+ shutil.rmtree(DIST_DIR)
+
+ print("Running cargo build --release ...")
+ subprocess.run(
+ ["cargo", "build", "--release"],
cwd=PROJECT_ROOT,
+ check=True,
)
- produced = DIST_DIR / PYINSTALLER_OUTPUT_NAME
- if not produced.is_dir():
- raise SystemExit(f"PyInstaller did not produce expected folder: {produced}")
- return produced
+ bin_src = TARGET_DIR / _binary_name_for_host()
+ if not bin_src.exists():
+ raise FileNotFoundError(
+ f"cargo build did not produce expected binary at {bin_src}"
+ )
-def _assemble_connector_folder(pyinstaller_output: Path) -> Path:
- connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
- if connector_dir.exists():
- shutil.rmtree(connector_dir)
- pyinstaller_output.rename(connector_dir)
+ bundle_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
+ bundle_dir.mkdir(parents=True)
+ shutil.copy(PROJECT_ROOT / "connector.json", bundle_dir / "connector.json")
+ shutil.copy(bin_src, bundle_dir / _binary_name_for_host())
- # The source-controlled connector.json uses the bare entry-point name so
- # `pip install -e .` works for development. For the bundled folder, the
- # binary lives next to connector.json, so rewrite `exec` to a relative path.
- manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8"))
- binary_name = "bonsaiviewer-autodesk.exe" if platform.system() == "Windows" else "bonsaiviewer-autodesk"
- manifest["exec"] = f"./{binary_name}"
- (connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
-
- return connector_dir
-
-
-def _zip_connector_folder(tag: str) -> Path:
- archive_base = DIST_DIR / f"{CONNECTOR_FOLDER_NAME}-{tag}"
- return Path(shutil.make_archive(str(archive_base), "zip", DIST_DIR, CONNECTOR_FOLDER_NAME))
-
-
-def main() -> int:
- tag = _platform_tag()
- print(f"Building Autodesk connector for {tag}")
- _clean()
- pyinstaller_output = _run_pyinstaller()
- connector_dir = _assemble_connector_folder(pyinstaller_output)
- archive = _zip_connector_folder(tag)
- print(f"Connector folder: {connector_dir}")
- print(f"Distribution zip: {archive}")
- return 0
+ zip_path = DIST_DIR / f"autodesk-{_platform_tag()}.zip"
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for path in bundle_dir.rglob("*"):
+ if path.is_file():
+ zf.write(path, path.relative_to(DIST_DIR))
+ print(f"Wrote {zip_path}")
+ print(f" {bundle_dir}")
if __name__ == "__main__":
- raise SystemExit(main())
+ main()
diff --git a/src/bonsaiviewer-autodesk/pyproject.toml b/src/bonsaiviewer-autodesk/pyproject.toml
deleted file mode 100644
index 5631553e47..0000000000
--- a/src/bonsaiviewer-autodesk/pyproject.toml
+++ /dev/null
@@ -1,32 +0,0 @@
-[build-system]
-requires = ["setuptools>=69", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "bonsaiviewer-autodesk"
-version = "0.1.0"
-description = "Autodesk cloud connector for Bonsai Viewer"
-readme = { text = "Autodesk cloud connector for Bonsai Viewer. See src/bonsaiviewer/docs/connectors/autodesk.rst in the IfcOpenShell repository.", content-type = "text/x-rst" }
-requires-python = ">=3.11"
-dependencies = [
- "customtkinter>=5.2",
- "httpx>=0.27",
- "keyring>=25.2"
-]
-
-[project.optional-dependencies]
-build = ["pyinstaller>=6.0"]
-test = ["pytest>=8"]
-
-[tool.setuptools]
-include-package-data = true
-
-[tool.setuptools.packages.find]
-where = ["."]
-include = ["bonsaiviewer_autodesk*"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-
-[project.scripts]
-bonsaiviewer-autodesk = "bonsaiviewer_autodesk.__main__:main"
diff --git a/src/bonsaiviewer-autodesk/src/aps/browse.rs b/src/bonsaiviewer-autodesk/src/aps/browse.rs
new file mode 100644
index 0000000000..564392eecc
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/src/aps/browse.rs
@@ -0,0 +1,305 @@
+//! Browsing endpoints (read-only): hubs → projects → top folders → folder
+//! contents → item-with-tip.
+
+use serde_json::Value;
+
+use super::types::{Entry, EntryType, Hub, ItemTip, Project};
+use super::ApsClient;
+use crate::progress::noop_auth;
+use crate::rpc::RpcError;
+
+impl ApsClient {
+ fn url(&self, suffix: &str) -> String {
+ format!("{}{}", self.base_url, suffix)
+ }
+
+ fn token(&self) -> Result {
+ self.auth.ensure_access_token(noop_auth())
+ }
+
+ pub(crate) fn get_json(&self, url: &str) -> Result {
+ let token = self.token()?;
+ let response = self
+ .agent
+ .get(url)
+ .set("Authorization", &format!("Bearer {token}"))
+ .call();
+ decode_json(response, url)
+ }
+
+ pub(crate) fn post_json(&self, url: &str, payload: &Value) -> Result {
+ let token = self.token()?;
+ let req = self
+ .agent
+ .post(url)
+ .set("Authorization", &format!("Bearer {token}"))
+ .set("Content-Type", "application/vnd.api+json")
+ .set("Accept", "application/vnd.api+json");
+ decode_json(req.send_json(payload.clone()), url)
+ }
+
+ pub fn list_hubs(&self) -> Result, RpcError> {
+ let payload = self.get_json(&self.url("/project/v1/hubs"))?;
+ let mut hubs: Vec = payload
+ .get("data")
+ .and_then(|v| v.as_array())
+ .map(|arr| arr.iter().map(hub_from_jsonapi).collect())
+ .unwrap_or_default();
+ hubs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+ Ok(hubs)
+ }
+
+ pub fn list_projects(&self, hub_id: &str) -> Result, RpcError> {
+ let mut url = self.url(&format!("/project/v1/hubs/{hub_id}/projects"));
+ let mut projects: Vec = Vec::new();
+ loop {
+ let payload = self.get_json(&url)?;
+ if let Some(arr) = payload.get("data").and_then(|v| v.as_array()) {
+ for item in arr {
+ projects.push(project_from_jsonapi(item));
+ }
+ }
+ match payload.pointer("/links/next/href").and_then(|v| v.as_str()) {
+ Some(next) if !next.is_empty() => url = next.to_string(),
+ _ => break,
+ }
+ }
+ projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+ Ok(projects)
+ }
+
+ pub fn list_top_folders(&self, hub_id: &str, project_id: &str) -> Result, RpcError> {
+ let payload = self.get_json(&self.url(&format!(
+ "/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
+ )))?;
+ let mut folders: Vec = payload
+ .get("data")
+ .and_then(|v| v.as_array())
+ .map(|arr| arr.iter().map(entry_from_jsonapi).collect())
+ .unwrap_or_default();
+ folders.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
+ Ok(folders)
+ }
+
+ pub fn list_folder_contents(
+ &self,
+ project_id: &str,
+ folder_id: &str,
+ object_types: &[&str],
+ extension_filter: Option<&dyn Fn(&Entry) -> bool>,
+ ) -> Result, RpcError> {
+ let mut url = self.url(&format!(
+ "/data/v1/projects/{project_id}/folders/{folder_id}/contents"
+ ));
+ if !object_types.is_empty() {
+ let qs: Vec = object_types
+ .iter()
+ .map(|t| format!("filter[type]={}", url_enc(t)))
+ .collect();
+ url.push('?');
+ url.push_str(&qs.join("&"));
+ }
+ let mut entries: Vec = Vec::new();
+ loop {
+ let payload = self.get_json(&url)?;
+ if let Some(arr) = payload.get("data").and_then(|v| v.as_array()) {
+ for item in arr {
+ let entry = entry_from_jsonapi(item);
+ if entry.entry_type == EntryType::Items {
+ if let Some(filter) = extension_filter {
+ if !filter(&entry) {
+ continue;
+ }
+ }
+ }
+ entries.push(entry);
+ }
+ }
+ match payload.pointer("/links/next/href").and_then(|v| v.as_str()) {
+ Some(next) if !next.is_empty() => url = next.to_string(),
+ _ => break,
+ }
+ }
+ entries.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
+ Ok(entries)
+ }
+
+ /// Item + current tip in a single request. `hidden` reflects the
+ /// soft-delete state — callers must check it before downloading.
+ pub fn get_item(&self, project_id: &str, item_id: &str) -> Result {
+ let payload = self.get_json(&self.url(&format!(
+ "/data/v1/projects/{}/items/{}?include=tip",
+ url_enc(project_id),
+ url_enc(item_id),
+ )))?;
+ let item = payload
+ .get("data")
+ .cloned()
+ .ok_or_else(|| RpcError::internal("Item response missing 'data'."))?;
+ let item_attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
+ let parent_folder_id = relationship_id(&item, "parent");
+ let tip_id = relationship_id(&item, "tip");
+ let id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ let tip = payload
+ .get("included")
+ .and_then(|v| v.as_array())
+ .and_then(|arr| {
+ arr.iter()
+ .find(|inc| {
+ inc.get("type").and_then(|v| v.as_str()) == Some("versions")
+ && inc.get("id").and_then(|v| v.as_str()) == tip_id.as_deref()
+ })
+ .cloned()
+ });
+
+ let Some(tip) = tip else {
+ let display = item_attrs
+ .get("displayName")
+ .and_then(|v| v.as_str())
+ .or_else(|| item_attrs.get("name").and_then(|v| v.as_str()))
+ .unwrap_or(&id)
+ .to_string();
+ return Ok(ItemTip {
+ id,
+ display_name: display,
+ hidden: true,
+ version_id: None,
+ storage_id: None,
+ version_number: None,
+ last_modified_time_utc: None,
+ last_modified_user_name: None,
+ parent_folder_id,
+ });
+ };
+ let tip_attrs = tip.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
+ let display = tip_attrs
+ .get("displayName")
+ .and_then(|v| v.as_str())
+ .or_else(|| tip_attrs.get("name").and_then(|v| v.as_str()))
+ .or_else(|| item_attrs.get("displayName").and_then(|v| v.as_str()))
+ .unwrap_or(&id)
+ .to_string();
+ Ok(ItemTip {
+ id,
+ display_name: display,
+ hidden: item_attrs.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false),
+ version_id: tip.get("id").and_then(|v| v.as_str()).map(String::from),
+ storage_id: relationship_id(&tip, "storage"),
+ version_number: tip_attrs.get("versionNumber").cloned(),
+ last_modified_time_utc: tip_attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from),
+ last_modified_user_name: tip_attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from),
+ parent_folder_id,
+ })
+ }
+}
+
+fn hub_from_jsonapi(item: &Value) -> Hub {
+ Hub {
+ id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ extension_type: item
+ .pointer("/attributes/extension/type")
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ .to_string(),
+ }
+}
+
+fn project_from_jsonapi(item: &Value) -> Project {
+ Project {
+ id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ extension_type: item
+ .pointer("/attributes/extension/type")
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ .to_string(),
+ root_folder_id: item
+ .pointer("/relationships/rootFolder/data/id")
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ .to_string(),
+ }
+}
+
+pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry {
+ let attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({}));
+ let display = attrs
+ .get("displayName")
+ .and_then(|v| v.as_str())
+ .or_else(|| attrs.get("name").and_then(|v| v.as_str()))
+ .unwrap_or("")
+ .to_string();
+ let raw_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
+ let entry_type = match raw_type {
+ "folders" => EntryType::Folders,
+ "items" => EntryType::Items,
+ _ => EntryType::Other,
+ };
+ Entry {
+ id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
+ entry_type,
+ display_name: display,
+ name: attrs.get("name").and_then(|v| v.as_str()).map(String::from),
+ extension_type: attrs
+ .pointer("/extension/type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string(),
+ }
+}
+
+pub(crate) fn relationship_id(data: &Value, name: &str) -> Option {
+ let rel = data.pointer(&format!("/relationships/{name}/data"))?;
+ if let Some(obj) = rel.as_object() {
+ return obj
+ .get("id")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(String::from);
+ }
+ if let Some(arr) = rel.as_array() {
+ return arr
+ .first()
+ .and_then(|x| x.get("id"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(String::from);
+ }
+ None
+}
+
+pub(crate) fn url_enc(s: &str) -> String {
+ url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
+}
+
+pub(crate) fn decode_json(
+ result: Result,
+ url: &str,
+) -> Result {
+ match result {
+ Ok(resp) => resp
+ .into_json::()
+ .map_err(|e| RpcError::internal(format!("Response from {url} was not JSON: {e}"))),
+ Err(ureq::Error::Status(code, resp)) => {
+ let body = resp.into_string().unwrap_or_default();
+ let msg = if body.trim().is_empty() {
+ format!("HTTP {code}")
+ } else {
+ body.trim().to_string()
+ };
+ Err(RpcError::internal(msg))
+ }
+ Err(e) => Err(RpcError::internal(e.to_string())),
+ }
+}
+
+pub(crate) fn entry_name_matches(entry: &Entry, expected: &str) -> bool {
+ let target = expected.to_lowercase();
+ entry.display_name.to_lowercase() == target
+ || entry
+ .name
+ .as_deref()
+ .map(|s| s.to_lowercase() == target)
+ .unwrap_or(false)
+}
diff --git a/src/bonsaiviewer-autodesk/src/aps/mod.rs b/src/bonsaiviewer-autodesk/src/aps/mod.rs
new file mode 100644
index 0000000000..9831d8260e
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/src/aps/mod.rs
@@ -0,0 +1,30 @@
+pub mod browse;
+pub mod transfer;
+pub mod types;
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use crate::auth::AuthSessionService;
+
+pub use types::{Entry, EntryType, Hub, ItemTip, Project, UploadResult};
+
+pub struct ApsClient {
+ pub(crate) auth: Arc,
+ pub(crate) agent: ureq::Agent,
+ pub(crate) base_url: String,
+}
+
+impl ApsClient {
+ pub fn new(auth: Arc) -> Self {
+ Self::with_base_url(auth, "https://developer.api.autodesk.com".into())
+ }
+
+ pub fn with_base_url(auth: Arc, base_url: String) -> Self {
+ Self {
+ auth,
+ agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(120)).build(),
+ base_url,
+ }
+ }
+}
diff --git a/src/bonsaiviewer-autodesk/src/aps/transfer.rs b/src/bonsaiviewer-autodesk/src/aps/transfer.rs
new file mode 100644
index 0000000000..7f9a1d3797
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/src/aps/transfer.rs
@@ -0,0 +1,449 @@
+//! Download (signed S3 GET) and upload (signed S3 multi-part PUT + complete).
+
+use std::cmp::min;
+use std::fs::File;
+use std::io::{Read, Write};
+use std::path::Path;
+
+use serde_json::{json, Value};
+
+use super::browse::{decode_json, entry_name_matches, url_enc};
+use super::types::UploadResult;
+use super::ApsClient;
+use crate::progress::ApsProgress;
+use crate::rpc::RpcError;
+
+const CHUNK_SIZE: u64 = 5 * 1024 * 1024;
+const PARTS_PER_BATCH: u32 = 5;
+const READ_BUF: usize = 64 * 1024;
+
+impl ApsClient {
+ pub fn download_storage_to_file(
+ &self,
+ storage_id: &str,
+ destination: &Path,
+ progress: Option,
+ ) -> Result<(), RpcError> {
+ let (bucket, object) = parse_storage_id(storage_id)?;
+ let signed_url = self.get_signed_download_url(&bucket, &object)?;
+ self.download_to_file(&signed_url, destination, progress)
+ }
+
+ pub fn upload_file_to_folder(
+ &self,
+ project_id: &str,
+ folder_id: &str,
+ local_path: &Path,
+ display_name: Option<&str>,
+ progress: Option,
+ ) -> Result {
+ if !local_path.exists() {
+ return Err(RpcError::internal(format!(
+ "Local file '{}' does not exist.",
+ local_path.display()
+ )));
+ }
+ let file_name = display_name
+ .map(str::to_string)
+ .unwrap_or_else(|| local_path.file_name().unwrap_or_default().to_string_lossy().into_owned());
+ let storage_id = self.create_storage(project_id, folder_id, &file_name)?;
+ let (bucket, object) = parse_storage_id(&storage_id)?;
+ self.upload_local_file_to_oss(&bucket, &object, local_path, progress)?;
+ if let Some(existing) = self.find_item_in_folder(project_id, folder_id, &file_name)? {
+ self.create_version(project_id, &existing, &file_name, &storage_id)
+ } else {
+ self.create_item(project_id, folder_id, &file_name, &storage_id)
+ }
+ }
+
+ fn get_signed_download_url(&self, bucket: &str, object: &str) -> Result {
+ let payload = self.get_json(&format!(
+ "{}/oss/v2/buckets/{}/objects/{}/signeds3download",
+ self.base_url,
+ url_enc(bucket),
+ url_enc(object),
+ ))?;
+ payload
+ .get("url")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+ .ok_or_else(|| RpcError::internal("Signed download URL response did not contain a URL."))
+ }
+
+ fn download_to_file(
+ &self,
+ url: &str,
+ destination: &Path,
+ progress: Option,
+ ) -> Result<(), RpcError> {
+ let resp = match self.agent.get(url).call() {
+ Ok(r) => r,
+ Err(ureq::Error::Status(code, resp)) => {
+ let body = resp.into_string().unwrap_or_default();
+ let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
+ return Err(RpcError::internal(msg));
+ }
+ Err(e) => return Err(RpcError::internal(e.to_string())),
+ };
+ let total_bytes: Option = resp.header("Content-Length").and_then(|s| s.parse().ok());
+ let name = destination
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned();
+ let mut reader = resp.into_reader();
+ let mut file = File::create(destination)
+ .map_err(|e| RpcError::internal(format!("Cannot create '{}': {e}", destination.display())))?;
+ let mut buf = vec![0u8; READ_BUF];
+ let mut downloaded: u64 = 0;
+ loop {
+ let n = reader
+ .read(&mut buf)
+ .map_err(|e| RpcError::internal(format!("Download read failed: {e}")))?;
+ if n == 0 {
+ break;
+ }
+ file.write_all(&buf[..n])
+ .map_err(|e| RpcError::internal(format!("Download write failed: {e}")))?;
+ downloaded += n as u64;
+ if let Some(cb) = &progress {
+ let percent = total_bytes.map(|t| min(100, ((downloaded as f64 / t as f64) * 100.0) as i32));
+ cb(&name, percent, Some(downloaded), total_bytes);
+ }
+ }
+ Ok(())
+ }
+
+ fn create_storage(&self, project_id: &str, folder_id: &str, file_name: &str) -> Result {
+ let payload = self.post_json(
+ &format!("{}/data/v1/projects/{}/storage", self.base_url, url_enc(project_id)),
+ &json!({
+ "jsonapi": {"version": "1.0"},
+ "data": {
+ "type": "objects",
+ "attributes": {"name": file_name},
+ "relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
+ },
+ }),
+ )?;
+ payload
+ .pointer("/data/id")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+ .ok_or_else(|| RpcError::internal("Storage creation did not return an object id."))
+ }
+
+ fn upload_local_file_to_oss(
+ &self,
+ bucket: &str,
+ object: &str,
+ local_path: &Path,
+ progress: Option,
+ ) -> Result<(), RpcError> {
+ let file_size = std::fs::metadata(local_path)
+ .map_err(|e| RpcError::internal(format!("stat '{}': {e}", local_path.display())))?
+ .len();
+ let total_parts = ((file_size + CHUNK_SIZE - 1) / CHUNK_SIZE).max(1);
+ let file_name = local_path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned();
+
+ let mut handle = File::open(local_path)
+ .map_err(|e| RpcError::internal(format!("open '{}': {e}", local_path.display())))?;
+ let mut upload_key: Option = None;
+ let mut parts_uploaded: u64 = 0;
+ let mut bytes_uploaded: u64 = 0;
+ let mut chunk = vec![0u8; CHUNK_SIZE as usize];
+
+ while parts_uploaded < total_parts {
+ let parts_to_request = min(total_parts - parts_uploaded, PARTS_PER_BATCH as u64) as u32;
+ let first_part = (parts_uploaded + 1) as u32;
+ let signed = self.get_signed_upload_urls(
+ bucket,
+ object,
+ upload_key.as_deref(),
+ first_part,
+ parts_to_request,
+ )?;
+ if upload_key.is_none() {
+ upload_key = signed.get("uploadKey").and_then(|v| v.as_str()).map(String::from);
+ }
+ let urls = signed
+ .get("urls")
+ .and_then(|v| v.as_array())
+ .filter(|a| !a.is_empty())
+ .ok_or_else(|| RpcError::internal("Upload URL response did not contain upload URLs."))?
+ .clone();
+ for url in &urls {
+ if parts_uploaded >= total_parts {
+ break;
+ }
+ let url_str = url
+ .as_str()
+ .ok_or_else(|| RpcError::internal("Upload URL was not a string."))?;
+ let n = fill(&mut handle, &mut chunk)
+ .map_err(|e| RpcError::internal(format!("Upload read failed: {e}")))?;
+ if n == 0 {
+ break;
+ }
+ self.put_bytes(url_str, &chunk[..n])?;
+ parts_uploaded += 1;
+ bytes_uploaded += n as u64;
+ if let Some(cb) = &progress {
+ let percent = if file_size == 0 {
+ 100
+ } else {
+ min(100, ((bytes_uploaded as f64 / file_size as f64) * 100.0) as i32)
+ };
+ cb(&file_name, Some(percent), Some(bytes_uploaded), Some(file_size));
+ }
+ }
+ }
+
+ let key = upload_key.ok_or_else(|| RpcError::internal("Upload did not return an upload key."))?;
+ self.complete_signed_upload(bucket, object, &key)
+ }
+
+ fn get_signed_upload_urls(
+ &self,
+ bucket: &str,
+ object: &str,
+ upload_key: Option<&str>,
+ first_part: u32,
+ parts: u32,
+ ) -> Result {
+ let token = self.auth.ensure_access_token(crate::progress::noop_auth())?;
+ let mut url = format!(
+ "{}/oss/v2/buckets/{}/objects/{}/signeds3upload?minutesExpiration=10&firstPart={}&parts={}",
+ self.base_url,
+ url_enc(bucket),
+ url_enc(object),
+ first_part,
+ parts,
+ );
+ if let Some(k) = upload_key {
+ url.push_str(&format!("&uploadKey={}", url_enc(k)));
+ }
+ let resp = self
+ .agent
+ .get(&url)
+ .set("Authorization", &format!("Bearer {token}"))
+ .call();
+ decode_json(resp, &url)
+ }
+
+ fn complete_signed_upload(&self, bucket: &str, object: &str, upload_key: &str) -> Result<(), RpcError> {
+ let token = self.auth.ensure_access_token(crate::progress::noop_auth())?;
+ let url = format!(
+ "{}/oss/v2/buckets/{}/objects/{}/signeds3upload",
+ self.base_url,
+ url_enc(bucket),
+ url_enc(object),
+ );
+ match self
+ .agent
+ .post(&url)
+ .set("Authorization", &format!("Bearer {token}"))
+ .set("Content-Type", "application/json")
+ .send_json(json!({"uploadKey": upload_key}))
+ {
+ Ok(_) => Ok(()),
+ Err(ureq::Error::Status(code, resp)) => {
+ let body = resp.into_string().unwrap_or_default();
+ let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
+ Err(RpcError::internal(msg))
+ }
+ Err(e) => Err(RpcError::internal(e.to_string())),
+ }
+ }
+
+ fn put_bytes(&self, url: &str, content: &[u8]) -> Result<(), RpcError> {
+ match self
+ .agent
+ .put(url)
+ .set("Content-Type", "application/octet-stream")
+ .send_bytes(content)
+ {
+ Ok(_) => Ok(()),
+ Err(ureq::Error::Status(code, resp)) => {
+ let body = resp.into_string().unwrap_or_default();
+ let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() };
+ Err(RpcError::internal(msg))
+ }
+ Err(e) => Err(RpcError::internal(e.to_string())),
+ }
+ }
+
+ fn find_item_in_folder(
+ &self,
+ project_id: &str,
+ folder_id: &str,
+ file_name: &str,
+ ) -> Result