diff --git a/.gitignore b/.gitignore index 80fbb01a00..e6255fca43 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat *.claude *.py.tmp* *.json.tmp* + +# ifcviewer-autodesk connector build artifacts +/src/ifcviewer-autodesk/build/ +/src/ifcviewer-autodesk/dist/ diff --git a/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md b/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md new file mode 100644 index 0000000000..99840b1648 --- /dev/null +++ b/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md @@ -0,0 +1,504 @@ +# IfcViewer cloud connectors + +IfcViewer will have the capability to load and save projects and models from a +cloud platform. Later on, there will be other resources stored on cloud +platforms too, such as issues, clash results, and so on, but this behaviour is +not currently designed. + +Due to the variety of cloud platforms, the IfcViewer itself will depend on +a "connector" to integrate with each platform. The connector is a separate +application which will communicate to and from the IfcViewer. + +The following types of resources may be managed with a connector: + + - Projects (.ifcfed) + - Models (.ifc, .rdb, .ifcview, .rdbview) + - Issues (.bcf, not yet supported nor defined) + - Specifications (.ids, not yet supported nor defined) + +## Communication protocol + +The IfcViewer launches one connector process per session, on first use, and +keeps it alive for the duration of the session. This allows the connector to +maintain authentication tokens, browse state, in-flight downloads, and caches +in memory across calls without re-authenticating on every request. + +Communication is over stdio using newline-delimited JSON-RPC 2.0: + + - Requests and responses are single-line JSON objects on the connector's + stdin/stdout. Each message is terminated by a single `\n`. + - The connector must not emit literal newlines inside a JSON message. + - The connector may write arbitrary diagnostic output to stderr; the IfcViewer + will not parse it. + +The IfcViewer shuts a connector down by closing its stdin. The connector should +exit cleanly. If it does not exit within a few seconds, the IfcViewer will +terminate it. + +## Connector scope + +The IfcViewer has minimal knowledge about connectors. IfcViewer only knows how +to work with local files. If it detects that a project or model is not local, +it will invoke a connector. The connector's job is to resolve the IfcViewer's +request back into a local file and cloud metadata. The connector must not +modify the file in any way. + +A connector will handle anything necessary for the cloud platform (or arbitrary +data source). This includes authentication, browsing files, filters and +searches, selecting or pinning revisions, progress bars, cache, +platform-specific requirements, etc. The connector may or may not display a UI. +This makes connectors very flexible. + +A single project may have different resources coming from different connectors. +For example, some models might be on one platform, and some projects hosted on +another platform. The permissions regarding model access can be quite granular +and therefore managed by the platform, and not IfcViewer. + +When a connector returns a local file, that file is required to be the sole +child in its directory. This is because there may be adjacent temporary files, +viewer-generated sidecar files, database locks or helpers (e.g. SQLite WAL), or +where filenames are significant (and cannot be renamed to prevent collisions), +or are actually directories containing other files. + +The connector is expected to persist this cache until explicitly cleared by a +user, because it will be used directly as a local path by the viewer. If a +connector invalidates a cache, it can simply delete the entire directory. If a +connector resolves to a new version of the file, it can create a fresh +directory (thus all sidecar artefacts will be regenerated if needed). It is not +prescribed how a connector manages cache. + +## Resource: Projects + +A project is defined using an `.ifcfed` file. The file stores settings (such as +units, home view coordinates, saved searches, etc) and models. + +For example: + +```json +# project.ifcfed +{ + "created": "2026-04-29T21:22:36Z", + "modified": "2026-04-29T21:22:36Z", + "home_view": null, + "models": ... # see Resources: Models, + ... +} +``` + +A project may have a manifest file (with `.manifest` as a suffix), which may +store metadata that a ifcfed was retrieved from a cloud source. + +```json +# project.ifcfed.manifest +{ + "connector": "mycompany", + # Arbitrary connector-specific data + "version": "2", + "url": "http://example.com/project.ifcfed", +} +``` + +## Resource: Models + +The list of models is defined in the .ifcfed. Each model may either point to a +local file (via the special "local" connector), or to a cloud file. + +Cloud connections may store arbitrary source data as keys. For example, they +might store a revision policy that determines whether the model is pinned to a +particular revision or must always be the latest. This is completely up to the +connector. + +Here is an example of how models might be stored in an .ifcfed: + +```json +[ + { + "display_name": "foo.ifc", + "id": "0503642e-e2f6-4700-87fd-16479542e801", + "source": { + "connector": "local", + "path": "path/to/foo.ifc" # Only for local + }, + }, + { + "display_name": "bar.ifc", + "id": "5fc69e6a-1ff0-4d8a-82c6-2215df53d2ed", + "source": { + "connector": "autodesk", + # Below is arbitrary data depending on the connector + "version": "1", + "hub_id": "b.hub123", + "project_id": "b.project456", + "item_id": "urn:adsk.wipprod:dm.lineage:abc", + "version_id": "urn:adsk.wipprod:fs.file:vf.xyz?version=3" + }, + }, +] +``` + +Note that cloud metadata (filename, cloud ID, revision, date modified, etc) is +not specified nor stored in the .ifcfed. This is to be returned by the +connector when requested. + +The IfcViewer will display all returned cloud metadata as simple text strings. +However some keys are treated specially and shown in more places in the +IfcViewer UI for convenience: + + - author + - revision + - date + +## Open from cloud workflow + +This opens a .ifcfed from a cloud platform and constitutes a fresh session. +Note that downloading the latest versions of all models immediately upon open +is not required. At a minimum, only the .ifcfed needs to be opened. It is +perfectly acceptable to give the user choice on whether to download all or some +models, or use cache (even if outdated). The user can always reopen the project +later. + + 1. The user presses a button in the IfcViewer UI that says "Open from Cloud" + 2. The user chooses a connector. + 3. The `pull_ifcfed_interactive` method is sent to the connector. + ```json + { "jsonrpc": "2.0", "id": "0", "method": "pull_ifcfed_interactive" } + ``` + 4. The connector: + - (Does optional workflow) authenticates, browses projects, filters files, etc + - The user selects an .ifcfed file from the connector's UI + - Downloads (or retrieves from cache) the cloud .ifcfed into a connector managed directory + - The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file: + ```json + { "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } } + ``` + 5. The IfcViewer loads the `path`. This constitutes a fresh session. + 6. The IfcViewer calls `pull_models`: + ```json + { "jsonrpc": "2.0", "id": "1", "method": "pull_models", "params": [ + { "display_name": ..., "id": ..., "source": ..., }, + { "display_name": ..., "id": ..., "source": ..., }, + ... + ] } + ``` + 7. The connector handles downloading files. It may always check and download the latest version of the file, or be designed to pin to a particular revision, or give the user the option of not downloading a file, etc. or retrieves from its own cache, and returns a path. + ```json + { "jsonrpc": "2.0", "id": "1", "result": [ + { "path": "/path/to/foo.ifc" }, + { + "path": "/path/to/model.ifc", # Used to load the model in IfcViewer + "metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats + }, + null, # If skipped, error, etc + { "path": "/path/to/bar.ifc" }, + ... + ] } + ``` + 8. The IfcViewer may call another connector with more models to be downloaded. + 9. The IfcViewer will load the downloaded models as regular files. Typically this will also result in the IfcViewer reading / writing a cache (e.g. .ifcview) alongside this file, but it is not expected that the connector will know or care about this. + +## Sync cloud to local + +This refreshes cloud-sourced resources in the currently-open project to their +latest cloud revisions, without prompting the user. It is available whenever +the project has any cloud resources: a `.ifcfed.manifest` adjacent to the +.ifcfed, or one or more models whose `source.connector` is not `local`. The +.ifcfed-refresh phase and the model-refresh phase are independent — only the +first requires a manifest. + + 1. The user presses a button in the IfcViewer UI that says "Sync Cloud to Local" + 2. IfcViewer reads the .ifcfed.manifest and invokes the relevant connector with the manifest data with the `pull_ifcfed` method: + ```json + { "jsonrpc": "2.0", "id": "0", "method": "pull_ifcfed", "params": { + "connector": "mycompany", "version": "2", "url": ... + } } + ``` + 3. The connector does what it needs: + - Authenticates (optional) + - (Typically without user interaction) finds the .ifcfed on the cloud platform using the ifcfed manifest + - Downloads (or retrieves from cache) the cloud .ifcfed into a connector managed directory + - The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file: + ```json + { "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } } + ``` + 4. Continue with step 5 of the "Open from cloud" workflow. + +If no `.ifcfed.manifest` is present, steps 2–4 are skipped. The .ifcfed on +disk is used as-is, and the IfcViewer continues from step 6 of the +"Open from cloud" workflow (calling `pull_models` for any cloud-sourced models +referenced in the .ifcfed). + +Additionally, if the .ifcfed returned in step 3 is unchanged from the one +already loaded (e.g. the connector served a cached copy because the cloud +revision matched), the IfcViewer skips step 5 as well and continues from +step 6, preserving the current session rather than forcing an unnecessary +fresh one. How "unchanged" is determined (byte equality, hash, mtime, etc.) +is left to the IfcViewer. + +## Save as to cloud + +This pushes a .ifcfed to a fresh location on a cloud platform, chosen by the +user. It is the "Save As" equivalent and is the only way to first establish a +cloud location for a project that does not yet have a `.ifcfed.manifest`. + + 1. The user presses a button in the IfcViewer UI that says "Save As to Cloud" + 2. The user chooses a connector. + 3. The `push_ifcfed_interactive` method is called with the path to the .ifcfed. The connector should treat this as a temporary .ifcfed file, as the real project may or may not be actually saved on disk. + ```json + { "jsonrpc": "2.0", "id": "0", "method": "push_ifcfed_interactive", "params": { "path": "/tmp/path/to/project.ifcfed" } } + ``` + 4. The connector does what it needs: + - (Does optional workflow) authenticates, browses projects, filters files, etc + - Selects existing or writes a new name for an .ifcfed file + - Uploads .ifcfed file to the cloud platform + - The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file: + ```json + { "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } } + ``` + 5. The IfcViewer "repoints" to the returned path. It is not necessary to do a full reload as no "changes" are made. + +## Save to cloud + +This pushes a .ifcfed back to the cloud location it originally came from, +without prompting the user. It is the "Save" equivalent and is only available +when there is a `.ifcfed.manifest` adjacent to the project. + + 1. The user presses a button in the IfcViewer UI that says "Save to Cloud" + 2. IfcViewer reads the .ifcfed.manifest and invokes the relevant connector with the `push_ifcfed` method, passing both the local path and the manifest data: + ```json + { "jsonrpc": "2.0", "id": "0", "method": "push_ifcfed", "params": { + "path": "/tmp/path/to/project.ifcfed", + "manifest": { "connector": "mycompany", "version": "2", "url": "..." } + } } + ``` + 3. The connector does what it needs: + - Authenticates (optional) + - (Typically without user interaction) locates the existing .ifcfed on the cloud platform using the manifest data + - Uploads the .ifcfed, overwriting or creating a new revision as the platform dictates + - The connector returns a path to the .ifcfed and rewrites the adjacent .ifcfed.manifest if any of its fields have changed (e.g. a new version number): + ```json + { "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } } + ``` + 4. The IfcViewer "repoints" to the returned path. It is not necessary to do a full reload as no "changes" are made. + +Conflict resolution for non-interactive push methods (the cloud copy moved on +since the manifest or source was captured, the user lacks write permission, +revision-pinning policies, etc.) is entirely the connector's responsibility. +The connector may silently overwrite, prompt the user, refuse with a JSON-RPC +error, or anything in between. The IfcViewer expresses no opinion. This rule +also applies to `push_model` below. + +## Add model from cloud + + 1. The user presses a button in the IfcViewer UI that says "Add model from cloud" + 2. The user chooses a connector. + 3. The `pull_models_interactive` method is sent to the connector. + ```json + { "jsonrpc": "2.0", "id": "0", "method": "pull_models_interactive" } + ``` + 4. The connector does what it needs: + - (Does optional workflow) authenticates, browses projects, filters files, etc + - Selects a model (.ifc, .ifcview, .rdbview, .rdb, etc) + - Downloads (or retrieves from cache) the model into a connector managed directory + - The connector returns a successful result: + ```json + { "jsonrpc": "2.0", "id": "0", "result": [ + { + "display_name": "bar.ifc", # Stored in .ifcfed + "source": { "connector": "autodesk", ... }, # Stored in .ifcfed + "path": "/path/to/model.ifc", # Used to load the model in IfcViewer + "metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats + }, + { ... }, + ... + ] } + ``` + 5. The IfcViewer updates the .ifcfed models section with new models using the + "source" and "display\_name" from the provided data. The models are + immediately loaded from the "path", and the IfcViewer stores the "metadata" + for display. The path and metadata is never stored in the .ifcfed. + +## Save model as to cloud + +This pushes a model to a fresh location on a cloud platform, chosen by the +user. It is the "Save As" equivalent and is the only way to first establish a +cloud `source` for a model whose current source is `local`. + + 1. The user presses a button in the IfcViewer UI that says "Save Model As to Cloud" + 2. The user chooses a connector. + 3. The `push_model_interactive` method is sent to the connector with a path to the model to be uploaded (typically a file, but RocksDB databases can be a folder). + ```json + { "jsonrpc": "2.0", "id": "0", "method": "push_model_interactive", "params": { "path": "/tmp/path/to/model.ifc" } } + ``` + 4. The connector does what it needs: + - (Does optional workflow) authenticates, browses projects, filters files, etc + - Selects existing or types a new name for the model + - Uploads the model (only the file in params, though the connector is free to do optional additional work) to the cloud platform + - The connector returns a successful result: + ```json + { "jsonrpc": "2.0", "id": "0", "result": { + "display_name": "bar.ifc", # Stored in .ifcfed + "path": "/path/to/model.ifc", + "source": { "connector": "autodesk", ... }, # Stored in .ifcfed + "metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats + } } + ``` + 5. The IfcViewer updates the .ifcfed models section with the new model metadata from the provided data. + +## Save model to cloud + +This pushes a model back to the cloud location it originally came from, +without prompting the user. It is the "Save" equivalent and is only available +for models whose .ifcfed `source` already points at a cloud connector (i.e. +anything other than `local`). + + 1. The user presses a button in the IfcViewer UI that says "Save Model to Cloud" + 2. IfcViewer invokes the connector named in the model's `source` with the `push_model` method, passing both the local path and the existing `source` object verbatim: + ```json + { "jsonrpc": "2.0", "id": "0", "method": "push_model", "params": { + "path": "/tmp/path/to/model.ifc", + "source": { "connector": "autodesk", "hub_id": "b.hub123", "item_id": "...", ... } + } } + ``` + 3. The connector does what it needs: + - Authenticates (optional) + - (Typically without user interaction) locates the existing model on the cloud platform using the `source` data + - Uploads the model, overwriting or creating a new revision as the platform dictates + - The connector returns a successful result. The returned `source` reflects the just-uploaded revision (e.g. a new `version_id`) and replaces the existing one in the .ifcfed; `display_name` is omitted (the existing one is retained): + ```json + { "jsonrpc": "2.0", "id": "0", "result": { + "source": { "connector": "autodesk", ... }, # Replaces existing source in .ifcfed + "metadata": { "revision": "C", "date": "19th May 2026" ... }, # Optional, used to display stats + } } + ``` + 4. The IfcViewer replaces the model's `source` in the .ifcfed and refreshes the stored metadata for display. + +## Connector settings (optional) + +A connector MAY implement an `open_settings` method that the IfcViewer invokes +when the user clicks the connector's settings entry (e.g. a gear icon next to +the connector name). The connector is responsible for the entire settings UI: +credentials, sign-out, default folders, anything connector-specific. + + 1. The user clicks the connector's settings entry in the IfcViewer UI. + 2. The IfcViewer sends `open_settings`: + ```json + { "jsonrpc": "2.0", "id": "0", "method": "open_settings" } + ``` + 3. The connector shows its own settings dialog. When the user closes it, the + connector returns: + ```json + { "jsonrpc": "2.0", "id": "0", "result": {} } + ``` + +If the connector returns a JSON-RPC `Method not found` error (code `-32601`), +the IfcViewer should treat that connector as having no settings and hide its +settings entry. There is no other discovery mechanism — the viewer probes by +calling the method when needed. + +The connector is free to use this method for things like: + + - Signing in / signing out + - Setting API keys, client ids, or other credentials + - Choosing default upload folders or revision policies + - Clearing the connector's cache + +The result object is currently always empty (`{}`); future revisions may add +optional fields (for example a fresh display label for the connector). + +### Error Response + +The connector owns all user-facing error handling: dialogs, retry prompts, +re-auth flows, logs. The IfcViewer does not interpret or display connector +errors directly. + +The protocol expresses only two outcomes: + + - **Per-item soft failure** (one model in a batch failed, others succeeded): + the connector returns `null` in that slot of the result array. The IfcViewer + skips it and continues. + - **Whole-call hard failure** (the connector cannot service the request at + all): the connector returns a JSON-RPC error object. The IfcViewer aborts + the operation. The `error.message` may be logged by the IfcViewer for + diagnostics, but is not shown to the user — the connector is expected to + have already surfaced the problem in its own UI. + +Diagnostic detail (stack traces, codes, retry context) should be written to +stderr, which the IfcViewer captures for logs. + +## Permissions + +Permissions are completely managed by the connector. For example: + +- one Autodesk model resolves successfully +- one Aconex model fails with access denied +- one Dropbox model resolves successfully + +The viewer will tolerate partial failure and report skipped resources that the connector cannot resolve. A federation does not need to become all-or-nothing just because some remote models are permission-restricted. + +## Connector discovery + +A connector is shipped as a folder containing a `connector.json` manifest and +an executable entry point. The IfcViewer discovers connectors by scanning a +small, fixed set of locations for these folders. + +### Connector bundle layout + +``` +/ + autodesk/ # folder name is arbitrary; id comes from connector.json + connector.json # required, at the folder root + ifcviewer-autodesk # the executable (or a wrapper script) + ... # anything else the connector ships +``` + +### `connector.json` + +```json +{ + "id": "autodesk", + "name": "Autodesk Forma", + "version": "0.1.0", + "exec": "./ifcviewer-autodesk" +} +``` + + - `id` — stable identifier used in `.ifcfed` `source.connector` fields and in + `.ifcfed.manifest`. Must be unique across all discovered connectors. + - `name` — human-readable label shown in the IfcViewer UI. + - `version` — connector version string; informational only. + - `exec` — how to launch the connector: + - Relative path (starts with `./` or `../`): resolved against the + connector folder. This is the recommended form for bundled connectors. + - Absolute path: used as-is. + - Bare name (no path separators): looked up via the system `PATH`. + + On Windows, the IfcViewer will also try `.exe` if `` does not + exist as written. + +### Search locations + +The IfcViewer scans, in order of precedence (first match wins for a given `id`): + + 1. **`IFCVIEWER_CONNECTOR_PATH` environment variable.** A list of directories + separated by the platform path separator (`:` on Linux/macOS, `;` on + Windows). Intended for development and unusual installs. + 2. **User connectors directory.** The platform's per-user application data + location: + - Linux: `~/.local/share/IfcOpenShell/IfcViewer/connectors/` + - macOS: `~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/` + - Windows: `%APPDATA%\IfcOpenShell\IfcViewer\connectors\` + +In each search location, the IfcViewer looks at every immediate subdirectory +and treats it as a connector iff it contains a `connector.json`. Connectors +are launched on demand when the user invokes a cloud workflow, not at startup. + +### Conflicts and errors + + - If two folders declare the same `id`, the one found earlier in the search + order wins; the loser is skipped and a warning is written to the IfcViewer's + log. + - A `connector.json` that is missing, unreadable, malformed, or missing + required fields causes that folder to be skipped (with a log entry); other + connectors are unaffected. + - A connector whose `exec` cannot be resolved or launched is reported to the + user only when the user actually tries to invoke it. diff --git a/src/ifcviewer-autodesk/README.md b/src/ifcviewer-autodesk/README.md new file mode 100644 index 0000000000..5a4c45b3cf --- /dev/null +++ b/src/ifcviewer-autodesk/README.md @@ -0,0 +1,97 @@ +# `ifcviewer-autodesk` + +Autodesk Forma (APS / Docs) connector for IfcViewer. + +Implements the JSON-RPC connector contract defined in +[`CLOUD_SYNC_PROTOCOL.md`](CLOUD_SYNC_PROTOCOL.md). The connector is a separate +process the viewer launches and speaks to over stdio. + +UI is built on **CustomTkinter** (Tcl/Tk under the hood), keeping the +packaged connector around 50 MB unpacked / 21 MB zipped on Linux. The Python +running this code must include `tkinter` (most distribution Python builds do; +on Gentoo make sure `USE="tk"` is set for `dev-lang/python`). + +## Install + +```bash +cd src/ifcviewer-autodesk +python -m venv venv +source venv/bin/activate +pip install -e . +``` + +## Run + +```bash +ifcviewer-autodesk +``` + +The connector launches without any configuration; on first run, invoke +`open_settings` (or, equivalently, set the `APS_CLIENT_ID` env var) to +configure the Autodesk client id. + +Then send newline-delimited JSON-RPC 2.0 requests on `stdin`. Examples: + +```json +{"jsonrpc":"2.0","id":"0","method":"open_settings"} +{"jsonrpc":"2.0","id":"1","method":"pull_ifcfed_interactive"} +{"jsonrpc":"2.0","id":"2","method":"pull_models","params":[{"display_name":"foo.ifc","id":"abc","source":{"connector":"autodesk","hub_id":"b.hub","project_id":"b.proj","item_id":"urn:adsk...","version_id":"latest"}}]} +{"jsonrpc":"2.0","id":"3","method":"push_ifcfed_interactive","params":{"path":"/tmp/project.ifcfed"}} +{"jsonrpc":"2.0","id":"4","method":"push_ifcfed","params":{"path":"/tmp/project.ifcfed","manifest":{"connector":"autodesk","hub_id":"b.hub","project_id":"b.proj","item_id":"urn:adsk..."}}} +``` + +The viewer is expected to launch this binary once per session and keep it alive +until shutdown; closing the connector's `stdin` triggers a clean exit. + +### Configuration + +The connector reads the Autodesk client id from two places, in order: + +1. The `APS_CLIENT_ID` environment variable (takes precedence — useful for dev + overrides). +2. `/settings.json` (persisted via the settings dialog). + +The config directory is platform-specific: + +- Linux: `~/.config/ifcviewer-autodesk/` +- macOS: `~/Library/Application Support/ifcviewer-autodesk/` +- Windows: `%APPDATA%\ifcviewer-autodesk\` + +OAuth tokens are stored in the OS keychain (Secret Service on Linux, Keychain +on macOS, Credential Manager on Windows), keyed by the client id, so changing +the client id starts a fresh session. + +## Cache + +The connector owns its own cache. Resolved files live under (Linux): + +``` +~/.cache/ifcviewer-autodesk/ + ifcfeds//.ifcfed[.manifest] + models// +``` + +Each file is the sole child in its directory so the viewer can write sidecar +files (e.g. `.ifcview`) next to it without colliding. A new resolved version of +a model lands in a fresh `models//` directory; the old directory may be +removed manually to clear space. + +## Status + +What is implemented: + +- Strict JSON-RPC 2.0 host over stdio +- APS PKCE sign-in with keyring-backed token store +- Hub / project / folder browsing (Qt UI) +- `pull_ifcfed_interactive`, `pull_ifcfed`, `pull_models`, `pull_models_interactive` +- `push_ifcfed_interactive`, `push_ifcfed`, `push_model_interactive`, `push_model` +- `open_settings` — edit the client id, sign out +- Connector-managed cache with sole-child invariant +- Adjacent `.ifcfed.manifest` written/read alongside `.ifcfed` files + +What is intentionally not implemented: + +- JSON-RPC notifications for progress streaming (the connector shows its own + progress dialog instead, per spec) +- Cancellation of in-flight downloads +- Subdirectory upload layouts inside push destinations (one flat file at a time) diff --git a/src/ifcviewer-autodesk/connector.json b/src/ifcviewer-autodesk/connector.json new file mode 100644 index 0000000000..6246935b2d --- /dev/null +++ b/src/ifcviewer-autodesk/connector.json @@ -0,0 +1,6 @@ +{ + "id": "autodesk", + "name": "Autodesk Forma", + "version": "0.1.0", + "exec": "ifcviewer-autodesk" +} diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/__init__.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/__main__.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/__main__.py new file mode 100644 index 0000000000..54e665a2fe --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/__main__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import sys + +from ifcviewer_autodesk.connector import AutodeskConnector +from ifcviewer_autodesk.rpc import JsonRpcHost +from ifcviewer_autodesk.ui import ensure_tk_app + + +def main() -> int: + ensure_tk_app() + connector = AutodeskConnector() + host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr) + return host.run() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/autodesk.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/autodesk.py new file mode 100644 index 0000000000..23ea5ea800 --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/autodesk.py @@ -0,0 +1,717 @@ +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 ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError + + +Progress = Callable[[str, str, "int | None"], None] + + +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 + + +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, + ) -> 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) + + 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 = dt.datetime.now(dt.timezone.utc) + 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._wait_for_callback( + 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 = dt.datetime.now(dt.timezone.utc) + 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, + ) + + def _wait_for_callback(self, host: str, port: int, path: str, expected_state: str) -> str: + result: dict[str, str] = {} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path != path: + self.send_response(404) + self.end_headers() + return + query = urllib.parse.parse_qs(parsed.query) + result["state"] = query.get("state", [""])[0] + result["code"] = query.get("code", [""])[0] + result["error"] = query.get("error", [""])[0] + body = b"

