download settings

This commit is contained in:
Hanashi 2026-08-05 01:24:57 -04:00
parent d1ac2e6102
commit f57edc4d39
19 changed files with 731 additions and 148 deletions

View File

@ -1,5 +1,6 @@
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Serialize;
@ -14,7 +15,10 @@ pub struct GetAppPreferencesResponse {
pub struct AppPreferencesPayload {
/// The downloads directory to use when a user downloads a file.
pub preferred_download_directory: PreferredDownloadDirectory,
/// How downloaded generation files are named on disk.
pub preferred_download_filename: PreferredDownloadFilename,
/// Play sounds on events.
pub play_sounds: bool,
@ -63,6 +67,7 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
let prefs = app_prefs.get_clone()?;
Ok(AppPreferencesPayload {
preferred_download_directory: prefs.preferred_download_directory,
preferred_download_filename: prefs.preferred_download_filename,
play_sounds: prefs.play_sounds,
delete_file_sound: prefs.delete_file_sound,
enqueue_success_sound: prefs.enqueue_success_sound,

View File

@ -1,5 +1,6 @@
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
@ -15,18 +16,24 @@ pub struct UpdateAppPreferencesRequest {
pub value: Option<ValueType>,
}
/// NB: Untagged — variant ORDER matters. `DownloadFilename` must precede
/// `String` (its unit variant arrives as the string "artcraft_convention"),
/// and its custom variant keys on "custom_format" so it can't collide with
/// `DownloadDirectory`'s "custom".
#[derive(Deserialize, Debug)]
#[serde(untagged)]
pub enum ValueType {
Bool(bool),
String(String),
DownloadDirectory(PreferredDownloadDirectory),
DownloadFilename(PreferredDownloadFilename),
String(String),
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PreferenceName {
PreferredDownloadDirectory,
PreferredDownloadFilename,
PlaySounds,
DeleteFileSound,
EnqueueSuccessSound,
@ -78,6 +85,19 @@ async fn update_prefs(
return Err(anyhow!("Invalid value: {:?}", request.value)),
}
}
PreferenceName::PreferredDownloadFilename => {
match request.value {
Some(ValueType::DownloadFilename(filename)) => {
if let PreferredDownloadFilename::Custom(format) = &filename {
PreferredDownloadFilename::validate_custom_format(format)
.map_err(|reason| anyhow!("Invalid filename format: {}", reason))?;
}
prefs.preferred_download_filename = filename;
}
_ =>
return Err(anyhow!("Invalid value: {:?}", request.value)),
}
}
PreferenceName::PlaySounds => {
match request.value {
Some(ValueType::Bool(val)) =>
@ -125,3 +145,37 @@ fn string_value(value: &ValueType) -> AnyhowResult<String> {
_ => Err(anyhow!("Invalid value type: {:?}", value)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The untagged `ValueType` decode is order-sensitive; pin the behavior.
#[test]
fn value_type_decoding() {
assert!(matches!(
serde_json::from_str::<ValueType>("true").unwrap(),
ValueType::Bool(true),
));
assert!(matches!(
serde_json::from_str::<ValueType>("\"artcraft_convention\"").unwrap(),
ValueType::DownloadFilename(PreferredDownloadFilename::ArtcraftConvention),
));
assert!(matches!(
serde_json::from_str::<ValueType>("{\"custom_format\":\"{model}_{date}\"}").unwrap(),
ValueType::DownloadFilename(PreferredDownloadFilename::Custom(_)),
));
assert!(matches!(
serde_json::from_str::<ValueType>("{\"custom\":\"/tmp\"}").unwrap(),
ValueType::DownloadDirectory(PreferredDownloadDirectory::Custom(_)),
));
assert!(matches!(
serde_json::from_str::<ValueType>("{\"system\":\"downloads\"}").unwrap(),
ValueType::DownloadDirectory(PreferredDownloadDirectory::System(_)),
));
assert!(matches!(
serde_json::from_str::<ValueType>("\"done\"").unwrap(),
ValueType::String(_),
));
}
}

View File

@ -4,6 +4,8 @@ use crate::error::artcraftx_error::ArtcraftXError;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::database::task_database::TaskDatabase;
use crate::state::downloads::preferred_download_filename::{model_slug_from_model_type_str, DownloadFilenameParts};
use chrono::Local;
use crate::utils::download_url_to_download_dir_via_temp::download_url_to_download_dir_via_temp;
use crate::utils::enum_conversion::generation_source::to_generation_service_provider;
use crate::utils::enum_conversion::task_type::to_generation_action;
@ -71,7 +73,7 @@ pub async fn handle_successful_job(
return Ok(()); // If anything breaks with queries, don't spam events.
}
download_all_files(app_data_root, app_preferences, job, &media_files).await;
download_all_files(app_data_root, app_preferences, job, task, &media_files).await;
send_additional_success_events(app_handle, job, task, &media_files).await;
@ -92,12 +94,14 @@ pub async fn handle_successful_job(
}
/// Download every file the job produced into the user's configured download
/// directory (temp dir first, then moved into place). Fails open per file so
/// one bad download doesn't lose the rest.
/// directory (temp dir first, then moved into place), named per the user's
/// preferred filename convention. Fails open per file so one bad download
/// doesn't lose the rest.
async fn download_all_files(
app_data_root: &AppDataRoot,
app_preferences: &AppPreferencesManager,
job: &JobStatusPayload,
task: &Task,
media_files: &[JobMediaFileInfo],
) {
let app_prefs = match app_preferences.get_clone() {
@ -122,8 +126,28 @@ async fn download_all_files(
.collect::<Vec<_>>()
};
for cdn_url in &cdn_urls {
match download_url_to_download_dir_via_temp(cdn_url, app_data_root, &app_prefs).await {
let model_slug = task.model_type
.as_ref()
.map(|model_type| model_slug_from_model_type_str(model_type.to_str()))
.unwrap_or_else(|| "artcraft".to_string());
let download_time = Local::now();
for (index, cdn_url) in cdn_urls.iter().enumerate() {
let extension = cdn_url.path()
.rsplit('.')
.next()
.unwrap_or("bin")
.to_string();
let filename = app_prefs.preferred_download_filename.build_filename(&DownloadFilenameParts {
model_slug: &model_slug,
download_time,
maybe_batch_index: (cdn_urls.len() > 1).then(|| index + 1),
extension: &extension,
});
match download_url_to_download_dir_via_temp(cdn_url, Some(&filename), app_data_root, &app_prefs).await {
Ok(path) => {
info!("Downloaded job {} file to {:?}", job.job_token.as_str(), path);
}

View File

@ -1,5 +1,6 @@
use crate::state::app_preferences::app_preferences_serializable::AppPreferencesSerializable;
use crate::state::app_preferences::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory};
use crate::state::downloads::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory};
use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename;
use crate::state::data_dir::app_data_root::AppDataRoot;
#[derive(Clone)]
@ -7,6 +8,9 @@ pub struct AppPreferences {
/// The downloads directory to use when a user downloads a file.
pub preferred_download_directory: PreferredDownloadDirectory,
/// How downloaded generation files are named on disk.
pub preferred_download_filename: PreferredDownloadFilename,
/// Play sounds on events.
pub play_sounds: bool,
@ -36,6 +40,7 @@ impl Default for AppPreferences {
fn default() -> Self {
Self {
preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads),
preferred_download_filename: PreferredDownloadFilename::ArtcraftConvention,
play_sounds: true,
// NB: These are defined in the frontend.
enqueue_success_sound: Some("done".to_string()),

View File

@ -1,5 +1,6 @@
use crate::state::app_preferences::app_preferences::AppPreferences;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename;
use crate::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
@ -7,7 +8,8 @@ use serde_derive::{Deserialize, Serialize};
/// Vector clock versioning string rather than semver.
/// - Version 1 - initial version.
/// - Version 2 - added "delete_file_sound", marked optionals "skip_serializing_if"
const CURRENT_VERSION: &str = "2";
/// - Version 3 - added "preferred_download_filename"
const CURRENT_VERSION: &str = "3";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppPreferencesSerializable {
@ -17,6 +19,11 @@ pub struct AppPreferencesSerializable {
/// The downloads directory to use when a user downloads a file.
pub preferred_download_directory: Option<PreferredDownloadDirectory>,
/// How downloaded generation files are named on disk.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub preferred_download_filename: Option<PreferredDownloadFilename>,
/// Play sounds on events.
#[serde(skip_serializing_if = "Option::is_none")]
pub play_sounds: Option<bool>,
@ -54,6 +61,7 @@ impl AppPreferencesSerializable {
Self {
version: CURRENT_VERSION.to_string(),
preferred_download_directory: Some(preferences.preferred_download_directory.clone()),
preferred_download_filename: Some(preferences.preferred_download_filename.clone()),
play_sounds: Some(preferences.play_sounds),
delete_file_sound: preferences.delete_file_sound.clone(),
generation_success_sound: preferences.generation_success_sound.clone(),
@ -69,6 +77,10 @@ impl AppPreferencesSerializable {
preferences.preferred_download_directory = preferred_download_directory.clone();
}
if let Some(preferred_download_filename) = &self.preferred_download_filename {
preferences.preferred_download_filename = preferred_download_filename.clone();
}
if let Some(play_sounds) = self.play_sounds {
preferences.play_sounds = play_sounds;
}

View File

@ -1,4 +1,3 @@
pub mod app_preferences;
pub mod app_preferences_manager;
pub mod app_preferences_serializable;
pub mod preferred_download_directory;

View File

@ -0,0 +1,2 @@
pub mod preferred_download_directory;
pub mod preferred_download_filename;

View File

@ -81,8 +81,8 @@ impl Display for SystemDownloadDirectory {
#[cfg(test)]
mod tests {
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::app_preferences::preferred_download_directory::SystemDownloadDirectory;
use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::downloads::preferred_download_directory::SystemDownloadDirectory;
mod json {

View File

@ -0,0 +1,318 @@
use chrono::{DateTime, Datelike, Local, Timelike};
use serde_derive::{Deserialize, Serialize};
/// Characters that may never appear in a download filename (or a custom
/// filename format): path separators, traversal, quoting, and shell-unsafe
/// characters.
const UNSAFE_CHARACTERS: &[char] = &[
'/', '\\', '\'', '"', '`', '%', '<', '>', '|', ':', '*', '?', '\0',
];
/// How downloaded generation files are named on disk.
///
/// Stored in the app preferences file. NEVER change existing serialized
/// values; only add new ones.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PreferredDownloadFilename {
/// `{model}_{date}(_{batch_index}).{ext}`
/// - model slug, eg. "seedance_2.0", "flux_pro_1.1"
/// - date is "YYYY-MM-DD-HH-MM-SS" (download time)
/// - batch index is 1-n when the job produced more than one file
/// - ext matches the cloud file (jpg, png, mp4, ...)
///
/// NB: Serializes as `"artcraft_convention"` in JSON.
ArtcraftConvention,
/// A user-defined format string. Supported tokens:
/// - `{model}` — the model slug
/// - `{date}` — "YYYY-MM-DD-HH-MM-SS" (download time)
/// - `{YYYY}`, `{YY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{SS}` — date parts
/// - `{batch_index}` — 1-n (appended automatically when the job produced
/// more than one file and the format doesn't include it)
///
/// Slash, quote, and other unsafe characters are rejected.
///
/// NB: Serializes as `{"custom_format": "..."}` in JSON. (The key is NOT
/// "custom" so it can't be confused with `PreferredDownloadDirectory`'s
/// custom variant in loosely-typed payloads.)
#[serde(rename = "custom_format")]
Custom(String),
}
impl Default for PreferredDownloadFilename {
fn default() -> Self {
Self::ArtcraftConvention
}
}
/// Everything needed to render a download filename.
pub struct DownloadFilenameParts<'a> {
/// Model slug, eg. "seedance_2.0". Use [`model_slug_from_model_type_str`].
pub model_slug: &'a str,
/// The download time.
pub download_time: DateTime<Local>,
/// 1-based index when the job produced more than one file.
pub maybe_batch_index: Option<usize>,
/// File extension without the leading dot, matching the cloud file.
pub extension: &'a str,
}
impl PreferredDownloadFilename {
/// Render the filename (including extension) for one downloaded file.
/// The output is sanitized: unsafe characters can never appear.
pub fn build_filename(&self, parts: &DownloadFilenameParts<'_>) -> String {
let stem = match self {
Self::ArtcraftConvention => {
let mut stem = format!(
"{}_{}",
parts.model_slug,
full_date_string(&parts.download_time),
);
if let Some(index) = parts.maybe_batch_index {
stem.push_str(&format!("_{index}"));
}
stem
}
Self::Custom(format) => {
let mut stem = expand_format_tokens(format, parts);
let has_batch_token = format.contains("{batch_index}");
if !has_batch_token {
if let Some(index) = parts.maybe_batch_index {
stem.push_str(&format!("_{index}"));
}
}
stem
}
};
let stem = sanitize(&stem);
let extension = sanitize(parts.extension);
format!("{stem}.{extension}")
}
/// Vet a user-supplied custom format string.
pub fn validate_custom_format(format: &str) -> Result<(), String> {
if format.trim().is_empty() {
return Err("Filename format cannot be empty".to_string());
}
if format.contains("..") {
return Err("Filename format cannot contain '..'".to_string());
}
if let Some(bad) = format.chars().find(|c| UNSAFE_CHARACTERS.contains(c) || c.is_control()) {
return Err(format!("Filename format cannot contain {bad:?}"));
}
Ok(())
}
}
/// Derive a friendly model slug from a `TaskModelType` string value:
/// version markers like "2p0" become "2.0" (eg. "seedance_2p0" ->
/// "seedance_2.0", "flux_pro_1p1" -> "flux_pro_1.1").
pub fn model_slug_from_model_type_str(model_type: &str) -> String {
let mut slug = String::with_capacity(model_type.len());
let chars = model_type.chars().collect::<Vec<_>>();
for (i, c) in chars.iter().enumerate() {
let is_version_dot = *c == 'p'
&& i > 0
&& i + 1 < chars.len()
&& chars[i - 1].is_ascii_digit()
&& chars[i + 1].is_ascii_digit();
slug.push(if is_version_dot { '.' } else { *c });
}
slug
}
fn full_date_string(time: &DateTime<Local>) -> String {
format!(
"{:04}-{:02}-{:02}-{:02}-{:02}-{:02}",
time.year(), time.month(), time.day(),
time.hour(), time.minute(), time.second(),
)
}
fn expand_format_tokens(format: &str, parts: &DownloadFilenameParts<'_>) -> String {
let time = &parts.download_time;
format
.replace("{model}", parts.model_slug)
.replace("{date}", &full_date_string(time))
.replace("{YYYY}", &format!("{:04}", time.year()))
.replace("{YY}", &format!("{:02}", time.year() % 100))
.replace("{MM}", &format!("{:02}", time.month()))
.replace("{DD}", &format!("{:02}", time.day()))
.replace("{HH}", &format!("{:02}", time.hour()))
.replace("{mm}", &format!("{:02}", time.minute()))
.replace("{SS}", &format!("{:02}", time.second()))
.replace(
"{batch_index}",
&parts.maybe_batch_index.map(|i| i.to_string()).unwrap_or_default(),
)
}
/// Strip anything unsafe out of a rendered filename component.
fn sanitize(value: &str) -> String {
value.chars()
.filter(|c| !UNSAFE_CHARACTERS.contains(c) && !c.is_control())
.collect::<String>()
.replace("..", "_")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn test_time() -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 8, 5, 14, 30, 9).unwrap()
}
mod artcraft_convention {
use super::*;
#[test]
fn single_file() {
let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts {
model_slug: "seedance_2.0",
download_time: test_time(),
maybe_batch_index: None,
extension: "mp4",
});
assert_eq!(name, "seedance_2.0_2026-08-05-14-30-09.mp4");
}
#[test]
fn batch_file() {
let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts {
model_slug: "flux_pro_1.1",
download_time: test_time(),
maybe_batch_index: Some(3),
extension: "png",
});
assert_eq!(name, "flux_pro_1.1_2026-08-05-14-30-09_3.png");
}
}
mod custom_format {
use super::*;
#[test]
fn tokens_expand() {
let format = PreferredDownloadFilename::Custom("{model}-{YYYY}{MM}{DD}-{HH}{mm}{SS}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "nano_banana",
download_time: test_time(),
maybe_batch_index: None,
extension: "jpg",
});
assert_eq!(name, "nano_banana-20260805-143009.jpg");
}
#[test]
fn batch_index_token() {
let format = PreferredDownloadFilename::Custom("gen_{batch_index}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "unused",
download_time: test_time(),
maybe_batch_index: Some(2),
extension: "png",
});
assert_eq!(name, "gen_2.png");
}
#[test]
fn batch_index_appended_when_absent_from_format() {
let format = PreferredDownloadFilename::Custom("gen".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "unused",
download_time: test_time(),
maybe_batch_index: Some(2),
extension: "png",
});
assert_eq!(name, "gen_2.png");
}
#[test]
fn unsafe_characters_stripped_from_output() {
let format = PreferredDownloadFilename::Custom("weird{model}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "a/b\\c\"d",
download_time: test_time(),
maybe_batch_index: None,
extension: "png",
});
assert_eq!(name, "weirdabcd.png");
}
}
mod validation {
use super::*;
#[test]
fn accepts_reasonable_formats() {
assert!(PreferredDownloadFilename::validate_custom_format("{model}_{date}").is_ok());
assert!(PreferredDownloadFilename::validate_custom_format("my file {YYYY}").is_ok());
}
#[test]
fn rejects_unsafe_formats() {
assert!(PreferredDownloadFilename::validate_custom_format("").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a/b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a\\b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a\"b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a'b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("../etc").is_err());
}
}
mod model_slug {
use super::*;
#[test]
fn version_markers_become_dots() {
assert_eq!(model_slug_from_model_type_str("seedance_2p0"), "seedance_2.0");
assert_eq!(model_slug_from_model_type_str("flux_pro_1p1"), "flux_pro_1.1");
assert_eq!(model_slug_from_model_type_str("seedream_5p0_pro_u"), "seedream_5.0_pro_u");
}
#[test]
fn non_version_names_unchanged() {
assert_eq!(model_slug_from_model_type_str("nano_banana"), "nano_banana");
assert_eq!(model_slug_from_model_type_str("midjourney_8"), "midjourney_8");
assert_eq!(model_slug_from_model_type_str("marble_1p1_plus"), "marble_1.1_plus");
}
}
mod serialization {
use super::*;
#[test]
fn artcraft_convention_serializes_as_string() {
let val = PreferredDownloadFilename::ArtcraftConvention;
assert_eq!(serde_json::to_string(&val).unwrap(), "\"artcraft_convention\"");
}
#[test]
fn custom_serializes_with_custom_format_key() {
let val = PreferredDownloadFilename::Custom("{model}_{date}".to_string());
assert_eq!(serde_json::to_string(&val).unwrap(), "{\"custom_format\":\"{model}_{date}\"}");
}
#[test]
fn round_trips() {
for val in [
PreferredDownloadFilename::ArtcraftConvention,
PreferredDownloadFilename::Custom("{model}".to_string()),
] {
let json = serde_json::to_string(&val).unwrap();
let back: PreferredDownloadFilename = serde_json::from_str(&json).unwrap();
assert_eq!(back, val);
}
}
}
}

View File

@ -2,5 +2,6 @@ pub mod app_preferences;
pub mod artcraft_usage_tracker;
pub mod data_dir;
pub mod database;
pub mod downloads;
pub mod runtime;
pub mod window;

View File

@ -1,7 +1,7 @@
use crate::error::artcraftx_error::ArtcraftXError;
use crate::state::app_preferences::app_preferences::AppPreferences;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::utils::download_url_to_user_download_dir::download_file_name_from_url;
use crate::utils::download_url_to_user_download_dir::{check_download_file_name, download_file_name_from_url};
use anyhow::anyhow;
use log::info;
use std::io::Write;
@ -12,15 +12,26 @@ use url::Url;
/// configured download directory. Downloading to temp first means partially
/// downloaded files never land in the download directory.
///
/// `maybe_filename` overrides the URL-derived filename (eg. to apply the
/// user's preferred download filename convention). It is vetted with the
/// same rules as URL-derived names.
///
/// Returns the final path. If a file with the same name already exists in the
/// download directory, nothing is downloaded and
/// [`ArtcraftXError::CannotDownloadFilePathAlreadyExists`] is returned.
pub async fn download_url_to_download_dir_via_temp(
url: &Url,
maybe_filename: Option<&str>,
app_data_root: &AppDataRoot,
app_prefs: &AppPreferences,
) -> Result<PathBuf, ArtcraftXError> {
let url_file_name = download_file_name_from_url(url)?;
let url_file_name = match maybe_filename {
Some(filename) => {
check_download_file_name(filename)?;
filename.to_string()
}
None => download_file_name_from_url(url)?,
};
let download_directory = app_prefs
.preferred_download_directory
@ -90,7 +101,7 @@ mod tests {
// A known completed generation output.
let url = Url::parse("https://cdn-2.fakeyou.com/media/j/q/z/s/x/jqzsxxd8r20bnx42jw2dhdj6tq81q9xf/artcraft_jqzsxxd8r20bnx42jw2dhdj6tq81q9xf.png").unwrap();
let path = download_url_to_download_dir_via_temp(&url, &app_data_root, &app_prefs)
let path = download_url_to_download_dir_via_temp(&url, None, &app_data_root, &app_prefs)
.await
.expect("download should succeed");
@ -100,7 +111,7 @@ mod tests {
assert!(path.starts_with(app_prefs.preferred_download_directory.download_directory(&app_data_root)));
// Second attempt must refuse to clobber.
let second = download_url_to_download_dir_via_temp(&url, &app_data_root, &app_prefs).await;
let second = download_url_to_download_dir_via_temp(&url, None, &app_data_root, &app_prefs).await;
assert!(matches!(second, Err(ArtcraftXError::CannotDownloadFilePathAlreadyExists { .. })));
std::fs::remove_file(&path).expect("cleanup");

View File

@ -74,13 +74,15 @@ pub fn download_file_name_from_url(url: &Url) -> Result<String, ArtcraftXError>
url_file_name = format!("artcraft_{}", url_file_name);
}
check_url_file_name_for_downloadability(&url_file_name)?;
check_download_file_name(&url_file_name)?;
Ok(url_file_name)
}
/// Vet a filename (from any source) before writing it into the download
/// directory.
// TODO: This likely needs more protections.
fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), ArtcraftXError> {
pub fn check_download_file_name(filename: &str) -> Result<(), ArtcraftXError> {
if filename.trim().is_empty() {
error!("Download filename is empty!");
return Err(ArtcraftXError::BadDownloadFilename { path: "".into() }.into());

View File

@ -36,8 +36,11 @@ export function PromptShell({
const [receipt, setReceipt] = useState<Receipt | null>(null);
const savedIdsRef = useRef<Set<string>>(new Set());
// Save each completed file exactly once. The Rust side already flashes a
// toast if a download fails, so errors only suppress the receipt here.
// Save each completed file exactly once. ArtCraft-provider files are
// already auto-saved to disk by the Rust polling thread (named per the
// user's filename convention), so they only get receipted here —
// downloading them again would create duplicates. The Rust side already
// flashes a toast if a download fails, so errors only suppress the receipt.
useEffect(() => {
if (!completed?.length) return;
const fresh = completed.filter((f) => !savedIdsRef.current.has(f.id));
@ -47,7 +50,12 @@ export function PromptShell({
let cancelled = false;
(async () => {
const savedNames: string[] = [];
let savedOnDiskCount = 0;
for (const file of fresh) {
if (file.provider === "artcraft") {
savedOnDiskCount += 1;
continue;
}
try {
await DownloadUrl(file.url);
savedNames.push(fileNameFromUrl(file.url));
@ -55,12 +63,15 @@ export function PromptShell({
// Rust flashed the failure toast; nothing to receipt.
}
}
if (cancelled || savedNames.length === 0) return;
const total = savedNames.length + savedOnDiskCount;
if (cancelled || total === 0) return;
setReceipt({
text:
savedNames.length === 1
savedNames.length === 1 && savedOnDiskCount === 0
? savedNames[0]
: `${savedNames.length} files`,
: total === 1
? "1 file"
: `${total} files`,
key: Date.now(),
});
})();

View File

@ -16,6 +16,10 @@ export interface CompletedFile {
/** Unique per file (task id, or task id + batch index). */
id: string;
url: string;
/** Generation provider ("artcraft", "fal", ...) — ArtCraft files are
* auto-saved by the Rust polling thread, so the frontend must not
* download them again. */
provider?: string;
}
const TASK_TYPES: Record<ComposerTaskModality, ReadonlySet<string>> = {
@ -112,9 +116,11 @@ async function expandTaskFiles(
t: Awaited<ReturnType<typeof GetTaskQueue>>["tasks"][number],
mediaFilesApi: MediaFilesApi,
): Promise<CompletedFile[]> {
const provider = t.provider ? String(t.provider) : undefined;
const primary: CompletedFile = {
id: t.id,
url: t.completed_item!.primary_media_file.cdn_url,
provider,
};
const batchToken = t.completed_item?.maybe_batch_token;
@ -131,7 +137,7 @@ async function expandTaskFiles(
.map((file: any, index: number): CompletedFile | null => {
const cdnUrl = file.media_links?.cdn_url;
if (!cdnUrl) return null;
return { id: `${t.id}:${file.token ?? index}`, url: cdnUrl };
return { id: `${t.id}:${file.token ?? index}`, url: cdnUrl, provider };
})
.filter((f: CompletedFile | null): f is CompletedFile => f !== null);
return files.length > 0 ? files : [primary];

View File

@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import {
AppPreferencesPayload,
CustomDirectory,
CustomFilenameFormat,
GetAppPreferences,
PreferredDownloadFilename,
SystemDirectory,
} from "@storyteller/tauri-api";
import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Switch } from "@storyteller/ui-switch";
import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { Folder, RotateCcw, Search } from "lucide-react";
import { Select, SelectValue } from "@storyteller/ui-select";
import { SettingsBlock, SettingsRow } from "./SettingsRow";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/api";
const ARTCRAFT_CONVENTION = "artcraft_convention";
const CUSTOM = "custom";
const DEFAULT_CUSTOM_FORMAT = "{model}_{date}";
// Characters the backend rejects in custom formats.
const UNSAFE_FORMAT_CHARACTERS = /[/\\'"`%<>|:*?]/;
const FILENAME_OPTIONS = [
{ value: ARTCRAFT_CONVENTION, label: "ArtCraft convention" },
{ value: CUSTOM, label: "Custom format" },
];
interface DownloadsSettingsPaneProps {}
export const DownloadsSettingsPane = (args: DownloadsSettingsPaneProps) => {
const [preferences, setPreferences] = useState<
AppPreferencesPayload | undefined
>(undefined);
const [filenameMode, setFilenameMode] = useState<string>(ARTCRAFT_CONVENTION);
const [customFormat, setCustomFormat] = useState<string>(DEFAULT_CUSTOM_FORMAT);
const [formatError, setFormatError] = useState<string | null>(null);
const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] =
useState<boolean>(() => getAskLocationBeforeDownload());
const toggleAskLocationBeforeDownload = (enabled: boolean) => {
setAskLocationBeforeDownload(enabled);
setAskLocationBeforeDownloadState(enabled);
};
const applyPreferences = (prefs: AppPreferencesPayload) => {
setPreferences(prefs);
const filename: PreferredDownloadFilename | undefined =
prefs.preferred_download_filename;
if (filename && typeof filename === "object" && "custom_format" in filename) {
setFilenameMode(CUSTOM);
setCustomFormat(filename.custom_format);
} else {
setFilenameMode(ARTCRAFT_CONVENTION);
}
};
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppPreferences();
applyPreferences(prefs.preferences);
};
fetchData();
}, []);
const reloadPreferences = async () => {
const prefs = await GetAppPreferences();
applyPreferences(prefs.preferences);
};
// NB: This might be a complex type.
const outerDownloadObject = preferences?.preferred_download_directory || {};
const downloadDirectory =
"custom" in outerDownloadObject
? (outerDownloadObject.custom as string)
: "";
const currentDownloadLabel =
"system" in outerDownloadObject
? "System Download Directory"
: downloadDirectory;
const openDirectoryPicker = async () => {
let directory = await open({
multiple: false,
directory: true,
defaultPath: downloadDirectory || undefined,
});
if (directory === null) {
return; // User dismissed the dialog choice
}
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
custom: directory,
} as CustomDirectory,
});
await reloadPreferences();
};
const clearDirectory = async () => {
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
system: "downloads",
} as SystemDirectory,
});
await reloadPreferences();
};
const showDirectory = async () => {
await DownloadDirectoryReveal();
};
const saveFilenamePreference = async (
value: PreferredDownloadFilename,
): Promise<boolean> => {
try {
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadFilename,
value,
});
await reloadPreferences();
return true;
} catch (error) {
setFormatError(String(error));
return false;
}
};
const changeFilenameMode = async (mode: string) => {
setFilenameMode(mode);
setFormatError(null);
if (mode === ARTCRAFT_CONVENTION) {
await saveFilenamePreference(ARTCRAFT_CONVENTION);
}
// Custom mode saves on "Save format" so half-typed formats never persist.
};
const saveCustomFormat = async () => {
const format = customFormat.trim();
if (!format) {
setFormatError("Format cannot be empty");
return;
}
if (UNSAFE_FORMAT_CHARACTERS.test(format) || format.includes("..")) {
setFormatError("Format cannot contain slashes, quotes, or other unsafe characters");
return;
}
setFormatError(null);
await saveFilenamePreference({ custom_format: format } as CustomFilenameFormat);
};
return (
<div className="text-base-fg">
<SettingsBlock
title="Download directory"
description="Generated files are written straight to this folder."
>
<div className="w-full overflow-x-auto rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-putty">
{currentDownloadLabel}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={openDirectoryPicker}>
<Folder className="h-3.5 w-3.5" />
Choose folder
</Button>
<Button variant="secondary" onClick={showDirectory}>
<Search className="h-3.5 w-3.5" />
Show in explorer
</Button>
<Button variant="ghost" onClick={clearDirectory}>
<RotateCcw className="h-3.5 w-3.5" />
Use default
</Button>
</div>
</SettingsBlock>
<SettingsBlock
title="Filename convention"
description="How downloaded files are named. The ArtCraft convention is {model}_{date}.{ext}, with a batch index when a generation produces several files."
>
<div className="max-w-xs">
<Select
options={FILENAME_OPTIONS}
value={filenameMode}
onChange={(val: SelectValue) => changeFilenameMode(val as string)}
/>
</div>
{filenameMode === CUSTOM && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={customFormat}
onChange={(e) => setCustomFormat(e.target.value)}
placeholder={DEFAULT_CUSTOM_FORMAT}
spellCheck={false}
className="w-72 rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-base-fg outline-none focus:border-putty"
/>
<Button variant="secondary" onClick={saveCustomFormat}>
Save format
</Button>
</div>
<p className="text-[12px] text-mud">
{"Tokens: {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH}, {mm}, {SS}, {batch_index}. The file extension is added automatically."}
</p>
{formatError && (
<p className="text-[12px] text-red-400">{formatError}</p>
)}
</div>
)}
</SettingsBlock>
<SettingsRow
title="Ask location before download"
description="When on, a file picker appears for every download so you choose where each file goes. When off, downloads go straight to the folder above."
>
<Switch
enabled={askLocationBeforeDownload}
setEnabled={toggleAskLocationBeforeDownload}
/>
</SettingsRow>
</div>
);
};

View File

@ -1,132 +1,15 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import {
AppPreferencesPayload,
CustomDirectory,
GetAppPreferences,
SystemDirectory,
} from "@storyteller/tauri-api";
import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Switch } from "@storyteller/ui-switch";
import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { Folder, RotateCcw, Search } from "lucide-react";
import { useEnterToGenerateStore } from "@storyteller/ui-promptbox";
import { SettingsBlock, SettingsRow } from "./SettingsRow";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/api";
import { Switch } from "@storyteller/ui-switch";
import { SettingsRow } from "./SettingsRow";
interface MiscSettingsPaneProps {}
export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
const [preferences, setPreferences] = useState<
AppPreferencesPayload | undefined
>(undefined);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] =
useState<boolean>(() => getAskLocationBeforeDownload());
const toggleAskLocationBeforeDownload = (enabled: boolean) => {
setAskLocationBeforeDownload(enabled);
setAskLocationBeforeDownloadState(enabled);
};
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppPreferences();
console.log("prefs", prefs);
setPreferences(prefs.preferences);
};
fetchData();
}, []);
// NB: This might be a complex type.
const outerDownloadObject = preferences?.preferred_download_directory || {};
const downloadDirectory =
"custom" in outerDownloadObject
? (outerDownloadObject.custom as string)
: "";
const currentDownloadLabel =
"system" in outerDownloadObject
? "System Download Directory"
: downloadDirectory;
const reloadPreferences = async () => {
const prefs = await GetAppPreferences();
console.log("prefs", prefs);
setPreferences(prefs.preferences);
};
const openDirectoryPicker = async () => {
let directory = await open({
multiple: false,
directory: true,
defaultPath: downloadDirectory || undefined,
});
if (directory === null) {
return; // User dismissed the dialog choice
}
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
custom: directory,
} as CustomDirectory,
});
await reloadPreferences();
};
const clearDirectory = async () => {
await UpdateAppPreferences({
preference: PreferenceName.PreferredDownloadDirectory,
value: {
system: "downloads",
} as SystemDirectory,
});
await reloadPreferences();
};
const showDirectory = async () => {
await DownloadDirectoryReveal();
};
return (
<div className="text-base-fg">
<SettingsBlock
title="Download directory"
description="Generated files are written straight to this folder."
>
<div className="w-full overflow-x-auto rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-putty">
{currentDownloadLabel}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={openDirectoryPicker}>
<Folder className="h-3.5 w-3.5" />
Choose folder
</Button>
<Button variant="secondary" onClick={showDirectory}>
<Search className="h-3.5 w-3.5" />
Show in explorer
</Button>
<Button variant="ghost" onClick={clearDirectory}>
<RotateCcw className="h-3.5 w-3.5" />
Use default
</Button>
</div>
</SettingsBlock>
<SettingsRow
title="Ask location before download"
description="When on, a file picker appears for every download so you choose where each file goes. When off, downloads go straight to the folder above."
>
<Switch
enabled={askLocationBeforeDownload}
setEnabled={toggleAskLocationBeforeDownload}
/>
</SettingsRow>
<SettingsRow
title="Enter to generate"
description="When on, Enter submits the prompt and Shift+Enter adds a new line. When off, both add a new line — use the button to submit."

View File

@ -2,6 +2,7 @@ import { Modal } from "@storyteller/ui-modal";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
import { DownloadsSettingsPane } from "./panes/DownloadsSettingsPane";
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
@ -15,6 +16,7 @@ import { ExperimentalConfirmModal } from "./ExperimentalConfirmModal";
export type SettingsSection =
| "general"
| "downloads"
| "appearance"
| "accounts"
| "alerts"
@ -58,6 +60,7 @@ export const SettingsContent = ({
const sections = [
{ id: "general" as const, label: "General" },
{ id: "downloads" as const, label: "Downloads" },
{ id: "accounts" as const, label: "Accounts" },
{ id: "billing" as const, label: "Plan & Credits" },
{ id: "alerts" as const, label: "Alerts" },
@ -75,6 +78,8 @@ export const SettingsContent = ({
return <AudioSettingsPane />;
case "general":
return <MiscSettingsPane />;
case "downloads":
return <DownloadsSettingsPane />;
case "accounts":
return (
<AccountSettingsPane

View File

@ -8,6 +8,9 @@ export interface AppPreferencesPayload {
// Preferred download directory
preferred_download_directory: PreferredDownloadDirectory,
// How downloaded generation files are named on disk.
preferred_download_filename: PreferredDownloadFilename,
// Play sounds on events.
play_sounds: boolean,
@ -23,6 +26,15 @@ export interface AppPreferencesPayload {
export type PreferredDownloadDirectory = SystemDirectory | CustomDirectory;
// "artcraft_convention" or a custom format string wrapper.
// Custom formats accept {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH},
// {mm}, {SS}, and {batch_index} tokens.
export type PreferredDownloadFilename = "artcraft_convention" | CustomFilenameFormat;
export interface CustomFilenameFormat {
custom_format: string,
}
export interface SystemDirectory {
// If the directory is a system directory.
system: string,

View File

@ -1,14 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import { PreferredDownloadDirectory } from "./GetAppPreferences";
import { PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences";
export interface UpdateAppPreferencesRequest {
preference: PreferenceName,
value: undefined | string | boolean | PreferredDownloadDirectory,
value: undefined | string | boolean | PreferredDownloadDirectory | PreferredDownloadFilename,
}
export enum PreferenceName {
PreferredDownloadDirectory = "preferred_download_directory",
PreferredDownloadFilename = "preferred_download_filename",
PlaySounds = "play_sounds",
DeleteFileSound = "delete_file_sound",
EnqueueSuccessSound = "enqueue_success_sound",