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, RpcError> { + let children = self.list_folder_contents(project_id, folder_id, &["items"], None)?; + Ok(children + .into_iter() + .find(|c| entry_name_matches(c, file_name)) + .map(|c| c.id)) + } + + fn create_version( + &self, + project_id: &str, + item_id: &str, + file_name: &str, + storage_id: &str, + ) -> Result { + let payload = self.post_json( + &format!("{}/data/v1/projects/{}/versions", self.base_url, url_enc(project_id)), + &json!({ + "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}}, + }, + }, + }), + )?; + let version = payload + .get("data") + .ok_or_else(|| RpcError::internal("Create version response missing 'data'."))?; + let attrs = version.get("attributes").cloned().unwrap_or_else(|| json!({})); + Ok(UploadResult { + item_id: item_id.to_string(), + version_id: version.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), + display_name: file_name.to_string(), + version_number: attrs.get("versionNumber").cloned(), + last_modified_time_utc: attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from), + last_modified_user_name: attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from), + }) + } + + fn create_item( + &self, + project_id: &str, + folder_id: &str, + file_name: &str, + storage_id: &str, + ) -> Result { + let payload = self.post_json( + &format!("{}/data/v1/projects/{}/items", self.base_url, url_enc(project_id)), + &json!({ + "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}}}, + }], + }), + )?; + let item = payload + .get("data") + .ok_or_else(|| RpcError::internal("Create item response missing 'data'."))?; + let item_id = item + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| RpcError::internal("Create item response missing item id."))? + .to_string(); + let mut version_id = "1".to_string(); + let mut version_number: Option = Some(json!(1)); + let mut last_modified_time: Option = None; + let mut last_modified_user: Option = None; + if let Some(included) = payload.get("included").and_then(|v| v.as_array()) { + if let Some(ver) = included + .iter() + .find(|i| i.get("type").and_then(|t| t.as_str()) == Some("versions")) + { + if let Some(id) = ver.get("id").and_then(|v| v.as_str()) { + version_id = id.to_string(); + } + if let Some(attrs) = ver.get("attributes") { + if let Some(v) = attrs.get("versionNumber") { + version_number = Some(v.clone()); + } + last_modified_time = attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from); + last_modified_user = attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from); + } + } + } + Ok(UploadResult { + item_id, + version_id, + display_name: file_name.to_string(), + version_number, + last_modified_time_utc: last_modified_time, + last_modified_user_name: last_modified_user, + }) + } +} + +pub(crate) fn parse_storage_id(storage_id: &str) -> Result<(String, String), RpcError> { + let marker = "urn:adsk.objects:os.object:"; + let path = storage_id + .strip_prefix(marker) + .ok_or_else(|| RpcError::internal(format!("Unsupported storage identifier '{storage_id}'.")))?; + let slash = path + .find('/') + .ok_or_else(|| RpcError::internal(format!("Malformed storage identifier '{storage_id}'.")))?; + if slash == 0 || slash == path.len() - 1 { + return Err(RpcError::internal(format!("Malformed storage identifier '{storage_id}'."))); + } + Ok((path[..slash].to_string(), path[slash + 1..].to_string())) +} + +fn fill(file: &mut File, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while filled < buf.len() { + let n = file.read(&mut buf[filled..])?; + if n == 0 { + break; + } + filled += n; + } + Ok(filled) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_storage_id() { + let (bucket, object) = + parse_storage_id("urn:adsk.objects:os.object:wip.dm.prod/abc.ifc").unwrap(); + assert_eq!(bucket, "wip.dm.prod"); + assert_eq!(object, "abc.ifc"); + } + + #[test] + fn rejects_bad_storage_id() { + assert!(parse_storage_id("nope").is_err()); + assert!(parse_storage_id("urn:adsk.objects:os.object:nobucket").is_err()); + assert!(parse_storage_id("urn:adsk.objects:os.object:/noobject").is_err()); + } +} diff --git a/src/bonsaiviewer-autodesk/src/aps/types.rs b/src/bonsaiviewer-autodesk/src/aps/types.rs new file mode 100644 index 0000000000..215aeebf12 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/aps/types.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hub { + pub id: String, + pub name: String, + pub extension_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: String, + pub name: String, + pub extension_type: String, + pub root_folder_id: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EntryType { + Folders, + Items, + #[serde(other)] + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + pub id: String, + #[serde(rename = "type")] + pub entry_type: EntryType, + pub display_name: String, + pub name: Option, + pub extension_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ItemTip { + pub id: String, + pub display_name: String, + pub hidden: bool, + pub version_id: Option, + pub storage_id: Option, + pub version_number: Option, + pub last_modified_time_utc: Option, + pub last_modified_user_name: Option, + pub parent_folder_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadResult { + pub item_id: String, + pub version_id: String, + pub display_name: String, + pub version_number: Option, + pub last_modified_time_utc: Option, + pub last_modified_user_name: Option, +} diff --git a/src/bonsaiviewer-autodesk/src/auth.rs b/src/bonsaiviewer-autodesk/src/auth.rs new file mode 100644 index 0000000000..e8fdf0d518 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/auth.rs @@ -0,0 +1,597 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::sync::Arc; +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use rand::rngs::OsRng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::progress::AuthProgress; +use crate::rpc::RpcError; + +pub const KEYRING_SERVICE: &str = "bonsaiviewer-autodesk"; +pub const AUTHORIZE_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/authorize"; +pub const TOKEN_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/token"; + +const REFRESH_TTL_DEFAULT_SECONDS: i64 = 15 * 24 * 60 * 60; +const TOKEN_SKEW_SECONDS: i64 = 30; + +fn no_keyring_error() -> RpcError { + RpcError::internal( + "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.", + ) +} + +/// Abstract token storage so tests can swap in an in-memory backend. +pub trait TokenStore: Send + Sync { + fn load(&self) -> Result, RpcError>; + fn save(&self, token: &StoredToken) -> Result<(), RpcError>; + fn delete(&self) -> Result<(), RpcError>; +} + +pub struct KeyringTokenStore { + pub service: String, + pub username: String, +} + +impl KeyringTokenStore { + pub fn new(username: impl Into) -> Self { + Self { service: KEYRING_SERVICE.to_string(), username: username.into() } + } + + fn entry(&self) -> Result { + keyring::Entry::new(&self.service, &self.username).map_err(|_| no_keyring_error()) + } +} + +impl TokenStore for KeyringTokenStore { + fn load(&self) -> Result, RpcError> { + match self.entry()?.get_password() { + Ok(raw) => serde_json::from_str(&raw) + .map(Some) + .map_err(|e| RpcError::internal(format!("Stored token is corrupt: {e}"))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_)) => { + Err(no_keyring_error()) + } + Err(e) => Err(RpcError::internal(format!("Keyring read failed: {e}"))), + } + } + + fn save(&self, token: &StoredToken) -> Result<(), RpcError> { + let serialised = serde_json::to_string(token) + .map_err(|e| RpcError::internal(format!("Serialise token failed: {e}")))?; + self.entry()? + .set_password(&serialised) + .map_err(|_| no_keyring_error()) + } + + fn delete(&self) -> Result<(), RpcError> { + match self.entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_)) => { + Err(no_keyring_error()) + } + Err(e) => Err(RpcError::internal(format!("Keyring delete failed: {e}"))), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredToken { + pub client_id: String, + pub access_token: String, + pub refresh_token: String, + pub access_token_expires_at: DateTime, + pub refresh_token_expires_at: DateTime, + pub scope: String, +} + +fn base64url(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn generate_code_verifier() -> String { + let mut buf = [0u8; 48]; + OsRng.fill_bytes(&mut buf); + base64url(&buf) +} + +pub fn generate_code_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + base64url(&digest) +} + +pub fn generate_state() -> String { + let mut buf = [0u8; 16]; + OsRng.fill_bytes(&mut buf); + let mut s = String::with_capacity(32); + for b in &buf { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Block on a single OAuth redirect to `http://host:port/path` and return the +/// authorization code. Handles exactly one request, then closes. +pub fn wait_for_oauth_callback( + host: &str, + port: u16, + path: &str, + expected_state: &str, +) -> Result { + let listener = TcpListener::bind((host, port)) + .map_err(|e| RpcError::internal(format!("Cannot bind OAuth callback to {host}:{port}: {e}")))?; + let (mut stream, _) = listener + .accept() + .map_err(|e| RpcError::internal(format!("OAuth callback accept failed: {e}")))?; + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); + + let request_line = { + let mut reader = BufReader::new( + stream.try_clone().map_err(|e| RpcError::internal(e.to_string()))?, + ); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| RpcError::internal(e.to_string()))?; + line + }; + + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let (req_path, query) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target, ""), + }; + + let matched = req_path == path; + let body: &[u8] = if matched { + b"

Authentication complete. You can close this window.

" + } else { + b"

Not Found

" + }; + let status = if matched { "200 OK" } else { "404 Not Found" }; + let header = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); + + if !matched { + return Err(RpcError::internal("OAuth callback hit unexpected path.")); + } + + let mut state: Option = None; + let mut code: Option = None; + let mut error: Option = None; + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + match k.as_ref() { + "state" => state = Some(v.into_owned()), + "code" => code = Some(v.into_owned()), + "error" => error = Some(v.into_owned()), + _ => {} + } + } + + if let Some(err) = error.filter(|s| !s.is_empty()) { + return Err(RpcError::internal(format!("Autodesk returned OAuth error '{err}'."))); + } + if state.as_deref() != Some(expected_state) { + return Err(RpcError::internal("OAuth state mismatch.")); + } + code.filter(|s| !s.is_empty()) + .ok_or_else(|| RpcError::internal("OAuth callback did not return an authorization code.")) +} + +/// Pluggable callback strategy — production binds a localhost socket, tests +/// inject a stub that returns a canned code. +pub type CallbackWaiter = Arc Result + Send + Sync>; + +pub fn default_callback_waiter() -> CallbackWaiter { + Arc::new(|host, port, path, state| wait_for_oauth_callback(host, port, path, state)) +} + +/// Pluggable "open this URL in a browser" — production opens it for real, +/// tests use a no-op. +pub type Browser = Arc; + +pub fn default_browser() -> Browser { + Arc::new(|url| { + let _ = webbrowser::open(url); + }) +} + +pub struct AuthSessionService { + pub client_id: String, + pub callback_url: String, + pub scope: String, + token_store: Box, + callback_waiter: CallbackWaiter, + browser: Browser, + authorize_endpoint: String, + token_endpoint: String, + agent: ureq::Agent, +} + +pub struct AuthBuilder { + client_id: String, + callback_url: String, + scope: String, + token_store: Box, + callback_waiter: CallbackWaiter, + browser: Browser, + authorize_endpoint: String, + token_endpoint: String, +} + +impl AuthBuilder { + pub fn new(client_id: String, callback_url: String, scope: String, token_store: Box) -> Self { + Self { + client_id, + callback_url, + scope, + token_store, + callback_waiter: default_callback_waiter(), + browser: default_browser(), + authorize_endpoint: AUTHORIZE_ENDPOINT.to_string(), + token_endpoint: TOKEN_ENDPOINT.to_string(), + } + } + + pub fn with_callback_waiter(mut self, waiter: CallbackWaiter) -> Self { + self.callback_waiter = waiter; + self + } + pub fn with_browser(mut self, browser: Browser) -> Self { + self.browser = browser; + self + } + pub fn with_endpoints(mut self, authorize: String, token: String) -> Self { + self.authorize_endpoint = authorize; + self.token_endpoint = token; + self + } + + pub fn build(self) -> AuthSessionService { + AuthSessionService { + client_id: self.client_id, + callback_url: self.callback_url, + scope: self.scope, + token_store: self.token_store, + callback_waiter: self.callback_waiter, + browser: self.browser, + authorize_endpoint: self.authorize_endpoint, + token_endpoint: self.token_endpoint, + agent: ureq::AgentBuilder::new() + .timeout(Duration::from_secs(60)) + .build(), + } + } +} + +impl AuthSessionService { + pub fn new(client_id: String, callback_url: String, scope: String) -> Self { + let store = Box::new(KeyringTokenStore::new(client_id.clone())); + AuthBuilder::new(client_id, callback_url, scope, store).build() + } + + pub fn get_token(&self) -> Result, RpcError> { + self.token_store.load() + } + + pub fn sign_out(&self) -> Result<(), RpcError> { + self.token_store.delete() + } + + pub fn ensure_access_token(&self, progress: AuthProgress) -> Result { + let token = self.token_store.load()?; + let now = Utc::now(); + if let Some(t) = &token { + if t.access_token_expires_at > now + ChronoDuration::minutes(1) { + return Ok(t.access_token.clone()); + } + if t.refresh_token_expires_at > now + ChronoDuration::minutes(1) { + return Ok(self.refresh(t, progress)?.access_token); + } + } + Ok(self.login_interactive(progress)?.access_token) + } + + pub fn login_interactive(&self, progress: AuthProgress) -> Result { + progress("auth", "Preparing Autodesk sign-in", None); + let verifier = generate_code_verifier(); + let challenge = generate_code_challenge(&verifier); + let state = generate_state(); + let callback = url::Url::parse(&self.callback_url) + .map_err(|e| RpcError::internal(format!("Bad callback URL: {e}")))?; + if callback.scheme() != "http" + || !matches!(callback.host_str(), Some("127.0.0.1") | Some("localhost")) + { + return Err(RpcError::internal("Callback URL must be http://localhost or http://127.0.0.1.")); + } + + let authorize_url = { + let mut u = url::Url::parse(&self.authorize_endpoint) + .map_err(|e| RpcError::internal(format!("Bad authorize endpoint: {e}")))?; + u.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &self.client_id) + .append_pair("redirect_uri", &self.callback_url) + .append_pair("scope", &self.scope) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state); + u.to_string() + }; + + progress("auth", "Opening browser for Autodesk sign-in", None); + (self.browser)(&authorize_url); + + let host = callback.host_str().unwrap_or("127.0.0.1"); + let port = callback.port().unwrap_or(80); + let path = callback.path(); + let code = (self.callback_waiter)(host, port, path, &state)?; + + progress("auth", "Exchanging authorization code for token", None); + let payload = self.post_form(&[ + ("client_id", self.client_id.as_str()), + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("code_verifier", verifier.as_str()), + ("redirect_uri", self.callback_url.as_str()), + ])?; + let token = self.token_from_payload(&payload)?; + self.token_store.save(&token)?; + progress("auth", "Signed in to Autodesk", Some(100)); + Ok(token) + } + + fn refresh(&self, token: &StoredToken, progress: AuthProgress) -> Result { + progress("auth", "Refreshing Autodesk session", None); + let payload = self + .post_form(&[ + ("client_id", self.client_id.as_str()), + ("grant_type", "refresh_token"), + ("refresh_token", token.refresh_token.as_str()), + ("scope", self.scope.as_str()), + ]) + .map_err(|e| RpcError::internal(format!("Token refresh failed: {}", e.message)))?; + let refreshed = self.token_from_payload(&payload)?; + self.token_store.save(&refreshed)?; + progress("auth", "Session refreshed", Some(100)); + Ok(refreshed) + } + + fn post_form(&self, form: &[(&str, &str)]) -> Result { + match self.agent.post(&self.token_endpoint).send_form(form) { + Ok(resp) => resp + .into_json::() + .map_err(|e| RpcError::internal(format!("Token endpoint returned non-JSON: {e}"))), + Err(ureq::Error::Status(_, resp)) => { + let body = resp.into_string().unwrap_or_default(); + Err(RpcError::internal(format!("Token exchange failed: {body}"))) + } + Err(e) => Err(RpcError::internal(format!("Token request failed: {e}"))), + } + } + + fn token_from_payload(&self, payload: &Value) -> Result { + let now = Utc::now(); + let expires_in = payload + .get("expires_in") + .and_then(|v| v.as_i64()) + .ok_or_else(|| RpcError::internal("Token response missing 'expires_in'."))?; + let refresh_ttl = payload + .get("refresh_token_expires_in") + .and_then(|v| v.as_i64()) + .unwrap_or(REFRESH_TTL_DEFAULT_SECONDS); + let access_token = payload + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| RpcError::internal("Token response missing 'access_token'."))?; + let refresh_token = payload + .get("refresh_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| RpcError::internal("Token response missing 'refresh_token'."))?; + Ok(StoredToken { + client_id: self.client_id.clone(), + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + access_token_expires_at: now + ChronoDuration::seconds(expires_in - TOKEN_SKEW_SECONDS), + refresh_token_expires_at: now + ChronoDuration::seconds(refresh_ttl - TOKEN_SKEW_SECONDS), + scope: self.scope.clone(), + }) + } +} + +/// In-memory token store for tests and ephemeral sessions. +pub struct InMemoryTokenStore { + inner: std::sync::Mutex>, +} + +impl InMemoryTokenStore { + pub fn empty() -> Self { + Self { inner: std::sync::Mutex::new(None) } + } + pub fn preloaded(token: StoredToken) -> Self { + Self { inner: std::sync::Mutex::new(Some(token)) } + } +} + +impl TokenStore for InMemoryTokenStore { + fn load(&self) -> Result, RpcError> { + Ok(self.inner.lock().unwrap().clone()) + } + fn save(&self, token: &StoredToken) -> Result<(), RpcError> { + *self.inner.lock().unwrap() = Some(token.clone()); + Ok(()) + } + fn delete(&self) -> Result<(), RpcError> { + *self.inner.lock().unwrap() = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[test] + fn code_challenge_matches_known_pkce_vector() { + // RFC 7636 appendix B + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + generate_code_challenge(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn verifier_and_state_length() { + let v = generate_code_verifier(); + assert!(v.len() >= 43); + let s = generate_state(); + assert_eq!(s.len(), 32); + } + + #[test] + fn store_round_trip_in_memory() { + let token = StoredToken { + client_id: "x".into(), + access_token: "a".into(), + refresh_token: "r".into(), + access_token_expires_at: Utc::now(), + refresh_token_expires_at: Utc::now(), + scope: "s".into(), + }; + let store = InMemoryTokenStore::empty(); + assert!(store.load().unwrap().is_none()); + store.save(&token).unwrap(); + assert_eq!(store.load().unwrap().as_ref(), Some(&token)); + store.delete().unwrap(); + assert!(store.load().unwrap().is_none()); + } + + fn make_service(store: Box, token_endpoint: String, code: &'static str) -> AuthSessionService { + AuthBuilder::new( + "client-xyz".into(), + "http://127.0.0.1:8080/".into(), + "data:read".into(), + store, + ) + .with_callback_waiter(Arc::new(move |_, _, _, _| Ok(code.to_string()))) + .with_browser(Arc::new(|_| {})) + .with_endpoints("https://example.invalid/authorize".into(), token_endpoint) + .build() + } + + /// Tiny single-shot HTTP responder for the token endpoint. + fn spawn_token_endpoint(response_body: &'static str) -> String { + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + // Drain request headers (best-effort) + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if line == "\r\n" || line == "\n" { + break; + } + } + let body = response_body.as_bytes(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); + }); + format!("http://127.0.0.1:{port}/") + } + + #[test] + fn login_interactive_saves_token() { + let endpoint = spawn_token_endpoint( + r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600,"refresh_token_expires_in":86400}"#, + ); + let store = Box::new(InMemoryTokenStore::empty()); + let saved_check: Arc>> = Arc::new(Mutex::new(None)); + struct PeekStore { + shadow: Arc>>, + inner: InMemoryTokenStore, + } + impl TokenStore for PeekStore { + fn load(&self) -> Result, RpcError> { self.inner.load() } + fn save(&self, t: &StoredToken) -> Result<(), RpcError> { + *self.shadow.lock().unwrap() = Some(t.clone()); + self.inner.save(t) + } + fn delete(&self) -> Result<(), RpcError> { self.inner.delete() } + } + let _ = store; + let peek = Box::new(PeekStore { shadow: saved_check.clone(), inner: InMemoryTokenStore::empty() }); + + let svc = make_service(peek, endpoint, "AUTH_CODE"); + let token = svc.login_interactive(crate::progress::noop_auth()).unwrap(); + assert_eq!(token.access_token, "AT"); + assert_eq!(token.refresh_token, "RT"); + let stored = saved_check.lock().unwrap().clone().unwrap(); + assert_eq!(stored.access_token, "AT"); + } + + #[test] + fn ensure_access_token_returns_cached() { + let future = Utc::now() + ChronoDuration::hours(1); + let token = StoredToken { + client_id: "client-xyz".into(), + access_token: "CACHED".into(), + refresh_token: "r".into(), + access_token_expires_at: future, + refresh_token_expires_at: future, + scope: "data:read".into(), + }; + let store = Box::new(InMemoryTokenStore::preloaded(token)); + let svc = make_service(store, "http://127.0.0.1:1/never".into(), "x"); + let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap(); + assert_eq!(access, "CACHED"); + } + + #[test] + fn ensure_access_token_refreshes_when_expired_but_refresh_valid() { + let endpoint = spawn_token_endpoint( + r#"{"access_token":"FRESH","refresh_token":"RT2","expires_in":3600,"refresh_token_expires_in":86400}"#, + ); + let past = Utc::now() - ChronoDuration::hours(1); + let future = Utc::now() + ChronoDuration::hours(24); + let token = StoredToken { + client_id: "client-xyz".into(), + access_token: "STALE".into(), + refresh_token: "OLD_RT".into(), + access_token_expires_at: past, + refresh_token_expires_at: future, + scope: "data:read".into(), + }; + let store = Box::new(InMemoryTokenStore::preloaded(token)); + let svc = make_service(store, endpoint, "unused"); + let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap(); + assert_eq!(access, "FRESH"); + } +} diff --git a/src/bonsaiviewer-autodesk/src/cache.rs b/src/bonsaiviewer-autodesk/src/cache.rs new file mode 100644 index 0000000000..1cae588a98 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/cache.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const APP_DIR: &str = "bonsaiviewer-autodesk"; + +/// Test seam: when set, overrides the platform cache_root(). +static OVERRIDE_ROOT: Mutex> = Mutex::new(None); + +#[cfg(test)] +pub(crate) fn set_root_override(path: Option) { + *OVERRIDE_ROOT.lock().unwrap() = path; +} + +pub fn cache_root() -> PathBuf { + if let Some(path) = OVERRIDE_ROOT.lock().unwrap().clone() { + let _ = fs::create_dir_all(&path); + return path; + } + let root = dirs::cache_dir() + .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))) + .join(APP_DIR); + let _ = fs::create_dir_all(&root); + root +} + +fn hash_parts(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + hasher.update([0x1f]); + } + hasher.update(part.as_bytes()); + } + let digest = hasher.finalize(); + let mut out = String::with_capacity(64); + for byte in digest.iter() { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// Stable directory for an .ifcfed; re-downloads overwrite in place so the +/// viewer's open path remains valid across sync operations. +pub fn ifcfed_dir(project_id: &str, item_id: &str) -> PathBuf { + cache_root().join("ifcfeds").join(hash_parts(&[project_id, item_id])) +} + +/// Per-version directory for a model. A new resolved version → a new +/// directory, so sidecars regenerate when the model file changes. +pub fn model_dir(project_id: &str, item_id: &str, version_id: &str) -> PathBuf { + cache_root().join("models").join(hash_parts(&[project_id, item_id, version_id])) +} + +/// Clear the directory so the file we write is the only child. +pub fn prepare_sole_child_dir(dir: &Path) -> std::io::Result { + if dir.exists() { + fs::remove_dir_all(dir)?; + } + fs::create_dir_all(dir)?; + Ok(dir.to_path_buf()) +} + +fn manifest_path_for(ifcfed: &Path) -> PathBuf { + let mut name = ifcfed.file_name().unwrap_or_default().to_os_string(); + name.push(".manifest"); + ifcfed.with_file_name(name) +} + +pub fn write_manifest(ifcfed: &Path, manifest: &T) -> std::io::Result { + let path = manifest_path_for(ifcfed); + let pretty = serde_json::to_string_pretty(manifest) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + fs::write(&path, pretty + "\n")?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + static LOCK: Mutex<()> = Mutex::new(()); + + struct RootGuard; + impl Drop for RootGuard { + fn drop(&mut self) { + set_root_override(None); + } + } + + fn with_tmp() -> (tempfile::TempDir, RootGuard) { + let tmp = tempfile::tempdir().unwrap(); + set_root_override(Some(tmp.path().to_path_buf())); + (tmp, RootGuard) + } + + #[test] + fn ifcfed_dir_is_stable_for_same_inputs() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + let a = ifcfed_dir("p1", "i1"); + let b = ifcfed_dir("p1", "i1"); + assert_eq!(a, b); + let c = ifcfed_dir("p1", "i2"); + assert_ne!(a, c); + } + + #[test] + fn model_dir_changes_with_version() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + let v1 = model_dir("p", "i", "v1"); + let v2 = model_dir("p", "i", "v2"); + assert_ne!(v1, v2); + } + + #[test] + fn prepare_sole_child_dir_clears_existing() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + let dir = cache_root().join("scratch"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("stale.txt"), b"old").unwrap(); + prepare_sole_child_dir(&dir).unwrap(); + assert!(dir.read_dir().unwrap().next().is_none()); + } + + #[test] + fn write_manifest_creates_sidecar() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + let dir = cache_root().join("man"); + fs::create_dir_all(&dir).unwrap(); + let ifcfed = dir.join("model.ifcfed"); + fs::write(&ifcfed, b"data").unwrap(); + let manifest_path = write_manifest(&ifcfed, &json!({"k": "v"})).unwrap(); + assert_eq!(manifest_path.file_name().unwrap(), "model.ifcfed.manifest"); + let read = fs::read_to_string(&manifest_path).unwrap(); + assert!(read.contains("\"k\"")); + } + + #[test] + fn hash_length_is_full_sha256_hex() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + let dir = ifcfed_dir("a", "b"); + let basename = dir.file_name().unwrap().to_string_lossy(); + assert_eq!(basename.len(), 64); + } +} diff --git a/src/bonsaiviewer-autodesk/src/connector.rs b/src/bonsaiviewer-autodesk/src/connector.rs new file mode 100644 index 0000000000..4e9872f1e9 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/connector.rs @@ -0,0 +1,654 @@ +//! Top-level connector: maps the nine JSON-RPC methods onto APS + cache + UI. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::aps::{ApsClient, ItemTip, UploadResult}; +use crate::auth::AuthSessionService; +use crate::cache; +use crate::progress::{download_callback, upload_callback}; +use crate::rpc::{Handler, RpcError}; +use crate::settings as cfg; +use crate::ui::{prompt_for_filename, run_with_progress, BrowseDialog, Mode, SettingsDialog}; + +pub const CONNECTOR_ID: &str = "autodesk"; +pub const DEFAULT_SCOPE: &str = "data:read data:write data:create"; + +/// Manifest written next to a cached .ifcfed. Identifies the cloud item it +/// came from so push_ifcfed can resolve the parent folder later. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub connector: String, + pub hub_id: String, + pub project_id: String, + pub item_id: String, + pub display_name: String, +} + +/// Per-model source pointer, sent to and received from the host. Tells +/// pull_models/push_model which cloud item a local copy maps to. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Source { + pub connector: String, + pub hub_id: String, + pub project_id: String, + pub item_id: String, +} + +#[derive(Debug, Deserialize)] +struct PullIfcfedParams { + hub_id: String, + project_id: String, + item_id: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Deserialize)] +struct PullModelEntry { + source: Source, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Deserialize)] +struct PushIfcfedParams { + path: PathBuf, + manifest: Manifest, +} + +#[derive(Debug, Deserialize)] +struct PushModelParams { + path: PathBuf, + source: Source, +} + +#[derive(Debug, Deserialize)] +struct PushInteractiveParams { + path: PathBuf, +} + +#[derive(Clone)] +struct ClientPair { + auth: Arc, + aps: Arc, +} + +pub struct AutodeskConnector { + inner: RefCell>, +} + +impl AutodeskConnector { + pub fn new() -> Self { + let me = Self { inner: RefCell::new(None) }; + me.reload_credentials(); + me + } + + pub fn reload_credentials(&self) { + let client_id = cfg::load_client_id(); + if client_id.is_empty() { + *self.inner.borrow_mut() = None; + return; + } + let callback_url = format!("http://localhost:{}/", cfg::stored_callback_port()); + let auth = Arc::new(AuthSessionService::new( + client_id, + callback_url, + DEFAULT_SCOPE.to_string(), + )); + let aps = Arc::new(ApsClient::new(auth.clone())); + *self.inner.borrow_mut() = Some(ClientPair { auth, aps }); + } + + fn require(&self) -> Result { + self.inner.borrow().clone().ok_or_else(|| { + RpcError::internal( + "Autodesk client id is not configured. Open the connector settings to set it.", + ) + }) + } + + pub fn handlers(self: Rc) -> std::collections::HashMap { + use std::collections::HashMap; + let mut map: HashMap = HashMap::new(); + macro_rules! reg { + ($name:literal, $method:ident) => {{ + let me = self.clone(); + map.insert($name.into(), Box::new(move |p| me.$method(p))); + }}; + } + reg!("pull_ifcfed_interactive", pull_ifcfed_interactive); + reg!("pull_ifcfed", pull_ifcfed); + reg!("pull_models", pull_models); + reg!("pull_models_interactive", pull_models_interactive); + reg!("push_ifcfed_interactive", push_ifcfed_interactive); + reg!("push_ifcfed", push_ifcfed); + reg!("push_model_interactive", push_model_interactive); + reg!("push_model", push_model); + reg!("open_settings", open_settings); + map + } + + // ---- open_settings ------------------------------------------------------ + + fn open_settings(&self, _params: Value) -> Result { + // Capture the connector's RefCell so SettingsDialog can ask us to + // rebuild credentials in-place after Save. + let inner_ptr: *const RefCell> = &self.inner; + // SAFETY: SettingsDialog::run blocks the calling thread, and the + // callback fires only on this thread before run returns. The pointer + // outlives the call because `self` is borrowed by the RPC dispatch. + let on_reload: Arc Result<(), RpcError>> = Arc::new(move || { + let cell: &RefCell> = unsafe { &*inner_ptr }; + let client_id = cfg::load_client_id(); + if client_id.is_empty() { + *cell.borrow_mut() = None; + return Ok(()); + } + let callback_url = format!("http://localhost:{}/", cfg::stored_callback_port()); + let auth = Arc::new(AuthSessionService::new( + client_id, + callback_url, + DEFAULT_SCOPE.to_string(), + )); + let aps = Arc::new(ApsClient::new(auth.clone())); + *cell.borrow_mut() = Some(ClientPair { auth, aps }); + Ok(()) + }); + SettingsDialog::new(on_reload).run(); + Ok(json!({})) + } + + // ---- pull_ifcfed_interactive ------------------------------------------- + + fn pull_ifcfed_interactive(&self, _params: Value) -> Result { + let pair = self.require()?; + let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Ifcfed).run()?; + let entry = chosen + .entries + .first() + .cloned() + .ok_or_else(|| RpcError::internal("No entry selected."))?; + let hub_id = chosen.hub.id.clone(); + let project_id = chosen.project.id.clone(); + let item_id = entry.id.clone(); + let display_name = entry.display_name.clone(); + let aps = pair.aps.clone(); + let path = run_with_progress("Downloading project", move |report| { + download_ifcfed(&aps, &hub_id, &project_id, &item_id, &display_name, Some(download_callback(report, 0, 0))) + })?; + Ok(json!({"path": path.to_string_lossy()})) + } + + // ---- pull_ifcfed -------------------------------------------------------- + + fn pull_ifcfed(&self, params: Value) -> Result { + let pair = self.require()?; + let p: PullIfcfedParams = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("pull_ifcfed: {e}")) + })?; + let display = p.display_name.unwrap_or_else(|| p.item_id.clone()); + let aps = pair.aps.clone(); + let path = run_with_progress("Downloading project", move |report| { + download_ifcfed(&aps, &p.hub_id, &p.project_id, &p.item_id, &display, Some(download_callback(report, 0, 0))) + })?; + Ok(json!({"path": path.to_string_lossy()})) + } + + // ---- pull_models -------------------------------------------------------- + + fn pull_models(&self, params: Value) -> Result { + let pair = self.require()?; + let entries: Vec = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("pull_models: {e}")) + })?; + let total = entries.len(); + let aps = pair.aps.clone(); + let results = run_with_progress("Downloading models", move |report| { + let mut out: Vec = Vec::with_capacity(total); + for (index, entry) in entries.into_iter().enumerate() { + let cb = download_callback(report.clone(), index + 1, total); + match resolve_scripted_model(&aps, &entry, Some(cb)) { + Ok(Some(v)) => out.push(v), + Ok(None) => out.push(Value::Null), + Err(e) => { + eprintln!("pull_models[{index}] skipped: {}", e.message); + out.push(Value::Null); + } + } + } + Ok::<_, RpcError>(out) + })?; + Ok(Value::Array(results)) + } + + // ---- pull_models_interactive -------------------------------------------- + + fn pull_models_interactive(&self, _params: Value) -> Result { + let pair = self.require()?; + let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Model).run()?; + let total = chosen.entries.len(); + let hub = chosen.hub.clone(); + let project = chosen.project.clone(); + let aps = pair.aps.clone(); + let results = run_with_progress("Downloading models", move |report| { + let mut out: Vec = Vec::new(); + for (index, entry) in chosen.entries.into_iter().enumerate() { + let cb = download_callback(report.clone(), index + 1, total); + match download_picked_model(&aps, &hub.id, &project.id, &entry.id, &entry.display_name, Some(cb)) { + Ok(Some(v)) => out.push(v), + Ok(None) => {} + Err(e) => eprintln!("pull_models_interactive[{index}] skipped: {}", e.message), + } + } + Ok::<_, RpcError>(out) + })?; + Ok(Value::Array(results)) + } + + // ---- push_ifcfed_interactive -------------------------------------------- + + fn push_ifcfed_interactive(&self, params: Value) -> Result { + let pair = self.require()?; + let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("push_ifcfed_interactive: {e}")) + })?; + check_local_exists(&p.path)?; + let default_name = file_name_of(&p.path); + if !default_name.to_lowercase().ends_with(".ifcfed") { + return Err(RpcError::invalid_params("push_ifcfed_interactive expects an .ifcfed file.")); + } + + let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?; + let folder = chosen + .entries + .first() + .cloned() + .ok_or_else(|| RpcError::internal("No destination folder selected."))?; + + let raw = prompt_for_filename("Save Project", "Save .ifcfed as:", &default_name) + .ok_or_else(|| RpcError::internal("User cancelled save to cloud."))?; + let file_name = if raw.to_lowercase().ends_with(".ifcfed") { raw } else { format!("{raw}.ifcfed") }; + + let project_id = chosen.project.id.clone(); + let folder_id = folder.id.clone(); + let aps = pair.aps.clone(); + let local_path = p.path.clone(); + let upload_name = file_name.clone(); + let uploaded = run_with_progress("Uploading project", move |report| { + aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) + })?; + + let cached_path = cache_ifcfed_locally(&chosen.project.id, &uploaded.item_id, &file_name, &p.path)?; + cache::write_manifest(&cached_path, &Manifest { + connector: CONNECTOR_ID.into(), + hub_id: chosen.hub.id, + project_id: chosen.project.id, + item_id: uploaded.item_id, + display_name: file_name, + }).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; + Ok(json!({"path": cached_path.to_string_lossy()})) + } + + // ---- push_ifcfed -------------------------------------------------------- + + fn push_ifcfed(&self, params: Value) -> Result { + let pair = self.require()?; + let p: PushIfcfedParams = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("push_ifcfed: {e}")) + })?; + check_local_exists(&p.path)?; + if !file_name_of(&p.path).to_lowercase().ends_with(".ifcfed") { + return Err(RpcError::invalid_params("push_ifcfed expects an .ifcfed file.")); + } + if p.manifest.connector != CONNECTOR_ID { + return Err(RpcError::invalid_params(format!("Manifest connector is not '{CONNECTOR_ID}'."))); + } + + let item = pair.aps.get_item(&p.manifest.project_id, &p.manifest.item_id)?; + ensure_visible(&item)?; + let folder_id = item + .parent_folder_id + .clone() + .ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.manifest.item_id)))?; + let file_name = if p.manifest.display_name.is_empty() { + item.display_name.clone() + } else { + p.manifest.display_name.clone() + }; + + let aps = pair.aps.clone(); + let local_path = p.path.clone(); + let project_id = p.manifest.project_id.clone(); + let upload_name = file_name.clone(); + let uploaded = run_with_progress("Uploading project", move |report| { + aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) + })?; + + let cached_path = cache_ifcfed_locally(&p.manifest.project_id, &uploaded.item_id, &file_name, &p.path)?; + cache::write_manifest(&cached_path, &Manifest { + connector: CONNECTOR_ID.into(), + hub_id: p.manifest.hub_id, + project_id: p.manifest.project_id, + item_id: uploaded.item_id, + display_name: file_name, + }).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; + Ok(json!({"path": cached_path.to_string_lossy()})) + } + + // ---- push_model_interactive --------------------------------------------- + + fn push_model_interactive(&self, params: Value) -> Result { + let pair = self.require()?; + let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("push_model_interactive: {e}")) + })?; + check_local_exists(&p.path)?; + + let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?; + let folder = chosen + .entries + .first() + .cloned() + .ok_or_else(|| RpcError::internal("No destination folder selected."))?; + + let default_name = file_name_of(&p.path); + let file_name = prompt_for_filename("Save Model", "Save model as:", &default_name) + .ok_or_else(|| RpcError::internal("User cancelled save to cloud."))?; + + let project_id = chosen.project.id.clone(); + let folder_id = folder.id.clone(); + let aps = pair.aps.clone(); + let local_path = p.path.clone(); + let upload_name = file_name.clone(); + let uploaded = run_with_progress("Uploading model", move |report| { + aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) + })?; + + let cached_path = cache_model_locally(&chosen.project.id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?; + Ok(json!({ + "display_name": file_name, + "path": cached_path.to_string_lossy(), + "source": Source { + connector: CONNECTOR_ID.into(), + hub_id: chosen.hub.id, + project_id: chosen.project.id, + item_id: uploaded.item_id.clone(), + }, + "metadata": build_metadata_from_upload(&uploaded), + })) + } + + // ---- push_model --------------------------------------------------------- + + fn push_model(&self, params: Value) -> Result { + let pair = self.require()?; + let p: PushModelParams = serde_json::from_value(params).map_err(|e| { + RpcError::invalid_params(format!("push_model: {e}")) + })?; + check_local_exists(&p.path)?; + if p.source.connector != CONNECTOR_ID { + return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'."))); + } + + let item = pair.aps.get_item(&p.source.project_id, &p.source.item_id)?; + ensure_visible(&item)?; + let folder_id = item + .parent_folder_id + .clone() + .ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.source.item_id)))?; + let file_name = if item.display_name.is_empty() { file_name_of(&p.path) } else { item.display_name.clone() }; + + let aps = pair.aps.clone(); + let local_path = p.path.clone(); + let project_id = p.source.project_id.clone(); + let upload_name = file_name.clone(); + let uploaded = run_with_progress("Uploading model", move |report| { + aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) + })?; + + let _cached_path = cache_model_locally(&p.source.project_id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?; + Ok(json!({ + "source": Source { + connector: CONNECTOR_ID.into(), + hub_id: p.source.hub_id, + project_id: p.source.project_id, + item_id: uploaded.item_id.clone(), + }, + "metadata": build_metadata_from_upload(&uploaded), + })) + } +} + +// ---- shared helpers -------------------------------------------------------- + +fn download_ifcfed( + aps: &ApsClient, + hub_id: &str, + project_id: &str, + item_id: &str, + display_name_hint: &str, + progress: Option, +) -> Result { + let item = aps.get_item(project_id, item_id)?; + ensure_visible(&item)?; + let storage_id = item + .storage_id + .clone() + .ok_or_else(|| RpcError::internal(format!("Autodesk item '{item_id}' has no downloadable storage.")))?; + let file_name = if !item.display_name.is_empty() { + item.display_name.clone() + } else if !display_name_hint.is_empty() { + display_name_hint.to_string() + } else { + item_id.to_string() + }; + if !file_name.to_lowercase().ends_with(".ifcfed") { + return Err(RpcError::internal(format!("Item '{file_name}' is not an .ifcfed file."))); + } + let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id)) + .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; + let ifcfed_path = directory.join(&file_name); + aps.download_storage_to_file(&storage_id, &ifcfed_path, progress)?; + cache::write_manifest(&ifcfed_path, &Manifest { + connector: CONNECTOR_ID.into(), + hub_id: hub_id.into(), + project_id: project_id.into(), + item_id: item_id.into(), + display_name: file_name, + }).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; + Ok(ifcfed_path) +} + +fn resolve_scripted_model( + aps: &ApsClient, + entry: &PullModelEntry, + progress: Option, +) -> Result, RpcError> { + if entry.source.connector != CONNECTOR_ID { + return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'."))); + } + let display_hint = entry + .display_name + .clone() + .unwrap_or_else(|| entry.source.item_id.clone()); + + let item = aps.get_item(&entry.source.project_id, &entry.source.item_id)?; + if item.hidden { + eprintln!("Autodesk item '{}' is hidden/deleted; returning null.", entry.source.item_id); + return Ok(None); + } + let storage_id = item + .storage_id + .clone() + .ok_or_else(|| RpcError::internal(format!("Autodesk item '{}' has no downloadable storage.", entry.source.item_id)))?; + let file_name = if item.display_name.is_empty() { display_hint } else { item.display_name.clone() }; + let version_id = item.version_id.clone().unwrap_or_default(); + + let directory = cache::model_dir(&entry.source.project_id, &entry.source.item_id, &version_id); + let model_path = directory.join(&file_name); + if !model_path.exists() { + cache::prepare_sole_child_dir(&directory) + .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; + aps.download_storage_to_file(&storage_id, &model_path, progress)?; + } + Ok(Some(json!({ + "path": model_path.to_string_lossy(), + "metadata": build_metadata_from_item(&item), + }))) +} + +fn download_picked_model( + aps: &ApsClient, + hub_id: &str, + project_id: &str, + entry_id: &str, + entry_display: &str, + progress: Option, +) -> Result, RpcError> { + let item = aps.get_item(project_id, entry_id)?; + ensure_visible(&item)?; + let storage_id = item + .storage_id + .clone() + .ok_or_else(|| RpcError::internal(format!("Autodesk item '{entry_id}' has no downloadable storage.")))?; + let file_name = if !item.display_name.is_empty() { + item.display_name.clone() + } else if !entry_display.is_empty() { + entry_display.to_string() + } else { + entry_id.to_string() + }; + let version_id = item.version_id.clone().unwrap_or_default(); + + let directory = cache::model_dir(project_id, entry_id, &version_id); + let model_path = directory.join(&file_name); + if !model_path.exists() { + cache::prepare_sole_child_dir(&directory) + .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; + aps.download_storage_to_file(&storage_id, &model_path, progress)?; + } + + Ok(Some(json!({ + "display_name": file_name, + "source": Source { + connector: CONNECTOR_ID.into(), + hub_id: hub_id.into(), + project_id: project_id.into(), + item_id: entry_id.into(), + }, + "path": model_path.to_string_lossy(), + "metadata": build_metadata_from_item(&item), + }))) +} + +fn cache_ifcfed_locally(project_id: &str, item_id: &str, file_name: &str, src: &Path) -> Result { + let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id)) + .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; + let cached_path = directory.join(file_name); + std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?; + Ok(cached_path) +} + +fn cache_model_locally(project_id: &str, item_id: &str, version_id: &str, file_name: &str, src: &Path) -> Result { + let directory = cache::model_dir(project_id, item_id, version_id); + cache::prepare_sole_child_dir(&directory) + .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; + let cached_path = directory.join(file_name); + std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?; + Ok(cached_path) +} + +fn build_metadata_from_item(item: &ItemTip) -> Value { + let mut metadata = serde_json::Map::new(); + if let Some(v) = &item.version_number { + if !v.is_null() { + metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v)))); + } + } + if let Some(s) = &item.last_modified_time_utc { + if !s.is_empty() { + metadata.insert("date".into(), json!(s)); + } + } + if let Some(s) = &item.last_modified_user_name { + if !s.is_empty() { + metadata.insert("author".into(), json!(s)); + } + } + Value::Object(metadata) +} + +fn build_metadata_from_upload(uploaded: &UploadResult) -> Value { + let mut metadata = serde_json::Map::new(); + if let Some(v) = &uploaded.version_number { + if !v.is_null() { + metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v)))); + } + } + if let Some(s) = &uploaded.last_modified_time_utc { + if !s.is_empty() { + metadata.insert("date".into(), json!(s)); + } + } + if let Some(s) = &uploaded.last_modified_user_name { + if !s.is_empty() { + metadata.insert("author".into(), json!(s)); + } + } + Value::Object(metadata) +} + +fn json_value_to_display(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + } +} + +fn ensure_visible(item: &ItemTip) -> Result<(), RpcError> { + if item.hidden { + return Err(RpcError::internal(format!("Autodesk item '{}' has been deleted.", item.id))); + } + Ok(()) +} + +fn check_local_exists(path: &Path) -> Result<(), RpcError> { + if !path.exists() { + return Err(RpcError::invalid_params(format!("Local file '{}' does not exist.", path.display()))); + } + Ok(()) +} + +fn file_name_of(path: &Path) -> String { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The host attaches a top-level "id" we don't consume; serde should + /// ignore unknown fields rather than fail the whole batch. + #[test] + fn pull_model_entry_ignores_extra_fields() { + let json = serde_json::json!({ + "id": "abc", + "display_name": "bar.ifc", + "source": {"connector": "autodesk", "hub_id": "h", "project_id": "p", "item_id": "i"} + }); + let entry: PullModelEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.display_name.as_deref(), Some("bar.ifc")); + } +} diff --git a/src/bonsaiviewer-autodesk/src/lib.rs b/src/bonsaiviewer-autodesk/src/lib.rs new file mode 100644 index 0000000000..c3cadb3e59 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/lib.rs @@ -0,0 +1,8 @@ +pub mod aps; +pub mod auth; +pub mod cache; +pub mod connector; +pub mod progress; +pub mod rpc; +pub mod settings; +pub mod ui; diff --git a/src/bonsaiviewer-autodesk/src/main.rs b/src/bonsaiviewer-autodesk/src/main.rs new file mode 100644 index 0000000000..efd341199a --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/main.rs @@ -0,0 +1,17 @@ +use std::rc::Rc; + +use bonsaiviewer_autodesk::connector::AutodeskConnector; +use bonsaiviewer_autodesk::rpc::JsonRpcHost; +use bonsaiviewer_autodesk::ui; + +fn main() { + // FLTK must be initialised on the main thread before any widget is + // touched. We do it eagerly here so RPC handlers can open dialogs + // without a cold-start race. + let _app = ui::ensure_app(); + + let connector = Rc::new(AutodeskConnector::new()); + let handlers = connector.handlers(); + let mut host = JsonRpcHost::new(handlers, std::io::stdin().lock(), std::io::stdout().lock()); + std::process::exit(host.run()); +} diff --git a/src/bonsaiviewer-autodesk/src/progress.rs b/src/bonsaiviewer-autodesk/src/progress.rs new file mode 100644 index 0000000000..b708e45729 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/progress.rs @@ -0,0 +1,129 @@ +use std::sync::{Arc, Mutex}; + +/// Sink for progress reports flowing up to the UI. `(phase, message, percent, detail)`. +pub type Report = Arc, Option<&str>) + Send + Sync>; + +/// Auth-layer progress (no byte counts): `(phase, message, percent)`. +pub type AuthProgress = Arc) + Send + Sync>; + +/// APS transfer progress: `(filename, percent, bytes_done, bytes_total)`. +pub type ApsProgress = Arc, Option, Option) + Send + Sync>; + +pub fn noop_auth() -> AuthProgress { + Arc::new(|_, _, _| {}) +} + +pub fn noop_report() -> Report { + Arc::new(|_, _, _, _| {}) +} + +/// Render a byte count as a short human-readable string (e.g. "3.4 MB"). +pub fn format_bytes(value: u64) -> String { + if value < 1024 { + return format!("{value} B"); + } + let mut scaled = value as f64; + for unit in ["KB", "MB", "GB", "TB"] { + scaled /= 1024.0; + if scaled < 1024.0 || unit == "TB" { + return format!("{scaled:.1} {unit}"); + } + } + format!("{value} B") +} + +pub fn progress_detail(percent: Option, done: Option, total: Option) -> String { + let mut parts: Vec = Vec::new(); + if let Some(p) = percent { + parts.push(format!("{p}%")); + } + match (done, total) { + (Some(d), Some(t)) if t > 0 => parts.push(format!("{} / {}", format_bytes(d), format_bytes(t))), + (Some(d), _) => parts.push(format_bytes(d)), + _ => {} + } + parts.join(", ") +} + +/// Wrap a `Report` sink as a download-side ApsProgress, optionally annotating +/// "(i/N)" when batching. Pass `total = 0` for single-file transfers. +pub fn download_callback(report: Report, index: usize, total: usize) -> ApsProgress { + Arc::new(move |name, percent, done, total_bytes| { + let suffix = if total != 0 { format!(" ({index}/{total})") } else { String::new() }; + let detail = progress_detail(percent, done, total_bytes); + let msg = format!("Downloading {name}{suffix}"); + let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) }; + report("download", &msg, percent, detail_ref); + }) +} + +pub fn upload_callback(report: Report) -> ApsProgress { + Arc::new(move |name, percent, done, total_bytes| { + let detail = progress_detail(percent, done, total_bytes); + let msg = format!("Uploading {name}"); + let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) }; + report("upload", &msg, percent, detail_ref); + }) +} + +/// Adapt an auth-flow sink (no byte counts) onto a `Report` sink. +pub fn auth_to_report(report: Report) -> AuthProgress { + Arc::new(move |phase, message, percent| { + report(phase, message, percent, None); + }) +} + +/// Latest-wins hand-off from worker thread → UI thread. Intermediate updates +/// are coalesced: only the freshest matters for a progress bar. +pub struct ProgressBridge { + pending: Mutex, Option)>>, +} + +impl ProgressBridge { + pub fn new() -> Arc { + Arc::new(Self { pending: Mutex::new(None) }) + } + + pub fn report_fn(self: Arc) -> Report { + Arc::new(move |phase, message, percent, detail| { + let mut slot = self.pending.lock().unwrap(); + *slot = Some((phase.to_string(), message.to_string(), percent, detail.map(str::to_string))); + }) + } + + pub fn take(&self) -> Option<(String, String, Option, Option)> { + self.pending.lock().unwrap().take() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_bytes_units() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1024), "1.0 KB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); + } + + #[test] + fn progress_detail_combines_parts() { + assert_eq!(progress_detail(Some(50), Some(512), Some(1024)), "50%, 512 B / 1.0 KB"); + assert_eq!(progress_detail(None, Some(512), None), "512 B"); + assert_eq!(progress_detail(None, None, None), ""); + } + + #[test] + fn bridge_coalesces_latest() { + let bridge = ProgressBridge::new(); + let report = bridge.clone().report_fn(); + report("a", "first", None, None); + report("a", "second", Some(10), Some("d")); + let taken = bridge.take().unwrap(); + assert_eq!(taken.1, "second"); + assert_eq!(taken.2, Some(10)); + assert!(bridge.take().is_none()); + } +} diff --git a/src/bonsaiviewer-autodesk/src/rpc.rs b/src/bonsaiviewer-autodesk/src/rpc.rs new file mode 100644 index 0000000000..1561fc47e0 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/rpc.rs @@ -0,0 +1,233 @@ +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; + +use serde_json::{json, Value}; + +pub const JSONRPC_PARSE_ERROR: i32 = -32700; +pub const JSONRPC_INVALID_REQUEST: i32 = -32600; +pub const JSONRPC_METHOD_NOT_FOUND: i32 = -32601; +pub const JSONRPC_INVALID_PARAMS: i32 = -32602; +pub const JSONRPC_INTERNAL_ERROR: i32 = -32603; + +#[derive(Debug, Clone)] +pub struct RpcError { + pub code: i32, + pub message: String, + pub data: Option, +} + +impl RpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { code, message: message.into(), data: None } + } + + pub fn internal(message: impl Into) -> Self { + Self::new(JSONRPC_INTERNAL_ERROR, message) + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new(JSONRPC_INVALID_PARAMS, message) + } +} + +impl std::fmt::Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RpcError {} + +pub type Handler = Box Result>; + +pub struct JsonRpcHost { + handlers: HashMap, + stdin: BufReader, + stdout: W, +} + +impl JsonRpcHost { + pub fn new(handlers: HashMap, stdin: R, stdout: W) -> Self { + Self { handlers, stdin: BufReader::new(stdin), stdout } + } + + pub fn run(&mut self) -> i32 { + let mut line = String::new(); + loop { + line.clear(); + match self.stdin.read_line(&mut line) { + Ok(0) => return 0, + Ok(_) => {} + Err(_) => return 0, + } + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + continue; + } + self.handle_line(&trimmed); + } + } + + fn handle_line(&mut self, line: &str) { + let message: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + self.respond_error(Value::Null, JSONRPC_PARSE_ERROR, &format!("Parse error: {e}"), None); + return; + } + }; + + let obj = match message.as_object() { + Some(o) => o, + None => { + self.respond_error(Value::Null, JSONRPC_INVALID_REQUEST, "Request must be a JSON object", None); + return; + } + }; + + let message_id = obj.get("id").cloned().unwrap_or(Value::Null); + + if obj.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") { + self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version", None); + return; + } + + let method = match obj.get("method").and_then(|v| v.as_str()) { + Some(m) => m.to_string(), + None => { + self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string", None); + return; + } + }; + + let params = obj.get("params").cloned().unwrap_or(Value::Null); + if !params.is_null() && !params.is_object() && !params.is_array() { + self.respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array", None); + return; + } + + let outcome = match self.handlers.get(&method) { + Some(handler) => handler(params), + None => { + self.respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, &format!("Unknown method '{method}'"), None); + return; + } + }; + + match outcome { + Ok(result) => { + if !message_id.is_null() { + self.respond_result(message_id, result); + } + } + Err(err) => { + eprintln!("Handler '{method}' raised: {}", err.message); + self.respond_error(message_id, err.code, &err.message, err.data); + } + } + } + + fn respond_result(&mut self, id: Value, result: Value) { + let payload = json!({"jsonrpc": "2.0", "id": id, "result": result}); + self.write(&payload); + } + + fn respond_error(&mut self, id: Value, code: i32, message: &str, data: Option) { + let mut error = json!({"code": code, "message": message}); + if let Some(d) = data { + error.as_object_mut().unwrap().insert("data".into(), d); + } + let payload = json!({"jsonrpc": "2.0", "id": id, "error": error}); + self.write(&payload); + } + + fn write(&mut self, payload: &Value) { + let line = serde_json::to_string(payload).unwrap_or_else(|_| "{}".into()); + let _ = self.stdout.write_all(line.as_bytes()); + let _ = self.stdout.write_all(b"\n"); + let _ = self.stdout.flush(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_once(handlers: HashMap, input: &str) -> Vec { + let mut buf: Vec = Vec::new(); + { + let mut host = JsonRpcHost::new(handlers, input.as_bytes(), &mut buf); + host.run(); + } + let text = String::from_utf8(buf).unwrap(); + text.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).unwrap()) + .collect() + } + + fn echo_handlers() -> HashMap { + let mut m: HashMap = HashMap::new(); + m.insert("echo".into(), Box::new(|p| Ok(p))); + m.insert("boom".into(), Box::new(|_| Err(RpcError::internal("bang")))); + m.insert("bad_params".into(), Box::new(|_| Err(RpcError::invalid_params("nope")))); + m + } + + #[test] + fn dispatches_result() { + let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"x\":1}}\n"); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["result"], json!({"x": 1})); + assert_eq!(out[0]["id"], json!(1)); + } + + #[test] + fn notification_no_response() { + let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{}}\n"); + assert!(out.is_empty()); + } + + #[test] + fn unknown_method() { + let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"nope\"}\n"); + assert_eq!(out[0]["error"]["code"], json!(JSONRPC_METHOD_NOT_FOUND)); + } + + #[test] + fn parse_error_yields_null_id() { + let out = run_once(echo_handlers(), "not json\n"); + assert_eq!(out[0]["error"]["code"], json!(JSONRPC_PARSE_ERROR)); + assert_eq!(out[0]["id"], json!(null)); + } + + #[test] + fn missing_jsonrpc_version() { + let out = run_once(echo_handlers(), "{\"id\":1,\"method\":\"echo\"}\n"); + assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_REQUEST)); + } + + #[test] + fn invalid_params_shape() { + let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":42}\n"); + assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS)); + } + + #[test] + fn handler_error_passes_code() { + let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"bad_params\"}\n"); + assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS)); + assert_eq!(out[0]["error"]["message"], json!("nope")); + } + + #[test] + fn multiple_lines_processed() { + let out = run_once( + echo_handlers(), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{}}\n\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"echo\",\"params\":[]}\n", + ); + assert_eq!(out.len(), 2); + assert_eq!(out[0]["id"], json!(1)); + assert_eq!(out[1]["id"], json!(2)); + } +} diff --git a/src/bonsaiviewer-autodesk/src/settings.rs b/src/bonsaiviewer-autodesk/src/settings.rs new file mode 100644 index 0000000000..6996390f32 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/settings.rs @@ -0,0 +1,136 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_CALLBACK_PORT: u16 = 8080; +const APP_DIR: &str = "bonsaiviewer-autodesk"; + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +struct Stored { + #[serde(default, skip_serializing_if = "String::is_empty")] + client_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + callback_port: Option, +} + +/// Test seam: when set, overrides the platform config_root(). +static OVERRIDE_ROOT: Mutex> = Mutex::new(None); + +#[cfg(test)] +pub(crate) fn set_root_override(path: Option) { + *OVERRIDE_ROOT.lock().unwrap() = path; +} + +pub fn config_root() -> PathBuf { + if let Some(path) = OVERRIDE_ROOT.lock().unwrap().clone() { + let _ = fs::create_dir_all(&path); + return path; + } + let root = dirs::config_dir() + .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))) + .join(APP_DIR); + let _ = fs::create_dir_all(&root); + root +} + +fn settings_path() -> PathBuf { + config_root().join("settings.json") +} + +fn read() -> Stored { + let path = settings_path(); + let Ok(text) = fs::read_to_string(&path) else { + return Stored::default(); + }; + serde_json::from_str(&text).unwrap_or_default() +} + +fn write(data: &Stored) { + let pretty = serde_json::to_string_pretty(data).unwrap_or_else(|_| "{}".into()); + let _ = fs::write(settings_path(), pretty + "\n"); +} + +pub fn load_client_id() -> String { + read().client_id.trim().to_string() +} + +pub fn save_client_id(client_id: &str) { + let mut data = read(); + data.client_id = client_id.trim().to_string(); + write(&data); +} + +pub fn stored_callback_port() -> u16 { + match read().callback_port { + Some(p) if (1..=u16::MAX).contains(&p) => p, + _ => DEFAULT_CALLBACK_PORT, + } +} + +pub fn save_callback_port(port: u16) -> Result<(), String> { + if port == 0 { + return Err("Callback port must be between 1 and 65535.".into()); + } + let mut data = read(); + data.callback_port = Some(port); + write(&data); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct RootGuard; + impl Drop for RootGuard { + fn drop(&mut self) { + set_root_override(None); + } + } + + fn with_tmp() -> (tempfile::TempDir, RootGuard) { + let tmp = tempfile::tempdir().unwrap(); + set_root_override(Some(tmp.path().to_path_buf())); + (tmp, RootGuard) + } + + // Settings are global; run inside a process-wide lock so parallel tests + // do not stomp on each other's tmpdir override. + static LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn client_id_round_trip() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + assert_eq!(load_client_id(), ""); + save_client_id(" abc123 "); + assert_eq!(load_client_id(), "abc123"); + } + + #[test] + fn callback_port_round_trip_and_default() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + assert_eq!(stored_callback_port(), DEFAULT_CALLBACK_PORT); + save_callback_port(9090).unwrap(); + assert_eq!(stored_callback_port(), 9090); + } + + #[test] + fn corrupt_settings_yields_defaults() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + fs::write(settings_path(), "{not json").unwrap(); + assert_eq!(load_client_id(), ""); + assert_eq!(stored_callback_port(), DEFAULT_CALLBACK_PORT); + } + + #[test] + fn rejects_port_zero() { + let _g = LOCK.lock().unwrap(); + let (_tmp, _guard) = with_tmp(); + assert!(save_callback_port(0).is_err()); + } +} diff --git a/src/bonsaiviewer-autodesk/src/ui/browse.rs b/src/bonsaiviewer-autodesk/src/ui/browse.rs new file mode 100644 index 0000000000..7c0d52beef --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/ui/browse.rs @@ -0,0 +1,646 @@ +//! BrowseDialog: hub combo + projects list + lazy-loaded folder tree. +//! +//! UI events (button clicks, selections, expansion) use direct FLTK +//! callbacks that hide/show the window or mutate `Rc>` and +//! optionally spawn a worker thread. Worker results come back via one +//! FLTK channel, drained at the top of the event loop. There is no +//! intermediate message bus for plain widget events — clicking Close +//! literally just calls `win.hide()`. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::Arc; +use std::thread; + +use fltk::{ + app, + browser::HoldBrowser, + button::Button, + enums::{Align, Event, Key}, + frame::Frame, + group::Flex, + menu::Choice, + prelude::*, + tree::{Tree, TreeReason, TreeSelect}, + window::Window, +}; + +use crate::aps::{ApsClient, Entry, EntryType, Hub, Project}; +use crate::auth::AuthSessionService; +use crate::progress::noop_auth; +use crate::rpc::RpcError; +use crate::ui::dialogs::{center_on_screen, drain_after_close, show_error}; +use crate::ui::{ensure_app, MODEL_EXTENSIONS}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Ifcfed, + Model, + Destination, +} + +impl Mode { + fn title_and_action(self) -> (&'static str, &'static str) { + match self { + Mode::Ifcfed => ("Open Project From Autodesk", "Open"), + Mode::Model => ("Add Model From Autodesk", "Add"), + Mode::Destination => ("Choose Autodesk Destination", "Select"), + } + } +} + +#[derive(Clone, Debug)] +pub struct BrowseChoice { + pub hub: Hub, + pub project: Project, + pub entries: Vec, +} + +const PLACEHOLDER_LABEL: &str = "Loading…"; + +/// Worker-thread completions. Pure UI events do not go through this — they +/// use direct callbacks. This enum exists only because worker threads +/// cannot touch FLTK widgets. +#[derive(Clone, Debug)] +enum WorkerMsg { + HubsLoaded(Result, RpcError>), + ProjectsLoaded(Result, RpcError>), + TopFoldersLoaded(Result, RpcError>), + FolderContentsLoaded(String, Result, RpcError>), +} + +struct State { + hubs: Vec, + selected_hub: Option, + projects: Vec, + selected_project: Option, + tree_entries: HashMap, + loaded_folders: HashSet, + selected_entries: Vec, + result: Option, +} + +pub struct BrowseDialog { + auth: Arc, + aps: Arc, + mode: Mode, +} + +impl BrowseDialog { + pub fn new(auth: Arc, aps: Arc, mode: Mode) -> Self { + Self { auth, aps, mode } + } + + pub fn run(self) -> Result { + ensure_app(); + let (title, action_label) = self.mode.title_and_action(); + let mode = self.mode; + let multi_select = mode == Mode::Model; + + // ---- Build widgets ---------------------------------------------- + + let mut win = Window::default().with_size(920, 620).with_label(title); + win.make_modal(true); + + let mut root = Flex::default_fill().column(); + root.set_margins(16, 16, 16, 16); + root.set_spacing(12); + + let mut top = Flex::default().row(); + top.set_spacing(8); + let mut sign_in = Button::default().with_label("Sign In"); + top.fixed(&sign_in, 100); + let mut hub_combo = Choice::default(); + hub_combo.add_choice("Select hub"); + hub_combo.set_value(0); + top.end(); + root.fixed(&top, 32); + + let mut split = Flex::default().row(); + split.set_spacing(8); + + let mut projects_col = Flex::default().column(); + projects_col.set_spacing(4); + let projects_browser = HoldBrowser::default(); + projects_col.end(); + split.fixed(&projects_col, 280); + + let mut folders_col = Flex::default().column(); + folders_col.set_spacing(4); + let mut tree = Tree::default(); + tree.set_show_root(false); + tree.set_root_label(""); + tree.set_select_mode(if multi_select { + TreeSelect::Multi + } else { + TreeSelect::Single + }); + folders_col.end(); + + split.end(); + + let status = Frame::default().with_label("Sign in to browse Autodesk projects."); + let mut status_mut = status.clone(); + status_mut.set_align(Align::Left | Align::Inside); + root.fixed(&status, 22); + + let mut actions = Flex::default().row(); + actions.set_spacing(8); + Frame::default(); + let mut cancel_btn = Button::default().with_label("Cancel"); + actions.fixed(&cancel_btn, 100); + let mut action_btn = Button::default().with_label(action_label); + action_btn.deactivate(); + actions.fixed(&action_btn, 100); + actions.end(); + root.fixed(&actions, 32); + + root.end(); + win.end(); + win.make_resizable(true); + center_on_screen(&mut win); + + // ---- Shared state + worker-result channel ----------------------- + + let state: Rc> = Rc::new(RefCell::new(State { + hubs: Vec::new(), + selected_hub: None, + projects: Vec::new(), + selected_project: None, + tree_entries: HashMap::new(), + loaded_folders: HashSet::new(), + selected_entries: Vec::new(), + result: None, + })); + let (worker_tx, worker_rx) = app::channel::(); + let aps = self.aps; + let auth = self.auth; + + // ---- Direct UI callbacks ---------------------------------------- + + cancel_btn.set_callback({ + let mut win = win.clone(); + move |_| win.hide() + }); + + win.handle({ + let mut win = win.clone(); + move |_, ev| match ev { + Event::KeyDown if app::event_key() == Key::Escape => { + win.hide(); + true + } + _ => false, + } + }); + + sign_in.set_callback({ + let aps = aps.clone(); + let auth = auth.clone(); + let worker_tx = worker_tx; + let mut status = status_mut.clone(); + move |_| { + status.set_label("Signing in to Autodesk…"); + let aps = aps.clone(); + let auth = auth.clone(); + thread::spawn(move || { + let outcome = auth + .login_interactive(noop_auth()) + .and_then(|_| aps.list_hubs()); + worker_tx.send(WorkerMsg::HubsLoaded(outcome)); + }); + } + }); + + hub_combo.set_callback({ + let aps = aps.clone(); + let state = state.clone(); + let worker_tx = worker_tx; + let mut projects_browser = projects_browser.clone(); + let mut tree = tree.clone(); + let mut status = status_mut.clone(); + let action_btn_clone = action_btn.clone(); + move |c| { + let idx = c.value(); + if idx <= 0 { + return; + } + let hub_idx = (idx - 1) as usize; + let mut s = state.borrow_mut(); + if hub_idx >= s.hubs.len() { + return; + } + s.selected_hub = Some(hub_idx); + s.selected_project = None; + s.selected_entries.clear(); + s.projects.clear(); + s.tree_entries.clear(); + s.loaded_folders.clear(); + projects_browser.clear(); + tree.clear(); + tree.redraw(); + refresh_action_button(&mut action_btn_clone.clone(), &s, mode); + let hub_id = s.hubs[hub_idx].id.clone(); + let hub_name = s.hubs[hub_idx].name.clone(); + drop(s); + status.set_label(&format!("Loading projects in {hub_name}…")); + let aps = aps.clone(); + thread::spawn(move || { + worker_tx.send(WorkerMsg::ProjectsLoaded(aps.list_projects(&hub_id))); + }); + } + }); + + projects_browser.clone().set_callback({ + let aps = aps.clone(); + let state = state.clone(); + let worker_tx = worker_tx; + let mut tree = tree.clone(); + let mut status = status_mut.clone(); + let action_btn_clone = action_btn.clone(); + move |b| { + let line = b.value(); + if line <= 0 { + return; + } + let project_idx = (line - 1) as usize; + let mut s = state.borrow_mut(); + if project_idx >= s.projects.len() { + return; + } + s.selected_project = Some(project_idx); + s.selected_entries.clear(); + s.tree_entries.clear(); + s.loaded_folders.clear(); + tree.clear(); + tree.redraw(); + refresh_action_button(&mut action_btn_clone.clone(), &s, mode); + let hub_id = s + .selected_hub + .and_then(|i| s.hubs.get(i).map(|h| h.id.clone())) + .unwrap_or_default(); + let project_id = s.projects[project_idx].id.clone(); + let project_name = s.projects[project_idx].name.clone(); + drop(s); + status.set_label(&format!("Loading top folders in {project_name}…")); + let aps = aps.clone(); + thread::spawn(move || { + worker_tx + .send(WorkerMsg::TopFoldersLoaded(aps.list_top_folders(&hub_id, &project_id))); + }); + } + }); + + tree.clone().set_callback({ + let aps = aps.clone(); + let state = state.clone(); + let worker_tx = worker_tx; + let mut status = status_mut.clone(); + let action_btn_clone = action_btn.clone(); + move |t| match t.callback_reason() { + TreeReason::Selected | TreeReason::Deselected => { + let mut s = state.borrow_mut(); + s.selected_entries = collect_selected_entries(t, &s.tree_entries); + update_status_for_selection(&mut status, &s.selected_entries, mode); + refresh_action_button(&mut action_btn_clone.clone(), &s, mode); + } + TreeReason::Opened => { + let Some(item) = t.callback_item() else { return }; + let Ok(path) = t.item_pathname(&item) else { return }; + let mut s = state.borrow_mut(); + let Some(entry) = s.tree_entries.get(&path).cloned() else { return }; + if entry.entry_type != EntryType::Folders { + return; + } + if s.loaded_folders.contains(&path) { + return; + } + s.loaded_folders.insert(path.clone()); + // Drop the placeholder so the user doesn't see "Loading…" + // alongside the real children once they arrive. + if let Some(first_child) = item.child(0) { + if first_child.label().as_deref() == Some(PLACEHOLDER_LABEL) + && item.child(1).is_none() + { + let _ = t.remove(&first_child); + } + } + let project_id = s + .selected_project + .and_then(|i| s.projects.get(i).map(|p| p.id.clone())); + drop(s); + let Some(project_id) = project_id else { return }; + let folder_id = entry.id.clone(); + let aps = aps.clone(); + thread::spawn(move || { + let result = list_folder_contents_for_mode(&aps, &project_id, &folder_id, mode); + worker_tx.send(WorkerMsg::FolderContentsLoaded(path, result)); + }); + } + _ => {} + } + }); + + action_btn.set_callback({ + let state = state.clone(); + let mut win = win.clone(); + move |_| { + let mut s = state.borrow_mut(); + let valid: Vec = s + .selected_entries + .iter() + .filter(|e| is_valid_selection(mode, e)) + .cloned() + .collect(); + if valid.is_empty() { + return; + } + let (Some(h), Some(p)) = (s.selected_hub, s.selected_project) else { + return; + }; + s.result = Some(BrowseChoice { + hub: s.hubs[h].clone(), + project: s.projects[p].clone(), + entries: valid, + }); + drop(s); + win.hide(); + } + }); + + // If we already have a token, populate hubs in the background. + if auth.get_token().ok().flatten().is_some() { + let aps = aps.clone(); + thread::spawn(move || { + worker_tx.send(WorkerMsg::HubsLoaded(aps.list_hubs())); + }); + } + + // ---- Event loop: pump app, drain worker results ---------------- + + win.show(); + while win.shown() { + app::wait(); + while let Some(msg) = worker_rx.recv() { + apply_worker_msg( + msg, + &state, + &mut hub_combo, + &mut projects_browser.clone(), + &mut tree, + &mut status_mut, + &mut action_btn, + mode, + ); + } + } + drain_after_close(); + + let result = state.borrow_mut().result.take(); + result.ok_or_else(|| RpcError::internal("User cancelled the Autodesk picker.")) + } +} + +// ---- worker-result application ------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn apply_worker_msg( + msg: WorkerMsg, + state: &Rc>, + hub_combo: &mut Choice, + projects_browser: &mut HoldBrowser, + tree: &mut Tree, + status: &mut Frame, + action_btn: &mut Button, + mode: Mode, +) { + match msg { + WorkerMsg::HubsLoaded(Err(e)) => show_error("Sign In Failed", &e.message), + WorkerMsg::HubsLoaded(Ok(hubs)) => { + let mut s = state.borrow_mut(); + s.hubs = hubs; + refill_hub_combo(hub_combo, &s.hubs); + status.set_label("Signed in. Select a hub."); + } + WorkerMsg::ProjectsLoaded(Err(e)) => show_error("Load Projects Failed", &e.message), + WorkerMsg::ProjectsLoaded(Ok(projects)) => { + let mut s = state.borrow_mut(); + s.projects = projects; + projects_browser.clear(); + for p in &s.projects { + projects_browser.add(&p.name); + } + if let Some(idx) = s.selected_hub { + if let Some(hub) = s.hubs.get(idx) { + status.set_label(&format!("Hub: {}. Select a project.", hub.name)); + } + } + } + WorkerMsg::TopFoldersLoaded(Err(e)) => show_error("Load Project Failed", &e.message), + WorkerMsg::TopFoldersLoaded(Ok(folders)) => { + let mut s = state.borrow_mut(); + for entry in folders { + insert_tree_entry(tree, &mut s.tree_entries, "", &entry); + } + tree.redraw(); + if let Some(p) = s.selected_project { + if let Some(project) = s.projects.get(p) { + let line = match mode { + Mode::Destination => format!( + "Project: {}. Browse folders and choose a destination.", + project.name + ), + _ => format!("Project: {}. Browse folders and pick a file.", project.name), + }; + status.set_label(&line); + } + } + } + WorkerMsg::FolderContentsLoaded(parent, Err(e)) => { + state.borrow_mut().loaded_folders.remove(&parent); + show_error("Load Folder Failed", &e.message); + } + WorkerMsg::FolderContentsLoaded(parent, Ok(children)) => { + let mut s = state.borrow_mut(); + for entry in &children { + insert_tree_entry(tree, &mut s.tree_entries, &parent, entry); + } + tree.redraw(); + } + } + let _ = action_btn; +} + +// ---- worker helpers ------------------------------------------------------- + +fn list_folder_contents_for_mode( + aps: &Arc, + project_id: &str, + folder_id: &str, + mode: Mode, +) -> Result, RpcError> { + let object_types: &[&str] = if mode == Mode::Destination { + &["folders"] + } else { + &["folders", "items"] + }; + let filter_box: Option bool>> = match mode { + Mode::Ifcfed => Some(Box::new(|e: &Entry| { + e.display_name.to_lowercase().ends_with(".ifcfed") + })), + Mode::Model => Some(Box::new(|e: &Entry| { + MODEL_EXTENSIONS + .iter() + .any(|ext| e.display_name.to_lowercase().ends_with(ext)) + })), + Mode::Destination => None, + }; + let filter_ref: Option<&dyn Fn(&Entry) -> bool> = filter_box.as_deref(); + aps.list_folder_contents(project_id, folder_id, object_types, filter_ref) +} + +// ---- widget helpers ------------------------------------------------------- + +fn refill_hub_combo(combo: &mut Choice, hubs: &[Hub]) { + combo.clear(); + combo.add_choice("Select hub"); + for hub in hubs { + combo.add_choice(&hub.name.replace('/', " ")); + } + combo.set_value(0); +} + +fn insert_tree_entry( + tree: &mut Tree, + tree_entries: &mut HashMap, + parent_canonical: &str, + entry: &Entry, +) -> String { + let parent_add = parent_canonical.strip_prefix('/').unwrap_or(parent_canonical); + let label = if entry.display_name.is_empty() { + entry.id.clone() + } else { + entry.display_name.clone() + }; + let canonical = add_unique(tree, parent_add, &label); + tree_entries.insert(canonical.clone(), entry.clone()); + if entry.entry_type == EntryType::Folders { + let inner = canonical.strip_prefix('/').unwrap_or(&canonical); + let _ = add_unique(tree, inner, PLACEHOLDER_LABEL); + // FLTK's Fl_Tree_Item defaults to open_=1, which would auto-expand + // every folder and show the placeholder before the user clicks. + // Force-collapse so the first user click on the triangle fires the + // Opened callback we hang lazy-load off. + if let Some(mut item) = tree.find_item(&canonical) { + item.close(); + } + } + canonical +} + +fn add_unique(tree: &mut Tree, parent_add: &str, label: &str) -> String { + let sanitised = label.replace('/', " "); + let mut candidate = sanitised.clone(); + let mut counter = 2; + loop { + let probe_path = if parent_add.is_empty() { + candidate.clone() + } else { + format!("{parent_add}/{candidate}") + }; + if tree.find_item(&probe_path).is_none() { + match tree.add(&probe_path) { + Some(item) => { + return tree + .item_pathname(&item) + .unwrap_or_else(|_| format!("/{probe_path}")); + } + None => return format!("/{probe_path}"), + } + } + candidate = format!("{sanitised} ({counter})"); + counter += 1; + } +} + +fn collect_selected_entries(tree: &Tree, tree_entries: &HashMap) -> Vec { + let Some(items) = tree.get_selected_items() else { return Vec::new() }; + items + .into_iter() + .filter_map(|item| tree.item_pathname(&item).ok()) + .filter_map(|path| tree_entries.get(&path).cloned()) + .collect() +} + +fn update_status_for_selection(status: &mut Frame, entries: &[Entry], mode: Mode) { + match entries.len() { + 0 => {} + 1 => { + let entry = &entries[0]; + let kind = match entry.entry_type { + EntryType::Folders => "folders", + EntryType::Items => "items", + EntryType::Other => "entry", + }; + status.set_label(&format!("Selected {kind}: {}", entry.display_name)); + } + n => { + let valid = entries.iter().filter(|e| is_valid_selection(mode, e)).count(); + status.set_label(&format!("Selected {valid} of {n} items.")); + } + } +} + +fn refresh_action_button(btn: &mut Button, state: &State, mode: Mode) { + let valid = state + .selected_entries + .iter() + .any(|e| is_valid_selection(mode, e)); + if state.selected_project.is_some() && valid { + btn.activate(); + } else { + btn.deactivate(); + } +} + +fn is_valid_selection(mode: Mode, entry: &Entry) -> bool { + match mode { + Mode::Destination => entry.entry_type == EntryType::Folders, + Mode::Ifcfed => { + entry.entry_type == EntryType::Items + && entry.display_name.to_lowercase().ends_with(".ifcfed") + } + Mode::Model => { + entry.entry_type == EntryType::Items + && MODEL_EXTENSIONS + .iter() + .any(|ext| entry.display_name.to_lowercase().ends_with(ext)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, ty: EntryType) -> Entry { + Entry { + id: name.into(), + entry_type: ty, + display_name: name.into(), + name: None, + extension_type: String::new(), + } + } + + #[test] + fn is_valid_selection_modes() { + assert!(is_valid_selection(Mode::Ifcfed, &entry("a.ifcfed", EntryType::Items))); + assert!(!is_valid_selection(Mode::Ifcfed, &entry("a.ifc", EntryType::Items))); + assert!(is_valid_selection(Mode::Model, &entry("a.ifc", EntryType::Items))); + assert!(is_valid_selection(Mode::Model, &entry("a.rdb", EntryType::Items))); + assert!(is_valid_selection(Mode::Destination, &entry("folder", EntryType::Folders))); + assert!(!is_valid_selection(Mode::Destination, &entry("file.ifc", EntryType::Items))); + } +} diff --git a/src/bonsaiviewer-autodesk/src/ui/dialogs.rs b/src/bonsaiviewer-autodesk/src/ui/dialogs.rs new file mode 100644 index 0000000000..261dc70978 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/ui/dialogs.rs @@ -0,0 +1,221 @@ +//! Small modal dialogs: filename prompt, confirm, error alert. +//! +//! All three follow the same pattern: build widgets, set each button's +//! callback to mutate a shared `Rc>` for the result and call +//! `win.hide()`, then pump `app::wait()` until `win.shown()` is false. + +use std::cell::RefCell; +use std::rc::Rc; + +use fltk::{ + app, + button::Button, + enums::{Align, Event, Key}, + frame::Frame, + group::Flex, + input::Input, + prelude::*, + window::Window, +}; + +use crate::ui::ensure_app; + +pub fn prompt_for_filename(title: &str, label: &str, default: &str) -> Option { + ensure_app(); + + let mut win = Window::default().with_size(440, 170).with_label(title); + win.make_modal(true); + + let mut col = Flex::default_fill().column(); + col.set_margins(20, 20, 20, 20); + col.set_spacing(8); + + let mut lab = Frame::default().with_label(label); + lab.set_align(Align::Left | Align::Inside); + col.fixed(&lab, 18); + + let mut entry = Input::default(); + entry.set_value(default); + let _ = entry.set_position(default.len() as i32); + col.fixed(&entry, 28); + + Frame::default(); // flexible spacer + + let mut buttons = Flex::default().row(); + buttons.set_spacing(8); + Frame::default(); // pushes buttons right + let mut cancel = Button::default().with_label("Cancel"); + buttons.fixed(&cancel, 100); + let mut ok = Button::default().with_label("OK"); + buttons.fixed(&ok, 100); + buttons.end(); + col.fixed(&buttons, 32); + + col.end(); + win.end(); + center_on_screen(&mut win); + + let result: Rc>> = Rc::new(RefCell::new(None)); + + ok.set_callback({ + let entry = entry.clone(); + let mut win = win.clone(); + let result = result.clone(); + move |_| { + let v = entry.value().trim().to_string(); + if !v.is_empty() { + *result.borrow_mut() = Some(v); + } + win.hide(); + } + }); + cancel.set_callback({ + let mut win = win.clone(); + move |_| win.hide() + }); + win.handle({ + let entry = entry.clone(); + let result = result.clone(); + let mut win_for_keys = win.clone(); + move |_, ev| match ev { + Event::KeyDown => match app::event_key() { + Key::Enter => { + let v = entry.value().trim().to_string(); + if !v.is_empty() { + *result.borrow_mut() = Some(v); + } + win_for_keys.hide(); + true + } + Key::Escape => { + win_for_keys.hide(); + true + } + _ => false, + }, + _ => false, + } + }); + + let _ = entry.take_focus(); + win.show(); + while win.shown() { + app::wait(); + } + drain_after_close(); + + let out = result.borrow_mut().take(); + out +} + +pub fn confirm(title: &str, message: &str) -> bool { + ensure_app(); + + let mut win = Window::default().with_size(440, 180).with_label(title); + win.make_modal(true); + + let mut col = Flex::default_fill().column(); + col.set_margins(20, 20, 20, 20); + col.set_spacing(8); + + let mut msg = Frame::default().with_label(message); + msg.set_align(Align::Left | Align::Inside | Align::Wrap); + + let mut buttons = Flex::default().row(); + buttons.set_spacing(8); + Frame::default(); + let mut no = Button::default().with_label("No"); + buttons.fixed(&no, 100); + let mut yes = Button::default().with_label("Yes"); + buttons.fixed(&yes, 100); + buttons.end(); + col.fixed(&buttons, 32); + + col.end(); + win.end(); + center_on_screen(&mut win); + + let answer: Rc> = Rc::new(RefCell::new(false)); + + yes.set_callback({ + let mut win = win.clone(); + let answer = answer.clone(); + move |_| { + *answer.borrow_mut() = true; + win.hide(); + } + }); + no.set_callback({ + let mut win = win.clone(); + move |_| win.hide() + }); + + win.show(); + while win.shown() { + app::wait(); + } + drain_after_close(); + + let out = *answer.borrow(); + out +} + +pub fn show_error(title: &str, message: &str) { + ensure_app(); + + let mut win = Window::default().with_size(440, 180).with_label(title); + win.make_modal(true); + + let mut col = Flex::default_fill().column(); + col.set_margins(20, 20, 20, 20); + col.set_spacing(8); + + let mut msg = Frame::default().with_label(message); + msg.set_align(Align::Left | Align::Inside | Align::Wrap); + + let mut buttons = Flex::default().row(); + Frame::default(); + let mut ok = Button::default().with_label("OK"); + buttons.fixed(&ok, 100); + buttons.end(); + col.fixed(&buttons, 32); + + col.end(); + win.end(); + center_on_screen(&mut win); + + ok.set_callback({ + let mut win = win.clone(); + move |_| win.hide() + }); + + win.show(); + while win.shown() { + app::wait(); + } + drain_after_close(); +} + +pub(crate) fn center_on_screen(win: &mut Window) { + let (sw, sh) = (app::screen_size().0 as i32, app::screen_size().1 as i32); + let x = (sw - win.width()) / 2; + let y = (sh - win.height()) / 2; + win.set_pos(x.max(0), y.max(0)); +} + +/// Drain FLTK's pending output after a dialog's event loop exits. +/// +/// `Window::hide()` queues an X11 unmap, but FLTK only flushes its X +/// output buffer during an event-loop tick. When a dialog runs inside an +/// RPC handler the connector goes back to a blocking stdin read the +/// moment the dialog returns, so without this helper the window stays +/// visible on screen even though `shown()` is already false. The first +/// few `wait_for(0.0)` calls process any follow-up events the close +/// triggered (focus changes, redraws of newly-exposed windows); the final +/// `flush()` pushes everything out to the X server. +pub(crate) fn drain_after_close() { + for _ in 0..5 { + let _ = app::wait_for(0.0); + } + app::flush(); +} diff --git a/src/bonsaiviewer-autodesk/src/ui/mod.rs b/src/bonsaiviewer-autodesk/src/ui/mod.rs new file mode 100644 index 0000000000..634b896d92 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/ui/mod.rs @@ -0,0 +1,38 @@ +//! FLTK-based dialogs. +//! +//! Each dialog opens its own modal `Window` and pumps the shared FLTK event +//! loop until the window closes. Unlike eframe::run_simple_native, FLTK +//! creates the app object exactly once (via [`ensure_app`]) and every +//! dialog reuses it — opening and closing windows in sequence is the +//! intended pattern, not an edge case. + +pub mod browse; +pub mod dialogs; +pub mod progress; +pub mod settings; + +use std::sync::OnceLock; + +use fltk::app; +use fltk_theme::{color_themes::fleet, ColorTheme, SchemeType, WidgetScheme}; + +pub use browse::{BrowseDialog, Mode}; +pub use dialogs::prompt_for_filename; +pub use progress::run_with_progress; +pub use settings::SettingsDialog; + +pub const MODEL_EXTENSIONS: &[&str] = &[".ifc", ".ifcview", ".rdb", ".rdbview"]; + +static APP_INIT: OnceLock = OnceLock::new(); + +/// Initialise FLTK exactly once. Subsequent calls return the same handle. +pub fn ensure_app() -> app::App { + *APP_INIT.get_or_init(|| { + let a = app::App::default(); + WidgetScheme::new(SchemeType::Aqua).apply(); + ColorTheme::new(&fleet::MATERIAL_DARK).apply(); + app::set_font_size(13); + app::set_visible_focus(false); + a + }) +} diff --git a/src/bonsaiviewer-autodesk/src/ui/progress.rs b/src/bonsaiviewer-autodesk/src/ui/progress.rs new file mode 100644 index 0000000000..7e0638f766 --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/ui/progress.rs @@ -0,0 +1,176 @@ +//! ProgressDialog and run_with_progress. +//! +//! `run_with_progress` spawns the caller's work on a background thread and +//! pumps the FLTK event loop on the main thread, draining progress reports +//! through a thread-safe bridge. Returns the worker's result. + +use std::sync::{Arc, Mutex}; +use std::thread; + +use fltk::{app, frame::Frame, group::Flex, misc::Progress, prelude::*, window::Window}; + +use crate::progress::{ProgressBridge, Report}; +use crate::rpc::RpcError; +use crate::ui::dialogs::{center_on_screen, drain_after_close}; +use crate::ui::ensure_app; + +const WIDTH: i32 = 560; +const HEIGHT: i32 = 160; + +pub struct ProgressDialog { + win: Window, + title_label: Frame, + detail_label: Frame, + bar: Progress, + determinate: bool, + /// Animation phase for the indeterminate bar (0..100). + indeterminate_phase: f64, +} + +impl ProgressDialog { + pub fn new(title: &str) -> Self { + let _app = ensure_app(); + let mut win = Window::default().with_size(WIDTH, HEIGHT).with_label(title); + win.make_modal(true); + + let mut col = Flex::default_fill().column(); + col.set_margins(20, 20, 20, 20); + col.set_spacing(8); + + let mut title_label = Frame::default().with_label(&elide_middle(title, 78)); + title_label.set_align(fltk::enums::Align::Left | fltk::enums::Align::Inside); + col.fixed(&title_label, 22); + + let mut detail_label = Frame::default().with_label(" "); + detail_label.set_align(fltk::enums::Align::Left | fltk::enums::Align::Inside); + col.fixed(&detail_label, 18); + + let mut bar = Progress::default(); + bar.set_minimum(0.0); + bar.set_maximum(100.0); + bar.set_value(0.0); + col.fixed(&bar, 14); + + col.end(); + win.end(); + center_on_screen(&mut win); + + Self { + win, + title_label, + detail_label, + bar, + determinate: false, + indeterminate_phase: 0.0, + } + } + + pub fn show(&mut self) { + self.win.show(); + app::flush(); + } + + pub fn close(&mut self) { + self.win.hide(); + } + + pub fn visible(&self) -> bool { + self.win.shown() + } + + pub fn report(&mut self, message: &str, percent: Option, detail: Option<&str>) { + self.title_label.set_label(&elide_middle(message, 78)); + self.detail_label + .set_label(&elide_middle(detail.unwrap_or(" "), 78)); + match percent { + Some(p) => { + self.determinate = true; + self.bar.set_value(p.clamp(0, 100) as f64); + } + None => { + self.indeterminate_phase = (self.indeterminate_phase + 5.0) % 100.0; + self.bar.set_value(self.indeterminate_phase); + self.determinate = false; + } + } + app::flush(); + } +} + +/// Show a progress modal and run `work` on a worker thread. +pub fn run_with_progress(message: &str, work: F) -> Result +where + F: FnOnce(Report) -> Result + Send + 'static, + T: Send + 'static, +{ + let _app = ensure_app(); + let mut dialog = ProgressDialog::new(message); + dialog.show(); + + let bridge = ProgressBridge::new(); + let report = bridge.clone().report_fn(); + + // Shared slot: presence == worker finished. The worker writes here; the + // UI thread polls each iteration. + let outcome: Arc>>> = Arc::new(Mutex::new(None)); + let outcome_for_worker = outcome.clone(); + let handle = thread::spawn(move || { + *outcome_for_worker.lock().unwrap() = Some(work(report)); + }); + + // Pump the event loop until either the worker finishes or the user + // closes the window. `wait_for(0.03)` keeps the indeterminate bar + // animating even when there's no UI input. + while dialog.visible() { + let _ = app::wait_for(0.03); + if let Some((_phase, msg, percent, detail)) = bridge.take() { + dialog.report(&msg, percent, detail.as_deref()); + } + if outcome.lock().unwrap().is_some() { + break; + } + } + + dialog.close(); + drain_after_close(); + + // If the user closed the window before the worker finished, wait it out + // so we never report success or failure that hasn't actually happened. + let _ = handle.join(); + let final_outcome = outcome.lock().unwrap().take(); + final_outcome.unwrap_or_else(|| Err(RpcError::internal("Worker thread died unexpectedly."))) +} + +fn elide_middle(text: &str, max_chars: usize) -> String { + let count = text.chars().count(); + if count <= max_chars { + return text.to_string(); + } + let head: String = text.chars().take(max_chars / 2).collect(); + let tail: String = text + .chars() + .rev() + .take(max_chars - 1 - max_chars / 2) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elide_preserves_short_strings() { + assert_eq!(elide_middle("hello", 10), "hello"); + } + + #[test] + fn elide_inserts_ellipsis() { + let s = elide_middle("abcdefghijklmno", 7); + assert!(s.contains('…')); + assert!(s.chars().count() <= 7); + } +} diff --git a/src/bonsaiviewer-autodesk/src/ui/settings.rs b/src/bonsaiviewer-autodesk/src/ui/settings.rs new file mode 100644 index 0000000000..a815579fbb --- /dev/null +++ b/src/bonsaiviewer-autodesk/src/ui/settings.rs @@ -0,0 +1,168 @@ +//! Settings dialog: APS client id + OAuth callback port + sign-out. + +use std::sync::Arc; + +use fltk::{ + app, + button::Button, + enums::Align, + frame::Frame, + group::Flex, + input::Input, + prelude::*, + window::Window, +}; + +use crate::auth::{KeyringTokenStore, TokenStore}; +use crate::rpc::RpcError; +use crate::settings as cfg; +use crate::ui::dialogs::{center_on_screen, confirm, drain_after_close, show_error}; +use crate::ui::ensure_app; + +pub struct SettingsDialog { + pub on_reload: Arc Result<(), RpcError>>, +} + +impl SettingsDialog { + pub fn new(on_reload: Arc Result<(), RpcError>>) -> Self { + Self { on_reload } + } + + pub fn run(self) { + ensure_app(); + let client_id = cfg::load_client_id(); + let callback_port = cfg::stored_callback_port(); + + let mut win = Window::default() + .with_size(520, 400) + .with_label("Autodesk Connector Settings"); + win.make_modal(true); + + let mut col = Flex::default_fill().column(); + col.set_margins(24, 24, 24, 24); + col.set_spacing(8); + + let mut title = Frame::default().with_label("Autodesk Platform Services"); + title.set_align(Align::Left | Align::Inside); + col.fixed(&title, 22); + + let mut intro = Frame::default().with_label( + "The connector signs in to Autodesk using a PKCE flow.\n\ + The client id below comes from your APS application.", + ); + intro.set_align(Align::Left | Align::Inside | Align::Wrap); + col.fixed(&intro, 40); + + let mut id_label = Frame::default().with_label("APS client id"); + id_label.set_align(Align::Left | Align::Inside); + col.fixed(&id_label, 18); + + let id_entry = Input::default(); + let mut id_entry_mut = id_entry.clone(); + id_entry_mut.set_value(&client_id); + col.fixed(&id_entry, 28); + + let mut port_label = Frame::default().with_label("OAuth callback port"); + port_label.set_align(Align::Left | Align::Inside); + col.fixed(&port_label, 18); + + let port_entry = Input::default(); + let mut port_entry_mut = port_entry.clone(); + port_entry_mut.set_value(&callback_port.to_string()); + col.fixed(&port_entry, 28); + + let initial_status = if client_id.is_empty() { + "No client id configured.".to_string() + } else { + format!("Signed in as {client_id}") + }; + let status_label = Frame::default().with_label(&initial_status); + let mut status_label_mut = status_label.clone(); + status_label_mut.set_align(Align::Left | Align::Inside | Align::Wrap); + + let mut buttons = Flex::default().row(); + buttons.set_spacing(8); + let mut signout_btn = Button::default().with_label("Sign Out"); + if client_id.is_empty() { + signout_btn.deactivate(); + } + buttons.fixed(&signout_btn, 110); + Frame::default(); // spacer + let mut close_btn = Button::default().with_label("Close"); + buttons.fixed(&close_btn, 100); + let mut save_btn = Button::default().with_label("Save"); + buttons.fixed(&save_btn, 100); + buttons.end(); + col.fixed(&buttons, 32); + + col.end(); + win.end(); + center_on_screen(&mut win); + + // Close: just hide the window. Nothing more. + close_btn.set_callback({ + let mut win = win.clone(); + move |_| win.hide() + }); + + // Save: validate, persist, reload, hide. Stay open on validation + // errors so the user can correct without retyping everything. + save_btn.set_callback({ + let id_entry = id_entry.clone(); + let port_entry = port_entry.clone(); + let mut win = win.clone(); + let on_reload = self.on_reload.clone(); + move |_| { + let new_id = id_entry.value().trim().to_string(); + let port_str = port_entry.value().trim().to_string(); + let Ok(port_value) = port_str.parse::() else { + show_error( + "Invalid Callback Port", + "Callback port must be a number between 1 and 65535.", + ); + return; + }; + if let Err(e) = cfg::save_callback_port(port_value) { + show_error("Invalid Callback Port", &e); + return; + } + cfg::save_client_id(&new_id); + if let Err(e) = on_reload() { + show_error("Reload Failed", &e.message); + return; + } + win.hide(); + } + }); + + signout_btn.set_callback({ + let mut signout_btn = signout_btn.clone(); + let mut status_label = status_label_mut.clone(); + move |_| { + let id_now = cfg::load_client_id(); + if id_now.is_empty() { + return; + } + if !confirm( + "Sign Out", + &format!("Forget the stored Autodesk session for {id_now}?"), + ) { + return; + } + let store = KeyringTokenStore::new(id_now); + if let Err(e) = store.delete() { + show_error("Sign Out Failed", &e.message); + return; + } + status_label.set_label("Signed out. Next operation will prompt for sign-in."); + signout_btn.deactivate(); + } + }); + + win.show(); + while win.shown() { + app::wait(); + } + drain_after_close(); + } +} diff --git a/src/bonsaiviewer-autodesk/tests/aps.rs b/src/bonsaiviewer-autodesk/tests/aps.rs new file mode 100644 index 0000000000..71a18f61dd --- /dev/null +++ b/src/bonsaiviewer-autodesk/tests/aps.rs @@ -0,0 +1,373 @@ +//! Integration test: drive ApsClient end-to-end against a local TCP server +//! that serves canned APS responses. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; + +use bonsaiviewer_autodesk::aps::{ApsClient, EntryType}; +use bonsaiviewer_autodesk::auth::{AuthBuilder, InMemoryTokenStore, StoredToken}; +use bonsaiviewer_autodesk::progress::noop_auth; + +/// Canned reply: a Vec of (method, path-prefix) → status + body. The stub +/// matches the request against the first entry whose method matches and whose +/// path starts with the prefix, then pops it so the next request uses the +/// next entry (call-order matters in our tests). +struct StubServer { + base_url: String, + handle: Option>, +} + +impl Drop for StubServer { + fn drop(&mut self) { + if let Some(h) = self.handle.take() { + // Best effort: the server thread exits on TCP error when the test + // tears down. Don't block tests on join. + drop(h); + } + } +} + +fn spawn_stub(routes: Vec<(&'static str, &'static str, u16, String)>) -> StubServer { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut routes = routes; + let handle = thread::spawn(move || { + for incoming in listener.incoming() { + let Ok(mut stream) = incoming else { return }; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + // Read request line + headers. + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).unwrap_or(0) == 0 { + return; + } + let mut content_length: usize = 0; + loop { + let mut h = String::new(); + if reader.read_line(&mut h).unwrap_or(0) == 0 { + break; + } + if h == "\r\n" || h == "\n" { + break; + } + if let Some(v) = h.strip_prefix("Content-Length: ").or_else(|| h.strip_prefix("content-length: ")) { + content_length = v.trim().parse().unwrap_or(0); + } + } + // Drain the body so the client doesn't choke on a half-read socket + // when we send our reply and close. + if content_length > 0 { + let mut body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body); + } + + let parts: Vec<&str> = request_line.split_whitespace().collect(); + let method = parts.first().copied().unwrap_or(""); + let path = parts.get(1).copied().unwrap_or("/"); + + let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p)); + let (status, body) = match idx { + Some(i) => { + let (_, _, status, body) = routes.remove(i); + (status, body) + } + None => (404, format!("no route for {method} {path}")), + }; + let header = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + status, + status_text(status), + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + }); + StubServer { + base_url: format!("http://127.0.0.1:{port}"), + handle: Some(handle), + } +} + +fn status_text(code: u16) -> &'static str { + match code { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 404 => "Not Found", + _ => "OK", + } +} + +fn auth_with_valid_token() -> AuthBuilder { + let token = StoredToken { + client_id: "test".into(), + access_token: "AT".into(), + refresh_token: "RT".into(), + access_token_expires_at: Utc::now() + ChronoDuration::hours(1), + refresh_token_expires_at: Utc::now() + ChronoDuration::hours(24), + scope: "data:read data:write data:create".into(), + }; + AuthBuilder::new( + "test".into(), + "http://127.0.0.1:9999/".into(), + "data:read data:write data:create".into(), + Box::new(InMemoryTokenStore::preloaded(token)), + ) +} + +fn make_client(base_url: String) -> ApsClient { + let auth = Arc::new(auth_with_valid_token().build()); + // Quick sanity check that ensure_access_token returns the cached token. + assert_eq!(auth.ensure_access_token(noop_auth()).unwrap(), "AT"); + ApsClient::with_base_url(auth, base_url) +} + +#[test] +fn list_hubs_decodes_and_sorts() { + let body = r#"{ + "data": [ + {"id": "h2", "type": "hubs", "attributes": {"name": "Beta", "extension": {"type": "hubs:autodesk.bim360:Account"}}}, + {"id": "h1", "type": "hubs", "attributes": {"name": "alpha", "extension": {"type": "hubs:autodesk.core:Hub"}}} + ] + }"#; + let server = spawn_stub(vec![("GET", "/project/v1/hubs", 200, body.into())]); + let client = make_client(server.base_url.clone()); + let hubs = client.list_hubs().unwrap(); + assert_eq!(hubs.len(), 2); + assert_eq!(hubs[0].name, "alpha"); + assert_eq!(hubs[1].name, "Beta"); +} + +#[test] +fn list_folder_contents_filters_ifcfed() { + let body = r#"{ + "data": [ + {"id": "f1", "type": "folders", "attributes": {"displayName": "sub"}}, + {"id": "i1", "type": "items", "attributes": {"displayName": "model.ifc"}}, + {"id": "i2", "type": "items", "attributes": {"displayName": "model.ifcfed"}} + ] + }"#; + let server = spawn_stub(vec![("GET", "/data/v1/projects/P/folders/F/contents", 200, body.into())]); + let client = make_client(server.base_url.clone()); + let filter = |e: &bonsaiviewer_autodesk::aps::Entry| e.display_name.to_lowercase().ends_with(".ifcfed"); + let entries = client + .list_folder_contents("P", "F", &["folders", "items"], Some(&filter)) + .unwrap(); + // Folders bypass the filter; only ifcfed items are kept. + let names: Vec<_> = entries.iter().map(|e| e.display_name.clone()).collect(); + assert_eq!(names, vec!["model.ifcfed".to_string(), "sub".to_string()]); +} + +#[test] +fn get_item_returns_tip_metadata() { + let body = r#"{ + "data": { + "id": "I", + "type": "items", + "attributes": {"displayName": "model.ifc", "hidden": false}, + "relationships": { + "tip": {"data": {"type": "versions", "id": "V"}}, + "parent": {"data": {"type": "folders", "id": "F"}} + } + }, + "included": [{ + "id": "V", + "type": "versions", + "attributes": { + "displayName": "model.ifc", + "versionNumber": 3, + "lastModifiedTime": "2025-01-01T00:00:00Z", + "lastModifiedUserName": "alice" + }, + "relationships": { + "storage": {"data": {"type": "objects", "id": "urn:adsk.objects:os.object:bk/obj"}} + } + }] + }"#; + let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]); + let client = make_client(server.base_url.clone()); + let item = client.get_item("P", "I").unwrap(); + assert!(!item.hidden); + assert_eq!(item.version_id.as_deref(), Some("V")); + assert_eq!(item.storage_id.as_deref(), Some("urn:adsk.objects:os.object:bk/obj")); + assert_eq!(item.last_modified_user_name.as_deref(), Some("alice")); + assert_eq!(item.parent_folder_id.as_deref(), Some("F")); +} + +#[test] +fn get_item_handles_missing_tip_as_hidden() { + let body = r#"{ + "data": { + "id": "I", + "type": "items", + "attributes": {"displayName": "deleted.ifc", "hidden": true}, + "relationships": { + "tip": {"data": {"type": "versions", "id": "missing"}}, + "parent": {"data": {"type": "folders", "id": "F"}} + } + } + }"#; + let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]); + let client = make_client(server.base_url.clone()); + let item = client.get_item("P", "I").unwrap(); + assert!(item.hidden); + assert!(item.storage_id.is_none()); + assert!(item.version_id.is_none()); +} + +#[test] +fn download_signed_then_get_writes_file() { + let tmp = tempfile::tempdir().unwrap(); + let dst = tmp.path().join("out.bin"); + let signed = format!( + r#"{{"url": "{base}/payload"}}"#, + base = "PLACEHOLDER" + ); + // We need two-stage: first signed url returns a pointer into the same + // stub, then the GET against that path returns the bytes. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let base = format!("http://127.0.0.1:{port}"); + let signed_with_url = signed.replace("PLACEHOLDER", &base); + let payload_bytes = b"hello-bytes-12345"; + let mut routes: Vec<(&'static str, &'static str, u16, String)> = vec![ + ("GET", "/oss/v2/buckets/bk/objects/obj/signeds3download", 200, signed_with_url), + ("GET", "/payload", 200, String::from_utf8(payload_bytes.to_vec()).unwrap()), + ]; + // Hand-roll a stub on the bound listener so we can re-use the same port. + let handle = thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + loop { + let mut h = String::new(); + if reader.read_line(&mut h).unwrap_or(0) == 0 { + break; + } + if h == "\r\n" || h == "\n" { + break; + } + } + let parts: Vec<&str> = request_line.split_whitespace().collect(); + let method = parts[0]; + let path = parts[1]; + let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p)).unwrap(); + let (_, _, status, body) = routes.remove(idx); + let header = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + }); + let client = make_client(base); + client + .download_storage_to_file("urn:adsk.objects:os.object:bk/obj", &dst, None) + .unwrap(); + handle.join().unwrap(); + let read = std::fs::read(&dst).unwrap(); + assert_eq!(read, payload_bytes); +} + +#[test] +fn upload_small_file_creates_new_item() { + use std::cell::Cell; + + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("model.ifc"); + std::fs::write(&src, b"local file contents").unwrap(); + + // Sequence: create_storage → signed_upload (presigned URL list) → PUT + // bytes → complete_signed_upload → list folder (no existing) → + // create_item. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let base = format!("http://127.0.0.1:{port}"); + let presigned_url = format!("{base}/presigned/0"); + + let put_bytes_received: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); + let put_bytes_clone = put_bytes_received.clone(); + + let create_storage_body = r#"{"data":{"id":"urn:adsk.objects:os.object:bk/obj","type":"objects"}}"#.to_string(); + let signed_upload_body = format!(r#"{{"uploadKey":"UK1","urls":["{presigned_url}"]}}"#); + let complete_body = r#"{}"#.to_string(); + let folder_contents_body = r#"{"data":[]}"#.to_string(); + let create_item_body = r#"{ + "data": {"id":"NEW_ITEM","type":"items"}, + "included": [{"id":"V1","type":"versions","attributes":{"versionNumber":1,"lastModifiedTime":"2025-01-01T00:00:00Z","lastModifiedUserName":"bob"}}] + }"#.to_string(); + + let put_seen = Cell::new(false); + let _ = put_seen; + + let handle = thread::spawn(move || { + let expected = vec![ + ("POST", "/data/v1/projects/P/storage", create_storage_body), + ("GET", "/oss/v2/buckets/bk/objects/obj/signeds3upload", signed_upload_body), + ("PUT", "/presigned/0", String::new()), + ("POST", "/oss/v2/buckets/bk/objects/obj/signeds3upload", complete_body), + ("GET", "/data/v1/projects/P/folders/F/contents", folder_contents_body), + ("POST", "/data/v1/projects/P/items", create_item_body), + ]; + for (method, prefix, body) in expected { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let mut content_length: usize = 0; + loop { + let mut h = String::new(); + if reader.read_line(&mut h).unwrap_or(0) == 0 { + break; + } + if h == "\r\n" || h == "\n" { + break; + } + let lower = h.to_ascii_lowercase(); + if let Some(v) = lower.strip_prefix("content-length: ") { + content_length = v.trim().parse().unwrap_or(0); + } + } + if content_length > 0 { + let mut body_bytes = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body_bytes); + if method == "PUT" { + *put_bytes_clone.lock().unwrap() = body_bytes; + } + } + let parts: Vec<&str> = request_line.split_whitespace().collect(); + assert_eq!(parts[0], method, "request_line={request_line:?}"); + assert!(parts[1].starts_with(prefix), "got {} expected prefix {prefix}", parts[1]); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + }); + + let client = make_client(base); + let uploaded = client + .upload_file_to_folder("P", "F", &src, None, None) + .unwrap(); + handle.join().unwrap(); + assert_eq!(uploaded.item_id, "NEW_ITEM"); + assert_eq!(uploaded.version_id, "V1"); + assert_eq!(*put_bytes_received.lock().unwrap(), b"local file contents"); + let _ = EntryType::Items; // keep the import live +} diff --git a/src/bonsaiviewer-autodesk/tests/conftest.py b/src/bonsaiviewer-autodesk/tests/conftest.py deleted file mode 100644 index f5c4f7780b..0000000000 --- a/src/bonsaiviewer-autodesk/tests/conftest.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Shared fixtures for the bonsaiviewer-autodesk test suite.""" - -from __future__ import annotations - -import keyring -import keyring.backend -import keyring.errors -import pytest - -from bonsaiviewer_autodesk import cache as cache_module -from bonsaiviewer_autodesk import settings as settings_module - - -@pytest.fixture -def cache_dir(tmp_path, monkeypatch): - """Redirect ``bonsaiviewer_autodesk.cache`` at an isolated tmp directory. - - Patches ``cache_root`` itself rather than ``XDG_CACHE_HOME`` so the - redirect holds on every platform — on macOS/Windows ``cache_root`` ignores - the XDG variables. Returns the directory ``cache_root()`` now resolves to. - """ - root = tmp_path / "cache" / "bonsaiviewer-autodesk" - root.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(cache_module, "cache_root", lambda: root) - return root - - -@pytest.fixture -def config_dir(tmp_path, monkeypatch): - """Redirect ``bonsaiviewer_autodesk.settings`` at an isolated tmp directory. - - Patches ``config_root`` directly (platform-independent). Returns the - directory ``config_root()`` now resolves to. - """ - root = tmp_path / "config" / "bonsaiviewer-autodesk" - root.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(settings_module, "config_root", lambda: root) - return root - - -class InMemoryKeyring(keyring.backend.KeyringBackend): - """A keyring backend that keeps secrets in a dict — never touches the OS.""" - - priority = 1 # type: ignore[assignment] - - def __init__(self) -> None: - super().__init__() - self._store: dict[tuple[str, str], str] = {} - - def get_password(self, service: str, username: str) -> str | None: - return self._store.get((service, username)) - - def set_password(self, service: str, username: str, password: str) -> None: - self._store[(service, username)] = password - - def delete_password(self, service: str, username: str) -> None: - try: - del self._store[(service, username)] - except KeyError as exc: - raise keyring.errors.PasswordDeleteError("not found") from exc - - -@pytest.fixture -def memory_keyring(): - """Swap in the in-memory keyring backend for the duration of a test.""" - backend = InMemoryKeyring() - previous = keyring.get_keyring() - keyring.set_keyring(backend) - try: - yield backend - finally: - keyring.set_keyring(previous) diff --git a/src/bonsaiviewer-autodesk/tests/rpc_smoke.rs b/src/bonsaiviewer-autodesk/tests/rpc_smoke.rs new file mode 100644 index 0000000000..bc1a10838f --- /dev/null +++ b/src/bonsaiviewer-autodesk/tests/rpc_smoke.rs @@ -0,0 +1,80 @@ +//! End-to-end smoke test: launch the connector binary and exchange a few +//! JSON-RPC requests over stdin/stdout. Verifies wire-format parity with the +//! Python implementation. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +fn binary_path() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("target"); + path.push(if cfg!(debug_assertions) { "debug" } else { "release" }); + path.push(if cfg!(windows) { "bonsaiviewer-autodesk.exe" } else { "bonsaiviewer-autodesk" }); + path +} + +fn exchange(requests: &[&str]) -> Vec { + let path = binary_path(); + assert!(path.exists(), "expected binary at {}; build with `cargo build --bin bonsaiviewer-autodesk` first", path.display()); + let mut child = Command::new(&path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn connector binary"); + { + let stdin = child.stdin.as_mut().expect("stdin"); + for r in requests { + stdin.write_all(r.as_bytes()).unwrap(); + stdin.write_all(b"\n").unwrap(); + } + stdin.flush().unwrap(); + } + // Closing stdin signals EOF; the run loop exits. + drop(child.stdin.take()); + + let stdout = child.stdout.take().expect("stdout"); + let mut reader = BufReader::new(stdout); + let mut out: Vec = Vec::new(); + let mut buf = String::new(); + loop { + buf.clear(); + let n = reader.read_line(&mut buf).unwrap(); + if n == 0 { + break; + } + let trimmed = buf.trim(); + if trimmed.is_empty() { + continue; + } + out.push(serde_json::from_str(trimmed).unwrap()); + } + let _ = child.wait(); + out +} + +#[test] +#[ignore = "requires the binary; run `cargo build --bin bonsaiviewer-autodesk` first"] +fn parse_error_returns_minus_32700() { + let out = exchange(&["this is not json"]); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["error"]["code"], serde_json::json!(-32700)); + assert_eq!(out[0]["id"], serde_json::json!(null)); +} + +#[test] +#[ignore = "requires the binary; run `cargo build --bin bonsaiviewer-autodesk` first"] +fn unknown_method_returns_minus_32601() { + let out = exchange(&[r#"{"jsonrpc":"2.0","id":1,"method":"definitely_not_a_method"}"#]); + assert_eq!(out[0]["error"]["code"], serde_json::json!(-32601)); + assert_eq!(out[0]["id"], serde_json::json!(1)); +} + +#[test] +#[ignore = "requires the binary; run `cargo build --bin bonsaiviewer-autodesk` first"] +fn invalid_jsonrpc_version_returns_minus_32600() { + let out = exchange(&[r#"{"id":1,"method":"pull_ifcfed","params":{}}"#]); + assert_eq!(out[0]["error"]["code"], serde_json::json!(-32600)); +} diff --git a/src/bonsaiviewer-autodesk/tests/test_autodesk.py b/src/bonsaiviewer-autodesk/tests/test_autodesk.py deleted file mode 100644 index ef31832b9c..0000000000 --- a/src/bonsaiviewer-autodesk/tests/test_autodesk.py +++ /dev/null @@ -1,676 +0,0 @@ -"""Tests for the Autodesk auth + APS client (``bonsaiviewer_autodesk.autodesk``). - -HTTP is mocked through the injectable ``transport`` seam using -``httpx.MockTransport``; time through the injectable ``now`` clock; and the -OAuth redirect through the injectable ``callback_waiter``. -""" - -from __future__ import annotations - -import datetime as dt -import socket -import threading -import time -from collections import deque - -import httpx -import keyring.errors -import pytest - -from bonsaiviewer_autodesk import autodesk -from bonsaiviewer_autodesk.rpc import RpcError - - -# --- HTTP routing ------------------------------------------------------------ - - -def _build_response(spec: dict) -> httpx.Response: - kwargs = {key: spec[key] for key in ("json", "text", "content", "headers") if key in spec} - return httpx.Response(spec.get("status", 200), **kwargs) - - -class Router: - """A tiny httpx.MockTransport router. - - Routes match on HTTP method plus a substring of the request URL, in - declaration order. Pass multiple specs to a route to return them in turn - (the last one repeats); every request is recorded on ``requests``. - """ - - def __init__(self) -> None: - self._routes: list[tuple[str, str, deque]] = [] - self.requests: list[httpx.Request] = [] - - def add(self, method: str, contains: str, *specs: dict) -> "Router": - self._routes.append((method, contains, deque(specs or ({},)))) - return self - - def _handle(self, request: httpx.Request) -> httpx.Response: - self.requests.append(request) - for method, contains, specs in self._routes: - if request.method == method and contains in str(request.url): - spec = specs[0] if len(specs) == 1 else specs.popleft() - return _build_response(spec) - return httpx.Response(404, text=f"unrouted {request.method} {request.url}") - - @property - def transport(self) -> httpx.MockTransport: - return httpx.MockTransport(self._handle) - - def count(self, method: str, contains: str) -> int: - return sum( - 1 for r in self.requests if r.method == method and contains in str(r.url) - ) - - -# --- fakes ------------------------------------------------------------------- - - -class FakeTokenStore: - """In-memory stand-in for KeyringTokenStore.""" - - def __init__(self, initial: dict | None = None) -> None: - self.value = initial - - def load(self) -> dict | None: - return self.value - - def save(self, value: dict) -> None: - self.value = value - - def delete(self) -> None: - self.value = None - - -class FakeAuth: - """Minimal AuthSessionService stand-in for ApsClient tests.""" - - def ensure_access_token(self) -> str: - return "fake-token" - - -FIXED_NOW = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc) - - -def iso(offset_seconds: int) -> str: - return (FIXED_NOW + dt.timedelta(seconds=offset_seconds)).isoformat() - - -def stored_token_dict(*, access_offset: int, refresh_offset: int) -> dict: - return { - "client_id": "cid", - "access_token": "current-access", - "refresh_token": "current-refresh", - "access_token_expires_at_utc": iso(access_offset), - "refresh_token_expires_at_utc": iso(refresh_offset), - "scope": "data:read", - } - - -def make_auth( - *, - router: Router | None = None, - token_store: FakeTokenStore | None = None, - callback_waiter=None, - callback_url: str = "http://localhost:8080/", -) -> autodesk.AuthSessionService: - return autodesk.AuthSessionService( - client_id="cid", - callback_url=callback_url, - scope="data:read", - token_store=token_store or FakeTokenStore(), - transport=(router or Router()).transport, - now=lambda: FIXED_NOW, - callback_waiter=callback_waiter, - ) - - -def make_client(router: Router) -> autodesk.ApsClient: - return autodesk.ApsClient(FakeAuth(), transport=router.transport) - - -# --- pure helpers ------------------------------------------------------------ - - -def test_base64url_strips_padding(): - assert autodesk._base64url(b"\x00") == "AA" - assert "=" not in autodesk._base64url(b"\x00\x00") - - -def test_generate_code_verifier_is_url_safe_and_unique(): - verifier = autodesk.generate_code_verifier() - assert not set(verifier) & set("=+/") - assert autodesk.generate_code_verifier() != autodesk.generate_code_verifier() - - -def test_generate_code_challenge_matches_rfc7636_vector(): - # RFC 7636 Appendix B test vector. - verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" - assert ( - autodesk.generate_code_challenge(verifier) - == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" - ) - - -def test_parse_storage_id_splits_bucket_and_object(): - bucket, obj = autodesk.ApsClient._parse_storage_id( - "urn:adsk.objects:os.object:wip.dm.prod/abc-123.ifc" - ) - assert bucket == "wip.dm.prod" - assert obj == "abc-123.ifc" - - -@pytest.mark.parametrize( - "bad", - [ - "not-a-urn", - "urn:adsk.objects:os.object:bucketonly", - "urn:adsk.objects:os.object:/object", - "urn:adsk.objects:os.object:bucket/", - ], -) -def test_parse_storage_id_rejects_malformed(bad): - with pytest.raises(RpcError): - autodesk.ApsClient._parse_storage_id(bad) - - -def test_entry_extracts_fields(): - item = { - "id": "x", - "type": "items", - "attributes": { - "displayName": "Model.ifc", - "extension": {"type": "items:autodesk.bim360:File"}, - }, - } - assert autodesk.ApsClient._entry(item) == { - "id": "x", - "type": "items", - "display_name": "Model.ifc", - "name": None, - "extension_type": "items:autodesk.bim360:File", - } - - -def test_relationship_id_handles_dict_list_and_missing(): - data = { - "relationships": { - "parent": {"data": {"id": "p1"}}, - "files": {"data": [{"id": "f1"}, {"id": "f2"}]}, - } - } - assert autodesk.ApsClient._relationship_id(data, "parent") == "p1" - assert autodesk.ApsClient._relationship_id(data, "files") == "f1" - assert autodesk.ApsClient._relationship_id(data, "missing") is None - - -def test_entry_name_matches_is_case_insensitive(): - assert autodesk.ApsClient._entry_name_matches({"display_name": "Model.IFC"}, "model.ifc") - assert autodesk.ApsClient._entry_name_matches({"name": "Model.IFC"}, "model.ifc") - assert not autodesk.ApsClient._entry_name_matches({"display_name": "other.ifc"}, "model.ifc") - - -# --- KeyringTokenStore ------------------------------------------------------- - - -def test_keyring_token_store_round_trip(memory_keyring): - store = autodesk.KeyringTokenStore(service_name="svc", username="user") - assert store.load() is None - store.save({"access_token": "abc"}) - assert store.load() == {"access_token": "abc"} - store.delete() - assert store.load() is None - - -def test_keyring_token_store_delete_missing_is_noop(memory_keyring): - store = autodesk.KeyringTokenStore(service_name="svc", username="user") - store.delete() # no entry to delete — must not raise - - -def test_keyring_missing_backend_surfaces_friendly_error(monkeypatch): - def boom(*_args, **_kwargs): - raise keyring.errors.NoKeyringError("no backend") - - monkeypatch.setattr(autodesk.keyring, "get_password", boom) - store = autodesk.KeyringTokenStore(service_name="svc", username="user") - with pytest.raises(RpcError) as excinfo: - store.load() - assert "keyring" in excinfo.value.message.lower() - - -# --- token lifecycle / injected clock --------------------------------------- - - -def test_ensure_access_token_returns_unexpired_token_without_http(): - store = FakeTokenStore(stored_token_dict(access_offset=3600, refresh_offset=100_000)) - router = Router() - auth = make_auth(router=router, token_store=store) - assert auth.ensure_access_token() == "current-access" - assert router.requests == [] - - -def test_ensure_access_token_refreshes_when_access_expired(): - store = FakeTokenStore(stored_token_dict(access_offset=-100, refresh_offset=100_000)) - router = Router().add( - "POST", - "/authentication/v2/token", - { - "json": { - "access_token": "refreshed-access", - "refresh_token": "refreshed-refresh", - "expires_in": 3600, - "refresh_token_expires_in": 200_000, - } - }, - ) - auth = make_auth(router=router, token_store=store) - assert auth.ensure_access_token() == "refreshed-access" - assert store.value["access_token"] == "refreshed-access" - assert router.count("POST", "/authentication/v2/token") == 1 - - -def test_ensure_access_token_logs_in_when_both_tokens_expired(monkeypatch): - monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None) - store = FakeTokenStore() # nothing stored at all - router = Router().add( - "POST", - "/authentication/v2/token", - { - "json": { - "access_token": "logged-in-access", - "refresh_token": "logged-in-refresh", - "expires_in": 3600, - } - }, - ) - auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "auth-code") - assert auth.ensure_access_token() == "logged-in-access" - assert store.value["access_token"] == "logged-in-access" - - -def test_token_from_payload_uses_injected_clock(): - auth = make_auth() - token = auth._token_from_payload( - {"access_token": "a", "refresh_token": "r", "expires_in": 3600} - ) - assert token.access_token_expires_at_utc == iso(3600 - 30) - # Default refresh TTL is 15 days, minus the same 30s safety margin. - assert token.refresh_token_expires_at_utc == iso(15 * 24 * 60 * 60 - 30) - - -def test_login_interactive_rejects_non_local_callback(): - auth = make_auth(callback_url="https://example.com/callback") - with pytest.raises(RpcError, match="Callback URL"): - auth.login_interactive() - - -def test_login_interactive_exchanges_code_for_token(monkeypatch): - opened: list[str] = [] - monkeypatch.setattr(autodesk.webbrowser, "open", lambda url: opened.append(url)) - store = FakeTokenStore() - router = Router().add( - "POST", - "/authentication/v2/token", - { - "json": { - "access_token": "fresh-access", - "refresh_token": "fresh-refresh", - "expires_in": 3600, - } - }, - ) - auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "the-code") - token = auth.login_interactive() - assert token.access_token == "fresh-access" - assert store.value["access_token"] == "fresh-access" - assert opened and opened[0].startswith(autodesk.AuthSessionService.authorize_endpoint) - - -def test_token_exchange_failure_raises_rpc_error(monkeypatch): - monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None) - router = Router().add( - "POST", "/authentication/v2/token", {"status": 400, "text": "invalid_grant"} - ) - auth = make_auth(router=router, callback_waiter=lambda *_a: "the-code") - with pytest.raises(RpcError, match="invalid_grant"): - auth.login_interactive() - - -# --- wait_for_oauth_callback (real loopback socket) -------------------------- - - -def _free_port() -> int: - with socket.socket() as probe: - probe.bind(("127.0.0.1", 0)) - return probe.getsockname()[1] - - -def _get_with_retry(url: str, params: dict, timeout: float = 5.0) -> None: - """Fire one GET, retrying only while the server has not yet bound.""" - deadline = time.time() + timeout - while True: - try: - httpx.get(url, params=params) - return - except httpx.ConnectError: - if time.time() > deadline: - raise - time.sleep(0.02) - - -def drive_callback(expected_state: str, query: dict, path: str = "/cb") -> dict: - """Run ``wait_for_oauth_callback`` in a thread and fire one redirect at it.""" - port = _free_port() - outcome: dict = {} - - def server() -> None: - try: - outcome["code"] = autodesk.wait_for_oauth_callback( - "127.0.0.1", port, path, expected_state - ) - except BaseException as exc: # noqa: BLE001 - re-raised to the test - outcome["error"] = exc - - thread = threading.Thread(target=server, daemon=True) - thread.start() - _get_with_retry(f"http://127.0.0.1:{port}{path}", query) - thread.join(timeout=5) - return outcome - - -def test_wait_for_oauth_callback_returns_authorization_code(): - outcome = drive_callback("state-123", {"state": "state-123", "code": "the-code"}) - assert outcome.get("code") == "the-code" - - -def test_wait_for_oauth_callback_rejects_state_mismatch(): - outcome = drive_callback("expected-state", {"state": "tampered", "code": "c"}) - assert isinstance(outcome.get("error"), RpcError) - assert "state mismatch" in outcome["error"].message.lower() - - -def test_wait_for_oauth_callback_reports_oauth_error(): - outcome = drive_callback("state-123", {"state": "state-123", "error": "access_denied"}) - assert isinstance(outcome.get("error"), RpcError) - assert "access_denied" in outcome["error"].message - - -def test_wait_for_oauth_callback_requires_a_code(): - outcome = drive_callback("state-123", {"state": "state-123"}) - assert isinstance(outcome.get("error"), RpcError) - assert "authorization code" in outcome["error"].message.lower() - - -# --- ApsClient browsing ------------------------------------------------------ - - -def _hub(hub_id: str, name: str) -> dict: - return { - "id": hub_id, - "attributes": {"name": name, "extension": {"type": "hubs:autodesk.core:Hub"}}, - } - - -def _project(project_id: str, name: str) -> dict: - return { - "id": project_id, - "attributes": { - "name": name, - "extension": {"type": "projects:autodesk.bim360:Project"}, - }, - "relationships": {"rootFolder": {"data": {"id": f"root-{project_id}"}}}, - } - - -def test_list_hubs_sorts_case_insensitively(): - router = Router().add( - "GET", - "/project/v1/hubs", - {"json": {"data": [_hub("h2", "Beta"), _hub("h1", "alpha")]}}, - ) - hubs = make_client(router).list_hubs() - assert [h["name"] for h in hubs] == ["alpha", "Beta"] - assert hubs[0]["id"] == "h1" - - -def test_list_projects_follows_pagination(): - router = Router() - router.add( - "GET", - "page=2", - {"json": {"data": [_project("p2", "Zeta")], "links": {}}}, - ) - router.add( - "GET", - "/hubs/h/projects", - { - "json": { - "data": [_project("p1", "Alpha")], - "links": { - "next": { - "href": "https://developer.api.autodesk.com/project/v1/hubs/h/projects?page=2" - } - }, - } - }, - ) - projects = make_client(router).list_projects("h") - assert [p["id"] for p in projects] == ["p1", "p2"] - assert projects[0]["root_folder_id"] == "root-p1" - - -def test_get_item_returns_tip_details(): - router = Router().add( - "GET", - "/items/", - { - "json": { - "data": { - "id": "item-1", - "attributes": {"displayName": "Model.ifcfed", "hidden": False}, - "relationships": { - "parent": {"data": {"id": "folder-1"}}, - "tip": {"data": {"id": "v3"}}, - }, - }, - "included": [ - { - "type": "versions", - "id": "v3", - "attributes": { - "versionNumber": 3, - "lastModifiedTime": "2026-01-01T00:00:00Z", - "lastModifiedUserName": "Dion", - }, - "relationships": { - "storage": { - "data": {"id": "urn:adsk.objects:os.object:b/o"} - } - }, - } - ], - } - }, - ) - item = make_client(router).get_item("proj-1", "item-1") - assert item["hidden"] is False - assert item["version_id"] == "v3" - assert item["storage_id"] == "urn:adsk.objects:os.object:b/o" - assert item["version_number"] == 3 - assert item["parent_folder_id"] == "folder-1" - assert item["last_modified_user_name"] == "Dion" - - -def test_get_item_without_tip_is_treated_as_hidden(): - router = Router().add( - "GET", - "/items/", - {"json": {"data": {"id": "item-1", "attributes": {}, "relationships": {}}}}, - ) - item = make_client(router).get_item("proj-1", "item-1") - assert item["hidden"] is True - assert item["storage_id"] is None - assert item["version_id"] is None - - -def test_list_folder_contents_applies_extension_filter(): - router = Router().add( - "GET", - "/contents", - { - "json": { - "data": [ - { - "id": "f1", - "type": "folders", - "attributes": {"name": "Sub", "extension": {"type": "t"}}, - }, - { - "id": "i1", - "type": "items", - "attributes": {"displayName": "keep.ifcfed", "extension": {}}, - }, - { - "id": "i2", - "type": "items", - "attributes": {"displayName": "skip.txt", "extension": {}}, - }, - ], - "links": {}, - } - }, - ) - entries = make_client(router).list_folder_contents( - "proj", - "folder", - extension_filter=lambda e: (e["display_name"] or "").endswith(".ifcfed"), - ) - ids = {e["id"] for e in entries} - assert ids == {"f1", "i1"} # folders kept, non-.ifcfed item dropped - - -def test_get_json_maps_http_error_to_rpc_error(): - router = Router().add( - "GET", "/project/v1/hubs", {"status": 403, "text": "Forbidden: bad token"} - ) - with pytest.raises(RpcError, match="Forbidden"): - make_client(router).list_hubs() - - -# --- download / upload ------------------------------------------------------- - - -def test_download_storage_to_file_writes_content_and_reports_progress(tmp_path): - router = Router() - router.add( - "GET", - "/signeds3download", - {"json": {"url": "https://signed.example/blob"}}, - ) - router.add("GET", "signed.example/blob", {"content": b"hello world"}) - dest = tmp_path / "out.bin" - seen: list = [] - make_client(router).download_storage_to_file( - "urn:adsk.objects:os.object:bucket/object", - dest, - progress=lambda name, pct, done, total: seen.append((name, pct, done, total)), - ) - assert dest.read_bytes() == b"hello world" - assert seen[-1][1] == 100 # final progress callback reports 100% - - -def test_signed_download_url_missing_raises(): - router = Router().add("GET", "/signeds3download", {"json": {}}) - with pytest.raises(RpcError, match="URL"): - make_client(router).download_storage_to_file( - "urn:adsk.objects:os.object:bucket/object", "/tmp/ignored" - ) - - -def _upload_router() -> Router: - router = Router() - router.add( - "POST", "/storage", {"json": {"data": {"id": "urn:adsk.objects:os.object:bk/obj"}}} - ) - router.add( - "GET", - "/signeds3upload", - {"json": {"uploadKey": "ukey", "urls": ["https://up.example/part1"]}}, - ) - router.add("PUT", "up.example/part1", {"status": 200}) - router.add("POST", "/signeds3upload", {"status": 200, "json": {}}) - return router - - -def test_upload_file_creates_new_item_when_folder_is_empty(tmp_path): - local = tmp_path / "model.ifc" - local.write_bytes(b"x" * 1024) - router = _upload_router() - router.add("GET", "/contents", {"json": {"data": [], "links": {}}}) - router.add( - "POST", - "/items", - { - "json": { - "data": {"id": "new-item"}, - "included": [ - { - "type": "versions", - "id": "v1", - "attributes": { - "versionNumber": 1, - "lastModifiedTime": "2026-01-01T00:00:00Z", - "lastModifiedUserName": "Dion", - }, - } - ], - } - }, - ) - result = make_client(router).upload_file_to_folder( - "proj", "folder", local, display_name="model.ifc" - ) - assert result["item_id"] == "new-item" - assert result["version_id"] == "v1" - assert result["version_number"] == 1 - assert router.count("PUT", "up.example/part1") == 1 - - -def test_upload_file_creates_a_version_when_item_exists(tmp_path): - local = tmp_path / "model.ifc" - local.write_bytes(b"x" * 1024) - router = _upload_router() - router.add( - "GET", - "/contents", - { - "json": { - "data": [ - { - "id": "existing-item", - "type": "items", - "attributes": {"displayName": "model.ifc", "extension": {}}, - } - ], - "links": {}, - } - }, - ) - router.add( - "POST", - "/versions", - {"json": {"data": {"id": "v7", "attributes": {"versionNumber": 7}}}}, - ) - result = make_client(router).upload_file_to_folder( - "proj", "folder", local, display_name="model.ifc" - ) - assert result["item_id"] == "existing-item" - assert result["version_id"] == "v7" - assert result["version_number"] == 7 - - -def test_upload_rejects_missing_local_file(tmp_path): - with pytest.raises(RpcError, match="does not exist"): - make_client(Router()).upload_file_to_folder( - "proj", "folder", tmp_path / "missing.ifc" - ) diff --git a/src/bonsaiviewer-autodesk/tests/test_cache.py b/src/bonsaiviewer-autodesk/tests/test_cache.py deleted file mode 100644 index 184ebe884d..0000000000 --- a/src/bonsaiviewer-autodesk/tests/test_cache.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for the on-disk cache layout (``bonsaiviewer_autodesk.cache``).""" - -from __future__ import annotations - -from bonsaiviewer_autodesk import cache - - -def test_cache_root_uses_xdg_on_linux(tmp_path, monkeypatch): - monkeypatch.setattr(cache.platform, "system", lambda: "Linux") - monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg")) - root = cache.cache_root() - assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk" - assert root.is_dir() - - -def test_cache_root_uses_localappdata_on_windows(tmp_path, monkeypatch): - monkeypatch.setattr(cache.platform, "system", lambda: "Windows") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata")) - root = cache.cache_root() - assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" / "Cache" - assert root.is_dir() - - -def test_cache_root_uses_library_caches_on_macos(tmp_path, monkeypatch): - monkeypatch.setattr(cache.platform, "system", lambda: "Darwin") - monkeypatch.setattr(cache.Path, "home", lambda: tmp_path / "home") - root = cache.cache_root() - assert root == tmp_path / "home" / "Library" / "Caches" / "bonsaiviewer-autodesk" - assert root.is_dir() - - -def test_ifcfed_dir_is_deterministic(cache_dir): - first = cache.ifcfed_dir("project-1", "item-1") - second = cache.ifcfed_dir("project-1", "item-1") - assert first == second - - -def test_ifcfed_dir_varies_with_inputs(cache_dir): - assert cache.ifcfed_dir("p", "item-1") != cache.ifcfed_dir("p", "item-2") - assert cache.ifcfed_dir("p1", "item") != cache.ifcfed_dir("p2", "item") - - -def test_model_dir_is_per_version(cache_dir): - v1 = cache.model_dir("p", "item", "v1") - v2 = cache.model_dir("p", "item", "v2") - assert v1 != v2 - assert cache.model_dir("p", "item", "v1") == v1 - - -def test_prepare_sole_child_dir_wipes_existing_contents(cache_dir): - directory = cache.ifcfed_dir("p", "item") - directory.mkdir(parents=True) - (directory / "stale.txt").write_text("old") - - result = cache.prepare_sole_child_dir(directory) - - assert result == directory - assert directory.is_dir() - assert list(directory.iterdir()) == [] - - -def test_prepare_sole_child_dir_creates_when_absent(cache_dir): - directory = cache.model_dir("p", "item", "v1") - assert not directory.exists() - cache.prepare_sole_child_dir(directory) - assert directory.is_dir() - - -def test_manifest_round_trips(cache_dir): - directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item")) - ifcfed_path = directory / "model.ifcfed" - ifcfed_path.write_text("data") - manifest = {"connector": "autodesk", "item_id": "item", "hub_id": "h"} - - manifest_path = cache.write_manifest(ifcfed_path, manifest) - - assert manifest_path.name == "model.ifcfed.manifest" - assert cache.read_manifest(ifcfed_path) == manifest - - -def test_read_manifest_returns_none_when_absent(cache_dir): - directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item")) - assert cache.read_manifest(directory / "model.ifcfed") is None diff --git a/src/bonsaiviewer-autodesk/tests/test_connector.py b/src/bonsaiviewer-autodesk/tests/test_connector.py deleted file mode 100644 index 6905108c63..0000000000 --- a/src/bonsaiviewer-autodesk/tests/test_connector.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Tests for the RPC handlers (``bonsaiviewer_autodesk.connector``). - -The non-interactive handlers are exercised against a fake ``ApsClient`` so no -network or GUI is touched; ``run_with_progress`` is stubbed out. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from bonsaiviewer_autodesk import cache, connector, settings -from bonsaiviewer_autodesk.rpc import RpcError - - -# --- fakes / fixtures -------------------------------------------------------- - - -def _fake_run_with_progress(_message, work, *, parent=None): - """Run ``work`` inline with a no-op report — no worker thread, no GUI.""" - return work(lambda *_args, **_kwargs: None) - - -class FakeAps: - """Stand-in for ApsClient covering only what the handlers call.""" - - def __init__(self, *, item: dict | None = None, items: dict | None = None) -> None: - self._item = item - self._items = items or {} - self.downloaded: list[tuple] = [] - self.uploaded: list[tuple] = [] - - def get_item(self, _project_id: str, item_id: str) -> dict: - if item_id in self._items: - return dict(self._items[item_id]) - assert self._item is not None, f"no canned item for {item_id!r}" - return dict(self._item) - - def download_storage_to_file(self, storage_id, destination_path, *, progress=None) -> None: - Path(destination_path).write_bytes(b"") - self.downloaded.append((storage_id, Path(destination_path))) - - def upload_file_to_folder( - self, project_id, folder_id, local_path, *, display_name=None, progress=None - ) -> dict: - self.uploaded.append((project_id, folder_id, Path(local_path), display_name)) - return { - "item_id": "uploaded-item", - "version_id": "v1", - "version_number": 1, - "last_modified_time_utc": "2026-01-01T00:00:00Z", - "last_modified_user_name": "Dion", - } - - -@pytest.fixture -def make_connector(config_dir, cache_dir, monkeypatch): - """Build an AutodeskConnector wired to a fake ApsClient.""" - monkeypatch.setattr(connector, "run_with_progress", _fake_run_with_progress) - - def _make(aps: FakeAps) -> connector.AutodeskConnector: - conn = connector.AutodeskConnector() - conn.aps = aps - conn.auth = object() # only identity matters to _require_aps - return conn - - return _make - - -def _ifcfed_item(**overrides) -> dict: - item = { - "id": "item-1", - "hidden": False, - "storage_id": "urn:adsk.objects:os.object:bucket/object", - "display_name": "Project.ifcfed", - "version_id": "v1", - "version_number": 1, - "last_modified_time_utc": None, - "last_modified_user_name": None, - "parent_folder_id": "folder-1", - } - item.update(overrides) - return item - - -# --- pure helpers ------------------------------------------------------------ - - -@pytest.mark.parametrize( - "value,expected", - [ - (0, "0 B"), - (512, "512 B"), - (1024, "1.0 KB"), - (1536, "1.5 KB"), - (5 * 1024 * 1024, "5.0 MB"), - ], -) -def test_format_bytes(value, expected): - assert connector._format_bytes(value) == expected - - -def test_progress_detail_combines_percent_and_bytes(): - detail = connector._progress_detail(45, 4_500_000, 10_000_000) - assert detail.startswith("45%, ") - assert " / " in detail - - -def test_progress_detail_percent_only(): - assert connector._progress_detail(50, None, None) == "50%" - - -def test_progress_detail_bytes_without_total(): - assert connector._progress_detail(None, 2048, None) == "2.0 KB" - - -def test_progress_detail_empty_when_nothing_known(): - assert connector._progress_detail(None, None, None) == "" - - -def test_build_metadata_maps_all_fields(): - metadata = connector._build_metadata( - { - "version_number": 3, - "last_modified_time_utc": "2026-01-01T00:00:00Z", - "last_modified_user_name": "Dion", - } - ) - assert metadata == { - "revision": "v3", - "date": "2026-01-01T00:00:00Z", - "author": "Dion", - } - - -def test_build_metadata_omits_missing_fields(): - assert connector._build_metadata({}) == {} - assert connector._build_metadata({"version_number": None}) == {} - - -def test_require_string_rejects_missing_or_blank(): - assert connector._require_string({"k": "v"}, "k") == "v" - with pytest.raises(RpcError): - connector._require_string({"k": " "}, "k") - with pytest.raises(RpcError): - connector._require_string({}, "k") - - -def test_require_object_and_array_type_checks(): - assert connector._require_object({"a": 1}, "p") == {"a": 1} - assert connector._require_array([1, 2], "p") == [1, 2] - with pytest.raises(RpcError): - connector._require_object([], "p") - with pytest.raises(RpcError): - connector._require_array({}, "p") - - -# --- credential wiring ------------------------------------------------------- - - -def test_reload_credentials_without_client_id_leaves_aps_unset(config_dir): - conn = connector.AutodeskConnector() - assert conn.aps is None - assert conn.auth is None - with pytest.raises(RpcError, match="client id"): - conn._require_aps() - - -def test_reload_credentials_with_client_id_builds_aps(config_dir): - settings.save_client_id("my-client-id") - conn = connector.AutodeskConnector() - assert conn.aps is not None - assert conn.auth is not None - - -# --- pull_ifcfed ------------------------------------------------------------- - - -def test_pull_ifcfed_downloads_and_writes_manifest(make_connector): - aps = FakeAps(item=_ifcfed_item()) - conn = make_connector(aps) - - result = conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"}) - - path = Path(result["path"]) - assert path.exists() - assert path.name == "Project.ifcfed" - assert aps.downloaded # the fake actually got asked to download - - manifest = cache.read_manifest(path) - assert manifest["connector"] == "autodesk" - assert manifest["item_id"] == "item-1" - assert manifest["hub_id"] == "h" - - -def test_pull_ifcfed_rejects_non_ifcfed_file(make_connector): - aps = FakeAps(item=_ifcfed_item(display_name="model.ifc")) - conn = make_connector(aps) - with pytest.raises(RpcError, match="ifcfed"): - conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"}) - - -def test_pull_ifcfed_rejects_deleted_item(make_connector): - aps = FakeAps(item=_ifcfed_item(hidden=True)) - conn = make_connector(aps) - with pytest.raises(RpcError, match="deleted"): - conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"}) - - -def test_pull_ifcfed_requires_string_fields(make_connector): - conn = make_connector(FakeAps(item=_ifcfed_item())) - with pytest.raises(RpcError, match="item_id"): - conn.pull_ifcfed({"hub_id": "h", "project_id": "p"}) - - -# --- pull_models ------------------------------------------------------------- - - -def test_pull_models_skips_failures_with_none(make_connector): - aps = FakeAps( - items={ - "good": _ifcfed_item(id="good", display_name="good.ifc"), - "gone": _ifcfed_item(id="gone", hidden=True), - } - ) - conn = make_connector(aps) - models = [ - {"source": {"connector": "autodesk", "project_id": "p", "item_id": "good"}}, - {"source": {"connector": "autodesk", "project_id": "p", "item_id": "gone"}}, - {"source": {"connector": "other", "project_id": "p", "item_id": "good"}}, - ] - - results = conn.pull_models(models) - - assert len(results) == 3 - assert results[0] is not None and Path(results[0]["path"]).exists() - assert results[1] is None # hidden/deleted item - assert results[2] is None # wrong connector -> RpcError, swallowed - - -def test_pull_models_requires_a_json_array(make_connector): - conn = make_connector(FakeAps(item=_ifcfed_item())) - with pytest.raises(RpcError, match="array"): - conn.pull_models({"not": "an array"}) - - -# --- push_ifcfed ------------------------------------------------------------- - - -def test_push_ifcfed_uploads_and_caches_with_manifest(make_connector, tmp_path): - local = tmp_path / "local.ifcfed" - local.write_bytes(b"ifcfed bytes") - aps = FakeAps(item=_ifcfed_item()) - conn = make_connector(aps) - - result = conn.push_ifcfed( - { - "path": str(local), - "manifest": { - "connector": "autodesk", - "hub_id": "h", - "project_id": "p", - "item_id": "item-1", - }, - } - ) - - assert aps.uploaded - cached = Path(result["path"]) - assert cached.exists() - assert cache.read_manifest(cached)["item_id"] == "uploaded-item" - - -def test_push_ifcfed_rejects_missing_local_file(make_connector): - conn = make_connector(FakeAps(item=_ifcfed_item())) - with pytest.raises(RpcError, match="does not exist"): - conn.push_ifcfed( - { - "path": "/no/such/file.ifcfed", - "manifest": { - "connector": "autodesk", - "hub_id": "h", - "project_id": "p", - "item_id": "item-1", - }, - } - ) - - -def test_push_ifcfed_rejects_non_ifcfed_extension(make_connector, tmp_path): - local = tmp_path / "local.ifc" - local.write_bytes(b"data") - conn = make_connector(FakeAps(item=_ifcfed_item())) - with pytest.raises(RpcError, match="ifcfed"): - conn.push_ifcfed({"path": str(local), "manifest": {"connector": "autodesk"}}) - - -def test_push_ifcfed_rejects_foreign_connector_manifest(make_connector, tmp_path): - local = tmp_path / "local.ifcfed" - local.write_bytes(b"data") - conn = make_connector(FakeAps(item=_ifcfed_item())) - with pytest.raises(RpcError, match="connector"): - conn.push_ifcfed({"path": str(local), "manifest": {"connector": "other"}}) diff --git a/src/bonsaiviewer-autodesk/tests/test_rpc.py b/src/bonsaiviewer-autodesk/tests/test_rpc.py deleted file mode 100644 index 5dcd904ab8..0000000000 --- a/src/bonsaiviewer-autodesk/tests/test_rpc.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Tests for the JSON-RPC host (``bonsaiviewer_autodesk.rpc``).""" - -from __future__ import annotations - -import io -import json - -from bonsaiviewer_autodesk.rpc import ( - JSONRPC_INTERNAL_ERROR, - JSONRPC_INVALID_PARAMS, - JSONRPC_INVALID_REQUEST, - JSONRPC_METHOD_NOT_FOUND, - JSONRPC_PARSE_ERROR, - JsonRpcHost, - RpcError, -) - - -def run(line: str, handlers: dict | None = None) -> tuple[list[dict], str]: - """Drive a ``JsonRpcHost`` over in-memory streams. - - Returns ``(responses, stderr_text)`` where ``responses`` is the parsed - JSON written to stdout, one element per line. - """ - out = io.StringIO() - err = io.StringIO() - JsonRpcHost( - handlers or {}, - stdin=io.StringIO(line), - stdout=out, - stderr=err, - ).run() - responses = [json.loads(piece) for piece in out.getvalue().splitlines() if piece] - return responses, err.getvalue() - - -def test_parse_error_for_invalid_json(): - responses, _ = run("this is not json\n") - assert responses[0]["error"]["code"] == JSONRPC_PARSE_ERROR - assert responses[0]["id"] is None - - -def test_request_must_be_a_json_object(): - responses, _ = run("[1, 2, 3]\n") - assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST - - -def test_wrong_jsonrpc_version_keeps_id(): - responses, _ = run('{"jsonrpc": "1.0", "id": 7, "method": "go"}\n') - assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST - assert responses[0]["id"] == 7 - - -def test_missing_method_string(): - responses, _ = run('{"jsonrpc": "2.0", "id": 1}\n') - assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST - - -def test_params_must_be_object_or_array(): - responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "go", "params": 5}\n') - assert responses[0]["error"]["code"] == JSONRPC_INVALID_PARAMS - - -def test_unknown_method(): - responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "nope"}\n') - assert responses[0]["error"]["code"] == JSONRPC_METHOD_NOT_FOUND - - -def test_successful_result_round_trip(): - responses, _ = run( - '{"jsonrpc": "2.0", "id": 42, "method": "echo", "params": {"x": 1}}\n', - {"echo": lambda params: params}, - ) - assert responses == [{"jsonrpc": "2.0", "id": 42, "result": {"x": 1}}] - - -def test_rpc_error_is_forwarded_with_code_and_data(): - def handler(_params): - raise RpcError(JSONRPC_INTERNAL_ERROR, "boom", data={"detail": "x"}) - - responses, _ = run( - '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n', - {"go": handler}, - ) - assert responses[0]["error"] == { - "code": JSONRPC_INTERNAL_ERROR, - "message": "boom", - "data": {"detail": "x"}, - } - - -def test_unexpected_exception_becomes_internal_error(): - def handler(_params): - raise ValueError("kaboom") - - responses, stderr = run( - '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n', - {"go": handler}, - ) - assert responses[0]["error"]["code"] == JSONRPC_INTERNAL_ERROR - assert responses[0]["error"]["message"] == "kaboom" - assert "Traceback" in stderr - - -def test_notification_runs_handler_but_writes_no_response(): - calls: list[int] = [] - responses, _ = run( - '{"jsonrpc": "2.0", "method": "go"}\n', - {"go": lambda _params: calls.append(1)}, - ) - assert calls == [1] - assert responses == [] - - -def test_blank_lines_skipped_and_requests_processed_in_order(): - line = ( - '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n' - "\n" - " \n" - '{"jsonrpc": "2.0", "id": 2, "method": "go"}\n' - ) - responses, _ = run(line, {"go": lambda _params: "ok"}) - assert [r["id"] for r in responses] == [1, 2] diff --git a/src/bonsaiviewer-autodesk/tests/test_settings.py b/src/bonsaiviewer-autodesk/tests/test_settings.py deleted file mode 100644 index 3f2a905417..0000000000 --- a/src/bonsaiviewer-autodesk/tests/test_settings.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for settings persistence (``bonsaiviewer_autodesk.settings``).""" - -from __future__ import annotations - -import json - -import pytest - -from bonsaiviewer_autodesk import settings - - -def _write_settings_json(config_dir, data: dict) -> None: - (config_dir / "settings.json").write_text(json.dumps(data), encoding="utf-8") - - -def test_config_root_uses_xdg_on_linux(tmp_path, monkeypatch): - monkeypatch.setattr(settings.platform, "system", lambda: "Linux") - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) - root = settings.config_root() - assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk" - assert root.is_dir() - - -def test_config_root_uses_appdata_on_windows(tmp_path, monkeypatch): - monkeypatch.setattr(settings.platform, "system", lambda: "Windows") - monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) - root = settings.config_root() - assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" - assert root.is_dir() - - -def test_config_root_uses_application_support_on_macos(tmp_path, monkeypatch): - monkeypatch.setattr(settings.platform, "system", lambda: "Darwin") - monkeypatch.setattr(settings.Path, "home", lambda: tmp_path / "home") - root = settings.config_root() - assert root == tmp_path / "home" / "Library" / "Application Support" / "bonsaiviewer-autodesk" - assert root.is_dir() - - -def test_save_and_load_client_id_strips_whitespace(config_dir): - settings.save_client_id(" abc123 ") - assert settings.load_client_id() == "abc123" - - -def test_load_client_id_empty_when_nothing_configured(config_dir): - assert settings.load_client_id() == "" - - -def test_callback_port_round_trips(config_dir): - settings.save_callback_port(9001) - assert settings.stored_callback_port() == 9001 - - -def test_callback_port_defaults_when_unset(config_dir): - assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT - - -def test_callback_port_defaults_on_non_numeric_value(config_dir): - _write_settings_json(config_dir, {"callback_port": "not-a-number"}) - assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT - - -def test_callback_port_defaults_on_out_of_range_value(config_dir): - _write_settings_json(config_dir, {"callback_port": 70000}) - assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT - - -def test_save_callback_port_rejects_out_of_range(config_dir): - with pytest.raises(ValueError): - settings.save_callback_port(0) - with pytest.raises(ValueError): - settings.save_callback_port(70000) - - -def test_corrupt_settings_file_is_treated_as_empty(config_dir): - (config_dir / "settings.json").write_text("{ not valid json", encoding="utf-8") - assert settings.load_client_id() == "" - assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT - - -def test_save_client_id_preserves_other_keys(config_dir): - settings.save_callback_port(9001) - settings.save_client_id("abc") - assert settings.stored_callback_port() == 9001 - assert settings.load_client_id() == "abc"