Authentication complete. You can close this window.

" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + return + + server = HTTPServer((host, port), Handler) + server.handle_request() + server.server_close() + + if result.get("error"): + raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.") + if result.get("state") != expected_state: + raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.") + code = result.get("code", "") + if not code: + raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.") + return code + + +class ApsClient: + def __init__(self, auth: AuthSessionService) -> None: + self.auth = auth + self.http = httpx.Client(timeout=120) + + # Browsing ----------------------------------------------------------------- + + def list_hubs(self) -> list[dict[str, Any]]: + payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs") + return [ + { + "id": item["id"], + "name": item["attributes"]["name"], + "extension_type": item["attributes"]["extension"]["type"], + } + for item in payload.get("data", []) + ] + + 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 "" + 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" + ) + return [self._entry(item) for item in payload.get("data", [])] + + 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 "" + 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], 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], 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], None] | None, + ) -> None: + try: + with self.http.stream("GET", url) as response: + response.raise_for_status() + total_bytes = 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) + elif progress: + progress(destination_path.name, None) + 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], 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) + + if not upload_key: + raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.") + self._complete_signed_upload(bucket_key, object_key, upload_key) + + def _get_signed_upload_urls( + self, + bucket_key: str, + object_key: str, + *, + upload_key: str | None, + first_part: int, + parts: int, + ) -> dict[str, Any]: + token = self.auth.ensure_access_token() + params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts} + if upload_key: + params["uploadKey"] = upload_key + try: + response = self.http.get( + "https://developer.api.autodesk.com/oss/v2/buckets/" + f"{urllib.parse.quote(bucket_key, safe='')}/objects/" + f"{urllib.parse.quote(object_key, safe='')}/signeds3upload", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + body = exc.response.text.strip() + raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc + + def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None: + token = self.auth.ensure_access_token() + try: + response = self.http.post( + "https://developer.api.autodesk.com/oss/v2/buckets/" + f"{urllib.parse.quote(bucket_key, safe='')}/objects/" + f"{urllib.parse.quote(object_key, safe='')}/signeds3upload", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"uploadKey": upload_key}, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + body = exc.response.text.strip() + raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc + + def _put_bytes(self, url: str, content: bytes) -> None: + try: + response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"}) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + body = exc.response.text.strip() + raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc + + def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None: + children = self.list_folder_contents(project_id, folder_id, object_types=["items"]) + return next( + ( + child for child in children + if self._entry_name_matches(child, file_name) + ), + None, + ) + + def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]: + payload = self._post_json( + f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions", + { + "jsonapi": {"version": "1.0"}, + "data": { + "type": "versions", + "attributes": { + "name": file_name, + "extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"}, + }, + "relationships": { + "item": {"data": {"type": "items", "id": item_id}}, + "storage": {"data": {"type": "objects", "id": storage_id}}, + }, + }, + }, + ) + version = payload["data"] + attributes = version.get("attributes", {}) + return { + "item_id": item_id, + "version_id": version["id"], + "display_name": file_name, + "version_number": attributes.get("versionNumber"), + "last_modified_time_utc": attributes.get("lastModifiedTime"), + "last_modified_user_name": attributes.get("lastModifiedUserName"), + } + + def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]: + payload = self._post_json( + f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items", + { + "jsonapi": {"version": "1.0"}, + "data": { + "type": "items", + "attributes": { + "displayName": file_name, + "extension": {"type": "items:autodesk.bim360:File", "version": "1.0"}, + }, + "relationships": { + "tip": {"data": {"type": "versions", "id": "1"}}, + "parent": {"data": {"type": "folders", "id": folder_id}}, + }, + }, + "included": [ + { + "type": "versions", + "id": "1", + "attributes": { + "name": file_name, + "extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"}, + }, + "relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}}, + } + ], + }, + ) + item = payload["data"] + version_id = "1" + version_number: Any = 1 + last_modified_time: Any = None + last_modified_user: Any = None + for included in payload.get("included", []): + if included.get("type") == "versions": + version_id = included.get("id") or version_id + attributes = included.get("attributes", {}) + version_number = attributes.get("versionNumber", version_number) + last_modified_time = attributes.get("lastModifiedTime") + last_modified_user = attributes.get("lastModifiedUserName") + break + return { + "item_id": item["id"], + "version_id": version_id, + "display_name": file_name, + "version_number": version_number, + "last_modified_time_utc": last_modified_time, + "last_modified_user_name": last_modified_user, + } + + # Static helpers ----------------------------------------------------------- + + @staticmethod + def _parse_storage_id(storage_id: str) -> tuple[str, str]: + marker = "urn:adsk.objects:os.object:" + if not storage_id.startswith(marker): + raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.") + path = storage_id[len(marker):] + slash = path.find("/") + if slash <= 0 or slash == len(path) - 1: + raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.") + return path[:slash], path[slash + 1 :] + + @staticmethod + def _entry(item: dict[str, Any]) -> dict[str, Any]: + attributes = item["attributes"] + return { + "id": item["id"], + "type": item["type"], + "display_name": attributes.get("displayName") or attributes.get("name") or "", + "name": attributes.get("name"), + "extension_type": attributes.get("extension", {}).get("type", ""), + } + + @staticmethod + def _relationship_id(data: dict[str, Any], name: str) -> str | None: + rel_data = data.get("relationships", {}).get(name, {}).get("data") + if isinstance(rel_data, dict): + rel_id = rel_data.get("id") + return rel_id if isinstance(rel_id, str) and rel_id else None + if isinstance(rel_data, list) and rel_data: + rel_id = rel_data[0].get("id") + return rel_id if isinstance(rel_id, str) and rel_id else None + return None + + @staticmethod + def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool: + display_name = str(entry.get("display_name") or "").lower() + raw_name = str(entry.get("name") or "").lower() + expected = expected_name.lower() + return display_name == expected or raw_name == expected diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/cache.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/cache.py new file mode 100644 index 0000000000..1c454f2b21 --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/cache.py @@ -0,0 +1,62 @@ +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) / "ifcviewer-autodesk" / "Cache" + elif system == "Darwin": + root = Path.home() / "Library" / "Caches" / "ifcviewer-autodesk" + else: + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + root = Path(base) / "ifcviewer-autodesk" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _short_hash(*parts: str) -> str: + joined = "\x1f".join(parts) + return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16] + + +def ifcfed_dir(project_id: str, item_id: str) -> Path: + """Stable directory for an .ifcfed. Re-downloads overwrite in place so the + viewer's open path remains valid across sync operations.""" + return cache_root() / "ifcfeds" / _short_hash(project_id, item_id) + + +def model_dir(project_id: str, item_id: str, version_id: str) -> Path: + """Per-version directory for a model. A new resolved version → a new + directory, satisfying the spec's invariant that sidecars regenerate when + the model file changes.""" + return cache_root() / "models" / _short_hash(project_id, item_id, version_id) + + +def prepare_sole_child_dir(directory: Path) -> Path: + """Clear the directory so the file we write is the only child.""" + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path: + manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest") + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return manifest_path + + +def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None: + manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest") + if not manifest_path.exists(): + return None + return json.loads(manifest_path.read_text(encoding="utf-8")) diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/connector.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/connector.py new file mode 100644 index 0000000000..4b3bad01d7 --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/connector.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import sys +import traceback +from pathlib import Path +from typing import Any, Callable + +from ifcviewer_autodesk import cache, settings +from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore +from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError +from ifcviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename + + +ApsProgress = Callable[[str, "int | None"], None] +Report = Callable[[str, str, "int | None"], None] + + +def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress: + """Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg). + + 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) -> None: + suffix = f" ({index}/{total})" if total else "" + report("download", f"Downloading {name}{suffix}", percent) + return cb + + +def _upload_callback(report: Report) -> ApsProgress: + def cb(name: str, percent: int | None) -> None: + report("upload", f"Uploading {name}", percent) + return cb + + +CONNECTOR_ID = "autodesk" +KEYRING_SERVICE = "ifcviewer-autodesk" +DEFAULT_CALLBACK_URL = "http://localhost:8080/" +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) + self.auth = AuthSessionService( + client_id=client_id, + callback_url=DEFAULT_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["entry"] + with progress_dialog("Downloading project") as report: + path = 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 + with progress_dialog("Downloading project") as report: + path = 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") + results: list[dict[str, Any] | None] = [] + total = len(models) + with progress_dialog("Downloading models") as report: + 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 + + 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"] + entry = chosen["entry"] + item = aps.get_item(project["id"], entry["id"]) + if item["hidden"]: + raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item 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) + with progress_dialog("Downloading model") as report: + aps.download_storage_to_file( + storage_id, model_path, progress=_download_callback(report) + ) + + 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["entry"] + + 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" + + with progress_dialog("Uploading project") as report: + uploaded = 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 + + with progress_dialog("Uploading project") as report: + uploaded = 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["entry"] + + 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.") + + with progress_dialog("Uploading model") as report: + uploaded = 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 + + with progress_dialog("Uploading model") as report: + uploaded = aps.upload_file_to_folder( + project_id, + folder_id, + local_path, + display_name=file_name, + progress=_upload_callback(report), + ) + + directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"]) + cache.prepare_sole_child_dir(directory) + cached_path = directory / file_name + cached_path.write_bytes(local_path.read_bytes()) + + return { + "source": { + "connector": CONNECTOR_ID, + "hub_id": hub_id, + "project_id": project_id, + "item_id": uploaded["item_id"], + }, + "metadata": _build_metadata(uploaded), + } + + +def _require_object(params: Any, name: str) -> dict[str, Any]: + if not isinstance(params, dict): + raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.") + return params + + +def _require_array(params: Any, name: str) -> list[Any]: + if not isinstance(params, list): + raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.") + return params + + +def _require_string(obj: dict[str, Any], key: str) -> str: + value = obj.get(key) + if not isinstance(value, str) or not value.strip(): + raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.") + return value + + +def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]: + metadata: dict[str, Any] = {} + version_number = version_info.get("version_number") + if version_number is not None: + metadata["revision"] = f"v{version_number}" + last_modified = version_info.get("last_modified_time_utc") + if isinstance(last_modified, str) and last_modified: + metadata["date"] = last_modified + author = version_info.get("last_modified_user_name") + if isinstance(author, str) and author: + metadata["author"] = author + return metadata diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/rpc.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/rpc.py new file mode 100644 index 0000000000..a91ccbe0ef --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/rpc.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +import sys +import traceback +from typing import Any, Callable, TextIO + + +JSONRPC_PARSE_ERROR = -32700 +JSONRPC_INVALID_REQUEST = -32600 +JSONRPC_METHOD_NOT_FOUND = -32601 +JSONRPC_INVALID_PARAMS = -32602 +JSONRPC_INTERNAL_ERROR = -32603 + + +class RpcError(Exception): + def __init__(self, code: int, message: str, data: Any | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +Handler = Callable[[Any], Any] + + +class JsonRpcHost: + def __init__( + self, + handlers: dict[str, Handler], + *, + stdin: TextIO = sys.stdin, + stdout: TextIO = sys.stdout, + stderr: TextIO = sys.stderr, + ) -> None: + self.handlers = handlers + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + + def run(self) -> int: + for line in self.stdin: + line = line.strip() + if not line: + continue + self._handle_line(line) + return 0 + + def _handle_line(self, line: str) -> None: + message_id: Any = None + try: + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}") + return + + if not isinstance(message, dict): + self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object") + return + if message.get("jsonrpc") != "2.0": + self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version") + return + + message_id = message.get("id") + method = message.get("method") + if not isinstance(method, str): + self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string") + return + + params = message.get("params", None) + if params is not None and not isinstance(params, (dict, list)): + self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array") + return + + handler = self.handlers.get(method) + if handler is None: + self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'") + return + + try: + result = handler(params) + except RpcError as exc: + self._respond_error(message_id, exc.code, exc.message, exc.data) + return + except Exception as exc: + print(f"Handler '{method}' raised: {exc}", file=self.stderr) + traceback.print_exc(file=self.stderr) + self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc)) + return + + if message_id is not None: + self._respond_result(message_id, result) + except Exception as exc: + print(f"Unhandled host error: {exc}", file=self.stderr) + traceback.print_exc(file=self.stderr) + self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc)) + + def _respond_result(self, message_id: Any, result: Any) -> None: + self._write({"jsonrpc": "2.0", "id": message_id, "result": result}) + + def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None: + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + self._write({"jsonrpc": "2.0", "id": message_id, "error": error}) + + def _write(self, payload: dict[str, Any]) -> None: + line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + self.stdout.write(line + "\n") + self.stdout.flush() diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/settings.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/settings.py new file mode 100644 index 0000000000..8a4b8c037b --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/settings.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import os +import platform +from pathlib import Path +from typing import Any + + +def config_root() -> Path: + system = platform.system() + if system == "Windows": + base = os.environ.get("APPDATA") or os.path.expanduser("~") + root = Path(base) / "ifcviewer-autodesk" + elif system == "Darwin": + root = Path.home() / "Library" / "Application Support" / "ifcviewer-autodesk" + else: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + root = Path(base) / "ifcviewer-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 stored_client_id() -> str: + """Whatever is persisted in settings.json — ignores the env var.""" + return str(_read().get("client_id", "")).strip() + + +def env_client_id() -> str: + """Whatever APS_CLIENT_ID currently has — ignores settings.json.""" + return os.environ.get("APS_CLIENT_ID", "").strip() + + +def load_client_id() -> str: + """The effective value: env var wins, so dev overrides keep working.""" + return env_client_id() or stored_client_id() + + +def save_client_id(client_id: str) -> None: + data = _read() + data["client_id"] = client_id.strip() + _write(data) diff --git a/src/ifcviewer-autodesk/ifcviewer_autodesk/ui.py b/src/ifcviewer-autodesk/ifcviewer_autodesk/ui.py new file mode 100644 index 0000000000..1fabdcc23b --- /dev/null +++ b/src/ifcviewer-autodesk/ifcviewer_autodesk/ui.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING, Any, Callable, Literal + +import customtkinter as ctk + +from ifcviewer_autodesk import settings +from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore +from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError + +if TYPE_CHECKING: + from ifcviewer_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): + def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None: + super().__init__(title, size=(440, 130), resizable=False) + + body = ctk.CTkFrame(self) + body.pack(fill="both", expand=True, padx=20, pady=20) + + self.message = ctk.CTkLabel(body, text="Working…", anchor="w") + self.message.pack(fill="x", anchor="w") + + self.bar = ctk.CTkProgressBar(body, mode="indeterminate") + self.bar.pack(fill="x", pady=(12, 0)) + self.bar.start() + self._determinate = False + + self._center_on_screen() + self.deiconify() + self.lift() + self.update() + + def report(self, _phase: str, message: str, percent: int | None = None) -> None: + try: + self.message.configure(text=message) + 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 + + +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[[str, str, int | None], 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.destroy() + except tk.TclError: + pass + self.dialog = None + + +def progress_dialog(message: str) -> _ProgressContext: + """Standalone progress dialog usable outside the browse picker.""" + return _ProgressContext(None, message) + + +# --- 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.selected_hub: dict[str, Any] | None = None + self.selected_project: dict[str, Any] | None = None + self.selected_entry: dict[str, Any] | None = None + self._tree_entries: dict[str, dict[str, Any]] = {} + self._project_entries: dict[str, dict[str, Any]] = {} + + self._build_ui(action_label) + + def _build_ui(self, action_label: str) -> None: + root = ctk.CTkFrame(self, fg_color="transparent") + root.pack(fill="both", expand=True, padx=16, pady=16) + root.grid_rowconfigure(1, weight=1) + root.grid_columnconfigure(0, weight=1) + + top = ctk.CTkFrame(root, fg_color="transparent") + top.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + top.grid_columnconfigure(1, weight=1) + + self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in) + self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w") + + self.hub_combo = ctk.CTkOptionMenu( + top, + values=["Select hub"], + command=self._hub_changed, + anchor="w", + ) + self.hub_combo.grid(row=0, column=1, sticky="ew") + self.hub_combo.configure(state="disabled") + + split = ctk.CTkFrame(root, fg_color="transparent") + split.grid(row=1, column=0, sticky="nsew") + split.grid_rowconfigure(0, weight=1) + split.grid_columnconfigure(0, weight=3, uniform="col") + split.grid_columnconfigure(1, weight=7, uniform="col") + + self.projects_frame = ctk.CTkFrame(split) + self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) + self.tree_frame = ctk.CTkFrame(split) + self.tree_frame.grid(row=0, column=1, sticky="nsew") + + self.projects = self._make_treeview(self.projects_frame, "PROJECTS") + self.projects.bind("<>", lambda _e: self._project_changed()) + + self.tree = self._make_treeview(self.tree_frame, "FOLDERS") + self.tree.bind("<>", lambda _e: self._tree_selection_changed()) + self.tree.bind("<>", self._on_tree_open) + + self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w") + self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12)) + + actions = ctk.CTkFrame(root, fg_color="transparent") + actions.grid(row=3, column=0, sticky="ew") + actions.grid_columnconfigure(0, weight=1) + self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1) + self.cancel_button.grid(row=0, column=1, padx=(0, 8)) + self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm) + self.action_button.grid(row=0, column=2) + self.action_button.configure(state="disabled") + + def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> 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="browse") + 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_entry = None + 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() + entry = self._tree_entries.get(selection[0]) if selection else None + if isinstance(entry, dict) and not entry.get("__placeholder__"): + self.selected_entry = entry + 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: + self.selected_entry = None + self._refresh_action_button() + + def _refresh_action_button(self) -> None: + enabled = self.selected_project is not None and self.selected_entry is not None + if enabled: + assert self.selected_entry is not None + if self.mode == "destination": + enabled = self.selected_entry.get("type") == "folders" + elif self.mode == "ifcfed": + enabled = ( + self.selected_entry.get("type") == "items" + and (self.selected_entry.get("display_name") or "").lower().endswith(".ifcfed") + ) + else: + enabled = ( + self.selected_entry.get("type") == "items" + and (self.selected_entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS) + ) + self.action_button.configure(state="normal" if enabled else "disabled") + + def _confirm(self) -> None: + if not self.selected_hub or not self.selected_project or not self.selected_entry: + return + self.result = { + "hub": self.selected_hub, + "project": self.selected_project, + "entry": self.selected_entry, + } + self._on_close() + + def _clear_projects(self) -> None: + for iid in self.projects.get_children(): + self.projects.delete(iid) + self._project_entries.clear() + + def _clear_tree(self) -> None: + for iid in self.tree.get_children(): + self.tree.delete(iid) + self._tree_entries.clear() + + def _with_progress(self, message: str) -> _ProgressContext: + return _ProgressContext(self, message) + + +# --- filename prompt --------------------------------------------------------- + + +class _FilenamePrompt(_BaseDialog): + def __init__(self, *, title: str, label: str, default: str) -> None: + super().__init__(title, size=(440, 170), resizable=False) + body = ctk.CTkFrame(self, fg_color="transparent") + body.pack(fill="both", expand=True, padx=20, pady=20) + + ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x") + self.entry = ctk.CTkEntry(body) + self.entry.pack(fill="x", pady=(8, 16)) + self.entry.insert(0, default) + self.entry.select_range(0, "end") + self.entry.focus_set() + + buttons = ctk.CTkFrame(body, fg_color="transparent") + buttons.pack(fill="x") + buttons.grid_columnconfigure(0, weight=1) + ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8)) + ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2) + + self.bind("", lambda _e: self._confirm()) + self.bind("", lambda _e: self._on_close()) + + def _confirm(self) -> None: + value = self.entry.get().strip() + self.result = value or None + self._on_close() + + +def prompt_for_filename(*, title: str, label: str, default: str) -> str | None: + return _FilenamePrompt(title=title, label=label, default=default).run() + + +# --- message dialogs (CTk-styled replacements for tkinter.messagebox) -------- + + +class _ConfirmDialog(_BaseDialog): + def __init__(self, *, title: str, message: str) -> None: + super().__init__(title, size=(440, 180), resizable=False) + body = ctk.CTkFrame(self, fg_color="transparent") + body.pack(fill="both", expand=True, padx=20, pady=20) + + ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20)) + + buttons = ctk.CTkFrame(body, fg_color="transparent") + buttons.pack(fill="x") + buttons.grid_columnconfigure(0, weight=1) + ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8)) + ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2) + + self.bind("", lambda _e: self._confirm()) + self.bind("", lambda _e: self._on_close()) + + def _confirm(self) -> None: + self.result = True + self._on_close() + + +class _AlertDialog(_BaseDialog): + def __init__(self, *, title: str, message: str) -> None: + super().__init__(title, size=(440, 180), resizable=False) + body = ctk.CTkFrame(self, fg_color="transparent") + body.pack(fill="both", expand=True, padx=20, pady=20) + + ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20)) + + buttons = ctk.CTkFrame(body, fg_color="transparent") + buttons.pack(fill="x") + buttons.grid_columnconfigure(0, weight=1) + ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1) + + self.bind("", lambda _e: self._on_close()) + self.bind("", lambda _e: self._on_close()) + + +def confirm(*, title: str, message: str) -> bool: + return bool(_ConfirmDialog(title=title, message=message).run()) + + +def show_error(*, title: str, message: str) -> None: + _AlertDialog(title=title, message=message).run() + + +# --- settings ---------------------------------------------------------------- + + +class SettingsDialog(_BaseDialog): + """Edit the APS client id and sign out.""" + + def __init__(self, *, connector: "AutodeskConnector") -> None: + super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False) + self.connector = connector + + env_override = settings.env_client_id() + stored = settings.stored_client_id() + effective = env_override or stored + + 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, stored) + + if env_override: + ctk.CTkLabel( + body, + text=f"APS_CLIENT_ID environment variable is set ({env_override}) and overrides the saved value.", + anchor="w", + wraplength=460, + justify="left", + text_color=("#b45309", "#f59e0b"), + ).pack(fill="x", pady=(0, 8)) + + self.status_label = ctk.CTkLabel( + body, + text=f"Signed in as {effective}" if effective 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 effective: + 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() + 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.env_client_id() or settings.stored_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="ifcviewer-autodesk", username=client_id).delete() + except RpcError as exc: + show_error(title="Sign Out Failed", message=exc.message) + return + self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.") + self.signout_button.configure(state="disabled") diff --git a/src/ifcviewer-autodesk/packaging/README.md b/src/ifcviewer-autodesk/packaging/README.md new file mode 100644 index 0000000000..6364afa6f1 --- /dev/null +++ b/src/ifcviewer-autodesk/packaging/README.md @@ -0,0 +1,102 @@ +# Packaging the Autodesk connector + +The connector is shipped as a self-contained folder ready to drop into the +IfcViewer connectors directory. PyInstaller bundles the Python interpreter, +Qt, and all dependencies so end users do not need Python installed. + +PyInstaller does **not** cross-compile. Each OS must build on itself — +typically via a CI matrix. + +## Output + +``` +dist/ + autodesk/ # the connector folder, ready to install + connector.json + ifcviewer-autodesk[.exe] + _internal/... # PyInstaller dependencies (Qt, Python, …) + autodesk--.zip # the distribution archive +``` + +The folder is what the IfcViewer expects under +`~/.local/share/IfcOpenShell/IfcViewer/connectors/` (or the OS equivalent). + +## Build steps (any OS) + +```bash +cd src/ifcviewer-autodesk +python -m venv venv +venv/bin/activate # or venv\Scripts\activate on Windows +pip install -e ".[build]" +python packaging/build.py +``` + +The build: + +1. cleans `dist/` and `build/` +2. runs PyInstaller against `packaging/ifcviewer-autodesk.spec` +3. renames the produced folder to `autodesk/` and copies `connector.json` into it +4. zips the folder as `autodesk--.zip` + +The UI is Tcl/Tk via CustomTkinter, which keeps the bundle small. Expect +~50 MB unpacked / ~21 MB zipped per OS. The Python used to run the build +must include `tkinter` — most distribution and python-build-standalone +builds do; on Gentoo make sure `USE="tk"` is set for `dev-lang/python`. + +## Per-OS notes + +### Linux + +- Build on the **oldest glibc** you intend to support. Binaries built on a + newer glibc will not run on older distributions. Ubuntu 22.04 LTS + (glibc 2.35) is a reasonable lowest common denominator in 2026. +- The keyring backend used at runtime is `SecretService` (gnome-keyring or + KWallet); end users need a Secret Service provider running. +- Output: `autodesk-linux-x86_64.zip` (and/or `arm64`). + +### macOS + +- Each architecture builds separately. To support both Apple Silicon and + Intel, build on each and ship two zips, or post-process with `lipo` to + produce universal binaries. +- The keyring backend is the system Keychain. +- For distribution outside the developer's machine you will need to + **codesign** the executable and Tcl/Tk dylibs, and notarize the bundle. + Unsigned binaries trigger Gatekeeper warnings. Codesigning is left to the + caller; the spec's `codesign_identity` field can be wired up. +- Output: `autodesk-macos-arm64.zip` and/or `autodesk-macos-x86_64.zip`. + +### Windows + +- Build with the Microsoft Visual C++ runtime available (usually present in + any modern Python distribution). +- The keyring backend is Credential Manager. +- The `.exe` is built with `console=True` because the connector speaks + JSON-RPC over stdio. The IfcViewer must launch the connector with + `CREATE_NO_WINDOW` (Qt: `QProcess::setCreateProcessArgumentsModifier`) so + end users never see a console window flicker. +- For distribution: sign the `.exe` with an Authenticode certificate to + avoid SmartScreen warnings. Signing is left to the caller. +- Output: `autodesk-windows-x86_64.zip`. + +## Installing a built connector + +```bash +# Linux +unzip dist/autodesk-linux-x86_64.zip -d ~/.local/share/IfcOpenShell/IfcViewer/connectors/ + +# macOS +unzip dist/autodesk-macos-arm64.zip -d "~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/" + +# Windows (PowerShell) +Expand-Archive dist\autodesk-windows-x86_64.zip -DestinationPath "$env:APPDATA\IfcOpenShell\IfcViewer\connectors\" +``` + +The IfcViewer picks up the connector on next launch. + +## Out of scope here + +- Signing / notarization (caller's responsibility per OS) +- CI matrix (project-level concern) +- Auto-update (the IfcViewer or the host installer handles this) +- Universal macOS binaries via `lipo` (post-process step, not part of `build.py`) diff --git a/src/ifcviewer-autodesk/packaging/build.py b/src/ifcviewer-autodesk/packaging/build.py new file mode 100644 index 0000000000..61c3f13705 --- /dev/null +++ b/src/ifcviewer-autodesk/packaging/build.py @@ -0,0 +1,120 @@ +"""Build the Autodesk connector bundle for the current OS. + +Each OS builds on itself (PyInstaller does not cross-compile). The output is a +single zip ready to drop into the IfcViewer connectors directory: + + dist/autodesk--.zip + autodesk/ + connector.json + ifcviewer-autodesk[.exe] + _internal/... + +Usage: + + pip install -e ".[build]" + python packaging/build.py +""" + +from __future__ import annotations + +import json +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PACKAGING_DIR = PROJECT_ROOT / "packaging" +SPEC_FILE = PACKAGING_DIR / "ifcviewer-autodesk.spec" +DIST_DIR = PROJECT_ROOT / "dist" +BUILD_DIR = PROJECT_ROOT / "build" + +CONNECTOR_FOLDER_NAME = "autodesk" +PYINSTALLER_OUTPUT_NAME = "ifcviewer-autodesk" + + +def _platform_tag() -> str: + system = platform.system() + if system == "Darwin": + os_name = "macos" + elif system == "Windows": + os_name = "windows" + else: + os_name = system.lower() + + machine = platform.machine().lower() + if machine in {"amd64", "x86_64"}: + arch = "x86_64" + elif machine in {"arm64", "aarch64"}: + arch = "arm64" + else: + arch = machine + + return f"{os_name}-{arch}" + + +def _clean() -> None: + for path in (DIST_DIR, BUILD_DIR): + if path.exists(): + shutil.rmtree(path) + + +def _run_pyinstaller() -> Path: + subprocess.check_call( + [ + sys.executable, + "-m", + "PyInstaller", + str(SPEC_FILE), + "--noconfirm", + "--distpath", + str(DIST_DIR), + "--workpath", + str(BUILD_DIR), + ], + cwd=PROJECT_ROOT, + ) + produced = DIST_DIR / PYINSTALLER_OUTPUT_NAME + if not produced.is_dir(): + raise SystemExit(f"PyInstaller did not produce expected folder: {produced}") + return produced + + +def _assemble_connector_folder(pyinstaller_output: Path) -> Path: + connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME + if connector_dir.exists(): + shutil.rmtree(connector_dir) + pyinstaller_output.rename(connector_dir) + + # The source-controlled connector.json uses the bare entry-point name so + # `pip install -e .` works for development. For the bundled folder, the + # binary lives next to connector.json, so rewrite `exec` to a relative path. + manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8")) + binary_name = "ifcviewer-autodesk.exe" if platform.system() == "Windows" else "ifcviewer-autodesk" + manifest["exec"] = f"./{binary_name}" + (connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + return connector_dir + + +def _zip_connector_folder(tag: str) -> Path: + archive_base = DIST_DIR / f"{CONNECTOR_FOLDER_NAME}-{tag}" + return Path(shutil.make_archive(str(archive_base), "zip", DIST_DIR, CONNECTOR_FOLDER_NAME)) + + +def main() -> int: + tag = _platform_tag() + print(f"Building Autodesk connector for {tag}") + _clean() + pyinstaller_output = _run_pyinstaller() + connector_dir = _assemble_connector_folder(pyinstaller_output) + archive = _zip_connector_folder(tag) + print(f"Connector folder: {connector_dir}") + print(f"Distribution zip: {archive}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ifcviewer-autodesk/packaging/ifcviewer-autodesk.spec b/src/ifcviewer-autodesk/packaging/ifcviewer-autodesk.spec new file mode 100644 index 0000000000..6e995f62e8 --- /dev/null +++ b/src/ifcviewer-autodesk/packaging/ifcviewer-autodesk.spec @@ -0,0 +1,81 @@ +# PyInstaller spec for the IfcViewer Autodesk connector (Tk + CustomTkinter). +# +# The connector talks JSON-RPC over stdio, so `console=True` is required to +# attach stdin/stdout on Windows. The IfcViewer 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 / "ifcviewer_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="ifcviewer-autodesk", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + 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=True, + upx_exclude=[], + name="ifcviewer-autodesk", +) diff --git a/src/ifcviewer-autodesk/pyproject.toml b/src/ifcviewer-autodesk/pyproject.toml new file mode 100644 index 0000000000..09753ce18c --- /dev/null +++ b/src/ifcviewer-autodesk/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcviewer-autodesk" +version = "0.1.0" +description = "Autodesk cloud connector for IfcViewer" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "customtkinter>=5.2", + "httpx>=0.27", + "keyring>=25.2" +] + +[project.optional-dependencies] +build = ["pyinstaller>=6.0"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] + +[project.scripts] +ifcviewer-autodesk = "ifcviewer_autodesk.__main__:main"