bonsaiviewer-autodesk: satisfy cargo fmt and clippy

The crate had never been run through rustfmt, so the CI job's first step
(`cargo fmt --all -- --check`) failed and masked 12 clippy errors behind
it. Fix both.

Beyond the mechanical reformat and the redundant-closure/div_ceil/Default
lints, two changes carry meaning:

- SettingsDialog::on_reload is UI-thread only, so it becomes an Rc. The
  Arc was never shared across threads and clippy rightly flagged it as
  an Arc over a non-Send/Sync closure.
- WorkerMsg variants lose their shared `Loaded` postfix; the enum's doc
  comment already says these are worker completions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-27 20:39:21 +10:00
parent a84957fd45
commit 0aef201d50
12 changed files with 750 additions and 258 deletions
+64 -14
View File
@@ -77,7 +77,11 @@ impl ApsClient {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|arr| arr.iter().map(entry_from_jsonapi).collect()) .map(|arr| arr.iter().map(entry_from_jsonapi).collect())
.unwrap_or_default(); .unwrap_or_default();
folders.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase())); folders.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
Ok(folders) Ok(folders)
} }
@@ -120,7 +124,11 @@ impl ApsClient {
_ => break, _ => break,
} }
} }
entries.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase())); entries.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
Ok(entries) Ok(entries)
} }
@@ -136,10 +144,17 @@ impl ApsClient {
.get("data") .get("data")
.cloned() .cloned()
.ok_or_else(|| RpcError::internal("Item response missing 'data'."))?; .ok_or_else(|| RpcError::internal("Item response missing 'data'."))?;
let item_attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({})); let item_attrs = item
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let parent_folder_id = relationship_id(&item, "parent"); let parent_folder_id = relationship_id(&item, "parent");
let tip_id = relationship_id(&item, "tip"); let tip_id = relationship_id(&item, "tip");
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let tip = payload let tip = payload
.get("included") .get("included")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
@@ -171,7 +186,10 @@ impl ApsClient {
parent_folder_id, parent_folder_id,
}); });
}; };
let tip_attrs = tip.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({})); let tip_attrs = tip
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let display = tip_attrs let display = tip_attrs
.get("displayName") .get("displayName")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -182,12 +200,21 @@ impl ApsClient {
Ok(ItemTip { Ok(ItemTip {
id, id,
display_name: display, display_name: display,
hidden: item_attrs.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false), hidden: item_attrs
.get("hidden")
.and_then(|v| v.as_bool())
.unwrap_or(false),
version_id: tip.get("id").and_then(|v| v.as_str()).map(String::from), version_id: tip.get("id").and_then(|v| v.as_str()).map(String::from),
storage_id: relationship_id(&tip, "storage"), storage_id: relationship_id(&tip, "storage"),
version_number: tip_attrs.get("versionNumber").cloned(), version_number: tip_attrs.get("versionNumber").cloned(),
last_modified_time_utc: tip_attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from), last_modified_time_utc: tip_attrs
last_modified_user_name: tip_attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from), .get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from),
last_modified_user_name: tip_attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from),
parent_folder_id, parent_folder_id,
}) })
} }
@@ -195,8 +222,16 @@ impl ApsClient {
fn hub_from_jsonapi(item: &Value) -> Hub { fn hub_from_jsonapi(item: &Value) -> Hub {
Hub { Hub {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(), id: item
name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(), .get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
name: item
.pointer("/attributes/name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
extension_type: item extension_type: item
.pointer("/attributes/extension/type") .pointer("/attributes/extension/type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -207,8 +242,16 @@ fn hub_from_jsonapi(item: &Value) -> Hub {
fn project_from_jsonapi(item: &Value) -> Project { fn project_from_jsonapi(item: &Value) -> Project {
Project { Project {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(), id: item
name: item.pointer("/attributes/name").and_then(|v| v.as_str()).unwrap_or_default().to_string(), .get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
name: item
.pointer("/attributes/name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
extension_type: item extension_type: item
.pointer("/attributes/extension/type") .pointer("/attributes/extension/type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -223,7 +266,10 @@ fn project_from_jsonapi(item: &Value) -> Project {
} }
pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry { pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry {
let attrs = item.get("attributes").cloned().unwrap_or_else(|| serde_json::json!({})); let attrs = item
.get("attributes")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let display = attrs let display = attrs
.get("displayName") .get("displayName")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -237,7 +283,11 @@ pub(crate) fn entry_from_jsonapi(item: &Value) -> Entry {
_ => EntryType::Other, _ => EntryType::Other,
}; };
Entry { Entry {
id: item.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(), id: item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
entry_type, entry_type,
display_name: display, display_name: display,
name: attrs.get("name").and_then(|v| v.as_str()).map(String::from), name: attrs.get("name").and_then(|v| v.as_str()).map(String::from),
+3 -1
View File
@@ -23,7 +23,9 @@ impl ApsClient {
pub fn with_base_url(auth: Arc<AuthSessionService>, base_url: String) -> Self { pub fn with_base_url(auth: Arc<AuthSessionService>, base_url: String) -> Self {
Self { Self {
auth, auth,
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(120)).build(), agent: ureq::AgentBuilder::new()
.timeout(Duration::from_secs(120))
.build(),
base_url, base_url,
} }
} }
+117 -36
View File
@@ -43,9 +43,13 @@ impl ApsClient {
local_path.display() local_path.display()
))); )));
} }
let file_name = display_name let file_name = display_name.map(str::to_string).unwrap_or_else(|| {
.map(str::to_string) local_path
.unwrap_or_else(|| local_path.file_name().unwrap_or_default().to_string_lossy().into_owned()); .file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
});
let storage_id = self.create_storage(project_id, folder_id, &file_name)?; let storage_id = self.create_storage(project_id, folder_id, &file_name)?;
let (bucket, object) = parse_storage_id(&storage_id)?; let (bucket, object) = parse_storage_id(&storage_id)?;
self.upload_local_file_to_oss(&bucket, &object, local_path, progress)?; self.upload_local_file_to_oss(&bucket, &object, local_path, progress)?;
@@ -68,7 +72,9 @@ impl ApsClient {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(String::from) .map(String::from)
.ok_or_else(|| RpcError::internal("Signed download URL response did not contain a URL.")) .ok_or_else(|| {
RpcError::internal("Signed download URL response did not contain a URL.")
})
} }
fn download_to_file( fn download_to_file(
@@ -81,7 +87,11 @@ impl ApsClient {
Ok(r) => r, Ok(r) => r,
Err(ureq::Error::Status(code, resp)) => { Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default(); let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() }; let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
return Err(RpcError::internal(msg)); return Err(RpcError::internal(msg));
} }
Err(e) => return Err(RpcError::internal(e.to_string())), Err(e) => return Err(RpcError::internal(e.to_string())),
@@ -93,8 +103,9 @@ impl ApsClient {
.to_string_lossy() .to_string_lossy()
.into_owned(); .into_owned();
let mut reader = resp.into_reader(); let mut reader = resp.into_reader();
let mut file = File::create(destination) let mut file = File::create(destination).map_err(|e| {
.map_err(|e| RpcError::internal(format!("Cannot create '{}': {e}", destination.display())))?; RpcError::internal(format!("Cannot create '{}': {e}", destination.display()))
})?;
let mut buf = vec![0u8; READ_BUF]; let mut buf = vec![0u8; READ_BUF];
let mut downloaded: u64 = 0; let mut downloaded: u64 = 0;
loop { loop {
@@ -108,16 +119,26 @@ impl ApsClient {
.map_err(|e| RpcError::internal(format!("Download write failed: {e}")))?; .map_err(|e| RpcError::internal(format!("Download write failed: {e}")))?;
downloaded += n as u64; downloaded += n as u64;
if let Some(cb) = &progress { if let Some(cb) = &progress {
let percent = total_bytes.map(|t| min(100, ((downloaded as f64 / t as f64) * 100.0) as i32)); let percent =
total_bytes.map(|t| min(100, ((downloaded as f64 / t as f64) * 100.0) as i32));
cb(&name, percent, Some(downloaded), total_bytes); cb(&name, percent, Some(downloaded), total_bytes);
} }
} }
Ok(()) Ok(())
} }
fn create_storage(&self, project_id: &str, folder_id: &str, file_name: &str) -> Result<String, RpcError> { fn create_storage(
&self,
project_id: &str,
folder_id: &str,
file_name: &str,
) -> Result<String, RpcError> {
let payload = self.post_json( let payload = self.post_json(
&format!("{}/data/v1/projects/{}/storage", self.base_url, url_enc(project_id)), &format!(
"{}/data/v1/projects/{}/storage",
self.base_url,
url_enc(project_id)
),
&json!({ &json!({
"jsonapi": {"version": "1.0"}, "jsonapi": {"version": "1.0"},
"data": { "data": {
@@ -145,7 +166,7 @@ impl ApsClient {
let file_size = std::fs::metadata(local_path) let file_size = std::fs::metadata(local_path)
.map_err(|e| RpcError::internal(format!("stat '{}': {e}", local_path.display())))? .map_err(|e| RpcError::internal(format!("stat '{}': {e}", local_path.display())))?
.len(); .len();
let total_parts = ((file_size + CHUNK_SIZE - 1) / CHUNK_SIZE).max(1); let total_parts = file_size.div_ceil(CHUNK_SIZE).max(1);
let file_name = local_path let file_name = local_path
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
@@ -170,13 +191,18 @@ impl ApsClient {
parts_to_request, parts_to_request,
)?; )?;
if upload_key.is_none() { if upload_key.is_none() {
upload_key = signed.get("uploadKey").and_then(|v| v.as_str()).map(String::from); upload_key = signed
.get("uploadKey")
.and_then(|v| v.as_str())
.map(String::from);
} }
let urls = signed let urls = signed
.get("urls") .get("urls")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.filter(|a| !a.is_empty()) .filter(|a| !a.is_empty())
.ok_or_else(|| RpcError::internal("Upload URL response did not contain upload URLs."))? .ok_or_else(|| {
RpcError::internal("Upload URL response did not contain upload URLs.")
})?
.clone(); .clone();
for url in &urls { for url in &urls {
if parts_uploaded >= total_parts { if parts_uploaded >= total_parts {
@@ -197,14 +223,23 @@ impl ApsClient {
let percent = if file_size == 0 { let percent = if file_size == 0 {
100 100
} else { } else {
min(100, ((bytes_uploaded as f64 / file_size as f64) * 100.0) as i32) min(
100,
((bytes_uploaded as f64 / file_size as f64) * 100.0) as i32,
)
}; };
cb(&file_name, Some(percent), Some(bytes_uploaded), Some(file_size)); cb(
&file_name,
Some(percent),
Some(bytes_uploaded),
Some(file_size),
);
} }
} }
} }
let key = upload_key.ok_or_else(|| RpcError::internal("Upload did not return an upload key."))?; let key =
upload_key.ok_or_else(|| RpcError::internal("Upload did not return an upload key."))?;
self.complete_signed_upload(bucket, object, &key) self.complete_signed_upload(bucket, object, &key)
} }
@@ -216,7 +251,9 @@ impl ApsClient {
first_part: u32, first_part: u32,
parts: u32, parts: u32,
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
let token = self.auth.ensure_access_token(crate::progress::noop_auth())?; let token = self
.auth
.ensure_access_token(crate::progress::noop_auth())?;
let mut url = format!( let mut url = format!(
"{}/oss/v2/buckets/{}/objects/{}/signeds3upload?minutesExpiration=10&firstPart={}&parts={}", "{}/oss/v2/buckets/{}/objects/{}/signeds3upload?minutesExpiration=10&firstPart={}&parts={}",
self.base_url, self.base_url,
@@ -236,8 +273,15 @@ impl ApsClient {
decode_json(resp, &url) decode_json(resp, &url)
} }
fn complete_signed_upload(&self, bucket: &str, object: &str, upload_key: &str) -> Result<(), RpcError> { fn complete_signed_upload(
let token = self.auth.ensure_access_token(crate::progress::noop_auth())?; &self,
bucket: &str,
object: &str,
upload_key: &str,
) -> Result<(), RpcError> {
let token = self
.auth
.ensure_access_token(crate::progress::noop_auth())?;
let url = format!( let url = format!(
"{}/oss/v2/buckets/{}/objects/{}/signeds3upload", "{}/oss/v2/buckets/{}/objects/{}/signeds3upload",
self.base_url, self.base_url,
@@ -254,7 +298,11 @@ impl ApsClient {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(ureq::Error::Status(code, resp)) => { Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default(); let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() }; let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
Err(RpcError::internal(msg)) Err(RpcError::internal(msg))
} }
Err(e) => Err(RpcError::internal(e.to_string())), Err(e) => Err(RpcError::internal(e.to_string())),
@@ -271,7 +319,11 @@ impl ApsClient {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(ureq::Error::Status(code, resp)) => { Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default(); let body = resp.into_string().unwrap_or_default();
let msg = if body.trim().is_empty() { format!("HTTP {code}") } else { body.trim().to_string() }; let msg = if body.trim().is_empty() {
format!("HTTP {code}")
} else {
body.trim().to_string()
};
Err(RpcError::internal(msg)) Err(RpcError::internal(msg))
} }
Err(e) => Err(RpcError::internal(e.to_string())), Err(e) => Err(RpcError::internal(e.to_string())),
@@ -299,7 +351,11 @@ impl ApsClient {
storage_id: &str, storage_id: &str,
) -> Result<UploadResult, RpcError> { ) -> Result<UploadResult, RpcError> {
let payload = self.post_json( let payload = self.post_json(
&format!("{}/data/v1/projects/{}/versions", self.base_url, url_enc(project_id)), &format!(
"{}/data/v1/projects/{}/versions",
self.base_url,
url_enc(project_id)
),
&json!({ &json!({
"jsonapi": {"version": "1.0"}, "jsonapi": {"version": "1.0"},
"data": { "data": {
@@ -318,14 +374,27 @@ impl ApsClient {
let version = payload let version = payload
.get("data") .get("data")
.ok_or_else(|| RpcError::internal("Create version response missing 'data'."))?; .ok_or_else(|| RpcError::internal("Create version response missing 'data'."))?;
let attrs = version.get("attributes").cloned().unwrap_or_else(|| json!({})); let attrs = version
.get("attributes")
.cloned()
.unwrap_or_else(|| json!({}));
Ok(UploadResult { Ok(UploadResult {
item_id: item_id.to_string(), item_id: item_id.to_string(),
version_id: version.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), version_id: version
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
display_name: file_name.to_string(), display_name: file_name.to_string(),
version_number: attrs.get("versionNumber").cloned(), version_number: attrs.get("versionNumber").cloned(),
last_modified_time_utc: attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from), last_modified_time_utc: attrs
last_modified_user_name: attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from), .get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from),
last_modified_user_name: attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from),
}) })
} }
@@ -337,7 +406,11 @@ impl ApsClient {
storage_id: &str, storage_id: &str,
) -> Result<UploadResult, RpcError> { ) -> Result<UploadResult, RpcError> {
let payload = self.post_json( let payload = self.post_json(
&format!("{}/data/v1/projects/{}/items", self.base_url, url_enc(project_id)), &format!(
"{}/data/v1/projects/{}/items",
self.base_url,
url_enc(project_id)
),
&json!({ &json!({
"jsonapi": {"version": "1.0"}, "jsonapi": {"version": "1.0"},
"data": { "data": {
@@ -386,8 +459,14 @@ impl ApsClient {
if let Some(v) = attrs.get("versionNumber") { if let Some(v) = attrs.get("versionNumber") {
version_number = Some(v.clone()); version_number = Some(v.clone());
} }
last_modified_time = attrs.get("lastModifiedTime").and_then(|v| v.as_str()).map(String::from); last_modified_time = attrs
last_modified_user = attrs.get("lastModifiedUserName").and_then(|v| v.as_str()).map(String::from); .get("lastModifiedTime")
.and_then(|v| v.as_str())
.map(String::from);
last_modified_user = attrs
.get("lastModifiedUserName")
.and_then(|v| v.as_str())
.map(String::from);
} }
} }
} }
@@ -404,14 +483,16 @@ impl ApsClient {
pub(crate) fn parse_storage_id(storage_id: &str) -> Result<(String, String), RpcError> { pub(crate) fn parse_storage_id(storage_id: &str) -> Result<(String, String), RpcError> {
let marker = "urn:adsk.objects:os.object:"; let marker = "urn:adsk.objects:os.object:";
let path = storage_id let path = storage_id.strip_prefix(marker).ok_or_else(|| {
.strip_prefix(marker) RpcError::internal(format!("Unsupported storage identifier '{storage_id}'."))
.ok_or_else(|| RpcError::internal(format!("Unsupported storage identifier '{storage_id}'.")))?; })?;
let slash = path let slash = path.find('/').ok_or_else(|| {
.find('/') RpcError::internal(format!("Malformed storage identifier '{storage_id}'."))
.ok_or_else(|| RpcError::internal(format!("Malformed storage identifier '{storage_id}'.")))?; })?;
if slash == 0 || slash == path.len() - 1 { if slash == 0 || slash == path.len() - 1 {
return Err(RpcError::internal(format!("Malformed storage identifier '{storage_id}'."))); return Err(RpcError::internal(format!(
"Malformed storage identifier '{storage_id}'."
)));
} }
Ok((path[..slash].to_string(), path[slash + 1..].to_string())) Ok((path[..slash].to_string(), path[slash + 1..].to_string()))
} }
+61 -20
View File
@@ -16,7 +16,8 @@ use crate::progress::AuthProgress;
use crate::rpc::RpcError; use crate::rpc::RpcError;
pub const KEYRING_SERVICE: &str = "bonsaiviewer-autodesk"; pub const KEYRING_SERVICE: &str = "bonsaiviewer-autodesk";
pub const AUTHORIZE_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/authorize"; pub const AUTHORIZE_ENDPOINT: &str =
"https://developer.api.autodesk.com/authentication/v2/authorize";
pub const TOKEN_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/token"; pub const TOKEN_ENDPOINT: &str = "https://developer.api.autodesk.com/authentication/v2/token";
const REFRESH_TTL_DEFAULT_SECONDS: i64 = 15 * 24 * 60 * 60; const REFRESH_TTL_DEFAULT_SECONDS: i64 = 15 * 24 * 60 * 60;
@@ -44,7 +45,10 @@ pub struct KeyringTokenStore {
impl KeyringTokenStore { impl KeyringTokenStore {
pub fn new(username: impl Into<String>) -> Self { pub fn new(username: impl Into<String>) -> Self {
Self { service: KEYRING_SERVICE.to_string(), username: username.into() } Self {
service: KEYRING_SERVICE.to_string(),
username: username.into(),
}
} }
fn entry(&self) -> Result<keyring::Entry, RpcError> { fn entry(&self) -> Result<keyring::Entry, RpcError> {
@@ -128,8 +132,9 @@ pub fn wait_for_oauth_callback(
path: &str, path: &str,
expected_state: &str, expected_state: &str,
) -> Result<String, RpcError> { ) -> Result<String, RpcError> {
let listener = TcpListener::bind((host, port)) let listener = TcpListener::bind((host, port)).map_err(|e| {
.map_err(|e| RpcError::internal(format!("Cannot bind OAuth callback to {host}:{port}: {e}")))?; RpcError::internal(format!("Cannot bind OAuth callback to {host}:{port}: {e}"))
})?;
let (mut stream, _) = listener let (mut stream, _) = listener
.accept() .accept()
.map_err(|e| RpcError::internal(format!("OAuth callback accept failed: {e}")))?; .map_err(|e| RpcError::internal(format!("OAuth callback accept failed: {e}")))?;
@@ -137,7 +142,9 @@ pub fn wait_for_oauth_callback(
let request_line = { let request_line = {
let mut reader = BufReader::new( let mut reader = BufReader::new(
stream.try_clone().map_err(|e| RpcError::internal(e.to_string()))?, stream
.try_clone()
.map_err(|e| RpcError::internal(e.to_string()))?,
); );
let mut line = String::new(); let mut line = String::new();
reader reader
@@ -184,7 +191,9 @@ pub fn wait_for_oauth_callback(
} }
if let Some(err) = error.filter(|s| !s.is_empty()) { if let Some(err) = error.filter(|s| !s.is_empty()) {
return Err(RpcError::internal(format!("Autodesk returned OAuth error '{err}'."))); return Err(RpcError::internal(format!(
"Autodesk returned OAuth error '{err}'."
)));
} }
if state.as_deref() != Some(expected_state) { if state.as_deref() != Some(expected_state) {
return Err(RpcError::internal("OAuth state mismatch.")); return Err(RpcError::internal("OAuth state mismatch."));
@@ -195,10 +204,11 @@ pub fn wait_for_oauth_callback(
/// Pluggable callback strategy — production binds a localhost socket, tests /// Pluggable callback strategy — production binds a localhost socket, tests
/// inject a stub that returns a canned code. /// inject a stub that returns a canned code.
pub type CallbackWaiter = Arc<dyn Fn(&str, u16, &str, &str) -> Result<String, RpcError> + Send + Sync>; pub type CallbackWaiter =
Arc<dyn Fn(&str, u16, &str, &str) -> Result<String, RpcError> + Send + Sync>;
pub fn default_callback_waiter() -> CallbackWaiter { pub fn default_callback_waiter() -> CallbackWaiter {
Arc::new(|host, port, path, state| wait_for_oauth_callback(host, port, path, state)) Arc::new(wait_for_oauth_callback)
} }
/// Pluggable "open this URL in a browser" — production opens it for real, /// Pluggable "open this URL in a browser" — production opens it for real,
@@ -235,7 +245,12 @@ pub struct AuthBuilder {
} }
impl AuthBuilder { impl AuthBuilder {
pub fn new(client_id: String, callback_url: String, scope: String, token_store: Box<dyn TokenStore>) -> Self { pub fn new(
client_id: String,
callback_url: String,
scope: String,
token_store: Box<dyn TokenStore>,
) -> Self {
Self { Self {
client_id, client_id,
callback_url, callback_url,
@@ -317,7 +332,9 @@ impl AuthSessionService {
if callback.scheme() != "http" if callback.scheme() != "http"
|| !matches!(callback.host_str(), Some("127.0.0.1") | Some("localhost")) || !matches!(callback.host_str(), Some("127.0.0.1") | Some("localhost"))
{ {
return Err(RpcError::internal("Callback URL must be http://localhost or http://127.0.0.1.")); return Err(RpcError::internal(
"Callback URL must be http://localhost or http://127.0.0.1.",
));
} }
let authorize_url = { let authorize_url = {
@@ -356,7 +373,11 @@ impl AuthSessionService {
Ok(token) Ok(token)
} }
fn refresh(&self, token: &StoredToken, progress: AuthProgress) -> Result<StoredToken, RpcError> { fn refresh(
&self,
token: &StoredToken,
progress: AuthProgress,
) -> Result<StoredToken, RpcError> {
progress("auth", "Refreshing Autodesk session", None); progress("auth", "Refreshing Autodesk session", None);
let payload = self let payload = self
.post_form(&[ .post_form(&[
@@ -408,7 +429,8 @@ impl AuthSessionService {
access_token: access_token.to_string(), access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(), refresh_token: refresh_token.to_string(),
access_token_expires_at: now + ChronoDuration::seconds(expires_in - TOKEN_SKEW_SECONDS), access_token_expires_at: now + ChronoDuration::seconds(expires_in - TOKEN_SKEW_SECONDS),
refresh_token_expires_at: now + ChronoDuration::seconds(refresh_ttl - TOKEN_SKEW_SECONDS), refresh_token_expires_at: now
+ ChronoDuration::seconds(refresh_ttl - TOKEN_SKEW_SECONDS),
scope: self.scope.clone(), scope: self.scope.clone(),
}) })
} }
@@ -421,10 +443,14 @@ pub struct InMemoryTokenStore {
impl InMemoryTokenStore { impl InMemoryTokenStore {
pub fn empty() -> Self { pub fn empty() -> Self {
Self { inner: std::sync::Mutex::new(None) } Self {
inner: std::sync::Mutex::new(None),
}
} }
pub fn preloaded(token: StoredToken) -> Self { pub fn preloaded(token: StoredToken) -> Self {
Self { inner: std::sync::Mutex::new(Some(token)) } Self {
inner: std::sync::Mutex::new(Some(token)),
}
} }
} }
@@ -483,7 +509,11 @@ mod tests {
assert!(store.load().unwrap().is_none()); assert!(store.load().unwrap().is_none());
} }
fn make_service(store: Box<dyn TokenStore>, token_endpoint: String, code: &'static str) -> AuthSessionService { fn make_service(
store: Box<dyn TokenStore>,
token_endpoint: String,
code: &'static str,
) -> AuthSessionService {
AuthBuilder::new( AuthBuilder::new(
"client-xyz".into(), "client-xyz".into(),
"http://127.0.0.1:8080/".into(), "http://127.0.0.1:8080/".into(),
@@ -539,15 +569,22 @@ mod tests {
inner: InMemoryTokenStore, inner: InMemoryTokenStore,
} }
impl TokenStore for PeekStore { impl TokenStore for PeekStore {
fn load(&self) -> Result<Option<StoredToken>, RpcError> { self.inner.load() } fn load(&self) -> Result<Option<StoredToken>, RpcError> {
self.inner.load()
}
fn save(&self, t: &StoredToken) -> Result<(), RpcError> { fn save(&self, t: &StoredToken) -> Result<(), RpcError> {
*self.shadow.lock().unwrap() = Some(t.clone()); *self.shadow.lock().unwrap() = Some(t.clone());
self.inner.save(t) self.inner.save(t)
} }
fn delete(&self) -> Result<(), RpcError> { self.inner.delete() } fn delete(&self) -> Result<(), RpcError> {
self.inner.delete()
}
} }
let _ = store; let _ = store;
let peek = Box::new(PeekStore { shadow: saved_check.clone(), inner: InMemoryTokenStore::empty() }); let peek = Box::new(PeekStore {
shadow: saved_check.clone(),
inner: InMemoryTokenStore::empty(),
});
let svc = make_service(peek, endpoint, "AUTH_CODE"); let svc = make_service(peek, endpoint, "AUTH_CODE");
let token = svc.login_interactive(crate::progress::noop_auth()).unwrap(); let token = svc.login_interactive(crate::progress::noop_auth()).unwrap();
@@ -570,7 +607,9 @@ mod tests {
}; };
let store = Box::new(InMemoryTokenStore::preloaded(token)); let store = Box::new(InMemoryTokenStore::preloaded(token));
let svc = make_service(store, "http://127.0.0.1:1/never".into(), "x"); let svc = make_service(store, "http://127.0.0.1:1/never".into(), "x");
let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap(); let access = svc
.ensure_access_token(crate::progress::noop_auth())
.unwrap();
assert_eq!(access, "CACHED"); assert_eq!(access, "CACHED");
} }
@@ -591,7 +630,9 @@ mod tests {
}; };
let store = Box::new(InMemoryTokenStore::preloaded(token)); let store = Box::new(InMemoryTokenStore::preloaded(token));
let svc = make_service(store, endpoint, "unused"); let svc = make_service(store, endpoint, "unused");
let access = svc.ensure_access_token(crate::progress::noop_auth()).unwrap(); let access = svc
.ensure_access_token(crate::progress::noop_auth())
.unwrap();
assert_eq!(access, "FRESH"); assert_eq!(access, "FRESH");
} }
} }
+6 -2
View File
@@ -46,13 +46,17 @@ fn hash_parts(parts: &[&str]) -> String {
/// Stable directory for an .ifcfed; re-downloads overwrite in place so the /// Stable directory for an .ifcfed; re-downloads overwrite in place so the
/// viewer's open path remains valid across sync operations. /// viewer's open path remains valid across sync operations.
pub fn ifcfed_dir(project_id: &str, item_id: &str) -> PathBuf { pub fn ifcfed_dir(project_id: &str, item_id: &str) -> PathBuf {
cache_root().join("ifcfeds").join(hash_parts(&[project_id, item_id])) cache_root()
.join("ifcfeds")
.join(hash_parts(&[project_id, item_id]))
} }
/// Per-version directory for a model. A new resolved version → a new /// Per-version directory for a model. A new resolved version → a new
/// directory, so sidecars regenerate when the model file changes. /// directory, so sidecars regenerate when the model file changes.
pub fn model_dir(project_id: &str, item_id: &str, version_id: &str) -> PathBuf { pub fn model_dir(project_id: &str, item_id: &str, version_id: &str) -> PathBuf {
cache_root().join("models").join(hash_parts(&[project_id, item_id, version_id])) cache_root()
.join("models")
.join(hash_parts(&[project_id, item_id, version_id]))
} }
/// Clear the directory so the file we write is the only child. /// Clear the directory so the file we write is the only child.
+234 -93
View File
@@ -83,9 +83,17 @@ pub struct AutodeskConnector {
inner: RefCell<Option<ClientPair>>, inner: RefCell<Option<ClientPair>>,
} }
impl Default for AutodeskConnector {
fn default() -> Self {
Self::new()
}
}
impl AutodeskConnector { impl AutodeskConnector {
pub fn new() -> Self { pub fn new() -> Self {
let me = Self { inner: RefCell::new(None) }; let me = Self {
inner: RefCell::new(None),
};
me.reload_credentials(); me.reload_credentials();
me me
} }
@@ -144,7 +152,7 @@ impl AutodeskConnector {
// SAFETY: SettingsDialog::run blocks the calling thread, and the // SAFETY: SettingsDialog::run blocks the calling thread, and the
// callback fires only on this thread before run returns. The pointer // callback fires only on this thread before run returns. The pointer
// outlives the call because `self` is borrowed by the RPC dispatch. // outlives the call because `self` is borrowed by the RPC dispatch.
let on_reload: Arc<dyn Fn() -> Result<(), RpcError>> = Arc::new(move || { let on_reload: Rc<dyn Fn() -> Result<(), RpcError>> = Rc::new(move || {
let cell: &RefCell<Option<ClientPair>> = unsafe { &*inner_ptr }; let cell: &RefCell<Option<ClientPair>> = unsafe { &*inner_ptr };
let client_id = cfg::load_client_id(); let client_id = cfg::load_client_id();
if client_id.is_empty() { if client_id.is_empty() {
@@ -181,7 +189,14 @@ impl AutodeskConnector {
let display_name = entry.display_name.clone(); let display_name = entry.display_name.clone();
let aps = pair.aps.clone(); let aps = pair.aps.clone();
let path = run_with_progress("Downloading project", move |report| { let path = run_with_progress("Downloading project", move |report| {
download_ifcfed(&aps, &hub_id, &project_id, &item_id, &display_name, Some(download_callback(report, 0, 0))) download_ifcfed(
&aps,
&hub_id,
&project_id,
&item_id,
&display_name,
Some(download_callback(report, 0, 0)),
)
})?; })?;
Ok(json!({"path": path.to_string_lossy()})) Ok(json!({"path": path.to_string_lossy()}))
} }
@@ -190,13 +205,19 @@ impl AutodeskConnector {
fn pull_ifcfed(&self, params: Value) -> Result<Value, RpcError> { fn pull_ifcfed(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let p: PullIfcfedParams = serde_json::from_value(params).map_err(|e| { let p: PullIfcfedParams = serde_json::from_value(params)
RpcError::invalid_params(format!("pull_ifcfed: {e}")) .map_err(|e| RpcError::invalid_params(format!("pull_ifcfed: {e}")))?;
})?;
let display = p.display_name.unwrap_or_else(|| p.item_id.clone()); let display = p.display_name.unwrap_or_else(|| p.item_id.clone());
let aps = pair.aps.clone(); let aps = pair.aps.clone();
let path = run_with_progress("Downloading project", move |report| { let path = run_with_progress("Downloading project", move |report| {
download_ifcfed(&aps, &p.hub_id, &p.project_id, &p.item_id, &display, Some(download_callback(report, 0, 0))) download_ifcfed(
&aps,
&p.hub_id,
&p.project_id,
&p.item_id,
&display,
Some(download_callback(report, 0, 0)),
)
})?; })?;
Ok(json!({"path": path.to_string_lossy()})) Ok(json!({"path": path.to_string_lossy()}))
} }
@@ -205,9 +226,8 @@ impl AutodeskConnector {
fn pull_models(&self, params: Value) -> Result<Value, RpcError> { fn pull_models(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let entries: Vec<PullModelEntry> = serde_json::from_value(params).map_err(|e| { let entries: Vec<PullModelEntry> = serde_json::from_value(params)
RpcError::invalid_params(format!("pull_models: {e}")) .map_err(|e| RpcError::invalid_params(format!("pull_models: {e}")))?;
})?;
let total = entries.len(); let total = entries.len();
let aps = pair.aps.clone(); let aps = pair.aps.clone();
let results = run_with_progress("Downloading models", move |report| { let results = run_with_progress("Downloading models", move |report| {
@@ -241,7 +261,14 @@ impl AutodeskConnector {
let mut out: Vec<Value> = Vec::new(); let mut out: Vec<Value> = Vec::new();
for (index, entry) in chosen.entries.into_iter().enumerate() { for (index, entry) in chosen.entries.into_iter().enumerate() {
let cb = download_callback(report.clone(), index + 1, total); let cb = download_callback(report.clone(), index + 1, total);
match download_picked_model(&aps, &hub.id, &project.id, &entry.id, &entry.display_name, Some(cb)) { match download_picked_model(
&aps,
&hub.id,
&project.id,
&entry.id,
&entry.display_name,
Some(cb),
) {
Ok(Some(v)) => out.push(v), Ok(Some(v)) => out.push(v),
Ok(None) => {} Ok(None) => {}
Err(e) => eprintln!("pull_models_interactive[{index}] skipped: {}", e.message), Err(e) => eprintln!("pull_models_interactive[{index}] skipped: {}", e.message),
@@ -256,16 +283,18 @@ impl AutodeskConnector {
fn push_ifcfed_interactive(&self, params: Value) -> Result<Value, RpcError> { fn push_ifcfed_interactive(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| { let p: PushInteractiveParams = serde_json::from_value(params)
RpcError::invalid_params(format!("push_ifcfed_interactive: {e}")) .map_err(|e| RpcError::invalid_params(format!("push_ifcfed_interactive: {e}")))?;
})?;
check_local_exists(&p.path)?; check_local_exists(&p.path)?;
let default_name = file_name_of(&p.path); let default_name = file_name_of(&p.path);
if !default_name.to_lowercase().ends_with(".ifcfed") { if !default_name.to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::invalid_params("push_ifcfed_interactive expects an .ifcfed file.")); return Err(RpcError::invalid_params(
"push_ifcfed_interactive expects an .ifcfed file.",
));
} }
let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?; let chosen =
BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let folder = chosen let folder = chosen
.entries .entries
.first() .first()
@@ -274,7 +303,11 @@ impl AutodeskConnector {
let raw = prompt_for_filename("Save Project", "Save .ifcfed as:", &default_name) let raw = prompt_for_filename("Save Project", "Save .ifcfed as:", &default_name)
.ok_or_else(|| RpcError::internal("User cancelled save to cloud."))?; .ok_or_else(|| RpcError::internal("User cancelled save to cloud."))?;
let file_name = if raw.to_lowercase().ends_with(".ifcfed") { raw } else { format!("{raw}.ifcfed") }; let file_name = if raw.to_lowercase().ends_with(".ifcfed") {
raw
} else {
format!("{raw}.ifcfed")
};
let project_id = chosen.project.id.clone(); let project_id = chosen.project.id.clone();
let folder_id = folder.id.clone(); let folder_id = folder.id.clone();
@@ -282,17 +315,28 @@ impl AutodeskConnector {
let local_path = p.path.clone(); let local_path = p.path.clone();
let upload_name = file_name.clone(); let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading project", move |report| { let uploaded = run_with_progress("Uploading project", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?; })?;
let cached_path = cache_ifcfed_locally(&chosen.project.id, &uploaded.item_id, &file_name, &p.path)?; let cached_path =
cache::write_manifest(&cached_path, &Manifest { cache_ifcfed_locally(&chosen.project.id, &uploaded.item_id, &file_name, &p.path)?;
connector: CONNECTOR_ID.into(), cache::write_manifest(
hub_id: chosen.hub.id, &cached_path,
project_id: chosen.project.id, &Manifest {
item_id: uploaded.item_id, connector: CONNECTOR_ID.into(),
display_name: file_name, hub_id: chosen.hub.id,
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; project_id: chosen.project.id,
item_id: uploaded.item_id,
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(json!({"path": cached_path.to_string_lossy()})) Ok(json!({"path": cached_path.to_string_lossy()}))
} }
@@ -300,23 +344,30 @@ impl AutodeskConnector {
fn push_ifcfed(&self, params: Value) -> Result<Value, RpcError> { fn push_ifcfed(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let p: PushIfcfedParams = serde_json::from_value(params).map_err(|e| { let p: PushIfcfedParams = serde_json::from_value(params)
RpcError::invalid_params(format!("push_ifcfed: {e}")) .map_err(|e| RpcError::invalid_params(format!("push_ifcfed: {e}")))?;
})?;
check_local_exists(&p.path)?; check_local_exists(&p.path)?;
if !file_name_of(&p.path).to_lowercase().ends_with(".ifcfed") { if !file_name_of(&p.path).to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::invalid_params("push_ifcfed expects an .ifcfed file.")); return Err(RpcError::invalid_params(
"push_ifcfed expects an .ifcfed file.",
));
} }
if p.manifest.connector != CONNECTOR_ID { if p.manifest.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Manifest connector is not '{CONNECTOR_ID}'."))); return Err(RpcError::invalid_params(format!(
"Manifest connector is not '{CONNECTOR_ID}'."
)));
} }
let item = pair.aps.get_item(&p.manifest.project_id, &p.manifest.item_id)?; let item = pair
.aps
.get_item(&p.manifest.project_id, &p.manifest.item_id)?;
ensure_visible(&item)?; ensure_visible(&item)?;
let folder_id = item let folder_id = item.parent_folder_id.clone().ok_or_else(|| {
.parent_folder_id RpcError::internal(format!(
.clone() "Cannot resolve parent folder for item '{}'.",
.ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.manifest.item_id)))?; p.manifest.item_id
))
})?;
let file_name = if p.manifest.display_name.is_empty() { let file_name = if p.manifest.display_name.is_empty() {
item.display_name.clone() item.display_name.clone()
} else { } else {
@@ -328,17 +379,32 @@ impl AutodeskConnector {
let project_id = p.manifest.project_id.clone(); let project_id = p.manifest.project_id.clone();
let upload_name = file_name.clone(); let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading project", move |report| { let uploaded = run_with_progress("Uploading project", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?; })?;
let cached_path = cache_ifcfed_locally(&p.manifest.project_id, &uploaded.item_id, &file_name, &p.path)?; let cached_path = cache_ifcfed_locally(
cache::write_manifest(&cached_path, &Manifest { &p.manifest.project_id,
connector: CONNECTOR_ID.into(), &uploaded.item_id,
hub_id: p.manifest.hub_id, &file_name,
project_id: p.manifest.project_id, &p.path,
item_id: uploaded.item_id, )?;
display_name: file_name, cache::write_manifest(
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; &cached_path,
&Manifest {
connector: CONNECTOR_ID.into(),
hub_id: p.manifest.hub_id,
project_id: p.manifest.project_id,
item_id: uploaded.item_id,
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(json!({"path": cached_path.to_string_lossy()})) Ok(json!({"path": cached_path.to_string_lossy()}))
} }
@@ -346,12 +412,12 @@ impl AutodeskConnector {
fn push_model_interactive(&self, params: Value) -> Result<Value, RpcError> { fn push_model_interactive(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let p: PushInteractiveParams = serde_json::from_value(params).map_err(|e| { let p: PushInteractiveParams = serde_json::from_value(params)
RpcError::invalid_params(format!("push_model_interactive: {e}")) .map_err(|e| RpcError::invalid_params(format!("push_model_interactive: {e}")))?;
})?;
check_local_exists(&p.path)?; check_local_exists(&p.path)?;
let chosen = BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?; let chosen =
BrowseDialog::new(pair.auth.clone(), pair.aps.clone(), Mode::Destination).run()?;
let folder = chosen let folder = chosen
.entries .entries
.first() .first()
@@ -368,10 +434,22 @@ impl AutodeskConnector {
let local_path = p.path.clone(); let local_path = p.path.clone();
let upload_name = file_name.clone(); let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading model", move |report| { let uploaded = run_with_progress("Uploading model", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?; })?;
let cached_path = cache_model_locally(&chosen.project.id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?; let cached_path = cache_model_locally(
&chosen.project.id,
&uploaded.item_id,
&uploaded.version_id,
&file_name,
&p.path,
)?;
Ok(json!({ Ok(json!({
"display_name": file_name, "display_name": file_name,
"path": cached_path.to_string_lossy(), "path": cached_path.to_string_lossy(),
@@ -389,31 +467,50 @@ impl AutodeskConnector {
fn push_model(&self, params: Value) -> Result<Value, RpcError> { fn push_model(&self, params: Value) -> Result<Value, RpcError> {
let pair = self.require()?; let pair = self.require()?;
let p: PushModelParams = serde_json::from_value(params).map_err(|e| { let p: PushModelParams = serde_json::from_value(params)
RpcError::invalid_params(format!("push_model: {e}")) .map_err(|e| RpcError::invalid_params(format!("push_model: {e}")))?;
})?;
check_local_exists(&p.path)?; check_local_exists(&p.path)?;
if p.source.connector != CONNECTOR_ID { if p.source.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'."))); return Err(RpcError::invalid_params(format!(
"Source connector is not '{CONNECTOR_ID}'."
)));
} }
let item = pair.aps.get_item(&p.source.project_id, &p.source.item_id)?; let item = pair.aps.get_item(&p.source.project_id, &p.source.item_id)?;
ensure_visible(&item)?; ensure_visible(&item)?;
let folder_id = item let folder_id = item.parent_folder_id.clone().ok_or_else(|| {
.parent_folder_id RpcError::internal(format!(
.clone() "Cannot resolve parent folder for item '{}'.",
.ok_or_else(|| RpcError::internal(format!("Cannot resolve parent folder for item '{}'.", p.source.item_id)))?; p.source.item_id
let file_name = if item.display_name.is_empty() { file_name_of(&p.path) } else { item.display_name.clone() }; ))
})?;
let file_name = if item.display_name.is_empty() {
file_name_of(&p.path)
} else {
item.display_name.clone()
};
let aps = pair.aps.clone(); let aps = pair.aps.clone();
let local_path = p.path.clone(); let local_path = p.path.clone();
let project_id = p.source.project_id.clone(); let project_id = p.source.project_id.clone();
let upload_name = file_name.clone(); let upload_name = file_name.clone();
let uploaded = run_with_progress("Uploading model", move |report| { let uploaded = run_with_progress("Uploading model", move |report| {
aps.upload_file_to_folder(&project_id, &folder_id, &local_path, Some(&upload_name), Some(upload_callback(report))) aps.upload_file_to_folder(
&project_id,
&folder_id,
&local_path,
Some(&upload_name),
Some(upload_callback(report)),
)
})?; })?;
let _cached_path = cache_model_locally(&p.source.project_id, &uploaded.item_id, &uploaded.version_id, &file_name, &p.path)?; let _cached_path = cache_model_locally(
&p.source.project_id,
&uploaded.item_id,
&uploaded.version_id,
&file_name,
&p.path,
)?;
Ok(json!({ Ok(json!({
"source": Source { "source": Source {
connector: CONNECTOR_ID.into(), connector: CONNECTOR_ID.into(),
@@ -438,10 +535,11 @@ fn download_ifcfed(
) -> Result<PathBuf, RpcError> { ) -> Result<PathBuf, RpcError> {
let item = aps.get_item(project_id, item_id)?; let item = aps.get_item(project_id, item_id)?;
ensure_visible(&item)?; ensure_visible(&item)?;
let storage_id = item let storage_id = item.storage_id.clone().ok_or_else(|| {
.storage_id RpcError::internal(format!(
.clone() "Autodesk item '{item_id}' has no downloadable storage."
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{item_id}' has no downloadable storage.")))?; ))
})?;
let file_name = if !item.display_name.is_empty() { let file_name = if !item.display_name.is_empty() {
item.display_name.clone() item.display_name.clone()
} else if !display_name_hint.is_empty() { } else if !display_name_hint.is_empty() {
@@ -450,19 +548,25 @@ fn download_ifcfed(
item_id.to_string() item_id.to_string()
}; };
if !file_name.to_lowercase().ends_with(".ifcfed") { if !file_name.to_lowercase().ends_with(".ifcfed") {
return Err(RpcError::internal(format!("Item '{file_name}' is not an .ifcfed file."))); return Err(RpcError::internal(format!(
"Item '{file_name}' is not an .ifcfed file."
)));
} }
let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id)) let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id))
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let ifcfed_path = directory.join(&file_name); let ifcfed_path = directory.join(&file_name);
aps.download_storage_to_file(&storage_id, &ifcfed_path, progress)?; aps.download_storage_to_file(&storage_id, &ifcfed_path, progress)?;
cache::write_manifest(&ifcfed_path, &Manifest { cache::write_manifest(
connector: CONNECTOR_ID.into(), &ifcfed_path,
hub_id: hub_id.into(), &Manifest {
project_id: project_id.into(), connector: CONNECTOR_ID.into(),
item_id: item_id.into(), hub_id: hub_id.into(),
display_name: file_name, project_id: project_id.into(),
}).map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?; item_id: item_id.into(),
display_name: file_name,
},
)
.map_err(|e| RpcError::internal(format!("Manifest write failed: {e}")))?;
Ok(ifcfed_path) Ok(ifcfed_path)
} }
@@ -472,7 +576,9 @@ fn resolve_scripted_model(
progress: Option<crate::progress::ApsProgress>, progress: Option<crate::progress::ApsProgress>,
) -> Result<Option<Value>, RpcError> { ) -> Result<Option<Value>, RpcError> {
if entry.source.connector != CONNECTOR_ID { if entry.source.connector != CONNECTOR_ID {
return Err(RpcError::invalid_params(format!("Source connector is not '{CONNECTOR_ID}'."))); return Err(RpcError::invalid_params(format!(
"Source connector is not '{CONNECTOR_ID}'."
)));
} }
let display_hint = entry let display_hint = entry
.display_name .display_name
@@ -481,14 +587,23 @@ fn resolve_scripted_model(
let item = aps.get_item(&entry.source.project_id, &entry.source.item_id)?; let item = aps.get_item(&entry.source.project_id, &entry.source.item_id)?;
if item.hidden { if item.hidden {
eprintln!("Autodesk item '{}' is hidden/deleted; returning null.", entry.source.item_id); eprintln!(
"Autodesk item '{}' is hidden/deleted; returning null.",
entry.source.item_id
);
return Ok(None); return Ok(None);
} }
let storage_id = item let storage_id = item.storage_id.clone().ok_or_else(|| {
.storage_id RpcError::internal(format!(
.clone() "Autodesk item '{}' has no downloadable storage.",
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{}' has no downloadable storage.", entry.source.item_id)))?; entry.source.item_id
let file_name = if item.display_name.is_empty() { display_hint } else { item.display_name.clone() }; ))
})?;
let file_name = if item.display_name.is_empty() {
display_hint
} else {
item.display_name.clone()
};
let version_id = item.version_id.clone().unwrap_or_default(); let version_id = item.version_id.clone().unwrap_or_default();
let directory = cache::model_dir(&entry.source.project_id, &entry.source.item_id, &version_id); let directory = cache::model_dir(&entry.source.project_id, &entry.source.item_id, &version_id);
@@ -514,10 +629,11 @@ fn download_picked_model(
) -> Result<Option<Value>, RpcError> { ) -> Result<Option<Value>, RpcError> {
let item = aps.get_item(project_id, entry_id)?; let item = aps.get_item(project_id, entry_id)?;
ensure_visible(&item)?; ensure_visible(&item)?;
let storage_id = item let storage_id = item.storage_id.clone().ok_or_else(|| {
.storage_id RpcError::internal(format!(
.clone() "Autodesk item '{entry_id}' has no downloadable storage."
.ok_or_else(|| RpcError::internal(format!("Autodesk item '{entry_id}' has no downloadable storage.")))?; ))
})?;
let file_name = if !item.display_name.is_empty() { let file_name = if !item.display_name.is_empty() {
item.display_name.clone() item.display_name.clone()
} else if !entry_display.is_empty() { } else if !entry_display.is_empty() {
@@ -548,20 +664,33 @@ fn download_picked_model(
}))) })))
} }
fn cache_ifcfed_locally(project_id: &str, item_id: &str, file_name: &str, src: &Path) -> Result<PathBuf, RpcError> { fn cache_ifcfed_locally(
project_id: &str,
item_id: &str,
file_name: &str,
src: &Path,
) -> Result<PathBuf, RpcError> {
let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id)) let directory = cache::prepare_sole_child_dir(&cache::ifcfed_dir(project_id, item_id))
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let cached_path = directory.join(file_name); let cached_path = directory.join(file_name);
std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?; std::fs::copy(src, &cached_path)
.map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
Ok(cached_path) Ok(cached_path)
} }
fn cache_model_locally(project_id: &str, item_id: &str, version_id: &str, file_name: &str, src: &Path) -> Result<PathBuf, RpcError> { fn cache_model_locally(
project_id: &str,
item_id: &str,
version_id: &str,
file_name: &str,
src: &Path,
) -> Result<PathBuf, RpcError> {
let directory = cache::model_dir(project_id, item_id, version_id); let directory = cache::model_dir(project_id, item_id, version_id);
cache::prepare_sole_child_dir(&directory) cache::prepare_sole_child_dir(&directory)
.map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?; .map_err(|e| RpcError::internal(format!("Cache dir prep failed: {e}")))?;
let cached_path = directory.join(file_name); let cached_path = directory.join(file_name);
std::fs::copy(src, &cached_path).map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?; std::fs::copy(src, &cached_path)
.map_err(|e| RpcError::internal(format!("Cache copy failed: {e}")))?;
Ok(cached_path) Ok(cached_path)
} }
@@ -569,7 +698,10 @@ fn build_metadata_from_item(item: &ItemTip) -> Value {
let mut metadata = serde_json::Map::new(); let mut metadata = serde_json::Map::new();
if let Some(v) = &item.version_number { if let Some(v) = &item.version_number {
if !v.is_null() { if !v.is_null() {
metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v)))); metadata.insert(
"revision".into(),
json!(format!("v{}", json_value_to_display(v))),
);
} }
} }
if let Some(s) = &item.last_modified_time_utc { if let Some(s) = &item.last_modified_time_utc {
@@ -589,7 +721,10 @@ fn build_metadata_from_upload(uploaded: &UploadResult) -> Value {
let mut metadata = serde_json::Map::new(); let mut metadata = serde_json::Map::new();
if let Some(v) = &uploaded.version_number { if let Some(v) = &uploaded.version_number {
if !v.is_null() { if !v.is_null() {
metadata.insert("revision".into(), json!(format!("v{}", json_value_to_display(v)))); metadata.insert(
"revision".into(),
json!(format!("v{}", json_value_to_display(v))),
);
} }
} }
if let Some(s) = &uploaded.last_modified_time_utc { if let Some(s) = &uploaded.last_modified_time_utc {
@@ -616,14 +751,20 @@ fn json_value_to_display(v: &Value) -> String {
fn ensure_visible(item: &ItemTip) -> Result<(), RpcError> { fn ensure_visible(item: &ItemTip) -> Result<(), RpcError> {
if item.hidden { if item.hidden {
return Err(RpcError::internal(format!("Autodesk item '{}' has been deleted.", item.id))); return Err(RpcError::internal(format!(
"Autodesk item '{}' has been deleted.",
item.id
)));
} }
Ok(()) Ok(())
} }
fn check_local_exists(path: &Path) -> Result<(), RpcError> { fn check_local_exists(path: &Path) -> Result<(), RpcError> {
if !path.exists() { if !path.exists() {
return Err(RpcError::invalid_params(format!("Local file '{}' does not exist.", path.display()))); return Err(RpcError::invalid_params(format!(
"Local file '{}' does not exist.",
path.display()
)));
} }
Ok(()) Ok(())
} }
+35 -8
View File
@@ -38,7 +38,9 @@ pub fn progress_detail(percent: Option<i32>, done: Option<u64>, total: Option<u6
parts.push(format!("{p}%")); parts.push(format!("{p}%"));
} }
match (done, total) { match (done, total) {
(Some(d), Some(t)) if t > 0 => parts.push(format!("{} / {}", format_bytes(d), format_bytes(t))), (Some(d), Some(t)) if t > 0 => {
parts.push(format!("{} / {}", format_bytes(d), format_bytes(t)))
}
(Some(d), _) => parts.push(format_bytes(d)), (Some(d), _) => parts.push(format_bytes(d)),
_ => {} _ => {}
} }
@@ -49,10 +51,18 @@ pub fn progress_detail(percent: Option<i32>, done: Option<u64>, total: Option<u6
/// "(i/N)" when batching. Pass `total = 0` for single-file transfers. /// "(i/N)" when batching. Pass `total = 0` for single-file transfers.
pub fn download_callback(report: Report, index: usize, total: usize) -> ApsProgress { pub fn download_callback(report: Report, index: usize, total: usize) -> ApsProgress {
Arc::new(move |name, percent, done, total_bytes| { Arc::new(move |name, percent, done, total_bytes| {
let suffix = if total != 0 { format!(" ({index}/{total})") } else { String::new() }; let suffix = if total != 0 {
format!(" ({index}/{total})")
} else {
String::new()
};
let detail = progress_detail(percent, done, total_bytes); let detail = progress_detail(percent, done, total_bytes);
let msg = format!("Downloading {name}{suffix}"); let msg = format!("Downloading {name}{suffix}");
let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) }; let detail_ref = if detail.is_empty() {
None
} else {
Some(detail.as_str())
};
report("download", &msg, percent, detail_ref); report("download", &msg, percent, detail_ref);
}) })
} }
@@ -61,7 +71,11 @@ pub fn upload_callback(report: Report) -> ApsProgress {
Arc::new(move |name, percent, done, total_bytes| { Arc::new(move |name, percent, done, total_bytes| {
let detail = progress_detail(percent, done, total_bytes); let detail = progress_detail(percent, done, total_bytes);
let msg = format!("Uploading {name}"); let msg = format!("Uploading {name}");
let detail_ref = if detail.is_empty() { None } else { Some(detail.as_str()) }; let detail_ref = if detail.is_empty() {
None
} else {
Some(detail.as_str())
};
report("upload", &msg, percent, detail_ref); report("upload", &msg, percent, detail_ref);
}) })
} }
@@ -73,21 +87,31 @@ pub fn auth_to_report(report: Report) -> AuthProgress {
}) })
} }
/// A single coalesced report: `(phase, message, percent, detail)`.
type PendingReport = (String, String, Option<i32>, Option<String>);
/// Latest-wins hand-off from worker thread → UI thread. Intermediate updates /// Latest-wins hand-off from worker thread → UI thread. Intermediate updates
/// are coalesced: only the freshest matters for a progress bar. /// are coalesced: only the freshest matters for a progress bar.
pub struct ProgressBridge { pub struct ProgressBridge {
pending: Mutex<Option<(String, String, Option<i32>, Option<String>)>>, pending: Mutex<Option<PendingReport>>,
} }
impl ProgressBridge { impl ProgressBridge {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { pending: Mutex::new(None) }) Arc::new(Self {
pending: Mutex::new(None),
})
} }
pub fn report_fn(self: Arc<Self>) -> Report { pub fn report_fn(self: Arc<Self>) -> Report {
Arc::new(move |phase, message, percent, detail| { Arc::new(move |phase, message, percent, detail| {
let mut slot = self.pending.lock().unwrap(); let mut slot = self.pending.lock().unwrap();
*slot = Some((phase.to_string(), message.to_string(), percent, detail.map(str::to_string))); *slot = Some((
phase.to_string(),
message.to_string(),
percent,
detail.map(str::to_string),
));
}) })
} }
@@ -110,7 +134,10 @@ mod tests {
#[test] #[test]
fn progress_detail_combines_parts() { fn progress_detail_combines_parts() {
assert_eq!(progress_detail(Some(50), Some(512), Some(1024)), "50%, 512 B / 1.0 KB"); assert_eq!(
progress_detail(Some(50), Some(512), Some(1024)),
"50%, 512 B / 1.0 KB"
);
assert_eq!(progress_detail(None, Some(512), None), "512 B"); assert_eq!(progress_detail(None, Some(512), None), "512 B");
assert_eq!(progress_detail(None, None, None), ""); assert_eq!(progress_detail(None, None, None), "");
} }
+71 -15
View File
@@ -18,7 +18,11 @@ pub struct RpcError {
impl RpcError { impl RpcError {
pub fn new(code: i32, message: impl Into<String>) -> Self { pub fn new(code: i32, message: impl Into<String>) -> Self {
Self { code, message: message.into(), data: None } Self {
code,
message: message.into(),
data: None,
}
} }
pub fn internal(message: impl Into<String>) -> Self { pub fn internal(message: impl Into<String>) -> Self {
@@ -48,7 +52,11 @@ pub struct JsonRpcHost<R: Read, W: Write> {
impl<R: Read, W: Write> JsonRpcHost<R, W> { impl<R: Read, W: Write> JsonRpcHost<R, W> {
pub fn new(handlers: HashMap<String, Handler>, stdin: R, stdout: W) -> Self { pub fn new(handlers: HashMap<String, Handler>, stdin: R, stdout: W) -> Self {
Self { handlers, stdin: BufReader::new(stdin), stdout } Self {
handlers,
stdin: BufReader::new(stdin),
stdout,
}
} }
pub fn run(&mut self) -> i32 { pub fn run(&mut self) -> i32 {
@@ -72,7 +80,12 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let message: Value = match serde_json::from_str(line) { let message: Value = match serde_json::from_str(line) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
self.respond_error(Value::Null, JSONRPC_PARSE_ERROR, &format!("Parse error: {e}"), None); self.respond_error(
Value::Null,
JSONRPC_PARSE_ERROR,
&format!("Parse error: {e}"),
None,
);
return; return;
} }
}; };
@@ -80,7 +93,12 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let obj = match message.as_object() { let obj = match message.as_object() {
Some(o) => o, Some(o) => o,
None => { None => {
self.respond_error(Value::Null, JSONRPC_INVALID_REQUEST, "Request must be a JSON object", None); self.respond_error(
Value::Null,
JSONRPC_INVALID_REQUEST,
"Request must be a JSON object",
None,
);
return; return;
} }
}; };
@@ -88,28 +106,48 @@ impl<R: Read, W: Write> JsonRpcHost<R, W> {
let message_id = obj.get("id").cloned().unwrap_or(Value::Null); let message_id = obj.get("id").cloned().unwrap_or(Value::Null);
if obj.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") { if obj.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") {
self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version", None); self.respond_error(
message_id,
JSONRPC_INVALID_REQUEST,
"Missing or wrong 'jsonrpc' version",
None,
);
return; return;
} }
let method = match obj.get("method").and_then(|v| v.as_str()) { let method = match obj.get("method").and_then(|v| v.as_str()) {
Some(m) => m.to_string(), Some(m) => m.to_string(),
None => { None => {
self.respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string", None); self.respond_error(
message_id,
JSONRPC_INVALID_REQUEST,
"Missing 'method' string",
None,
);
return; return;
} }
}; };
let params = obj.get("params").cloned().unwrap_or(Value::Null); let params = obj.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() && !params.is_array() { if !params.is_null() && !params.is_object() && !params.is_array() {
self.respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array", None); self.respond_error(
message_id,
JSONRPC_INVALID_PARAMS,
"'params' must be a JSON object or array",
None,
);
return; return;
} }
let outcome = match self.handlers.get(&method) { let outcome = match self.handlers.get(&method) {
Some(handler) => handler(params), Some(handler) => handler(params),
None => { None => {
self.respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, &format!("Unknown method '{method}'"), None); self.respond_error(
message_id,
JSONRPC_METHOD_NOT_FOUND,
&format!("Unknown method '{method}'"),
None,
);
return; return;
} }
}; };
@@ -168,15 +206,21 @@ mod tests {
fn echo_handlers() -> HashMap<String, Handler> { fn echo_handlers() -> HashMap<String, Handler> {
let mut m: HashMap<String, Handler> = HashMap::new(); let mut m: HashMap<String, Handler> = HashMap::new();
m.insert("echo".into(), Box::new(|p| Ok(p))); m.insert("echo".into(), Box::new(Ok));
m.insert("boom".into(), Box::new(|_| Err(RpcError::internal("bang")))); m.insert("boom".into(), Box::new(|_| Err(RpcError::internal("bang"))));
m.insert("bad_params".into(), Box::new(|_| Err(RpcError::invalid_params("nope")))); m.insert(
"bad_params".into(),
Box::new(|_| Err(RpcError::invalid_params("nope"))),
);
m m
} }
#[test] #[test]
fn dispatches_result() { fn dispatches_result() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"x\":1}}\n"); let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"x\":1}}\n",
);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 1);
assert_eq!(out[0]["result"], json!({"x": 1})); assert_eq!(out[0]["result"], json!({"x": 1}));
assert_eq!(out[0]["id"], json!(1)); assert_eq!(out[0]["id"], json!(1));
@@ -184,13 +228,19 @@ mod tests {
#[test] #[test]
fn notification_no_response() { fn notification_no_response() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{}}\n"); let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{}}\n",
);
assert!(out.is_empty()); assert!(out.is_empty());
} }
#[test] #[test]
fn unknown_method() { fn unknown_method() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"nope\"}\n"); let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"nope\"}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_METHOD_NOT_FOUND)); assert_eq!(out[0]["error"]["code"], json!(JSONRPC_METHOD_NOT_FOUND));
} }
@@ -209,13 +259,19 @@ mod tests {
#[test] #[test]
fn invalid_params_shape() { fn invalid_params_shape() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":42}\n"); let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":42}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS)); assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS));
} }
#[test] #[test]
fn handler_error_passes_code() { fn handler_error_passes_code() {
let out = run_once(echo_handlers(), "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"bad_params\"}\n"); let out = run_once(
echo_handlers(),
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"bad_params\"}\n",
);
assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS)); assert_eq!(out[0]["error"]["code"], json!(JSONRPC_INVALID_PARAMS));
assert_eq!(out[0]["error"]["message"], json!("nope")); assert_eq!(out[0]["error"]["message"], json!("nope"));
} }
+68 -36
View File
@@ -64,10 +64,10 @@ const PLACEHOLDER_LABEL: &str = "Loading…";
/// cannot touch FLTK widgets. /// cannot touch FLTK widgets.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum WorkerMsg { enum WorkerMsg {
HubsLoaded(Result<Vec<Hub>, RpcError>), Hubs(Result<Vec<Hub>, RpcError>),
ProjectsLoaded(Result<Vec<Project>, RpcError>), Projects(Result<Vec<Project>, RpcError>),
TopFoldersLoaded(Result<Vec<Entry>, RpcError>), TopFolders(Result<Vec<Entry>, RpcError>),
FolderContentsLoaded(String, Result<Vec<Entry>, RpcError>), FolderContents(String, Result<Vec<Entry>, RpcError>),
} }
struct State { struct State {
@@ -198,7 +198,6 @@ impl BrowseDialog {
sign_in.set_callback({ sign_in.set_callback({
let aps = aps.clone(); let aps = aps.clone();
let auth = auth.clone(); let auth = auth.clone();
let worker_tx = worker_tx;
let mut status = status_mut.clone(); let mut status = status_mut.clone();
move |_| { move |_| {
status.set_label("Signing in to Autodesk…"); status.set_label("Signing in to Autodesk…");
@@ -208,7 +207,7 @@ impl BrowseDialog {
let outcome = auth let outcome = auth
.login_interactive(noop_auth()) .login_interactive(noop_auth())
.and_then(|_| aps.list_hubs()); .and_then(|_| aps.list_hubs());
worker_tx.send(WorkerMsg::HubsLoaded(outcome)); worker_tx.send(WorkerMsg::Hubs(outcome));
}); });
} }
}); });
@@ -216,7 +215,6 @@ impl BrowseDialog {
hub_combo.set_callback({ hub_combo.set_callback({
let aps = aps.clone(); let aps = aps.clone();
let state = state.clone(); let state = state.clone();
let worker_tx = worker_tx;
let mut projects_browser = projects_browser.clone(); let mut projects_browser = projects_browser.clone();
let mut tree = tree.clone(); let mut tree = tree.clone();
let mut status = status_mut.clone(); let mut status = status_mut.clone();
@@ -247,7 +245,7 @@ impl BrowseDialog {
status.set_label(&format!("Loading projects in {hub_name}")); status.set_label(&format!("Loading projects in {hub_name}"));
let aps = aps.clone(); let aps = aps.clone();
thread::spawn(move || { thread::spawn(move || {
worker_tx.send(WorkerMsg::ProjectsLoaded(aps.list_projects(&hub_id))); worker_tx.send(WorkerMsg::Projects(aps.list_projects(&hub_id)));
}); });
} }
}); });
@@ -255,7 +253,6 @@ impl BrowseDialog {
projects_browser.clone().set_callback({ projects_browser.clone().set_callback({
let aps = aps.clone(); let aps = aps.clone();
let state = state.clone(); let state = state.clone();
let worker_tx = worker_tx;
let mut tree = tree.clone(); let mut tree = tree.clone();
let mut status = status_mut.clone(); let mut status = status_mut.clone();
let action_btn_clone = action_btn.clone(); let action_btn_clone = action_btn.clone();
@@ -286,8 +283,9 @@ impl BrowseDialog {
status.set_label(&format!("Loading top folders in {project_name}")); status.set_label(&format!("Loading top folders in {project_name}"));
let aps = aps.clone(); let aps = aps.clone();
thread::spawn(move || { thread::spawn(move || {
worker_tx worker_tx.send(WorkerMsg::TopFolders(
.send(WorkerMsg::TopFoldersLoaded(aps.list_top_folders(&hub_id, &project_id))); aps.list_top_folders(&hub_id, &project_id),
));
}); });
} }
}); });
@@ -295,7 +293,6 @@ impl BrowseDialog {
tree.clone().set_callback({ tree.clone().set_callback({
let aps = aps.clone(); let aps = aps.clone();
let state = state.clone(); let state = state.clone();
let worker_tx = worker_tx;
let mut status = status_mut.clone(); let mut status = status_mut.clone();
let action_btn_clone = action_btn.clone(); let action_btn_clone = action_btn.clone();
move |t| match t.callback_reason() { move |t| match t.callback_reason() {
@@ -306,10 +303,16 @@ impl BrowseDialog {
refresh_action_button(&mut action_btn_clone.clone(), &s, mode); refresh_action_button(&mut action_btn_clone.clone(), &s, mode);
} }
TreeReason::Opened => { TreeReason::Opened => {
let Some(item) = t.callback_item() else { return }; let Some(item) = t.callback_item() else {
let Ok(path) = t.item_pathname(&item) else { return }; return;
};
let Ok(path) = t.item_pathname(&item) else {
return;
};
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
let Some(entry) = s.tree_entries.get(&path).cloned() else { return }; let Some(entry) = s.tree_entries.get(&path).cloned() else {
return;
};
if entry.entry_type != EntryType::Folders { if entry.entry_type != EntryType::Folders {
return; return;
} }
@@ -334,8 +337,9 @@ impl BrowseDialog {
let folder_id = entry.id.clone(); let folder_id = entry.id.clone();
let aps = aps.clone(); let aps = aps.clone();
thread::spawn(move || { thread::spawn(move || {
let result = list_folder_contents_for_mode(&aps, &project_id, &folder_id, mode); let result =
worker_tx.send(WorkerMsg::FolderContentsLoaded(path, result)); list_folder_contents_for_mode(&aps, &project_id, &folder_id, mode);
worker_tx.send(WorkerMsg::FolderContents(path, result));
}); });
} }
_ => {} _ => {}
@@ -373,7 +377,7 @@ impl BrowseDialog {
if auth.get_token().ok().flatten().is_some() { if auth.get_token().ok().flatten().is_some() {
let aps = aps.clone(); let aps = aps.clone();
thread::spawn(move || { thread::spawn(move || {
worker_tx.send(WorkerMsg::HubsLoaded(aps.list_hubs())); worker_tx.send(WorkerMsg::Hubs(aps.list_hubs()));
}); });
} }
@@ -416,15 +420,15 @@ fn apply_worker_msg(
mode: Mode, mode: Mode,
) { ) {
match msg { match msg {
WorkerMsg::HubsLoaded(Err(e)) => show_error("Sign In Failed", &e.message), WorkerMsg::Hubs(Err(e)) => show_error("Sign In Failed", &e.message),
WorkerMsg::HubsLoaded(Ok(hubs)) => { WorkerMsg::Hubs(Ok(hubs)) => {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
s.hubs = hubs; s.hubs = hubs;
refill_hub_combo(hub_combo, &s.hubs); refill_hub_combo(hub_combo, &s.hubs);
status.set_label("Signed in. Select a hub."); status.set_label("Signed in. Select a hub.");
} }
WorkerMsg::ProjectsLoaded(Err(e)) => show_error("Load Projects Failed", &e.message), WorkerMsg::Projects(Err(e)) => show_error("Load Projects Failed", &e.message),
WorkerMsg::ProjectsLoaded(Ok(projects)) => { WorkerMsg::Projects(Ok(projects)) => {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
s.projects = projects; s.projects = projects;
projects_browser.clear(); projects_browser.clear();
@@ -437,8 +441,8 @@ fn apply_worker_msg(
} }
} }
} }
WorkerMsg::TopFoldersLoaded(Err(e)) => show_error("Load Project Failed", &e.message), WorkerMsg::TopFolders(Err(e)) => show_error("Load Project Failed", &e.message),
WorkerMsg::TopFoldersLoaded(Ok(folders)) => { WorkerMsg::TopFolders(Ok(folders)) => {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
for entry in folders { for entry in folders {
insert_tree_entry(tree, &mut s.tree_entries, "", &entry); insert_tree_entry(tree, &mut s.tree_entries, "", &entry);
@@ -457,11 +461,11 @@ fn apply_worker_msg(
} }
} }
} }
WorkerMsg::FolderContentsLoaded(parent, Err(e)) => { WorkerMsg::FolderContents(parent, Err(e)) => {
state.borrow_mut().loaded_folders.remove(&parent); state.borrow_mut().loaded_folders.remove(&parent);
show_error("Load Folder Failed", &e.message); show_error("Load Folder Failed", &e.message);
} }
WorkerMsg::FolderContentsLoaded(parent, Ok(children)) => { WorkerMsg::FolderContents(parent, Ok(children)) => {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
for entry in &children { for entry in &children {
insert_tree_entry(tree, &mut s.tree_entries, &parent, entry); insert_tree_entry(tree, &mut s.tree_entries, &parent, entry);
@@ -474,6 +478,9 @@ fn apply_worker_msg(
// ---- worker helpers ------------------------------------------------------- // ---- worker helpers -------------------------------------------------------
/// Per-mode predicate deciding which listed entries survive.
type EntryFilter = Box<dyn Fn(&Entry) -> bool>;
fn list_folder_contents_for_mode( fn list_folder_contents_for_mode(
aps: &Arc<ApsClient>, aps: &Arc<ApsClient>,
project_id: &str, project_id: &str,
@@ -485,7 +492,7 @@ fn list_folder_contents_for_mode(
} else { } else {
&["folders", "items"] &["folders", "items"]
}; };
let filter_box: Option<Box<dyn Fn(&Entry) -> bool>> = match mode { let filter_box: Option<EntryFilter> = match mode {
Mode::Ifcfed => Some(Box::new(|e: &Entry| { Mode::Ifcfed => Some(Box::new(|e: &Entry| {
e.display_name.to_lowercase().ends_with(".ifcfed") e.display_name.to_lowercase().ends_with(".ifcfed")
})), })),
@@ -517,7 +524,9 @@ fn insert_tree_entry(
parent_canonical: &str, parent_canonical: &str,
entry: &Entry, entry: &Entry,
) -> String { ) -> String {
let parent_add = parent_canonical.strip_prefix('/').unwrap_or(parent_canonical); let parent_add = parent_canonical
.strip_prefix('/')
.unwrap_or(parent_canonical);
let label = if entry.display_name.is_empty() { let label = if entry.display_name.is_empty() {
entry.id.clone() entry.id.clone()
} else { } else {
@@ -565,7 +574,9 @@ fn add_unique(tree: &mut Tree, parent_add: &str, label: &str) -> String {
} }
fn collect_selected_entries(tree: &Tree, tree_entries: &HashMap<String, Entry>) -> Vec<Entry> { fn collect_selected_entries(tree: &Tree, tree_entries: &HashMap<String, Entry>) -> Vec<Entry> {
let Some(items) = tree.get_selected_items() else { return Vec::new() }; let Some(items) = tree.get_selected_items() else {
return Vec::new();
};
items items
.into_iter() .into_iter()
.filter_map(|item| tree.item_pathname(&item).ok()) .filter_map(|item| tree.item_pathname(&item).ok())
@@ -586,7 +597,10 @@ fn update_status_for_selection(status: &mut Frame, entries: &[Entry], mode: Mode
status.set_label(&format!("Selected {kind}: {}", entry.display_name)); status.set_label(&format!("Selected {kind}: {}", entry.display_name));
} }
n => { n => {
let valid = entries.iter().filter(|e| is_valid_selection(mode, e)).count(); let valid = entries
.iter()
.filter(|e| is_valid_selection(mode, e))
.count();
status.set_label(&format!("Selected {valid} of {n} items.")); status.set_label(&format!("Selected {valid} of {n} items."));
} }
} }
@@ -636,11 +650,29 @@ mod tests {
#[test] #[test]
fn is_valid_selection_modes() { fn is_valid_selection_modes() {
assert!(is_valid_selection(Mode::Ifcfed, &entry("a.ifcfed", EntryType::Items))); assert!(is_valid_selection(
assert!(!is_valid_selection(Mode::Ifcfed, &entry("a.ifc", EntryType::Items))); Mode::Ifcfed,
assert!(is_valid_selection(Mode::Model, &entry("a.ifc", EntryType::Items))); &entry("a.ifcfed", EntryType::Items)
assert!(is_valid_selection(Mode::Model, &entry("a.rdb", EntryType::Items))); ));
assert!(is_valid_selection(Mode::Destination, &entry("folder", EntryType::Folders))); assert!(!is_valid_selection(
assert!(!is_valid_selection(Mode::Destination, &entry("file.ifc", EntryType::Items))); Mode::Ifcfed,
&entry("a.ifc", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Model,
&entry("a.ifc", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Model,
&entry("a.rdb", EntryType::Items)
));
assert!(is_valid_selection(
Mode::Destination,
&entry("folder", EntryType::Folders)
));
assert!(!is_valid_selection(
Mode::Destination,
&entry("file.ifc", EntryType::Items)
));
} }
} }
+4 -10
View File
@@ -1,15 +1,9 @@
//! Settings dialog: APS client id + OAuth callback port + sign-out. //! Settings dialog: APS client id + OAuth callback port + sign-out.
use std::sync::Arc; use std::rc::Rc;
use fltk::{ use fltk::{
app, app, button::Button, enums::Align, frame::Frame, group::Flex, input::Input, prelude::*,
button::Button,
enums::Align,
frame::Frame,
group::Flex,
input::Input,
prelude::*,
window::Window, window::Window,
}; };
@@ -20,11 +14,11 @@ use crate::ui::dialogs::{center_on_screen, confirm, drain_after_close, show_erro
use crate::ui::ensure_app; use crate::ui::ensure_app;
pub struct SettingsDialog { pub struct SettingsDialog {
pub on_reload: Arc<dyn Fn() -> Result<(), RpcError>>, pub on_reload: Rc<dyn Fn() -> Result<(), RpcError>>,
} }
impl SettingsDialog { impl SettingsDialog {
pub fn new(on_reload: Arc<dyn Fn() -> Result<(), RpcError>>) -> Self { pub fn new(on_reload: Rc<dyn Fn() -> Result<(), RpcError>>) -> Self {
Self { on_reload } Self { on_reload }
} }
+72 -20
View File
@@ -57,7 +57,10 @@ fn spawn_stub(routes: Vec<(&'static str, &'static str, u16, String)>) -> StubSer
if h == "\r\n" || h == "\n" { if h == "\r\n" || h == "\n" {
break; break;
} }
if let Some(v) = h.strip_prefix("Content-Length: ").or_else(|| h.strip_prefix("content-length: ")) { if let Some(v) = h
.strip_prefix("Content-Length: ")
.or_else(|| h.strip_prefix("content-length: "))
{
content_length = v.trim().parse().unwrap_or(0); content_length = v.trim().parse().unwrap_or(0);
} }
} }
@@ -72,7 +75,9 @@ fn spawn_stub(routes: Vec<(&'static str, &'static str, u16, String)>) -> StubSer
let method = parts.first().copied().unwrap_or(""); let method = parts.first().copied().unwrap_or("");
let path = parts.get(1).copied().unwrap_or("/"); let path = parts.get(1).copied().unwrap_or("/");
let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p)); let idx = routes
.iter()
.position(|(m, p, _, _)| *m == method && path.starts_with(p));
let (status, body) = match idx { let (status, body) = match idx {
Some(i) => { Some(i) => {
let (_, _, status, body) = routes.remove(i); let (_, _, status, body) = routes.remove(i);
@@ -156,9 +161,15 @@ fn list_folder_contents_filters_ifcfed() {
{"id": "i2", "type": "items", "attributes": {"displayName": "model.ifcfed"}} {"id": "i2", "type": "items", "attributes": {"displayName": "model.ifcfed"}}
] ]
}"#; }"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/folders/F/contents", 200, body.into())]); let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/folders/F/contents",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone()); let client = make_client(server.base_url.clone());
let filter = |e: &bonsaiviewer_autodesk::aps::Entry| e.display_name.to_lowercase().ends_with(".ifcfed"); let filter =
|e: &bonsaiviewer_autodesk::aps::Entry| e.display_name.to_lowercase().ends_with(".ifcfed");
let entries = client let entries = client
.list_folder_contents("P", "F", &["folders", "items"], Some(&filter)) .list_folder_contents("P", "F", &["folders", "items"], Some(&filter))
.unwrap(); .unwrap();
@@ -193,12 +204,20 @@ fn get_item_returns_tip_metadata() {
} }
}] }]
}"#; }"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]); let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/items/I",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone()); let client = make_client(server.base_url.clone());
let item = client.get_item("P", "I").unwrap(); let item = client.get_item("P", "I").unwrap();
assert!(!item.hidden); assert!(!item.hidden);
assert_eq!(item.version_id.as_deref(), Some("V")); assert_eq!(item.version_id.as_deref(), Some("V"));
assert_eq!(item.storage_id.as_deref(), Some("urn:adsk.objects:os.object:bk/obj")); assert_eq!(
item.storage_id.as_deref(),
Some("urn:adsk.objects:os.object:bk/obj")
);
assert_eq!(item.last_modified_user_name.as_deref(), Some("alice")); assert_eq!(item.last_modified_user_name.as_deref(), Some("alice"));
assert_eq!(item.parent_folder_id.as_deref(), Some("F")); assert_eq!(item.parent_folder_id.as_deref(), Some("F"));
} }
@@ -216,7 +235,12 @@ fn get_item_handles_missing_tip_as_hidden() {
} }
} }
}"#; }"#;
let server = spawn_stub(vec![("GET", "/data/v1/projects/P/items/I", 200, body.into())]); let server = spawn_stub(vec![(
"GET",
"/data/v1/projects/P/items/I",
200,
body.into(),
)]);
let client = make_client(server.base_url.clone()); let client = make_client(server.base_url.clone());
let item = client.get_item("P", "I").unwrap(); let item = client.get_item("P", "I").unwrap();
assert!(item.hidden); assert!(item.hidden);
@@ -228,10 +252,7 @@ fn get_item_handles_missing_tip_as_hidden() {
fn download_signed_then_get_writes_file() { fn download_signed_then_get_writes_file() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let dst = tmp.path().join("out.bin"); let dst = tmp.path().join("out.bin");
let signed = format!( let signed = format!(r#"{{"url": "{base}/payload"}}"#, base = "PLACEHOLDER");
r#"{{"url": "{base}/payload"}}"#,
base = "PLACEHOLDER"
);
// We need two-stage: first signed url returns a pointer into the same // We need two-stage: first signed url returns a pointer into the same
// stub, then the GET against that path returns the bytes. // stub, then the GET against that path returns the bytes.
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -240,8 +261,18 @@ fn download_signed_then_get_writes_file() {
let signed_with_url = signed.replace("PLACEHOLDER", &base); let signed_with_url = signed.replace("PLACEHOLDER", &base);
let payload_bytes = b"hello-bytes-12345"; let payload_bytes = b"hello-bytes-12345";
let mut routes: Vec<(&'static str, &'static str, u16, String)> = vec![ let mut routes: Vec<(&'static str, &'static str, u16, String)> = vec![
("GET", "/oss/v2/buckets/bk/objects/obj/signeds3download", 200, signed_with_url), (
("GET", "/payload", 200, String::from_utf8(payload_bytes.to_vec()).unwrap()), "GET",
"/oss/v2/buckets/bk/objects/obj/signeds3download",
200,
signed_with_url,
),
(
"GET",
"/payload",
200,
String::from_utf8(payload_bytes.to_vec()).unwrap(),
),
]; ];
// Hand-roll a stub on the bound listener so we can re-use the same port. // Hand-roll a stub on the bound listener so we can re-use the same port.
let handle = thread::spawn(move || { let handle = thread::spawn(move || {
@@ -262,7 +293,10 @@ fn download_signed_then_get_writes_file() {
let parts: Vec<&str> = request_line.split_whitespace().collect(); let parts: Vec<&str> = request_line.split_whitespace().collect();
let method = parts[0]; let method = parts[0];
let path = parts[1]; let path = parts[1];
let idx = routes.iter().position(|(m, p, _, _)| *m == method && path.starts_with(p)).unwrap(); let idx = routes
.iter()
.position(|(m, p, _, _)| *m == method && path.starts_with(p))
.unwrap();
let (_, _, status, body) = routes.remove(idx); let (_, _, status, body) = routes.remove(idx);
let header = format!( let header = format!(
"HTTP/1.1 {status} OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", "HTTP/1.1 {status} OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -298,10 +332,12 @@ fn upload_small_file_creates_new_item() {
let base = format!("http://127.0.0.1:{port}"); let base = format!("http://127.0.0.1:{port}");
let presigned_url = format!("{base}/presigned/0"); let presigned_url = format!("{base}/presigned/0");
let put_bytes_received: Arc<std::sync::Mutex<Vec<u8>>> = Arc::new(std::sync::Mutex::new(Vec::new())); let put_bytes_received: Arc<std::sync::Mutex<Vec<u8>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let put_bytes_clone = put_bytes_received.clone(); let put_bytes_clone = put_bytes_received.clone();
let create_storage_body = r#"{"data":{"id":"urn:adsk.objects:os.object:bk/obj","type":"objects"}}"#.to_string(); let create_storage_body =
r#"{"data":{"id":"urn:adsk.objects:os.object:bk/obj","type":"objects"}}"#.to_string();
let signed_upload_body = format!(r#"{{"uploadKey":"UK1","urls":["{presigned_url}"]}}"#); let signed_upload_body = format!(r#"{{"uploadKey":"UK1","urls":["{presigned_url}"]}}"#);
let complete_body = r#"{}"#.to_string(); let complete_body = r#"{}"#.to_string();
let folder_contents_body = r#"{"data":[]}"#.to_string(); let folder_contents_body = r#"{"data":[]}"#.to_string();
@@ -316,10 +352,22 @@ fn upload_small_file_creates_new_item() {
let handle = thread::spawn(move || { let handle = thread::spawn(move || {
let expected = vec![ let expected = vec![
("POST", "/data/v1/projects/P/storage", create_storage_body), ("POST", "/data/v1/projects/P/storage", create_storage_body),
("GET", "/oss/v2/buckets/bk/objects/obj/signeds3upload", signed_upload_body), (
"GET",
"/oss/v2/buckets/bk/objects/obj/signeds3upload",
signed_upload_body,
),
("PUT", "/presigned/0", String::new()), ("PUT", "/presigned/0", String::new()),
("POST", "/oss/v2/buckets/bk/objects/obj/signeds3upload", complete_body), (
("GET", "/data/v1/projects/P/folders/F/contents", folder_contents_body), "POST",
"/oss/v2/buckets/bk/objects/obj/signeds3upload",
complete_body,
),
(
"GET",
"/data/v1/projects/P/folders/F/contents",
folder_contents_body,
),
("POST", "/data/v1/projects/P/items", create_item_body), ("POST", "/data/v1/projects/P/items", create_item_body),
]; ];
for (method, prefix, body) in expected { for (method, prefix, body) in expected {
@@ -350,7 +398,11 @@ fn upload_small_file_creates_new_item() {
} }
let parts: Vec<&str> = request_line.split_whitespace().collect(); let parts: Vec<&str> = request_line.split_whitespace().collect();
assert_eq!(parts[0], method, "request_line={request_line:?}"); assert_eq!(parts[0], method, "request_line={request_line:?}");
assert!(parts[1].starts_with(prefix), "got {} expected prefix {prefix}", parts[1]); assert!(
parts[1].starts_with(prefix),
"got {} expected prefix {prefix}",
parts[1]
);
let header = format!( let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len() body.len()
+15 -3
View File
@@ -10,14 +10,26 @@ use serde_json::Value;
fn binary_path() -> std::path::PathBuf { fn binary_path() -> std::path::PathBuf {
let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("target"); path.push("target");
path.push(if cfg!(debug_assertions) { "debug" } else { "release" }); path.push(if cfg!(debug_assertions) {
path.push(if cfg!(windows) { "bonsaiviewer-autodesk.exe" } else { "bonsaiviewer-autodesk" }); "debug"
} else {
"release"
});
path.push(if cfg!(windows) {
"bonsaiviewer-autodesk.exe"
} else {
"bonsaiviewer-autodesk"
});
path path
} }
fn exchange(requests: &[&str]) -> Vec<Value> { fn exchange(requests: &[&str]) -> Vec<Value> {
let path = binary_path(); let path = binary_path();
assert!(path.exists(), "expected binary at {}; build with `cargo build --bin bonsaiviewer-autodesk` first", path.display()); assert!(
path.exists(),
"expected binary at {}; build with `cargo build --bin bonsaiviewer-autodesk` first",
path.display()
);
let mut child = Command::new(&path) let mut child = Command::new(&path)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())