bonsaiviewer-autodesk: replace Python connector with the Rust impl

The Python implementation of the Autodesk Forma connector
(bonsaiviewer_autodesk/) is deprecated. The Rust port that's been
maturing under src/bonsaiviewer-autodesk-rs/ is now the connector
and takes over the original folder name.

## File operations

* `git rm -r src/bonsaiviewer-autodesk` — drop the 18 tracked Python
  source/test/packaging files. (~6.5k untracked build artefacts in
  venv/build/dist/egg-info are removed too, but those were never in
  the index.)
* `mv src/bonsaiviewer-autodesk-rs src/bonsaiviewer-autodesk` —
  the Rust impl takes over the canonical folder name.
* `rm -rf src/bonsaiviewer-autodesk-rs-egui` — abandoned egui-based
  experiment, never committed.
* `src/bonsaiviewer-autodesk/.gitignore` extended with `/dist` to
  keep packaging output out of the index alongside the existing
  `/target` rule.

The Rust binary in Cargo.toml already has `name = "bonsaiviewer-
autodesk"` and `connector.json`'s `exec` field already points at that
name — so the connector loader, build_viewer.sh symlink, and
win/build-all-win.py CONNECTOR_DIR all keep working without edits.

## Packaging shape preserved

`packaging/build.py` is rewritten to:

  * shell out to `cargo build --release` instead of pyinstaller,
  * copy the produced binary + connector.json into the same
    `dist/autodesk/` layout the PyInstaller flow produced,
  * zip into `dist/autodesk-<os>-<arch>.zip` with the same
    naming pattern (CI artifact uploads keep working).

The Rust binary statically links its deps, so unlike PyInstaller
there's no `_internal/` directory — single executable inside
`dist/autodesk/`. Everything downstream (`build_viewer.sh` symlink,
`win/build-all-win.py collect_connector_files`, the zip step in
`build_rocky.yml`) only cares that `dist/autodesk/` exists, so the
on-disk contract is preserved.

Verified locally: `python3 src/bonsaiviewer-autodesk/packaging/build.py`
produces `dist/autodesk/{bonsaiviewer-autodesk, connector.json}`
(3.9 MB stripped ELF) and `dist/autodesk-linux-x86_64.zip` (~1.5 MB
compressed).

## CI updates

* `.github/workflows/build_rocky.yml` and `build_rocky_arm.yml`:
  drop the `pip install ".[build]"` step — `packaging/build.py` is
  stdlib-only now, the cargo build wrapped inside it does the work.
* `.github/workflows/build_win.yml`: same — drop pip install,
  packaging script handles cargo internally.
* `.github/workflows/build-bonsaiviewer-autodesk.yml`: full rewrite
  of the dedicated connector test/build workflow. Replaces the
  Python {3.11, 3.13} test matrix with `cargo fmt --check`,
  `cargo clippy --all-targets -- -D warnings`, and `cargo test
  --all-features`. The OS/arch build matrix is unchanged
  (linux-x86_64, macos-arm64, macos-x86_64, windows-x86_64) but
  installs a Rust toolchain via dtolnay/rust-toolchain@stable and
  caches target/ via Swatinem/rust-cache.

