Compare commits

...
Author SHA1 Message Date
Brandon Thomas 65fbe5fd35 list directories 2026-01-05 02:44:05 -05:00
Brandon Thomas aab42703bb reveal download dir 2026-01-05 02:33:02 -05:00
Brandon Thomas 9246d28b26 flash error messages from backend 2026-01-04 22:28:30 -05:00
Brandon Thomas d9cbd3e8c7 download with error messages 2026-01-04 22:15:04 -05:00
Brandon Thomas ba4871118b downloads work 2026-01-04 21:55:16 -05:00
Brandon Thomas d9018028ab call to tauri to download works, still buggy 2026-01-04 21:34:12 -05:00
Brandon Thomas d2f6f56881 download endpoints 2026-01-04 21:19:09 -05:00
Brandon Thomas e35b29e317 Add info on root path 2026-01-04 19:42:43 -05:00
28 changed files with 840 additions and 19 deletions
Generated
+162 -8
View File
@@ -688,6 +688,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-upload",
"tempdir",
"tempfile",
@@ -805,6 +806,30 @@ dependencies = [
"zstd-safe 7.1.0",
]
[[package]]
name = "async-executor"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b10202063978b3351199d68f8b22c4e47e4b1b822f8d43fd862d5ea8c006b29a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand 2.3.0",
"futures-lite 2.6.0",
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite 2.6.0",
]
[[package]]
name = "async-io"
version = "2.4.0"
@@ -5780,6 +5805,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.0"
@@ -7561,6 +7605,18 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openai_sora_client"
version = "0.0.1"
@@ -7935,6 +7991,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.11.0"
@@ -11645,6 +11707,28 @@ dependencies = [
"time",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d75fdc9ba0391eafb3337aac9d1b34fd353601dd5d4e7498820733f738991e7"
dependencies = [
"dunce",
"glob",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"open",
"schemars",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
"url",
"windows 0.58.0",
"zbus",
]
[[package]]
name = "tauri-plugin-upload"
version = "2.3.1"
@@ -13465,8 +13549,8 @@ dependencies = [
"webview2-com-sys",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.0",
"windows-interface 0.59.1",
]
[[package]]
@@ -13575,6 +13659,16 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -13615,17 +13709,30 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
@@ -13639,6 +13746,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -13650,6 +13768,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
@@ -13684,8 +13813,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
@@ -13697,6 +13835,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -14409,9 +14557,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ff46f2a25abd690ed072054733e0bc3157e3d4c45f41bd183dce09c2ff8ab9"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"derivative",
"enumflags2",
"event-listener 5.2.0",
+1
View File
@@ -81,6 +81,7 @@ tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" }
# NB: The version of the `discord-rich-presence` crate (0.2.5) last published has non-Send/Sync error
# types and can't do threading. We need to wait for a new version to unpin from Git.
discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" }
tauri-plugin-opener = "2"
# # Transparent title bar on MacOS
# # NB: See the notes here:
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-close",
@@ -25,6 +27,7 @@
"url": "http://localhost:12345"
}
]
}
},
"opener:default"
]
}
}
@@ -1,6 +1,7 @@
use grok_client::error::grok_error::GrokError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
@@ -8,6 +9,7 @@ pub enum ArtcraftError {
AnyhowError(anyhow::Error),
DecodeError(base64::DecodeError),
IoError(std::io::Error),
ReqwestError(reqwest::Error),
// Service errors
GrokError(GrokError),
StorytellerError(StorytellerError),
@@ -15,6 +17,9 @@ pub enum ArtcraftError {
RwLockReadError,
RwLockWriteError,
MutexLockError,
// Download errors
BadDownloadFilename { path: PathBuf },
CannotDownloadFilePathAlreadyExists { path: PathBuf },
}
impl Error for ArtcraftError {}
@@ -25,11 +30,14 @@ impl Display for ArtcraftError {
Self::AnyhowError(e) => write!(f, "AnyhowError: {:?}", e),
Self::DecodeError(e) => write!(f, "DecodeError: {:?}", e),
Self::IoError(e) => write!(f, "IoError: {:?}", e),
Self::ReqwestError(e) => write!(f, "ReqwestError: {:?}", e),
Self::GrokError(e) => write!(f, "GrokError: {:?}", e),
Self::StorytellerError(e) => write!(f, "StorytellerError: {:?}", e),
Self::RwLockReadError => write!(f, "RwLockReadError"),
Self::RwLockWriteError => write!(f, "RwLockWriteError"),
Self::MutexLockError => write!(f, "MutexLockError"),
Self::BadDownloadFilename { path } => write!(f, "BadDownloadFilename: {:?}", path),
Self::CannotDownloadFilePathAlreadyExists { path } => write!(f, "CannotDownloadFilePathAlreadyExists {:?}", path),
}
}
}
@@ -52,6 +60,12 @@ impl From<std::io::Error> for ArtcraftError {
}
}
impl From<reqwest::Error> for ArtcraftError {
fn from(value: reqwest::Error) -> Self {
Self::ReqwestError(value)
}
}
impl From<GrokError> for ArtcraftError {
fn from(value: GrokError) -> Self {
Self::GrokError(value)
@@ -0,0 +1,70 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage, ResponseOrErrorType};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir;
use anyhow::anyhow;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Serialize)]
pub struct DownloadDirectoryRevealSuccessResponse {
}
impl SerializeMarker for DownloadDirectoryRevealSuccessResponse {}
#[tauri::command]
pub async fn download_directory_reveal_command(
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
app_data_root: State<'_, AppDataRoot>,
) -> ResponseOrErrorMessage<DownloadDirectoryRevealSuccessResponse> {
info!("download_directory_reveal_command called");
let result = handle_request(
&app,
&app_prefs,
&app_data_root,
).await;
if let Err(err) = result {
format!("Error revealing download dir: {:?}", err);
return Err("error revealing download dir".into())
}
Ok(DownloadDirectoryRevealSuccessResponse {}.into())
}
pub async fn handle_request(
app: &AppHandle,
app_prefs: &AppPreferencesManager,
app_data_root: &AppDataRoot,
) -> Result<(), ArtcraftError> {
let app_prefs = app_prefs.get_clone()?;
let download_directory = app_prefs
.preferred_download_directory
.download_directory(app_data_root);
info!("Revealing item in directory: {:?}", download_directory);
app.opener().reveal_item_in_dir(download_directory)
.map_err(|err| anyhow!("Failed to open directory: {:?}", err))?;
Ok(())
}
@@ -0,0 +1,104 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir;
use anyhow::anyhow;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
#[derive(Deserialize, Debug)]
pub struct DownloadMediaFileRequest {
pub media_token: MediaFileToken,
}
#[derive(Serialize)]
pub struct DownloadMediaFileSuccessResponse {
}
impl SerializeMarker for DownloadMediaFileSuccessResponse {}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum DownloadMediaFileErrorType {
FilesystemError,
NetworkError,
UnknownError,
}
#[tauri::command]
pub async fn download_media_file_command(
request: DownloadMediaFileRequest,
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
) -> ResponseOrErrorType<DownloadMediaFileSuccessResponse, DownloadMediaFileErrorType> {
info!("download_media_file_command called");
info!("request: {:?}", request);
let result = handle_request(
request,
&app,
&app_prefs,
&app_data_root,
&app_env_configs,
).await;
if let Err(err) = result {
error!("Error downloading media: {:?}", err);
// TODO: This error is semantically incorrect - just trying to get the code done
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::ServerError,
error_message: Some("error downloading file".to_string()),
error_type: Some(DownloadMediaFileErrorType::UnknownError),
error_details: None,
});
}
Ok(DownloadMediaFileSuccessResponse {}.into())
}
pub async fn handle_request(
request: DownloadMediaFileRequest,
app: &AppHandle,
app_prefs: &AppPreferencesManager,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs
) -> Result<(), ArtcraftError> {
let app_prefs = app_prefs.get_clone()?;
// TODO: Api should return the extension and suggested filename so we can better construct something.
let media_file = get_media_file(
&app_env_configs.storyteller_host,
&request.media_token,
).await?;
let asset_url = media_file.media_file.media_links.cdn_url;
let download_path = download_url_to_user_download_dir(
&asset_url,
app_data_root,
&app_prefs
).await?;
info!("downloaded to: {:?}", download_path);
Ok(())
}
@@ -0,0 +1,125 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::warning_events::flash_file_download_error_event::{FlashFileDownloadErrorType, FlashFileDownloadErrorEvent};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir;
use anyhow::anyhow;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
use tauri::{AppHandle, State};
use url::Url;
#[derive(Deserialize, Debug)]
pub struct DownloadUrlRequest {
pub url: Url,
}
#[derive(Serialize)]
pub struct DownloadUrlSuccessResponse {
}
impl SerializeMarker for DownloadUrlSuccessResponse {}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum DownloadUrlErrorType {
FilesystemError,
NetworkError,
UnknownError,
}
#[tauri::command]
pub async fn download_url_command(
request: DownloadUrlRequest,
app: AppHandle,
app_prefs: State<'_, AppPreferencesManager>,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
) -> ResponseOrErrorType<DownloadUrlSuccessResponse, DownloadUrlErrorType> {
info!("download_url_command called");
info!("request: {:?}", request);
let result = handle_request(
request,
&app,
&app_prefs,
&app_data_root,
&app_env_configs,
).await;
if let Err(err) = result {
error!("Error downloading media: {:?}", err);
let mut endpoint_message = "unknown error when downloading file";
let mut error_type = DownloadUrlErrorType::UnknownError;
let mut flash_error_type = FlashFileDownloadErrorType::UnknownError;
let mut flash_filename = None;
let mut flash_message = Some("Failed to download file".to_string());
match err {
ArtcraftError::CannotDownloadFilePathAlreadyExists { path } => {
endpoint_message = "file already downloaded";
error_type = DownloadUrlErrorType::FilesystemError;
flash_error_type = FlashFileDownloadErrorType::FileAlreadyDownloaded;
flash_message = Some(format!("File already downloaded: {:?}", path));
flash_filename = Some(path);
}
_ => {}, // NB: Fall-through
}
let event = FlashFileDownloadErrorEvent {
filename: flash_filename,
message: flash_message,
error_type: flash_error_type,
};
event.send_infallible(&app);
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::ServerError,
error_message: Some(endpoint_message.to_string()),
error_type: Some(error_type),
error_details: None,
});
}
Ok(DownloadUrlSuccessResponse {}.into())
}
pub async fn handle_request(
request: DownloadUrlRequest,
app: &AppHandle,
app_prefs: &AppPreferencesManager,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs
) -> Result<(), ArtcraftError> {
let app_prefs = app_prefs.get_clone()?;
let download_path = download_url_to_user_download_dir(
&request.url,
app_data_root,
&app_prefs
).await?;
info!("downloaded to: {:?}", download_path);
Ok(())
}
@@ -0,0 +1,3 @@
pub mod download_directory_reveal_command;
pub mod download_media_file_command;
pub mod download_url_command;
@@ -169,6 +169,9 @@ impl From<ArtcraftError> for GenerateError {
ArtcraftError::RwLockReadError => Self::AnyhowError(anyhow!("Lock read error")),
ArtcraftError::RwLockWriteError => Self::AnyhowError(anyhow!("Lock write error")),
ArtcraftError::MutexLockError => Self::AnyhowError(anyhow!("Mutex lock error")),
ArtcraftError::ReqwestError(err) => Self::AnyhowError(anyhow!("Reqwest error: {:?}", err)),
ArtcraftError::BadDownloadFilename { path } => Self::AnyhowError(anyhow!("Bad download file name: {:?}", path)),
ArtcraftError::CannotDownloadFilePathAlreadyExists { path } => Self::AnyhowError(anyhow!("Cannot download already existing file: {:?}", path)),
}
}
}
@@ -1,11 +1,15 @@
use crate::core::commands::response::shorthand::InfallibleResponse;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::artcraft_platform_info::{ArtcraftOs, ArtcraftPlatformInfo};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::os_platform::OsPlatform;
use chrono::{DateTime, Utc};
use log::info;
use log::{info, warn};
use serde_derive::Serialize;
use std::path::PathBuf;
use tauri::State;
#[derive(Debug, Serialize)]
@@ -22,6 +26,9 @@ pub struct AppInfoResponse {
pub os_version: String,
pub storyteller_host: String,
pub artcraft_root_directory: PathBuf,
pub download_directory: PathBuf,
}
impl SerializeMarker for AppInfoResponse {}
@@ -38,8 +45,10 @@ pub enum DetectedOs {
#[tauri::command]
pub fn get_app_info_command(
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
artcraft_platform_info: State<'_, ArtcraftPlatformInfo>,
app_prefs: State<'_, AppPreferencesManager>,
) -> InfallibleResponse<AppInfoResponse> {
info!("get_app_info_command called...");
@@ -52,6 +61,19 @@ pub fn get_app_info_command(
ArtcraftOs::Unknown => DetectedOs::Unknown,
};
let root_directory = app_data_root.path().to_path_buf();
let download_directory = match app_prefs.get_clone() {
Ok(app_prefs) => {
app_prefs.preferred_download_directory
.download_directory(&app_data_root)
}
Err(err) => {
warn!("Can't get user download directory: {:?}", err);
app_data_root.downloads_dir().path().to_path_buf()
}
};
AppInfoResponse {
artcraft_version: artcraft_platform_info.artcraft_version.clone(),
build_timestamp: artcraft_platform_info.build_timestamp,
@@ -61,6 +83,8 @@ pub fn get_app_info_command(
os_platform,
os_version: artcraft_platform_info.os_version.clone(),
storyteller_host,
artcraft_root_directory: root_directory,
download_directory,
}.into()
}
@@ -1,5 +1,6 @@
pub mod app_preferences;
pub mod app_state;
pub mod download;
pub mod enqueue;
pub mod flip_image;
pub mod get_app_info_command;
@@ -0,0 +1,26 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct FlashFileDownloadErrorEvent {
pub error_type: FlashFileDownloadErrorType,
pub filename: Option<PathBuf>,
pub message: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FlashFileDownloadErrorType {
FileAlreadyDownloaded,
FilesystemError,
NetworkError,
UnknownError,
}
impl BasicSendableEvent for FlashFileDownloadErrorEvent {
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::FlashFileDownloadErrorEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure;
}
@@ -1 +1,2 @@
pub mod flash_file_download_error_event;
pub mod flash_user_input_error_event;
@@ -1,8 +1,10 @@
use errors::AnyhowError;
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
const SYSTEM_DEFAULT_SENTINEL_VALUE: &str = "system_default";
@@ -26,6 +28,27 @@ pub enum SystemDownloadDirectory {
Documents,
}
impl PreferredDownloadDirectory {
/// This doesn't have a fallback.
pub fn maybe_to_download_directory(&self) -> Option<PathBuf> {
match self {
PreferredDownloadDirectory::Custom(path) => Some(path.clone()),
PreferredDownloadDirectory::System(SystemDownloadDirectory::Documents) => {
dirs::document_dir()
}
PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads) => {
dirs::download_dir()
}
}
}
/// Fallback to the Artcraft directory.
pub fn download_directory(&self, root: &AppDataRoot) -> PathBuf {
let maybe_path = self.maybe_to_download_directory();
maybe_path.unwrap_or_else(|| root.downloads_dir().path().to_path_buf())
}
}
impl FromStr for SystemDownloadDirectory {
type Err = AnyhowError;
@@ -0,0 +1,127 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use url::Url;
pub async fn download_url_to_user_download_dir(
url: &Url,
app_data_root: &AppDataRoot,
app_prefs: &AppPreferences,
) -> Result<PathBuf, ArtcraftError> {
let url_file_name = {
let path_segments = url.path_segments()
.ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (1): {:?}", url)))?;
let mut url_file_name = path_segments.last()
.ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (2): {:?}", url)))?
.trim()
.to_string();
if !url_file_name.to_lowercase().starts_with("artcraft") {
url_file_name = format!("artcraft_{}", url_file_name);
}
url_file_name
};
check_url_file_name_for_downloadability(&url_file_name)?;
let download_directory = app_prefs
.preferred_download_directory
.download_directory(app_data_root);
info!("Pushing path `{}` onto downloaded directory `{:?}`", url_file_name, download_directory);
let download_filename = {
let mut download_filename = download_directory.clone();
download_filename.push(&url_file_name);
download_filename
};
info!("Download filename: `{:?}`", download_filename);
if download_filename == download_directory {
return Err(ArtcraftError::AnyhowError(anyhow!("Download filename resolved to directory: {:?}", download_filename)))?;
}
if download_filename.exists() {
if download_filename.is_dir() {
return Err(ArtcraftError::AnyhowError(anyhow!("Download path exists and resolved to directory: {:?}", download_filename)));
} else {
return Err(ArtcraftError::CannotDownloadFilePathAlreadyExists { path: download_filename }.into());
}
}
info!("Downloading bytes from url: {:?}", url);
let response = reqwest::get(url.clone()).await?;
let response_bytes = response.bytes().await?;
info!("Writing file: {:?}", download_filename);
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&download_filename)?;
file.write_all(&response_bytes)?;
Ok(download_filename)
}
// TODO: This likely needs more protections.
fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), ArtcraftError> {
if filename.trim().is_empty() {
error!("Download filename is empty!");
return Err(ArtcraftError::BadDownloadFilename { path: "".into() }.into());
}
if !filename.is_ascii() {
error!("Download filename is not completely ASCII: {:?}", filename);
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
if filename.contains("/")
|| filename.contains("..")
|| filename.contains("\\")
{
error!("Cannot download filename that has relative path components: {:?}", filename);
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
if filename.contains("'")
|| filename.contains("\"")
|| filename.contains("%")
|| filename.contains("<")
|| filename.contains(">")
|| filename.contains("|")
{
error!("Download filename has dangerous path components: {:?}", filename);
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
if filename.ends_with(".apk")
|| filename.ends_with(".app")
|| filename.ends_with(".bat")
|| filename.ends_with(".cmd")
|| filename.ends_with(".com")
|| filename.ends_with(".ps1")
|| filename.ends_with(".sh")
|| filename.ends_with(".so")
|| filename.ends_with("dll") // Don't even risk the '.'
|| filename.ends_with("exe") // Don't even risk the '.'
{
error!("Cannot download filename that resembles executable: {:?}", filename);
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
Ok(())
}
@@ -1,6 +1,7 @@
pub mod clear_all_webview_cookies;
pub mod download_media_file_to_temp_dir;
pub mod download_url_to_temp_dir;
pub mod download_url_to_user_download_dir;
pub mod enum_conversion;
pub mod get_url_file_extension;
pub mod image;
+8 -1
View File
@@ -5,6 +5,9 @@ use tauri::Manager;
use crate::core::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::core::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::core::commands::download::download_directory_reveal_command::download_directory_reveal_command;
use crate::core::commands::download::download_media_file_command::download_media_file_command;
use crate::core::commands::download::download_url_command::download_url_command;
use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::enqueue_image_bg_removal_command;
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::enqueue_edit_image_command;
use crate::core::commands::enqueue::image_inpaint::enqueue_image_inpaint_command::enqueue_image_inpaint_command;
@@ -127,6 +130,7 @@ pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_upload::init())
.setup(move |app| {
// TODO(bt): This is broken on windows
@@ -190,6 +194,9 @@ pub fn run() {
// My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets.
let builder = builder.invoke_handler(tauri::generate_handler![
check_sora_session_command,
download_directory_reveal_command,
download_media_file_command,
download_url_command,
enqueue_edit_image_command,
enqueue_image_bg_removal_command,
enqueue_image_inpaint_command,
@@ -222,10 +229,10 @@ pub fn run() {
storyteller_open_customer_portal_cancel_plan_command,
storyteller_open_customer_portal_manage_plan_command,
storyteller_open_customer_portal_switch_plan_command,
tasks_nuke_all_command,
storyteller_open_customer_portal_update_payment_method_command,
storyteller_open_subscription_purchase_command,
storyteller_purge_credentials_command,
tasks_nuke_all_command,
update_app_preferences_command,
worldlabs_clear_credentials_command,
worldlabs_get_credential_info_command,
@@ -97,6 +97,11 @@ pub enum TauriEventName {
/// Flash a user input error message
#[serde(rename = "flash_user_input_error_event")]
FlashUserInputErrorEvent,
/// Warning event:
/// File with that name was already downloaded
#[serde(rename = "flash_file_download_error_event")]
FlashFileDownloadErrorEvent,
}
impl_enum_display_and_debug_using_to_str!(TauriEventName);
@@ -124,6 +129,7 @@ impl TauriEventName {
Self::ShowProviderBillingModalEvent => "show_provider_billing_modal_event",
Self::ShowProviderLoginModalEvent => "show_provider_login_modal_event",
Self::FlashUserInputErrorEvent => "flash_user_input_error_event",
Self::FlashFileDownloadErrorEvent => "flash_file_download_error_event",
}
}
@@ -145,6 +151,7 @@ impl TauriEventName {
"show_provider_billing_modal_event" => Ok(Self::ShowProviderBillingModalEvent),
"show_provider_login_modal_event" => Ok(Self::ShowProviderLoginModalEvent),
"flash_user_input_error_event" => Ok(Self::FlashUserInputErrorEvent),
"flash_file_download_error_event" => Ok(Self::FlashFileDownloadErrorEvent),
_ => Err(format!("invalid tauri_event_name: {:?}", job_status)),
}
}
@@ -169,6 +176,7 @@ impl TauriEventName {
Self::ShowProviderBillingModalEvent,
Self::ShowProviderLoginModalEvent,
Self::FlashUserInputErrorEvent,
Self::FlashFileDownloadErrorEvent,
])
}
}
@@ -199,6 +207,7 @@ mod tests {
assert_serialization(TauriEventName::ShowProviderBillingModalEvent, "show_provider_billing_modal_event");
assert_serialization(TauriEventName::ShowProviderLoginModalEvent, "show_provider_login_modal_event");
assert_serialization(TauriEventName::FlashUserInputErrorEvent, "flash_user_input_error_event");
assert_serialization(TauriEventName::FlashFileDownloadErrorEvent, "flash_file_download_error_event");
}
#[test]
@@ -219,6 +228,7 @@ mod tests {
assert_eq!(TauriEventName::ShowProviderBillingModalEvent.to_str(), "show_provider_billing_modal_event");
assert_eq!(TauriEventName::ShowProviderLoginModalEvent.to_str(), "show_provider_login_modal_event");
assert_eq!(TauriEventName::FlashUserInputErrorEvent.to_str(), "flash_user_input_error_event");
assert_eq!(TauriEventName::FlashFileDownloadErrorEvent.to_str(), "flash_file_download_error_event");
}
#[test]
@@ -239,12 +249,13 @@ mod tests {
assert_eq!(TauriEventName::from_str("show_provider_billing_modal_event").unwrap(), TauriEventName::ShowProviderBillingModalEvent);
assert_eq!(TauriEventName::from_str("show_provider_login_modal_event").unwrap(), TauriEventName::ShowProviderLoginModalEvent);
assert_eq!(TauriEventName::from_str("flash_user_input_error_event").unwrap(), TauriEventName::FlashUserInputErrorEvent);
assert_eq!(TauriEventName::from_str("flash_file_download_error_event").unwrap(), TauriEventName::FlashFileDownloadErrorEvent);
}
#[test]
fn all_variants() {
let mut variants = TauriEventName::all_variants();
assert_eq!(variants.len(), 16);
assert_eq!(variants.len(), 17);
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent));
@@ -261,6 +272,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TauriEventName::ShowProviderBillingModalEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::ShowProviderLoginModalEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::FlashUserInputErrorEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::FlashFileDownloadErrorEvent));
assert_eq!(variants.pop_first(), None);
}
}
@@ -63,6 +63,7 @@ import { APP_DESCRIPTORS } from "~/config/appMenu";
import { AppsQuickMenu } from "./AppsQuickMenu";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { DownloadUrl } from "@storyteller/tauri-api";
interface Props {
pageName: string;
@@ -170,14 +171,17 @@ export const TopBar = ({ pageName }: Props) => {
const downloadFile = async (url: string, mediaClass?: string) => {
try {
await downloadFileFromUrl(url);
//await downloadFileFromUrl(url);
await DownloadUrl(url);
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
} else {
toast.success(`Downloaded ${mediaClass}`);
}
} catch (error) {
toast.error("Failed to download file");
console.error(">>> Failed to download file:", error);
// NB: Rust/Tauri should now flash a toast instead.
//toast.error("Failed to download file");
}
};
@@ -31,7 +31,7 @@ import {
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import { useFlashUserInputErrorEvent } from "@storyteller/tauri-events";
import { useFlashFileDownloadErrorEvent, useFlashUserInputErrorEvent } from "@storyteller/tauri-events";
import { useGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueFailureEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueSuccessEvent } from "@storyteller/tauri-events";
@@ -98,6 +98,11 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
toast.error(event.message);
});
useFlashFileDownloadErrorEvent(async (event) => {
console.log("Flash file download error event received:", event);
toast.error(event.message || "File download failed");
});
useEffect(() => {
console.log("installing event listeners");
InstallSounds();
@@ -60,6 +60,20 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
<div>{appInfo?.os_platform} ({appInfo?.os_version})</div>
</div>
<div className="space-y-1">
<Label>
Artcraft Data Directory
</Label>
<div>{appInfo?.artcraft_root_directory}</div>
</div>
<div className="space-y-1">
<Label>
Downloads Directory
</Label>
<div>{appInfo?.download_directory}</div>
</div>
</div>
</>
);
@@ -9,6 +9,9 @@ import {
import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Label } from "@storyteller/ui-label";
import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFolder, faMagnifyingGlass, faRotateLeft } from "@fortawesome/pro-solid-svg-icons";
interface MiscSettingsPaneProps {}
@@ -71,6 +74,10 @@ export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
await reloadPreferences();
};
const showDirectory = async () => {
await DownloadDirectoryReveal();
};
return (
<div className="space-y-4 text-base-fg">
<div className="space-y-2">
@@ -85,11 +92,17 @@ export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
</div>
<div className="flex gap-2">
<Button variant="primary" onClick={openDirectoryPicker}>
<FontAwesomeIcon icon={faFolder} />
Choose Directory
</Button>
<Button variant="secondary" onClick={clearDirectory}>
<Button variant="destructive" onClick={clearDirectory}>
<FontAwesomeIcon icon={faRotateLeft} />
Use Default
</Button>
<Button variant="secondary" onClick={showDirectory}>
<FontAwesomeIcon icon={faMagnifyingGlass} />
Show Directory
</Button>
</div>
</div>
);
+2
View File
@@ -5,6 +5,8 @@ export * from "./lib/artcraft/task_queue/GetTaskQueue.js";
export * from "./lib/artcraft/task_queue/MarkTaskAsDismissed.js";
export * from "./lib/artcraft/task_queue/TasksNukeAll.js";
export * from "./lib/common/CommandStatus.js";
export * from "./lib/download/DownloadDirectoryReveal.js";
export * from "./lib/download/DownloadUrl.js";
export * from "./lib/enqueue/EnqueueEditImage.js";
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
export * from "./lib/enqueue/EnqueueImageInpaint.js";
@@ -0,0 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface DownloadDirectoryRevealSuccess extends CommandResult {
payload: DownloadDirectoryRevealPayload;
}
export interface DownloadDirectoryRevealPayload {
}
export const DownloadDirectoryReveal = async () : Promise<DownloadDirectoryRevealSuccess> => {
try {
return await invoke("download_directory_reveal_command") as DownloadDirectoryRevealSuccess;
} catch (error) {
throw error;
}
}
@@ -0,0 +1,21 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface DownloadUrlSuccess extends CommandResult {
payload: DownloadUrlPayload;
}
export interface DownloadUrlPayload {
}
export const DownloadUrl = async (url: string) : Promise<DownloadUrlSuccess> => {
try {
return await invoke("download_url_command", {
request: {
url: url
}
}) as DownloadUrlSuccess;
} catch (error) {
throw error;
}
}
@@ -14,6 +14,8 @@ export interface GetAppInfoPayload {
storyteller_host?: string | null;
os_platform?: string | null;
os_version?: string | null;
artcraft_root_directory: string;
download_directory: string;
}
// Returns the Success and Error variants directly.
+1
View File
@@ -13,3 +13,4 @@ export * from "./lib/events/generation/useGenerationEnqueueFailureEvent";
export * from "./lib/events/generation/useGenerationEnqueueSuccessEvent";
export * from "./lib/events/generation/useGenerationFailedEvent";
export * from "./lib/events/warning/FlashUserInputErrorEvent";
export * from "./lib/events/warning/FlashFileDownloadErrorEvent";
@@ -0,0 +1,43 @@
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { BasicEventWrapper } from '../../common/BasicEventWrapper';
import { useEffect } from 'react';
const EVENT_NAME : string = 'flash_file_download_error_event';
export enum FlashFileDownloadErrorType {
FileAlreadyDownloaded = "file_already_downloaded",
FilesystemError = "filesystem_error",
NetworkError = "network_error",
UnknownError = "unknown_error",
}
export interface FlashFileDownloadErrorEvent {
error_type: FlashFileDownloadErrorType,
message?: string,
filename?: string,
}
export const useFlashFileDownloadErrorEvent = (asyncCallback: (event: FlashFileDownloadErrorEvent) => Promise<void>) => {
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen<BasicEventWrapper<FlashFileDownloadErrorEvent>>(EVENT_NAME, async (wrappedEvent) => {
await asyncCallback(wrappedEvent.payload.data);
});
if (isUnmounted) {
unlisten.then(f => f()); // Unsubscribe if unmounted early.
}
};
setup();
return () => {
isUnmounted = true;
unlisten.then(f => f());
};
}, []);
}