mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
Add Autodesk cloud sync connector
Initial implementation of the ifcviewer-autodesk connector — a separate process that bridges the IfcViewer to Autodesk APS (BIM 360 / ACC). Speaks JSON-RPC 2.0 over stdio per CLOUD_SYNC_PROTOCOL.md (also added). PKCE OAuth with keyring-backed token storage, customtkinter browse/picker UI, and PyInstaller packaging. Implements both interactive and non-interactive variants of each push/pull (pull_ifcfed[_interactive], pull_models[_interactive], push_ifcfed[_interactive], push_model[_interactive]) so the viewer can offer both "Save"/"Open from Cloud" and "Save As"/"Add Model from Cloud" entry points. File transfers report progress through a dialog with per-byte updates; pull_models shows "(i/N)" for batches. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
<some-connectors-dir>/
|
||||
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 `<exec>.exe` if `<exec>` 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.
|
||||
@@ -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. `<config dir>/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/<hash>/<name>.ifcfed[.manifest]
|
||||
models/<hash>/<filename>
|
||||
```
|
||||
|
||||
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/<hash>/` 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)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "autodesk",
|
||||
"name": "Autodesk Forma",
|
||||
"version": "0.1.0",
|
||||
"exec": "ifcviewer-autodesk"
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
server = HTTPServer((host, port), Handler)
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
|
||||
if result.get("error"):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
|
||||
if result.get("state") != expected_state:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
|
||||
code = result.get("code", "")
|
||||
if not code:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
|
||||
return code
|
||||
|
||||
|
||||
class 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
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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("<<TreeviewSelect>>", lambda _e: self._project_changed())
|
||||
|
||||
self.tree = self._make_treeview(self.tree_frame, "FOLDERS")
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
||||
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
||||
|
||||
self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
|
||||
self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
|
||||
|
||||
actions = ctk.CTkFrame(root, fg_color="transparent")
|
||||
actions.grid(row=3, column=0, sticky="ew")
|
||||
actions.grid_columnconfigure(0, weight=1)
|
||||
self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
|
||||
self.cancel_button.grid(row=0, column=1, padx=(0, 8))
|
||||
self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
|
||||
self.action_button.grid(row=0, column=2)
|
||||
self.action_button.configure(state="disabled")
|
||||
|
||||
def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> 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("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
value = self.entry.get().strip()
|
||||
self.result = value or None
|
||||
self._on_close()
|
||||
|
||||
|
||||
def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
|
||||
return _FilenamePrompt(title=title, label=label, default=default).run()
|
||||
|
||||
|
||||
# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
|
||||
|
||||
|
||||
class _ConfirmDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
self.result = True
|
||||
self._on_close()
|
||||
|
||||
|
||||
class _AlertDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._on_close())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
|
||||
def confirm(*, title: str, message: str) -> bool:
|
||||
return bool(_ConfirmDialog(title=title, message=message).run())
|
||||
|
||||
|
||||
def show_error(*, title: str, message: str) -> None:
|
||||
_AlertDialog(title=title, message=message).run()
|
||||
|
||||
|
||||
# --- settings ----------------------------------------------------------------
|
||||
|
||||
|
||||
class SettingsDialog(_BaseDialog):
|
||||
"""Edit the APS client id and sign out."""
|
||||
|
||||
def __init__(self, *, connector: "AutodeskConnector") -> None:
|
||||
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
|
||||
self.connector = connector
|
||||
|
||||
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")
|
||||
@@ -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-<os>-<arch>.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-<os>-<arch>.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`)
|
||||
@@ -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-<os>-<arch>.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())
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user