`win/build-all-win.py` and `build_viewer.sh` are unchanged — they
only reference the `dist/autodesk/` path, which the new
`packaging/build.py` populates identically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-04 13:59:43 +10:00
parent b3fbcd6a66
commit 9d9f4054f6
46 changed files with 7581 additions and 3928 deletions
@@ -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
+5 -1
View File
@@ -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"
+5 -1
View File
@@ -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"
+4 -3
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/target
/dist
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
@@ -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())
@@ -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"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
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
@@ -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"))
@@ -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
@@ -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()
@@ -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)
@@ -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("<<TreeviewSelect>>", 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("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
self.tree.bind("<<TreeviewOpen>>", 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("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", 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("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", 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("<Return>", lambda _e: self._on_close())
self.bind("<Escape>", 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")
@@ -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");
}
@@ -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");
}
@@ -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<SchemeType>,
/// fltk-theme `WidgetTheme` overlay (default colors).
theme: Option<ThemeType>,
/// fltk-theme `ColorTheme` constant (palette).
color: Option<&'static [ColorMap]>,
}
fn presets() -> Vec<Preset> {
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<Preset>) {
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<String> = 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);
}
}
@@ -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",
)
+40 -64
View File
@@ -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-<os>-<arch>.zip` keep working
unchanged:
dist/autodesk-<os>-<arch>.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()
-32
View File
@@ -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"
+305
View File
@@ -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<String, RpcError> {
self.auth.ensure_access_token(noop_auth())
}
pub(crate) fn get_json(&self, url: &str) -> Result<Value, RpcError> {
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<Value, RpcError> {
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<Vec<Hub>, RpcError> {
let payload = self.get_json(&self.url("/project/v1/hubs"))?;
let mut hubs: Vec<Hub> = 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<Vec<Project>, RpcError> {
let mut url = self.url(&format!("/project/v1/hubs/{hub_id}/projects"));
let mut projects: Vec<Project> = 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<Vec<Entry>, RpcError> {
let payload = self.get_json(&self.url(&format!(
"/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
)))?;
let mut folders: Vec<Entry> = 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<Vec<Entry>, RpcError> {
let mut url = self.url(&format!(
"/data/v1/projects/{project_id}/folders/{folder_id}/contents"
));
if !object_types.is_empty() {
let qs: Vec<String> = object_types
.iter()
.map(|t| format!("filter[type]={}", url_enc(t)))
.collect();
url.push('?');
url.push_str(&qs.join("&"));
}
let mut entries: Vec<Entry> = 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<ItemTip, RpcError> {
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<String> {
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<ureq::Response, ureq::Error>,
url: &str,
) -> Result<Value, RpcError> {
match result {
Ok(resp) => resp
.into_json::<Value>()
.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)
}
+30
View File
@@ -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<AuthSessionService>,
pub(crate) agent: ureq::Agent,
pub(crate) base_url: String,
}
impl ApsClient {
pub fn new(auth: Arc<AuthSessionService>) -> Self {
Self::with_base_url(auth, "https://developer.api.autodesk.com".into())
}
pub fn with_base_url(auth: Arc<AuthSessionService>, base_url: String) -> Self {
Self {
auth,
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(120)).build(),
base_url,
}
}
}
@@ -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<ApsProgress>,
) -> 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<ApsProgress>,
) -> Result<UploadResult, RpcError> {
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<String, RpcError> {
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<ApsProgress>,
) -> 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<u64> = 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<String, RpcError> {
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<ApsProgress>,
) -> 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<String> = 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<Value, RpcError> {
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<Option<String>, 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<UploadResult, RpcError> {
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<UploadResult, RpcError> {
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<Value> = Some(json!(1));
let mut last_modified_time: Option<String> = None;
let mut last_modified_user: Option<String> = 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<usize> {
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());
}
}
@@ -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<String>,
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<String>,
pub storage_id: Option<String>,
pub version_number: Option<serde_json::Value>,
pub last_modified_time_utc: Option<String>,
pub last_modified_user_name: Option<String>,
pub parent_folder_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadResult {
pub item_id: String,
pub version_id: String,
pub display_name: String,
pub version_number: Option<serde_json::Value>,
pub last_modified_time_utc: Option<String>,
pub last_modified_user_name: Option<String>,
}
+597
View File
@@ -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<Option<StoredToken>, 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<String>) -> Self {
Self { service: KEYRING_SERVICE.to_string(), username: username.into() }
}
fn entry(&self) -> Result<keyring::Entry, RpcError> {
keyring::Entry::new(&self.service, &self.username).map_err(|_| no_keyring_error())
}
}
impl TokenStore for KeyringTokenStore {
fn load(&self) -> Result<Option<StoredToken>, 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<Utc>,
pub refresh_token_expires_at: DateTime<Utc>,
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<String, RpcError> {
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"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
} else {
b"<html><body><h2>Not Found</h2></body></html>"
};
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<String> = None;
let mut code: Option<String> = None;
let mut error: Option<String> = 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<dyn Fn(&str, u16, &str, &str) -> Result<String, RpcError> + 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<dyn Fn(&str) + Send + Sync>;
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<dyn TokenStore>,
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<dyn TokenStore>,
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<dyn TokenStore>) -> 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<Option<StoredToken>, 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<String, RpcError> {
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<StoredToken, RpcError> {
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<StoredToken, RpcError> {
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<Value, RpcError> {
match self.agent.post(&self.token_endpoint).send_form(form) {
Ok(resp) => resp
.into_json::<Value>()
.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<StoredToken, RpcError> {
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<Option<StoredToken>>,
}
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<Option<StoredToken>, 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<dyn TokenStore>, 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<Mutex<Option<StoredToken>>> = Arc::new(Mutex::new(None));
struct PeekStore {
shadow: Arc<Mutex<Option<StoredToken>>>,
inner: InMemoryTokenStore,
}
impl TokenStore for PeekStore {
fn load(&self) -> Result<Option<StoredToken>, 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");
}
}
+154
View File
@@ -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<Option<PathBuf>> = Mutex::new(None);
#[cfg(test)]
pub(crate) fn set_root_override(path: Option<PathBuf>) {
*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<PathBuf> {
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<T: Serialize>(ifcfed: &Path, manifest: &T) -> std::io::Result<PathBuf> {
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);
}
}
+654
View File
@@ -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<String>,
}
#[derive(Debug, Deserialize)]
struct PullModelEntry {
source: Source,
#[serde(default)]
display_name: Option<String>,
}
#[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<AuthSessionService>,
aps: Arc<ApsClient>,
}
pub struct AutodeskConnector {
inner: RefCell<Option<ClientPair>>,
}
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<ClientPair, RpcError> {
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<Self>) -> std::collections::HashMap<String, Handler> {
use std::collections::HashMap;
let mut map: HashMap<String, Handler> = 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<Value, RpcError> {
// Capture the connector's RefCell so SettingsDialog can ask us to
// rebuild credentials in-place after Save.
let inner_ptr: *const RefCell<Option<ClientPair>> = &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<dyn Fn() -> Result<(), RpcError>> = Arc::new(move || {
let cell: &RefCell<Option<ClientPair>> = 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<Value, RpcError> {
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<Value, RpcError> {
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<Value, RpcError> {
let pair = self.require()?;
let entries: Vec<PullModelEntry> = 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<Value> = 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<Value, RpcError> {
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<Value> = 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<Value, RpcError> {
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<Value, RpcError> {
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<Value, RpcError> {
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<Value, RpcError> {
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<crate::progress::ApsProgress>,
) -> Result<PathBuf, RpcError> {
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<crate::progress::ApsProgress>,
) -> Result<Option<Value>, 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<crate::progress::ApsProgress>,
) -> Result<Option<Value>, 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<PathBuf, RpcError> {
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<PathBuf, RpcError> {
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"));
}
}
+8
View File
@@ -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;
+17
View File
@@ -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());
}
+129
View File
@@ -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<dyn Fn(&str, &str, Option<i32>, Option<&str>) + Send + Sync>;
/// Auth-layer progress (no byte counts): `(phase, message, percent)`.
pub type AuthProgress = Arc<dyn Fn(&str, &str, Option<i32>) + Send + Sync>;
/// APS transfer progress: `(filename, percent, bytes_done, bytes_total)`.
pub type ApsProgress = Arc<dyn Fn(&str, Option<i32>, Option<u64>, Option<u64>) + 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<i32>, done: Option<u64>, total: Option<u64>) -> String {
let mut parts: Vec<String> = 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<(String, String, Option<i32>, Option<String>)>>,
}
impl ProgressBridge {
pub fn new() -> Arc<Self> {
Arc::new(Self { pending: Mutex::new(None) })
}
pub fn report_fn(self: Arc<Self>) -> 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<i32>, Option<String>)> {
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());
}
}
+233
View File
@@ -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<Value>,
}
impl RpcError {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self { code, message: message.into(), data: None }
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(JSONRPC_INTERNAL_ERROR, message)
}
pub fn invalid_params(message: impl Into<String>) -> 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<dyn Fn(Value) -> Result<Value, RpcError>>;
pub struct JsonRpcHost<R: Read, W: Write> {
handlers: HashMap<String, Handler>,
stdin: BufReader<R>,
stdout: W,
}
impl<R: Read, W: Write> JsonRpcHost<R, W> {
pub fn new(handlers: HashMap<String, Handler>, 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<Value>) {
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<String, Handler>, input: &str) -> Vec<Value> {
let mut buf: Vec<u8> = 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::<Value>(l).unwrap())
.collect()
}
fn echo_handlers() -> HashMap<String, Handler> {
let mut m: HashMap<String, Handler> = 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));
}
}
+136
View File
@@ -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<u16>,
}
/// Test seam: when set, overrides the platform config_root().
static OVERRIDE_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
#[cfg(test)]
pub(crate) fn set_root_override(path: Option<PathBuf>) {
*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());
}
}
+646
View File
@@ -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<RefCell<State>>` 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<Entry>,
}
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<Vec<Hub>, RpcError>),
ProjectsLoaded(Result<Vec<Project>, RpcError>),
TopFoldersLoaded(Result<Vec<Entry>, RpcError>),
FolderContentsLoaded(String, Result<Vec<Entry>, RpcError>),
}
struct State {
hubs: Vec<Hub>,
selected_hub: Option<usize>,
projects: Vec<Project>,
selected_project: Option<usize>,
tree_entries: HashMap<String, Entry>,
loaded_folders: HashSet<String>,
selected_entries: Vec<Entry>,
result: Option<BrowseChoice>,
}
pub struct BrowseDialog {
auth: Arc<AuthSessionService>,
aps: Arc<ApsClient>,
mode: Mode,
}
impl BrowseDialog {
pub fn new(auth: Arc<AuthSessionService>, aps: Arc<ApsClient>, mode: Mode) -> Self {
Self { auth, aps, mode }
}
pub fn run(self) -> Result<BrowseChoice, RpcError> {
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<RefCell<State>> = 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::<WorkerMsg>();
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<Entry> = 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<RefCell<State>>,
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<ApsClient>,
project_id: &str,
folder_id: &str,
mode: Mode,
) -> Result<Vec<Entry>, RpcError> {
let object_types: &[&str] = if mode == Mode::Destination {
&["folders"]
} else {
&["folders", "items"]
};
let filter_box: Option<Box<dyn Fn(&Entry) -> 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<String, Entry>,
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<String, Entry>) -> Vec<Entry> {
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)));
}
}
+221
View File
@@ -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<RefCell<…>>` 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<String> {
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<RefCell<Option<String>>> = 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<RefCell<bool>> = 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();
}
+38
View File
@@ -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<app::App> = 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
})
}
@@ -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<i32>, 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<T, F>(message: &str, work: F) -> Result<T, RpcError>
where
F: FnOnce(Report) -> Result<T, RpcError> + 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<Mutex<Option<Result<T, RpcError>>>> = 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::<Vec<_>>()
.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);
}
}
@@ -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<dyn Fn() -> Result<(), RpcError>>,
}
impl SettingsDialog {
pub fn new(on_reload: Arc<dyn Fn() -> 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::<u16>() 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();
}
}
+373
View File
@@ -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<thread::JoinHandle<()>>,
}
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<std::sync::Mutex<Vec<u8>>> = 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
}
@@ -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)
@@ -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<Value> {
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<Value> = 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));
}
@@ -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"
)
@@ -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
@@ -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"<ifc data>")
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"}})
-123
View File
@@ -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]
@@ -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"