mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -13,14 +13,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: test-py${{ matrix.python-version }}
|
name: cargo-test
|
||||||
runs-on: ubuntu-latest
|
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:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -29,20 +23,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- 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:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
workspaces: src/bonsaiviewer-autodesk
|
||||||
|
|
||||||
# The connector imports tkinter/customtkinter (via the test suite's
|
- name: cargo fmt --check
|
||||||
# connector coverage), so fail loudly here if Tk is missing.
|
run: cargo fmt --all -- --check
|
||||||
- name: Verify tkinter is available
|
|
||||||
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
|
||||||
|
|
||||||
- name: Install package and test deps
|
- name: cargo clippy
|
||||||
run: python -m pip install ".[test]"
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Run pytest
|
- name: cargo test
|
||||||
run: python -m pytest -q
|
run: cargo test --all-features
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: ${{ matrix.os_label }}-${{ matrix.arch }}
|
name: ${{ matrix.os_label }}-${{ matrix.arch }}
|
||||||
@@ -77,21 +73,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
workspaces: src/bonsaiviewer-autodesk
|
||||||
|
|
||||||
# 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]"
|
|
||||||
|
|
||||||
|
# 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
|
- name: Build connector bundle
|
||||||
run: python packaging/build.py
|
run: python3 packaging/build.py
|
||||||
|
|
||||||
- name: Upload connector zip
|
- name: Upload connector zip
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
VERSION=v`cat VERSION`
|
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
|
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
|
||||||
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
||||||
test -d "$autodesk_connector_dir"
|
test -d "$autodesk_connector_dir"
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
VERSION=v`cat VERSION`
|
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
|
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
|
||||||
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
||||||
test -d "$autodesk_connector_dir"
|
test -d "$autodesk_connector_dir"
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ jobs:
|
|||||||
|
|
||||||
# Build the Autodesk connector before the C++ build: build-all-win.py
|
# Build the Autodesk connector before the C++ build: build-all-win.py
|
||||||
# bundles it next to BonsaiViewer.exe while archiving the executables.
|
# 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
|
- name: Build Autodesk connector
|
||||||
working-directory: src/bonsaiviewer-autodesk
|
working-directory: src/bonsaiviewer-autodesk
|
||||||
run: |
|
run: python packaging/build.py
|
||||||
python -m pip install ".[build]"
|
|
||||||
python packaging/build.py
|
|
||||||
|
|
||||||
- name: Run Build Script And Pack .zip Archives
|
- name: Run Build Script And Pack .zip Archives
|
||||||
shell: cmd
|
shell: cmd
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/dist
|
||||||
Generated
+1959
File diff suppressed because it is too large
Load Diff
@@ -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",
|
|
||||||
)
|
|
||||||
@@ -1,38 +1,40 @@
|
|||||||
"""Build the Autodesk connector bundle for the current OS.
|
"""Build the Autodesk connector bundle for the current OS.
|
||||||
|
|
||||||
Each OS builds on itself (PyInstaller does not cross-compile). The output is a
|
Runs `cargo build --release` and packages the resulting binary +
|
||||||
single zip ready to drop into the Bonsai Viewer connectors directory:
|
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
|
dist/autodesk-<os>-<arch>.zip
|
||||||
autodesk/
|
autodesk/
|
||||||
connector.json
|
connector.json
|
||||||
bonsaiviewer-autodesk[.exe]
|
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:
|
Usage:
|
||||||
|
|
||||||
pip install -e ".[build]"
|
|
||||||
python packaging/build.py
|
python packaging/build.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
PACKAGING_DIR = PROJECT_ROOT / "packaging"
|
DIST_DIR = PROJECT_ROOT / "dist"
|
||||||
SPEC_FILE = PACKAGING_DIR / "bonsaiviewer-autodesk.spec"
|
TARGET_DIR = PROJECT_ROOT / "target" / "release"
|
||||||
DIST_DIR = PROJECT_ROOT / "dist"
|
|
||||||
BUILD_DIR = PROJECT_ROOT / "build"
|
|
||||||
|
|
||||||
CONNECTOR_FOLDER_NAME = "autodesk"
|
CONNECTOR_FOLDER_NAME = "autodesk"
|
||||||
PYINSTALLER_OUTPUT_NAME = "bonsaiviewer-autodesk"
|
BINARY_NAME = "bonsaiviewer-autodesk"
|
||||||
|
|
||||||
|
|
||||||
def _platform_tag() -> str:
|
def _platform_tag() -> str:
|
||||||
@@ -55,66 +57,40 @@ def _platform_tag() -> str:
|
|||||||
return f"{os_name}-{arch}"
|
return f"{os_name}-{arch}"
|
||||||
|
|
||||||
|
|
||||||
def _clean() -> None:
|
def _binary_name_for_host() -> str:
|
||||||
for path in (DIST_DIR, BUILD_DIR):
|
return f"{BINARY_NAME}.exe" if platform.system() == "Windows" else BINARY_NAME
|
||||||
if path.exists():
|
|
||||||
shutil.rmtree(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_pyinstaller() -> Path:
|
def main() -> None:
|
||||||
subprocess.check_call(
|
if DIST_DIR.exists():
|
||||||
[
|
shutil.rmtree(DIST_DIR)
|
||||||
sys.executable,
|
|
||||||
"-m",
|
print("Running cargo build --release ...")
|
||||||
"PyInstaller",
|
subprocess.run(
|
||||||
str(SPEC_FILE),
|
["cargo", "build", "--release"],
|
||||||
"--noconfirm",
|
|
||||||
"--distpath",
|
|
||||||
str(DIST_DIR),
|
|
||||||
"--workpath",
|
|
||||||
str(BUILD_DIR),
|
|
||||||
],
|
|
||||||
cwd=PROJECT_ROOT,
|
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:
|
bundle_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
|
||||||
connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
|
bundle_dir.mkdir(parents=True)
|
||||||
if connector_dir.exists():
|
shutil.copy(PROJECT_ROOT / "connector.json", bundle_dir / "connector.json")
|
||||||
shutil.rmtree(connector_dir)
|
shutil.copy(bin_src, bundle_dir / _binary_name_for_host())
|
||||||
pyinstaller_output.rename(connector_dir)
|
|
||||||
|
|
||||||
# The source-controlled connector.json uses the bare entry-point name so
|
zip_path = DIST_DIR / f"autodesk-{_platform_tag()}.zip"
|
||||||
# `pip install -e .` works for development. For the bundled folder, the
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
# binary lives next to connector.json, so rewrite `exec` to a relative path.
|
for path in bundle_dir.rglob("*"):
|
||||||
manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8"))
|
if path.is_file():
|
||||||
binary_name = "bonsaiviewer-autodesk.exe" if platform.system() == "Windows" else "bonsaiviewer-autodesk"
|
zf.write(path, path.relative_to(DIST_DIR))
|
||||||
manifest["exec"] = f"./{binary_name}"
|
print(f"Wrote {zip_path}")
|
||||||
(connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
print(f" {bundle_dir}")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
main()
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}})
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user