Compare commits

...
Author SHA1 Message Date
Brandon Thomas b8b2091067 thumbnail speedup 2026-09-28 01:21:30 -04:00
Brandon Thomas 93b664d871 client identity (origin, user agent) (#1962) 2026-09-27 23:41:15 -04:00
Brandon Thomas 92e6f438c3 better dev script (#1961) 2026-09-27 14:48:34 -04:00
65 changed files with 1819 additions and 249 deletions
Generated
+9
View File
@@ -262,6 +262,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client_identity",
"backoff",
"chrono",
"cloudflare_errors",
@@ -282,6 +283,13 @@ dependencies = [
"uuid_utils",
]
[[package]]
name = "artcraft_client_identity"
version = "0.0.1"
dependencies = [
"url",
]
[[package]]
name = "artcraft_router"
version = "0.0.1"
@@ -7018,6 +7026,7 @@ dependencies = [
name = "tauri-plugin-http"
version = "2.4.3"
dependencies = [
"artcraft_client_identity",
"bytes",
"cookie_store 0.21.1",
"data-url",
+2
View File
@@ -16,6 +16,7 @@ members = [
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
"crates/desktop/artcraft",
"crates/lib/artcraft_client_identity",
"crates/lib/browser_emulation",
"crates/lib/build_metadata",
"crates/lib/caching/memory_store",
@@ -49,6 +50,7 @@ publish = false
[workspace.dependencies]
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
browser_emulation = { path = "crates/lib/browser_emulation" }
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }
+53 -7
View File
@@ -9,21 +9,67 @@ To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher.
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Start the frontend and Rust/Tauri together in one terminal
./script/artcraft/unix_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
# Optionally start the free-port search at another port
ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh
```
Use the combined launcher instead of starting the two legacy dev scripts. It
works from any working directory when invoked by its path and installs frontend
dependencies if the local Vite installation is missing. Node.js 20+ is required.
The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying
successive ports until one is available. It leaves existing listeners alone.
Only after the real frontend socket is bound does it start Tauri with that exact
`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
are passed in memory, without changing the checked-in Tauri config.
The native HTTP bridge normalizes loopback development origins for first-party
API requests so an automatically selected port does not break login or media
loading. See the [desktop session regression checks](../docs/desktop-session-regression.md)
when changing ports, the launcher, or authentication.
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
React component edits use Fast Refresh. Changes that cannot be hot-replaced
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
restart**, including dependent workspace crates. SQLx metadata and SQLite
migrations are also watched. Rust reload is a process restart, so it does not
preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes).
After startup, a Vite configuration restart must reuse the chosen port or report
an error; it cannot silently switch to another port while Tauri uses an old URL.
Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app.
If the Rust watcher exits, the launcher closes Vite and propagates the exit code.
It never finds or terminates another app by name or port.
The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`,
`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment
overrides are preserved. It uses the normal Artcraft app data and Cargo target
directory; port separation does not create an isolated account/data profile, and
concurrent Rust builds can wait on Cargo's build lock.
To verify the launcher without compiling Rust or opening a desktop window:
```bash
node --test frontend/scripts/unix-dev.test.mjs
```
The tests use real Vite listeners and HMR, with a test-owned fake Cargo process
tree. They cover occupied ports, simultaneous launches, React edits, shutdown,
Rust-process failure, and watcher arguments. To verify native Rust reload, run
the launcher, wait for the desktop window, make a Rust source edit, and check for
a rebuild/restart in the same terminal. Frontend edits should update the webview
without restarting the Rust process.
**Windows Development**
```powershell
@@ -19,6 +19,7 @@ path = "src/lib.rs"
# Internal
artcraft_api_defs.workspace = true
artcraft_client_identity.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
errors.workspace = true
@@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke
debug!("Requesting {:?}", &url);
let response = http_get_anonymous(url).await?;
let response = http_get_anonymous(api_host, url).await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -56,8 +56,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
//let mut file = File::open(path)?;
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -86,7 +85,6 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -11,7 +12,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
@@ -72,8 +72,7 @@ pub async fn upload_image_media_file_from_bytes(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_bytes(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -65,8 +65,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
//let mut file = File::open(path)?;
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -1,7 +1,8 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -32,8 +32,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
@@ -49,7 +48,6 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -1,7 +1,8 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -58,8 +58,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(args.api_host)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
@@ -78,7 +77,6 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -1,4 +1,6 @@
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
const HTTP_SCHEME: &str = "http";
const HTTPS_SCHEME: &str = "https";
@@ -27,6 +29,15 @@ impl ApiHost {
}
}
/// The `Origin` the desktop client declares to this API.
pub fn request_origin(&self) -> &'static str {
match self {
ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN,
}
}
pub fn scheme(&self) -> &'static str {
match self {
ApiHost::Storyteller => HTTPS_SCHEME,
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -21,13 +21,11 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
info!("DELETE {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.delete(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -20,13 +20,11 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -30,13 +30,11 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
info!("Requesting {:?}", &url);
}
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
@@ -3,11 +3,11 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::filter_bad_response::filter_bad_response;
use errors::AnyhowResult;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -26,13 +26,11 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
let client = storyteller_client_builder(api_host)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -1,4 +1 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";
@@ -1,14 +1,12 @@
use crate::error::api_error::ApiError;
use reqwest::Client;
const USER_AGENT: &str = "storyteller-client/1.0";
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
let client = Client::builder()
.gzip(true)
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
let client = storyteller_client_builder(api_host)
.build()?;
let response = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
//.header("Accept-Encoding", "gzip, deflate, br")
.send()
@@ -7,7 +7,7 @@ use serde::de::DeserializeOwned;
use url::Url;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::USER_AGENT;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
/// Native transport for the session bridge. No request/response bodies, cookies,
/// or challenge credentials are logged, even at debug level. Never follows redirects.
@@ -25,14 +25,13 @@ pub struct LoginChallengeClientError {
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
let mut builder = Client::builder();
let mut builder = storyteller_client_builder(api_host);
if matches!(api_host, ApiHost::Localhost { .. }) {
builder = builder.no_proxy();
}
let client = builder
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.user_agent(USER_AGENT)
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
@@ -4,6 +4,7 @@ pub mod basic_json_post_request;
pub (crate) mod basic_query_string_post_request;
pub (crate) mod filter_bad_response;
pub (crate) mod http_get_anonymous;
pub (crate) mod storyteller_client_builder;
pub mod api_host;
pub mod constants;
pub mod status_codes;
@@ -0,0 +1,17 @@
use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT;
use reqwest::header::{HeaderMap, HeaderValue, ORIGIN};
use reqwest::{Client, ClientBuilder};
use crate::utils::api_host::ApiHost;
/// Every request to our API is built from this so the desktop client identifies itself
/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs.
pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin()));
Client::builder()
.user_agent(ARTCRAFT_DESKTOP_USER_AGENT)
.default_headers(headers)
.gzip(true)
}
+5 -4
View File
@@ -1,4 +1,5 @@
# Don't restart the dev server on Rust changes
crates/
Cargo.toml
Cargo.lock
# Rust sources and Cargo manifests must remain watched for `cargo tauri dev`.
# Vite owns frontend reloads; build artifacts must not trigger Rust rebuilds.
target/
node_modules/
dist/
@@ -0,0 +1,17 @@
[package]
name = "artcraft_client_identity"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "artcraft_client_identity"
path = "src/lib.rs"
[dependencies]
# External
url.workspace = true
[dev-dependencies]
# (None)
@@ -0,0 +1,166 @@
use url::{Host, Url};
use crate::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
use crate::third_party_provider::{ThirdPartyProvider, THIRD_PARTY_PROVIDERS};
/// Hostnames of our deployed APIs (HTTPS only).
const ARTCRAFT_API_HOSTNAMES: [&str; 3] = [
"api.storyteller.ai",
"api.getartcraft.com",
"api.fakeyou.com",
];
/// Domains we own. Subdomains match too (CDNs, uploads, websites).
const ARTCRAFT_DOMAINS: [&str; 4] = [
"storyteller.ai",
"getartcraft.com",
"fakeyou.com",
"artcraft.ai",
];
/// Who a request is going to, which decides how the desktop app identifies itself.
#[derive(Debug, PartialEq, Eq)]
pub enum Destination {
/// Our HTTP API, deployed or running locally, and the `Origin` to declare to it.
ArtcraftApi { origin: &'static str },
/// Our other hosts: CDNs, uploads, websites.
ArtcraftHost,
/// A known third-party provider.
ThirdParty(&'static ThirdPartyProvider),
/// Anything else.
Unknown,
}
impl Destination {
pub fn classify(destination: &Url) -> Self {
if is_local_development_api(destination) {
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN };
}
let Some(host) = destination.host_str() else {
return Self::Unknown;
};
if destination.scheme() == "https" && ARTCRAFT_API_HOSTNAMES.contains(&host) {
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_ORIGIN };
}
if ARTCRAFT_DOMAINS.iter().any(|domain| is_domain_or_subdomain(host, domain)) {
return Self::ArtcraftHost;
}
THIRD_PARTY_PROVIDERS
.iter()
.find(|provider| provider.domains.iter().any(|domain| is_domain_or_subdomain(host, domain)))
.map_or(Self::Unknown, Self::ThirdParty)
}
}
fn is_local_development_api(destination: &Url) -> bool {
if !matches!(destination.scheme(), "http" | "https") {
return false;
}
match destination.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
_ => false,
}
}
fn is_domain_or_subdomain(host: &str, domain: &str) -> bool {
host == domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.'))
}
#[cfg(test)]
mod tests {
use super::*;
mod artcraft {
use super::*;
#[test]
fn deployed_apis_get_desktop_origin() {
for destination in [
"https://api.storyteller.ai/v1/session",
"https://api.getartcraft.com/v1/session",
"https://api.fakeyou.com/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "https://desktop.getartcraft.com" });
}
}
#[test]
fn local_apis_get_development_origin() {
for destination in [
"http://localhost:12345/v1/session",
"https://localhost:12345/v1/session",
"http://127.0.0.1:12345/v1/session",
"http://[::1]:12345/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "http://localhost" });
}
}
#[test]
fn other_owned_hosts_are_not_apis() {
for destination in [
"https://cdn-2.fakeyou.com/media/a/b.png",
"https://cdn.storyteller.ai/media/a/b.png",
"https://style.storyteller.ai/",
"https://app.getartcraft.com/",
"https://getartcraft.com/",
"http://api.storyteller.ai/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftHost, "{destination}");
}
}
}
mod third_party {
use super::*;
#[test]
fn providers_get_their_own_origins() {
for (destination, name, origin) in [
("https://grok.com/rest/app-chat/conversations/new", "Grok", "https://grok.com"),
("https://assets.grok.com/users/a/b.mp4", "Grok", "https://grok.com"),
("https://imagine-public.x.ai/a.png", "Grok", "https://grok.com"),
("https://www.midjourney.com/api/submit-jobs", "Midjourney", "https://www.midjourney.com"),
("https://cdn.midjourney.com/a/0_0.png", "Midjourney", "https://www.midjourney.com"),
("https://sora.chatgpt.com/backend/nf/create", "Sora", "https://sora.chatgpt.com"),
("https://videos.openai.com/a.mp4", "Sora", "https://sora.chatgpt.com"),
("https://chatgpt.com/backend-api/sentinel", "ChatGPT", "https://chatgpt.com"),
("https://api.worldlabs.ai/api/v1/worlds", "World Labs", "https://marble.worldlabs.ai"),
("https://static.kinovi.ai/a.png", "Kinovi", "https://kinovi.ai"),
("https://static.seedance2-pro.com/materials/a.png", "Seedance 2 Pro", "https://seedance2-pro.com"),
("https://v3b.fal.media/files/a.png", "fal", "https://fal.ai"),
("https://console.gmicloud.ai/api", "GMI Cloud", "https://console.gmicloud.ai"),
] {
let Destination::ThirdParty(provider) = classify(destination) else {
panic!("{destination} is not a third party");
};
assert_eq!((provider.name, provider.origin), (name, origin));
}
}
#[test]
fn lookalikes_and_others_are_unknown() {
for destination in [
"https://api.storyteller.ai.example.com/v1/session",
"https://notstoryteller.ai/",
"https://evilgrok.com/",
"https://grok.com.example.com/",
"https://www.gravatar.com/avatar/a",
"https://storage.googleapis.com/bucket/a.png",
"http://192.168.1.2:12345/v1/session",
"tauri://localhost",
] {
assert_eq!(classify(destination), Destination::Unknown, "{destination}");
}
}
}
fn classify(destination: &str) -> Destination {
Destination::classify(&Url::parse(destination).unwrap())
}
}
@@ -0,0 +1,12 @@
//! artcraft_client_identity
//!
//! How the ArtCraft desktop app identifies itself (`Origin`, `User-Agent`) to each destination.
//!
//! Our own APIs get the declared ArtCraft desktop identity. Known third-party providers get an
//! identity close to their own website, and never ours. The native third-party Rust clients
//! (Grok, Midjourney, Sora, etc.) set their own headers and do not use this crate.
pub mod destination;
pub mod origins;
pub mod third_party_provider;
pub mod user_agents;
@@ -0,0 +1,11 @@
/// `Origin` sent to our deployed APIs.
/// Keep in sync with `actix_cors_configs/src/configs/artcraft_desktop.rs` in artcraft-services.
pub const ARTCRAFT_DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
/// `Origin` sent to a locally running API (eg. `http://localhost:12345`).
/// Development CORS accepts any `localhost` origin.
pub const ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN: &str = "http://localhost";
/// `Origin` packaged builds historically sent everywhere. Now only sent to our non-API hosts
/// (CDNs, websites), where it is left unchanged.
pub const LEGACY_DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai";
@@ -0,0 +1,30 @@
/// A third-party provider, and the `Origin` of its own website that we present to its hosts.
#[derive(Debug, PartialEq, Eq)]
pub struct ThirdPartyProvider {
pub name: &'static str,
/// Registrable domains; subdomains match too.
pub domains: &'static [&'static str],
pub origin: &'static str,
}
/// Every third party that gets an invented origin. Origins match what each provider's native
/// Rust client already sends. First match wins, so more specific entries go first.
pub const THIRD_PARTY_PROVIDERS: [ThirdPartyProvider; 9] = [
ThirdPartyProvider { name: "Grok", domains: &["grok.com", "x.ai"], origin: "https://grok.com" },
ThirdPartyProvider { name: "Midjourney", domains: &["midjourney.com"], origin: "https://www.midjourney.com" },
ThirdPartyProvider {
name: "Sora",
domains: &["sora.chatgpt.com", "sora.com", "openai.com", "oaistatic.com"],
origin: "https://sora.chatgpt.com",
},
ThirdPartyProvider { name: "ChatGPT", domains: &["chatgpt.com"], origin: "https://chatgpt.com" },
ThirdPartyProvider {
name: "World Labs",
domains: &["worldlabs.ai", "wlt-ai.art"],
origin: "https://marble.worldlabs.ai",
},
ThirdPartyProvider { name: "Kinovi", domains: &["kinovi.ai"], origin: "https://kinovi.ai" },
ThirdPartyProvider { name: "Seedance 2 Pro", domains: &["seedance2-pro.com"], origin: "https://seedance2-pro.com" },
ThirdPartyProvider { name: "fal", domains: &["fal.ai", "fal.run", "fal.media"], origin: "https://fal.ai" },
ThirdPartyProvider { name: "GMI Cloud", domains: &["gmicloud.ai"], origin: "https://console.gmicloud.ai" },
];
@@ -0,0 +1,23 @@
/// `User-Agent` sent to our own APIs and hosts.
///
/// TODO: Change to "artcraft-desktop/1.0" once the backend services can parse it.
pub const ARTCRAFT_DESKTOP_USER_AGENT: &str = "storyteller-client/1.0";
/// `User-Agent` sent to third parties: the default of the OS webview Tauri runs on
/// (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
///
/// NB: These are hardcoded snapshots and WILL go out of date as the OS webviews update
/// (especially WebView2's Chrome/Edge version, which auto-updates on Windows). Rust can't read
/// the webview's real `navigator.userAgent`, so refresh these periodically by checking
/// `navigator.userAgent` in the desktop app's devtools on each OS.
#[cfg(target_os = "macos")]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
#[cfg(target_os = "windows")]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)";
+1
View File
@@ -36,6 +36,7 @@ urlpattern = "0.3"
regex = "1"
[dependencies]
artcraft_client_identity = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true }
+207
View File
@@ -0,0 +1,207 @@
use artcraft_client_identity::destination::Destination;
use artcraft_client_identity::origins::LEGACY_DESKTOP_ORIGIN;
use artcraft_client_identity::user_agents::{ARTCRAFT_DESKTOP_USER_AGENT, WEBVIEW_USER_AGENT};
use http::{header, HeaderMap, HeaderValue};
use url::{Host, Url};
/// The `User-Agent` to send when the frontend didn't set one.
pub(crate) fn artcraft_default_user_agent(destination: &Url) -> &'static str {
match Destination::classify(destination) {
Destination::ArtcraftApi { .. } | Destination::ArtcraftHost => ARTCRAFT_DESKTOP_USER_AGENT,
Destination::ThirdParty(_) | Destination::Unknown => WEBVIEW_USER_AGENT,
}
}
/// Replace the webview's own `Origin` (packaged `tauri://localhost` etc., or a Vite loopback
/// origin at any port) with the identity chosen for the destination. The native HTTP bridge
/// shares the desktop cookie jar, so its identity must not depend on the webview.
pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) {
let origin = headers.get(header::ORIGIN).and_then(|value| value.to_str().ok());
// Packaged-app webview origins on macOS and Windows.
let packaged_origin = headers.get(header::ORIGIN).is_none()
|| matches!(origin, Some("null" | "http://tauri.localhost" | "tauri://localhost"));
// Development webview origins (Vite on any loopback port).
let webview_origin = packaged_origin || origin.is_some_and(is_loopback_origin);
let replacement = match Destination::classify(destination) {
// Our APIs (deployed or local) see the declared desktop identity.
Destination::ArtcraftApi { origin } if webview_origin => origin,
// Known third parties see an origin close to their own website.
Destination::ThirdParty(provider) if webview_origin => provider.origin,
// Our CDNs and websites keep the historical packaged-app handling.
Destination::ArtcraftHost if packaged_origin => LEGACY_DESKTOP_ORIGIN,
// Unknown hosts and ordinary website origins are left untouched.
_ => return,
};
// HeaderMap is a multimap; insert replaces all existing Origin values.
headers.insert(header::ORIGIN, HeaderValue::from_static(replacement));
}
fn is_loopback_origin(origin: &str) -> bool {
let Ok(url) = Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
match url.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
const API: &str = "https://api.storyteller.ai/v1/session";
const LOCAL_API: &str = "http://localhost:12345/v1/session";
const CDN: &str = "https://cdn-2.fakeyou.com/media/a/b.png";
const GCS: &str = "https://storage.googleapis.com/bucket/a.png";
const GROK: &str = "https://grok.com/rest/app-chat/conversations/new";
const MIDJOURNEY: &str = "https://www.midjourney.com/api/submit-jobs";
const UNKNOWN: &str = "https://www.gravatar.com/avatar/a";
const DEV_WEBVIEW_ORIGIN: &str = "http://127.0.0.1:5193";
const PACKAGED_ORIGINS: [Option<&str>; 4] =
[None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")];
mod development {
use super::*;
#[test]
fn normalizes_dynamic_ports_and_loopback_hosts() {
for origin in [
"http://127.0.0.1:5193",
"http://127.0.0.1:5194",
"http://127.0.0.1:65535",
"http://localhost:5173",
"http://[::1]:5193",
] {
assert_eq!(normalized(API, Some(origin)), "https://desktop.getartcraft.com");
assert_eq!(normalized(LOCAL_API, Some(origin)), "http://localhost");
assert_eq!(normalized(GROK, Some(origin)), "https://grok.com");
}
assert_eq!(
normalized("https://api.fakeyou.com/v1/session", Some(DEV_WEBVIEW_ORIGIN)),
"https://desktop.getartcraft.com"
);
}
#[test]
fn leaves_cdns_and_unknown_destinations_unchanged() {
for destination in [
CDN,
GCS,
UNKNOWN,
"https://api.storyteller.ai.example.com/v1/session",
"http://api.storyteller.ai/v1/session",
] {
assert_eq!(normalized(destination, Some(DEV_WEBVIEW_ORIGIN)), DEV_WEBVIEW_ORIGIN, "{destination}");
}
}
#[test]
fn leaves_non_webview_origins_unchanged() {
for origin in [
"https://app.getartcraft.com",
"https://example.com",
"http://localhost.example.com:5193",
"http://192.168.1.2:5193",
"not a URL",
"",
] {
assert_eq!(normalized(API, Some(origin)), origin);
assert_eq!(normalized(GROK, Some(origin)), origin);
}
}
}
mod packaged {
use super::*;
#[test]
fn our_apis_get_desktop_identity() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(API, origin), "https://desktop.getartcraft.com");
assert_eq!(normalized(LOCAL_API, origin), "http://localhost");
}
}
#[test]
fn third_parties_get_their_own_origin() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(GROK, origin), "https://grok.com");
assert_eq!(normalized(MIDJOURNEY, origin), "https://www.midjourney.com");
}
}
#[test]
fn cdns_keep_legacy_origin() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(CDN, origin), "https://studio.storyteller.ai");
}
}
#[test]
fn unknown_destinations_never_get_our_origin() {
for destination in [UNKNOWN, GCS] {
assert_eq!(normalized(destination, Some("tauri://localhost")), "tauri://localhost");
assert_eq!(normalized(destination, Some("null")), "null");
assert_eq!(normalized_or_missing(destination, None), None);
}
}
#[test]
fn replaces_duplicate_origin_values_without_changing_session_headers() {
let mut headers = HeaderMap::new();
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture"));
normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers);
assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1);
assert_eq!(headers[header::ORIGIN], "https://desktop.getartcraft.com");
assert_eq!(headers[header::COOKIE], "session=fixture");
}
}
mod user_agent {
use super::*;
#[test]
fn ours_get_artcraft_user_agent() {
for destination in [API, LOCAL_API, CDN] {
assert_eq!(user_agent(destination), "storyteller-client/1.0");
}
}
#[test]
fn third_parties_get_webview_user_agent() {
for destination in [GROK, MIDJOURNEY, GCS, UNKNOWN] {
let user_agent = user_agent(destination);
assert!(user_agent.starts_with("Mozilla/5.0 ("), "{user_agent}");
assert!(!user_agent.contains("storyteller"), "{user_agent}");
}
}
fn user_agent(destination: &str) -> &'static str {
artcraft_default_user_agent(&Url::parse(destination).unwrap())
}
}
fn normalized(destination: &str, origin: Option<&str>) -> String {
normalized_or_missing(destination, origin).unwrap()
}
fn normalized_or_missing(destination: &str, origin: Option<&str>) -> Option<String> {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
}
normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers);
headers.get(header::ORIGIN).map(|value| value.to_str().unwrap().to_owned())
}
}
+4 -24
View File
@@ -16,12 +16,11 @@ use tauri::{
use tokio::sync::oneshot::{channel, Receiver, Sender};
use crate::{
artcraft_identity::{artcraft_default_user_agent, normalize_artcraft_origin},
scope::{Entry, Scope},
Error, Http, Result,
};
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
struct ReqwestResponse(reqwest::Response);
impl tauri::Resource for ReqwestResponse {}
@@ -269,7 +268,7 @@ pub async fn fetch<R: Runtime>(
builder = builder.cookie_provider(state.cookies_jar.clone());
}
let mut request = builder.build()?.request(method.clone(), url);
let mut request = builder.build()?.request(method.clone(), url.clone());
// POST and PUT requests should always have a 0 length content-length,
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
@@ -284,7 +283,7 @@ pub async fn fetch<R: Runtime>(
}
if !headers.contains_key(header::USER_AGENT) {
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
headers.append(header::USER_AGENT, HeaderValue::from_static(artcraft_default_user_agent(&url)));
}
// ensure we have an Origin header set
@@ -305,26 +304,7 @@ pub async fn fetch<R: Runtime>(
headers.remove(header::ORIGIN);
};
// NB(bt): In production builds, the origin seems to be "null". This is what our server CORS filter sees:
// [2025-05-01T23:42:35Z WARN actix_cors_configs::configs::fakeyou] Invalid origin: "null"
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// ...
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// [2025-05-01T23:42:35Z INFO actix_web::middleware::logger] [storyteller-web-85f9b4974-ddvq7] IP=[redact]
// 939322e1095ee5e4-IAD "POST /v1/login HTTP/1.1" 400 42 "-" "tauri-plugin-http/2.4.3" 0.000076
let mut maybe_origin_header = headers.get(header::ORIGIN);
let replace_origin_header = maybe_origin_header.is_none()
|| maybe_origin_header == Some(&HeaderValue::from_static("null")) // Above error (Mac)
|| maybe_origin_header == Some(&HeaderValue::from_static("http://tauri.localhost")) // Windows
|| maybe_origin_header == Some(&HeaderValue::from_static("tauri://localhost")); // Mac plausibly causes this
if replace_origin_header {
// TODO(bt): Make this a sentinel origin, eg. sentinel.storyteller.ai.
// NB: HeadersMap is a multimap, but this should ensure we just have one value.
headers.insert(header::ORIGIN, HeaderValue::from_static("https://studio.storyteller.ai"));
}
normalize_artcraft_origin(&url, &mut headers);
if let Some(data) = data {
request = request.body(data);
+2
View File
@@ -12,6 +12,8 @@ use tauri::{
pub use error::{Error, Result};
mod artcraft_identity;
// NB(bt): We're making everything public so we can access it.
pub mod commands;
pub mod error;
+189
View File
@@ -0,0 +1,189 @@
# Desktop session regression — September 27, 2026
The combined development launcher (`92e6f438c3`, following the performance
change `df707bf8e1`) moved Vite from `localhost:5173` to an available port on
`127.0.0.1`. The vendored HTTP plugin forwarded that webview Origin to the
production API. The API rejected it with **400: Origin is not allowed to make
this request**.
The native login bridge and credits commands use a different HTTP transport and
continued working. Consequently the login modal correctly recognized the native
session, but Accounts, Library, and page media requests failed. Accounts treated
the error as logged out; its Log In button rechecked the valid native session
and immediately closed the login modal again.
The fix extends the existing packaged-desktop Origin normalization to loopback
development origins, at any port, for HTTPS requests to `api.storyteller.ai` and
`api.fakeyou.com`. Other development request destinations and non-loopback
website origins retain their headers. Cookie handling and API authorization are
unchanged. A frontend reload alone cannot update this fix: Rust must rebuild.
## Dedicated desktop origin and rollout
The desktop app now declares itself per destination. Both transports read the
values from one place, `crates/lib/artcraft_client_identity`
(`Destination::classify`, origins, user agents, third-party provider table):
| Destination | Origin sent | User-Agent |
|--------------------------------------------------------|-------------------------------------|--------------------------|
| Our APIs (`api.storyteller.ai`, `api.fakeyou.com`, …) | `https://desktop.getartcraft.com` | `storyteller-client/1.0` |
| Local API (`localhost`, `127.0.0.1`, `[::1]`) | `http://localhost` | `storyteller-client/1.0` |
| Our CDNs / sites (`*.storyteller.ai`, `*.fakeyou.com`) | unchanged (`studio.storyteller.ai`) | `storyteller-client/1.0` |
| Named third parties (`THIRD_PARTY_PROVIDERS`) | the provider's own site | OS webview |
| Any other host (incl. `storage.googleapis.com`) | webview origin, untouched | OS webview |
- The native Rust client (`artcraft_client`) sends the Origin and User-Agent on
every API request via `storyteller_client_builder`; it previously sent no
Origin.
- The "OS webview" User-Agents are hardcoded snapshots in
`artcraft_client_identity/src/user_agents.rs` and go out of date as the
webviews update (notably WebView2's Edge version on Windows). Refresh them
from `navigator.userAgent` in the desktop app's devtools on each OS.
- The vendored HTTP plugin only replaces the webview's own origin (packaged
`tauri://localhost` etc., or a loopback Vite origin), and only sets the
User-Agent when the frontend didn't.
- Native third-party Rust clients (Grok, Midjourney, Sora, World Labs, Kinovi)
set their own headers and are not affected.
- Older desktop releases keep working: the server still allows the studio and
Tauri origins, and requests without an Origin.
Keep the deployed origin in sync with
`../artcraft-services/crates/lib/actix_cors_configs/src/configs/artcraft_desktop.rs`.
Development servers accept any `localhost` origin.
Deploy the server allowlist to the APIs **before running or releasing a desktop
build with the new origin**. Otherwise the deployed server will reject desktop
requests until its allowlist is updated. The server retains the studio and
legacy Tauri origins so existing desktop releases continue working.
Check both repositories:
```sh
# From artcraft/
cargo test --offline -p artcraft_client_identity -p tauri-plugin-http --lib
# From artcraft-services/
cargo test --offline -p actix_cors_configs --lib
```
The server tests verify credentialed GET/POST requests and OPTIONS preflights in
production and development, rejection of lookalike domains/HTTP/alternate ports,
and compatibility with previous desktop origins. After server deployment, repeat
the native checks below using the updated app.
## Observed native results
These observations were captured with the earlier studio origin, before the
dedicated desktop origin migration. They do not establish that production has
deployed the new allowlist.
Checked in the real macOS Tauri dev app at `http://127.0.0.1:5193`, using its
existing session. Temporary probes were removed after verification. No cookies,
tokens, passwords, account identifiers, or media contents are recorded here.
| Check | Before | After |
|--------------------------------|---------------------------------------|----------------------------|
| Frontend HTTP `/v1/session` | 400, Origin rejected | 200 |
| Shared `UsersApi.GetSession()` | `success: false` | Success, logged in |
| Native vs. frontend identity | Frontend could not resolve an account | Same account |
| Shared media API, page size 1 | Not measured separately | Success, one item returned |
## Automated checks
From the repository root:
```sh
cargo test --offline -p tauri-plugin-http artcraft_origin --lib
./script/artcraft/unix_dev.sh
```
In a second terminal, substitute the URL printed by the launcher:
```sh
cd frontend
npm run test:desktop-session -- http://127.0.0.1:5193
```
The Rust tests cover the default port, alternate/collision ports, IPv4, IPv6,
localhost, packaged origins, duplicate headers, session-header preservation,
and destinations/origins that must remain unchanged.
The browser check uses the real frontend with an isolated Chrome profile and
mocked IPC. It verifies matching credits and Accounts state, a visible Library
image, media after opening the deferred image page, video/audio page rendering,
and logout followed by sign-in. It blocks remote browser traffic and never uses
your real account or native credentials. It requires installed Google Chrome.
It can also run against a locally served production frontend build.
These browser mocks cannot validate native transport or server CORS. The
performance fixture deliberately rejects HTTP requests and is **not** an
authentication acceptance test. Run the native check below when changing the
launcher, HTTP bridge, or authentication plumbing.
Validation on September 27: all 12 HTTP-plugin tests, 20 login-modal tests, and
10 app tests passed. The browser check passed against both the Vite dev server
and a fresh production build. To repeat the production check, serve a build in
one terminal, then pass its printed URL to `test:desktop-session` in another:
```sh
cd frontend/apps/artcraft
npm run build -- --outDir /tmp/artcraft-session-build
npm run preview -- --outDir /tmp/artcraft-session-build --host 127.0.0.1 --port 6215
```
## Repeat the real native check
1. Start the combined launcher, with an existing signed-in test session. Verify
Accounts displays that account, My Library loads its media, and Create Image,
Create Video, and Create Audio load their content.
2. Right-click inside the desktop webview, choose **Inspect Element**, and open
**Console**. Run this read-only probe. It prints statuses/counts, not credentials
or account/media contents:
```js
void (async () => {
const invoke = window.__TAURI_INTERNALS__.invoke;
const host = (await invoke("get_app_info_command")).payload.storyteller_host;
const options = { method: "GET", credentials: "include" };
const started = performance.now();
const [native, response] = await Promise.all([
invoke("storyteller_get_login_session_command"),
window.FetchProxy(`${host}/v1/session`, { ...options }),
]);
if (!response.ok) {
console.log({ origin: location.origin, sessionStatus: response.status });
return;
}
const session = await response.json();
const result = {
origin: location.origin,
sessionStatus: response.status,
nativeLoggedIn: !!native,
frontendLoggedIn: session.logged_in,
sameAccount: native?.user_token === session.user?.user_token,
sessionElapsedMs: performance.now() - started,
};
if (session.logged_in && session.user) {
const username = encodeURIComponent(session.user.username);
const media = await window.FetchProxy(
`${host}/v1/media_files/list/user/${username}?page_size=1`, { ...options },
);
result.mediaStatus = media.status;
if (media.ok) result.mediaCount = (await media.json()).results?.length;
}
console.log(result);
})().catch((error) => console.error("Desktop session probe failed", String(error)));
```
3. Repeat with a changed port (`ARTCRAFT_DEV_PORT=6200`) and with the starting
port occupied. For example, keep this listener running in another terminal
before starting the launcher; stop it with Ctrl-C afterward:
```sh
node -e 'require("node:net").createServer().listen(5193, "127.0.0.1")'
```
The launcher should select a later port, and both session transports should
still agree. An empty account may legitimately return zero media; it should
still return HTTP 200. Verify a genuinely signed-out session shows the login
screen using the isolated browser check, or a disposable account/profile.
+5
View File
@@ -6,6 +6,11 @@ headless Chrome with mocked Tauri services. They do **not** measure native
process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
or generation latency.
These timings are not authentication acceptance tests: the benchmark mocks the
native session and rejects frontend HTTP requests. Before accepting a desktop
change, also run the [session and Library checks](desktop-session-regression.md),
including real Tauri HTTP requests when changing the launcher or bridge.
## Changes and results
Seven samples per version; table values are medians. Each sample uses a fresh
@@ -8,13 +8,14 @@ import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "../../pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api";
import { MediaUploadApi } from "@storyteller/api";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
GenerateMesh,
EnqueueImageTo3dObjectModel,
GenerateSplat,
commandErrorMessage,
downloadFileFromUrl,
} from "@storyteller/tauri-api";
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
@@ -2,12 +2,15 @@ import toast from "react-hot-toast";
import {
FilterMediaClasses,
PromptsApi,
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
import {
DownloadUrl,
useModelsStore,
downloadUrlToPath,
pickDownloadDirectory,
promptDownloadLocationIfNeeded,
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
import { DownloadUrl, useModelsStore } from "@storyteller/tauri-api";
} from "@storyteller/tauri-api";
import {
RefImage,
RefVideo,
@@ -35,6 +35,11 @@ import {
const POLL_INTERVAL_MS = 5000;
// Newly completed videos that arrived without a thumbnail URL are looked up
// again on this interval, for up to MISSING_THUMBNAIL_GIVE_UP_MS.
const MISSING_THUMBNAIL_POLL_MS = 15_000;
const MISSING_THUMBNAIL_GIVE_UP_MS = 10 * 60_000;
const FAILED_STATUSES = new Set([
"complete_failure",
"attempt_failed",
@@ -197,6 +202,71 @@ export function useDesktopGenerationFeed(options: {
load();
});
// Newly completed videos occasionally arrive without a thumbnail URL (the
// Rust media-file lookup fails open). The library copy would have it, but the
// newly completed item takes precedence in the feed, so look it up again
// until the URL shows up. GalleryThumbnail then re-checks the image itself.
const newlyCompletedRef = useRef(newlyCompleted);
newlyCompletedRef.current = newlyCompleted;
const missingThumbnailSinceRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
if (mediaType !== "video") return;
let cancelled = false;
let running = false;
const lookUpMissingThumbnails = async () => {
if (running) return; // A slow round is still going.
running = true;
try {
await lookUpMissingThumbnailsOnce();
} finally {
running = false;
}
};
const lookUpMissingThumbnailsOnce = async () => {
const now = Date.now();
const missing = newlyCompletedRef.current.filter((item) => {
if (item.thumbnail) return false;
const since = missingThumbnailSinceRef.current.get(item.id) ?? now;
missingThumbnailSinceRef.current.set(item.id, since);
return now - since < MISSING_THUMBNAIL_GIVE_UP_MS;
});
for (const item of missing) {
const response = await mediaApiRef.current.GetMediaFileByToken({
mediaFileToken: item.id,
});
if (cancelled) return;
const links = response.success
? (response.data?.media_links as VideoMediaLinks | undefined)
: undefined;
// Without a preview or template, getMediaThumbnail would fall back to
// the .mp4 itself, which an <img> can't show. Try again next round.
if (!links?.maybe_video_previews?.animated && !links?.maybe_thumbnail_template) {
continue;
}
const thumbnail = getMediaThumbnail(links, "video", {
size: THUMBNAIL_SIZES.LARGE,
});
const stillThumbnail = getMediaStillThumbnail(links, {
size: THUMBNAIL_SIZES.LARGE,
});
setNewlyCompleted((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, thumbnail, stillThumbnail } : i,
),
);
}
};
const intervalId = setInterval(lookUpMissingThumbnails, MISSING_THUMBNAIL_POLL_MS);
return () => {
cancelled = true;
clearInterval(intervalId);
};
}, [mediaType]);
// Poll + reload on Tauri generation events and local "task-queue-update"
// dispatches (fired by the pages right after enqueueing).
useEffect(() => {
@@ -249,6 +319,20 @@ export function useDesktopGenerationFeed(options: {
};
}
// ── Types ───────────────────────────────────────────────────────────────────
// The media-file API's link fields used for video thumbnails. (The shared
// MediaFile type predates these fields.)
interface VideoMediaLinks {
cdn_url?: string | null;
maybe_thumbnail_template?: string | null;
maybe_video_previews?: {
animated?: string | null;
still?: string | null;
still_thumbnail_template?: string | null;
} | null;
}
// ── Task mapping helpers ────────────────────────────────────────────────────
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
@@ -596,6 +596,7 @@ export const TopBar = ({ pageName }: Props) => {
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
aria-label="Settings"
variant="secondary"
icon={SettingsIcon}
iconClassName="h-5 w-5 shrink-0"
@@ -5,7 +5,6 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
MediaUploadApi,
MediaFilesApi,
downloadFileFromUrl,
} from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
@@ -25,7 +24,7 @@ import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { SliderV2 } from "@storyteller/ui-sliderv2";
// import { Switch } from "@headlessui/react";
import { GenerateImage } from "@storyteller/tauri-api";
import { GenerateImage, downloadFileFromUrl } from "@storyteller/tauri-api";
import {
ClassyModelSelector,
useAnglesPageModelList,
@@ -1,5 +1,5 @@
import { ImagesIcon, MaximizeIcon, PencilIcon } from "lucide-react";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { DropletIcon, ImagesIcon, RotateCwIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { DownloadIcon, EyeIcon, ImagesIcon, PlusIcon, UploadIcon, WandSparklesIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
@@ -13,6 +12,7 @@ import {
import {
EnqueueImageBgRemoval,
useCanvasBgRemovedEvent,
downloadFileFromUrl,
} from "@storyteller/tauri-api";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { twMerge } from "tailwind-merge";
@@ -3,10 +3,7 @@ import type {
ExportSinkAdapter,
} from "@storyteller/ui-video-editor";
import { kindFromMime } from "@storyteller/ui-video-editor";
import {
promptDownloadLocationIfNeeded,
downloadUrlToPath,
} from "@storyteller/api";
import { promptDownloadLocationIfNeeded, downloadUrlToPath } from "@storyteller/tauri-api";
import { downloadDir } from "@tauri-apps/api/path";
import { uploadByKind } from "./upload-by-kind";
@@ -4,10 +4,10 @@ import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
downloadFileFromUrl,
MediaUploadApi,
EIntermediateFile,
} from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
@@ -3,7 +3,7 @@ import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIco
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
+2
View File
@@ -1,4 +1,5 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
import { resolve } from "node:path";
@@ -58,6 +59,7 @@ export default defineConfig({
},
},
plugins: [
react(),
sparkWasmDataUrlFix(),
tsconfigPaths({
// Resolve workspace packages to source for every importer, including shared
+1 -5
View File
@@ -14,9 +14,5 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
}
}
-1
View File
@@ -11,7 +11,6 @@ export * from "./lib/JobsApi.js";
export * from "./lib/MediaFilesApi.js";
export * from "./lib/MediaUploadApi.js";
export * from "./lib/MiscApi.js";
export * from "./lib/LocalApi.js";
export * from "./lib/ProjectsApi.js";
export * from "./lib/PromptsApi.js";
export * from "./lib/TagsApi.js";
@@ -1,41 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { LoaderCircleIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { useGalleryViewStore } from "./gallery-view-store";
import {
markVideoThumbnailFailed,
useVideoThumbnailState,
} from "./pending-video-thumbnails";
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
// video thumbnails 404 until the render job finishes (so we retry on a timer
// and on tab refocus), and broken image URLs fall back to a placeholder.
// ── Shared visibility listener for video thumbnail retries ────────────────
const visibilityCallbacks = new Set<() => void>();
function onTabVisible(cb: () => void) {
visibilityCallbacks.add(cb);
if (visibilityCallbacks.size === 1) {
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
}
return () => {
visibilityCallbacks.delete(cb);
if (visibilityCallbacks.size === 0) {
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
}
};
}
function fireVisibilityCallbacks() {
if (document.hidden) return;
visibilityCallbacks.forEach((cb) => cb());
}
// ── Retry constants ────────────────────────────────────────────────────────
const MAX_RETRIES = 20;
const RETRY_INTERVAL = 5000;
// video thumbnails 404 until the render job finishes (so failed ones are
// marked and re-checked in the background, see pending-video-thumbnails), and
// broken image URLs fall back to a placeholder.
// ── Component ──────────────────────────────────────────────────────────────
@@ -70,71 +48,17 @@ export function GalleryThumbnail({
}: GalleryThumbnailProps) {
const autoplayVideos = useGalleryViewStore((s) => s.autoplayVideos);
// With autoplay off, videos freeze on their still first frame. The still is
// also what the retry machinery below polls in that mode.
// also what gets re-checked in that mode.
const thumbnail =
isVideo && !autoplayVideos && stillThumbnail
? stillThumbnail
: animatedThumbnail;
// "retrying" flips to true only after the first error, so videos with ready
// thumbnails render the normal <img> path with zero overhead. While retrying,
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
const [retrying, setRetrying] = useState(false);
const retryImgRef = useRef<HTMLImageElement>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const retryCountRef = useRef(0);
const kickRetry = useCallback(() => {
if (!retryImgRef.current || !thumbnail) return;
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
}, [thumbnail]);
const scheduleRetry = useCallback(() => {
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
if (document.hidden) return;
retryTimerRef.current = setTimeout(() => {
retryCountRef.current++;
kickRetry();
}, RETRY_INTERVAL);
}, [thumbnail, kickRetry]);
// Subscribe to the shared visibility listener while a video card is retrying.
useEffect(() => {
if (!isVideo || !thumbnail || !retrying) return;
const unsubscribe = onTabVisible(() => {
retryCountRef.current = 0;
kickRetry();
});
return () => {
unsubscribe();
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [isVideo, thumbnail, retrying, kickRetry]);
// Reset retry state when the thumbnail URL changes.
useEffect(() => {
if (!isVideo) return;
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}, [isVideo, thumbnail]);
const handleLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo && retrying) {
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}
onLoad?.(e);
},
[isVideo, retrying, onLoad],
);
const videoState = useVideoThumbnailState(isVideo ? thumbnail : null);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo) {
setRetrying(true);
scheduleRetry();
if (thumbnail) markVideoThumbnailFailed(thumbnail);
} else {
const target = e.currentTarget;
if (target.dataset.fallback) return;
@@ -143,43 +67,32 @@ export function GalleryThumbnail({
target.style.opacity = "0.3";
}
},
[isVideo, scheduleRetry],
[isVideo, thumbnail],
);
if (retrying) {
if (videoState?.status === "pending") {
return (
<>
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<LoaderCircleIcon
className="animate-spin text-lg text-white/30" />
{showRetryLabel && (
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
)}
</div>
{/* Hidden img retries in the background via ref — zero re-renders */}
<img
ref={retryImgRef}
src={thumbnail!}
alt=""
className="absolute h-0 w-0 opacity-0"
aria-hidden
onLoad={handleLoad}
onError={handleError}
/>
</>
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<LoaderCircleIcon className="animate-spin text-lg text-white/30" />
{showRetryLabel && (
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
)}
</div>
);
}
if (thumbnail) {
// Loaded after a re-check: render the (cache-busted) URL that worked.
const src = videoState?.status === "ready" ? videoState.src : thumbnail;
if (src && videoState?.status !== "gave_up") {
return (
<img
src={thumbnail}
src={src}
alt={alt}
loading="lazy"
decoding="async"
className={imgClassName}
onLoad={handleLoad}
onLoad={onLoad}
onError={handleError}
/>
);
@@ -0,0 +1,173 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VideoIcon } from "lucide-react";
import {
getVideoThumbnailState,
markVideoThumbnailFailed,
resetVideoThumbnailsForTests,
} from "./pending-video-thumbnails";
import { GalleryThumbnail } from "./GalleryThumbnail";
vi.mock("@storyteller/icons", () => ({
DynamicIcon: () => <span data-testid="fallback-icon" />,
}));
vi.mock("@storyteller/common", () => ({
PLACEHOLDER_IMAGES: { DEFAULT: "placeholder.png" },
}));
const THUMB =
"https://cdn-2.fakeyou.com/cdn-cgi/image/width=512,quality=95/media/a/b/storyteller_x.mp4-thumb.gif";
// Probes made by the checker; a test settles each one by hand.
let probes: FakeImage[] = [];
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = "";
constructor() {
probes.push(this);
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("Image", FakeImage);
probes = [];
resetVideoThumbnailsForTests();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("checker", () => {
it("re-checks a failed thumbnail and hands back the URL that loaded", () => {
markVideoThumbnailFailed(THUMB);
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
vi.advanceTimersByTime(5_000);
expect(probes).toHaveLength(1);
expect(probes[0].src).toMatch(/^https:\/\/cdn-2\.fakeyou\.com\/.*\.mp4-thumb\.gif\?_r=\d+$/);
probes[0].onload!();
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: probes[0].src });
});
it("keeps checking past the old 20-retry limit, backing off", () => {
markVideoThumbnailFailed(THUMB);
failProbesFor(2 * 60_000);
expect(probes).toHaveLength(24); // Every 5s for the first two minutes.
failProbesFor(8 * 60_000);
expect(probes).toHaveLength(24 + 32); // Then every 15s.
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
});
it("gives up after an hour", () => {
markVideoThumbnailFailed(THUMB);
failProbesFor(60 * 60_000 + 60_000);
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "gave_up" });
const probeCount = probes.length;
vi.advanceTimersByTime(10 * 60_000);
expect(probes).toHaveLength(probeCount);
});
it("probes a URL once however many cards mark it", () => {
markVideoThumbnailFailed(THUMB);
markVideoThumbnailFailed(THUMB);
vi.advanceTimersByTime(5_000);
markVideoThumbnailFailed(THUMB);
vi.advanceTimersByTime(5_000);
expect(probes).toHaveLength(1); // The first probe is still in flight.
});
it("checks right away when the window regains focus", () => {
markVideoThumbnailFailed(THUMB);
failProbesFor(3 * 60_000); // Now on the 15s interval.
const probeCount = probes.length;
vi.advanceTimersByTime(1_000);
window.dispatchEvent(new Event("focus"));
vi.advanceTimersByTime(0);
expect(probes).toHaveLength(probeCount + 1);
});
it("pauses while the window is hidden", () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
markVideoThumbnailFailed(THUMB);
vi.advanceTimersByTime(60_000);
expect(probes).toHaveLength(0);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.advanceTimersByTime(0);
expect(probes).toHaveLength(1);
});
});
describe("GalleryThumbnail", () => {
it("shows a spinner for a failed video thumbnail, then swaps in the loaded one", () => {
renderVideoThumbnail();
fireEvent.error(screen.getByRole("img"));
expect(screen.getByText("Loading thumbnail…")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(5_000);
probes[0].onerror!();
vi.advanceTimersByTime(5_000);
probes[1].onload!();
});
expect(screen.getByRole("img").getAttribute("src")).toBe(probes[1].src);
});
it("renders an already-recovered thumbnail straight away when remounted", () => {
markVideoThumbnailFailed(THUMB);
vi.advanceTimersByTime(5_000);
probes[0].onload!();
renderVideoThumbnail();
expect(screen.getByRole("img").getAttribute("src")).toBe(probes[0].src);
});
it("falls back to the video icon once the thumbnail is given up on", () => {
markVideoThumbnailFailed(THUMB);
failProbesFor(60 * 60_000 + 60_000);
renderVideoThumbnail();
expect(screen.getByTestId("fallback-icon")).toBeTruthy();
});
it("leaves image thumbnails on the placeholder fallback", () => {
render(
<GalleryThumbnail thumbnail={THUMB} alt="Image" isVideo={false} fallbackIcon={VideoIcon} />,
);
fireEvent.error(screen.getByRole("img"));
expect(screen.getByRole("img").getAttribute("src")).toBe("placeholder.png");
expect(getVideoThumbnailState(THUMB)).toBeUndefined();
});
});
function renderVideoThumbnail() {
render(
<GalleryThumbnail thumbnail={THUMB} alt="Video" isVideo fallbackIcon={VideoIcon} />,
);
}
// Advance time, failing every probe the checker starts along the way.
function failProbesFor(ms: number) {
const end = Date.now() + ms;
while (Date.now() < end) {
vi.advanceTimersByTime(1_000);
for (const probe of probes) {
if (probe.onerror) {
const onerror = probe.onerror;
probe.onerror = null;
onerror();
}
}
}
}
@@ -0,0 +1,200 @@
import { useSyncExternalStore } from "react";
// Freshly generated video thumbnails are rendered asynchronously after the
// video lands, so the first <img> load usually 404s. A card whose video
// thumbnail fails marks it here; one shared checker then probes every marked
// URL on a backoff until it loads, and subscribed cards swap it in.
//
// Probes add a cache-busting query param (the CDN may have cached the 404 for
// the plain URL), and the URL that loaded is the one handed back to render.
// ── Check schedule ────────────────────────────────────────────────────────
// How often to re-check a marked thumbnail, by time since it was marked.
const CHECK_SCHEDULE: { untilMs: number; everyMs: number }[] = [
{ untilMs: 2 * 60_000, everyMs: 5_000 },
{ untilMs: 10 * 60_000, everyMs: 15_000 },
{ untilMs: 60 * 60_000, everyMs: 60_000 },
];
// Past the last window the thumbnail is treated as never coming.
const GIVE_UP_AFTER_MS = CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1].untilMs;
// ── Types ─────────────────────────────────────────────────────────────────
export type VideoThumbnailState =
// Render this src (the original URL, or the cache-busted URL that loaded).
| { status: "ready"; src: string }
// Failed to load; being re-checked in the background.
| { status: "pending" }
// Never loaded within GIVE_UP_AFTER_MS.
| { status: "gave_up" };
interface PendingEntry {
markedAt: number;
nextCheckAt: number;
probing: boolean;
}
// ── Registry state ────────────────────────────────────────────────────────
const PENDING_STATE: VideoThumbnailState = { status: "pending" };
const GAVE_UP_STATE: VideoThumbnailState = { status: "gave_up" };
const pending = new Map<string, PendingEntry>();
// Settled states, kept for the session so remounted cards skip the 404.
const settled = new Map<string, VideoThumbnailState>();
const listeners = new Map<string, Set<() => void>>();
let timer: ReturnType<typeof setTimeout> | undefined;
let wakeListenersAttached = false;
// ── Public API ────────────────────────────────────────────────────────────
/** Mark a video thumbnail URL as failed so it gets re-checked. */
export function markVideoThumbnailFailed(url: string) {
if (pending.has(url) || settled.get(url) === GAVE_UP_STATE) return;
const now = Date.now();
// Also covers a previously loaded src failing again: start over.
settled.delete(url);
pending.set(url, {
markedAt: now,
nextCheckAt: now + CHECK_SCHEDULE[0].everyMs,
probing: false,
});
attachWakeListeners();
notify(url);
scheduleNextTick();
}
export function getVideoThumbnailState(
url: string,
): VideoThumbnailState | undefined {
if (pending.has(url)) return PENDING_STATE;
return settled.get(url);
}
/**
* The render state for a video thumbnail URL. Untracked URLs render as-is.
* Pass null to opt out (non-video media, or no thumbnail).
*/
export function useVideoThumbnailState(
url: string | null,
): VideoThumbnailState | undefined {
return useSyncExternalStore(
(onChange) => (url ? subscribe(url, onChange) : () => {}),
() => (url ? getVideoThumbnailState(url) : undefined),
);
}
/** Test-only: forget all tracked thumbnails and stop the checker. */
export function resetVideoThumbnailsForTests() {
pending.clear();
settled.clear();
listeners.clear();
if (timer) clearTimeout(timer);
timer = undefined;
}
// ── Checker ───────────────────────────────────────────────────────────────
function scheduleNextTick() {
if (timer) clearTimeout(timer);
timer = undefined;
if (pending.size === 0) return;
let earliest = Infinity;
for (const entry of pending.values()) {
if (!entry.probing) earliest = Math.min(earliest, entry.nextCheckAt);
}
if (earliest === Infinity) return; // Every entry is mid-probe.
timer = setTimeout(tick, Math.max(0, earliest - Date.now()));
}
function tick() {
timer = undefined;
// Paused while the window is hidden; the wake listeners resume it.
if (typeof document !== "undefined" && document.hidden) return;
const now = Date.now();
for (const [url, entry] of pending) {
if (entry.probing || entry.nextCheckAt > now) continue;
if (now - entry.markedAt >= GIVE_UP_AFTER_MS) {
pending.delete(url);
settled.set(url, GAVE_UP_STATE);
notify(url);
continue;
}
probe(url, entry);
}
scheduleNextTick();
}
function probe(url: string, entry: PendingEntry) {
entry.probing = true;
const src = withCacheBust(url);
const img = new Image();
img.onload = () => {
if (pending.get(url) !== entry) return; // Reset while in flight.
pending.delete(url);
settled.set(url, { status: "ready", src });
notify(url);
scheduleNextTick();
};
img.onerror = () => {
if (pending.get(url) !== entry) return;
entry.probing = false;
entry.nextCheckAt = Date.now() + checkInterval(Date.now() - entry.markedAt);
scheduleNextTick();
};
img.src = src;
}
function checkInterval(ageMs: number): number {
const slot = CHECK_SCHEDULE.find((w) => ageMs < w.untilMs);
return (slot ?? CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1]).everyMs;
}
// Re-check everything immediately when the app comes back into view or focus
// (the desktop window rarely fires visibilitychange, but does fire focus).
function attachWakeListeners() {
if (wakeListenersAttached || typeof window === "undefined") return;
wakeListenersAttached = true;
const wake = () => {
if (document.hidden || pending.size === 0) return;
const now = Date.now();
for (const entry of pending.values()) {
entry.nextCheckAt = Math.min(entry.nextCheckAt, now);
}
scheduleNextTick();
};
document.addEventListener("visibilitychange", wake);
window.addEventListener("focus", wake);
}
// ── Subscriptions ─────────────────────────────────────────────────────────
function subscribe(url: string, onChange: () => void) {
let set = listeners.get(url);
if (!set) {
set = new Set();
listeners.set(url, set);
}
set.add(onChange);
return () => {
set.delete(onChange);
if (set.size === 0) listeners.delete(url);
};
}
function notify(url: string) {
listeners.get(url)?.forEach((cb) => cb());
}
function withCacheBust(url: string): string {
try {
const parsed = new URL(url);
parsed.searchParams.set("_r", Date.now().toString());
return parsed.toString();
} catch {
return `${url}${url.includes("?") ? "&" : "?"}_r=${Date.now()}`;
}
}
@@ -43,9 +43,6 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../../icons/tsconfig.lib.json"
},
@@ -56,7 +53,7 @@
"path": "../input/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
"path": "../../api/tsconfig.lib.json"
}
]
}
@@ -6,8 +6,8 @@ import {
CharactersApi,
Character,
MediaUploadApi,
downloadFileFromUrl,
} from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { toast } from "@storyteller/ui-toaster";
import { v4 as uuidv4 } from "uuid";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
@@ -9,7 +9,7 @@ import {
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { ImageIcon, ImagesIcon, LoaderCircleIcon, MusicIcon, PlayIcon, PlusIcon, SquareIcon, Trash2Icon, VideoIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { RefImage, RefVideo, RefAudio } from "./promptStore";
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
import { type UploadMediaFn } from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { toast } from "@storyteller/ui-toaster";
import { UploaderStates } from "@storyteller/common";
import {
@@ -9,6 +9,8 @@ vi.mock("@storyteller/tauri-api", () => ({
GetAppPreferences: api.get,
UpdateAppPreferences: api.update,
DownloadDirectoryReveal: vi.fn(),
getAskLocationBeforeDownload: () => false,
setAskLocationBeforeDownload: vi.fn(),
PreferenceName: {
AutoDownload: "auto_download",
PreferredDownloadDirectory: "preferred_download_directory",
@@ -16,10 +18,6 @@ vi.mock("@storyteller/tauri-api", () => ({
},
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("@storyteller/api", () => ({
getAskLocationBeforeDownload: () => false,
setAskLocationBeforeDownload: vi.fn(),
}));
beforeEach(() => {
vi.stubGlobal("ResizeObserver", class {
@@ -9,16 +9,14 @@ import {
PreferredDownloadFilename,
SystemDirectory,
UpdateAppPreferences,
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/tauri-api";
import { open } from "@tauri-apps/plugin-dialog";
import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { Select } from "@storyteller/ui-select";
import { FolderIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
} from "@storyteller/api";
const DEFAULT_CUSTOM_FORMAT = "{model}_{date}";
const FILENAME_OPTIONS = [
+3 -1
View File
@@ -15,6 +15,8 @@
}
},
"dependencies": {
"@tauri-apps/api": "^2.11.0"
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
}
+1
View File
@@ -6,6 +6,7 @@ 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/download/LocalDownloads.js";
export * from "./lib/enqueue/EnqueueEditImage.js";
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
export * from "./lib/enqueue/EnqueueImageInpaint.js";
@@ -1,7 +1,7 @@
import { download } from "@tauri-apps/plugin-upload";
import { open, save } from "@tauri-apps/plugin-dialog";
import { DownloadUrl, GetDownloadPath } from "@storyteller/tauri-api";
import { DownloadUrl, GetDownloadPath } from "./DownloadUrl";
const ASK_LOCATION_BEFORE_DOWNLOAD_KEY = "artcraft_ask_location_before_download";
+4 -6
View File
@@ -443,11 +443,7 @@
},
"libs/api": {
"name": "@storyteller/api",
"version": "0.0.1",
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
"version": "0.0.1"
},
"libs/api-enums": {
"name": "@storyteller/api-enums",
@@ -681,7 +677,9 @@
"name": "@storyteller/tauri-api",
"version": "0.0.1",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
},
"libs/tauri-events": {
+1
View File
@@ -3,6 +3,7 @@
"version": "0.0.0",
"license": "MIT",
"scripts": {
"test:desktop-session": "node tools/testing/desktop-session.mjs",
"perf:desktop": "node tools/performance/run.mjs"
},
"private": true,
+149
View File
@@ -0,0 +1,149 @@
// Invoked by script/artcraft/unix_dev.sh. Adapted from ArtcraftX's launcher.
// Living under frontend lets Node resolve the local Vite installation.
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const frontendDir = path.join(rootDir, "frontend");
const appDir = path.join(frontendDir, "apps/artcraft");
const rustDir = path.join(rootDir, "crates/desktop/artcraft");
const host = "127.0.0.1";
const startingPort = Number(process.env.ARTCRAFT_DEV_PORT ?? "5193");
let vite;
let rust;
let stopping = false;
async function main() {
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
throw new Error("ARTCRAFT_DEV_PORT must be an integer between 1024 and 65535.");
}
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
// Match the app build's cwd, including Tailwind config/content resolution.
process.chdir(appDir);
vite = await createServer({
configFile: path.join(appDir, "vite.config.ts"),
clearScreen: false,
server: {
host,
port: startingPort,
// Vite retries occupied ports while binding the real server. There is no
// port probe/release/rebind race, and no existing process is terminated.
strictPort: false,
open: false,
hmr: true,
},
});
if (stopping) return;
await vite.listen();
const { port } = vite.httpServer.address();
const devUrl = `http://${host}:${port}`;
// Only initial startup may choose another port. On a Vite config restart,
// rebind this port or fail visibly so Tauri never follows a silent port hop.
for (const options of [vite.config.server, vite.config.inlineConfig.server]) {
options.port = port;
options.strictPort = true;
}
const configPath = path.join(rustDir, "tauri-dev-hot-reload.conf.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
if (stopping) return;
const csp = config.app.security.csp;
const overrides = {
build: { devUrl, beforeDevCommand: "" },
app: {
security: {
devCsp: {
...csp,
"connect-src": [...csp["connect-src"], devUrl, `ws://${host}:${port}`],
},
},
},
};
console.log(`\n[artcraft] Frontend + HMR: ${devUrl}`);
console.log("[artcraft] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
console.log("[artcraft] Press Ctrl-C to stop both.\n");
rust = spawn("cargo", [
"tauri", "dev",
"--no-dev-server",
// Tauri watches the desktop crate and its workspace dependencies itself.
// SQLx's external metadata and migrations also affect the Rust build.
"--additional-watch-folders", path.join(rootDir, ".sqlx"),
"--additional-watch-folders", path.join(rootDir, "_database/sql/artcraft_migrations"),
"--config", configPath,
"--config", JSON.stringify(overrides),
], {
cwd: rootDir,
// All cargo/rustc/app descendants inherit this private Unix process group.
detached: true,
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
TAURI_FRONTEND_PATH: frontendDir,
TAURI_APP_PATH: rustDir,
SQLX_OFFLINE: process.env.SQLX_OFFLINE ?? "true",
RUSTFLAGS: process.env.RUSTFLAGS ?? "-Awarnings",
WEBKIT_DISABLE_DMABUF_RENDERER: process.env.WEBKIT_DISABLE_DMABUF_RENDERER ?? "1",
WEBKIT_DISABLE_COMPOSITING_MODE: process.env.WEBKIT_DISABLE_COMPOSITING_MODE ?? "1",
},
});
rust.on("error", fail);
rust.on("exit", (code, signal) => {
if (!stopping) {
console.log(`[artcraft] Rust/Tauri exited (${signal ?? code}).`);
void stop(code ?? 1);
}
});
}
async function stop(exitCode) {
if (stopping) return;
stopping = true;
console.log("\n[artcraft] Stopping frontend and Rust/Tauri...");
const deadline = setTimeout(() => {
signalRustGroup("SIGKILL");
process.exit(exitCode);
}, 5000);
signalRustGroup("SIGTERM");
await Promise.allSettled([
vite?.close(),
(async () => {
while (signalRustGroup(0)) await delay(50);
})(),
]);
clearTimeout(deadline);
process.exit(exitCode);
}
function signalRustGroup(signal) {
if (!rust?.pid) return false;
try {
process.kill(-rust.pid, signal);
return true;
} catch (error) {
if (error.code !== "ESRCH") throw error;
return false;
}
}
function fail(error) {
console.error(`[artcraft] ${error.stack ?? error}`);
void stop(1);
}
process.on("SIGINT", () => void stop(130));
process.on("SIGTERM", () => void stop(143));
process.on("SIGHUP", () => void stop(129));
process.on("uncaughtException", fail);
process.on("unhandledRejection", fail);
main().catch(fail);
+188
View File
@@ -0,0 +1,188 @@
// Integration tests: real Vite sockets + a fake cargo process tree, no GUI/build.
// Run: node --test frontend/scripts/unix-dev.test.mjs
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { test } from "node:test";
import WebSocket from "ws";
import { fileURLToPath } from "node:url";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const launcher = path.join(rootDir, "script/artcraft/unix_dev.sh");
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const startingPort = occupied.address().port;
const run = await fixture(t, { ARTCRAFT_DEV_PORT: String(startingPort) });
const record = await run.record();
const config = overrides(record);
const url = config.build.devUrl;
assert.ok(Number(new URL(url).port) > startingPort);
assert.equal(new URL(url).hostname, "127.0.0.1");
assert.equal(config.build.beforeDevCommand, "");
assert.ok(!record.args.includes("--no-watch"));
assert.ok(!record.args.includes("--no-dev-server-wait"));
assert.equal(record.env.TAURI_APP_PATH, path.join(rootDir, "crates/desktop/artcraft"));
assert.equal(record.env.TAURI_FRONTEND_PATH, path.join(rootDir, "frontend"));
assert.ok(config.app.security.devCsp["connect-src"].includes(url.replace("http:", "ws:")));
assert.match(await (await fetch(url)).text(), /\/src\/index\.tsx/);
assert.match(await (await fetch(`${url}/@vite/client`)).text(), /WebSocket/);
run.proc.kill("SIGINT");
assert.deepEqual(await run.exited, [130, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(url));
assert.equal(occupied.listening, true, "must not terminate the existing listener");
});
test("Tauri failure propagates and stops Vite and leftover descendants", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { TEST_EXIT_CODE: "17" });
const record = await run.record();
assert.deepEqual(await run.exited, [17, null], run.output());
await until(() => !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("SIGTERM shuts down the complete process tree", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
run.proc.kill("SIGTERM");
assert.deepEqual(await run.exited, [143, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("invalid ports fail before starting Tauri", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { ARTCRAFT_DEV_PORT: "65536" });
assert.deepEqual(await run.exited, [1, null]);
assert.match(run.output(), /ARTCRAFT_DEV_PORT must be an integer/);
});
test("concurrent launchers use different ports without replacing either frontend", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const env = { ARTCRAFT_DEV_PORT: String(occupied.address().port) };
const first = await fixture(t, env);
const second = await fixture(t, env);
const [a, b] = await Promise.all([first.record(), second.record()]);
const firstUrl = overrides(a).build.devUrl;
const secondUrl = overrides(b).build.devUrl;
assert.notEqual(firstUrl, secondUrl);
assert.equal((await fetch(firstUrl)).status, 200);
assert.equal((await fetch(secondUrl)).status, 200);
first.proc.kill("SIGINT");
await first.exited;
assert.equal((await fetch(secondUrl)).status, 200);
assert.ok(alive(b.pid));
});
test("a React source edit sends HMR on the selected port without restarting Rust", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
const url = overrides(record).build.devUrl;
const probeDir = await mkdtemp(path.join(rootDir, "frontend/apps/artcraft/app/src/dev-probe-"));
t.after(() => rm(probeDir, { recursive: true, force: true }));
const probe = path.join(probeDir, "Probe.tsx");
const moduleUrl = `/src/${path.basename(probeDir)}/Probe.tsx`;
await writeFile(probe, "export default function Probe() { return <div>before</div>; }\n");
const code = await (await fetch(`${url}${moduleUrl}`)).text();
assert.match(code, /\$RefreshReg\$/);
const client = await (await fetch(`${url}/@vite/client`)).text();
const token = client.match(/const wsToken = "([^"]+)"/)[1];
const ws = new WebSocket(`${url.replace("http:", "ws:")}/?token=${token}`, "vite-hmr");
t.after(() => ws.terminate());
const messages = [];
ws.on("message", (data) => messages.push(JSON.parse(data.toString())));
await once(ws, "open");
await writeFile(probe, "export default function Probe() { return <div>after</div>; }\n");
await until(() => messages.some((m) => m.type === "update" && m.updates.some((u) => u.path === moduleUrl)));
assert.match(await (await fetch(`${url}${moduleUrl}`)).text(), /after/);
assert.ok(alive(record.pid), "JS edits must leave the Rust watcher running");
assert.equal((await run.record()).pid, record.pid);
});
test("Rust watcher arguments include SQLx inputs and preserve caller overrides", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { SQLX_OFFLINE: "false", RUSTFLAGS: "-C debuginfo=1" });
const record = await run.record();
assert.equal(record.env.SQLX_OFFLINE, "false");
assert.equal(record.env.RUSTFLAGS, "-C debuginfo=1");
assert.ok(record.args.includes(path.join(rootDir, ".sqlx")));
assert.ok(record.args.includes(path.join(rootDir, "_database/sql/artcraft_migrations")));
const ignores = await readFile(path.join(rootDir, "crates/desktop/artcraft/.taurignore"), "utf8");
assert.doesNotMatch(ignores, /^(crates\/|Cargo\.toml|Cargo\.lock)$/m);
});
async function until(check) {
for (let attempt = 0; attempt < 200; attempt++) {
const result = await check();
if (result) return result;
await delay(50);
}
throw new Error("Timed out waiting for dev launcher");
}
async function fixture(t, env = {}) {
const dir = await mkdtemp(path.join(os.tmpdir(), "artcraft-dev-test-"));
t.after(() => rm(dir, { recursive: true, force: true }));
await writeFile(path.join(dir, "cargo"), `#!/usr/bin/env node
const fs = require("node:fs");
const { spawn } = require("node:child_process");
if (process.argv.includes("--version")) process.exit(0);
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(process.env.TEST_RECORD, JSON.stringify({
args: process.argv.slice(2), cwd: process.cwd(),
env: Object.fromEntries(["TAURI_APP_PATH", "TAURI_FRONTEND_PATH", "SQLX_OFFLINE", "RUSTFLAGS"].map(key => [key, process.env[key]])),
pid: process.pid, childPid: child.pid,
}));
if (process.env.TEST_EXIT_CODE) setTimeout(() => process.exit(Number(process.env.TEST_EXIT_CODE)), 200);
setInterval(() => {}, 1000);
`, { mode: 0o755 });
const recordFile = path.join(dir, "record.json");
const proc = spawn(launcher, [], {
// Prove the launcher is independent of the invoking working directory.
cwd: dir,
env: { ...process.env, ...env, PATH: `${dir}:${process.env.PATH}`, TEST_RECORD: recordFile },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
proc.stdout.on("data", (chunk) => { output += chunk; });
proc.stderr.on("data", (chunk) => { output += chunk; });
const exited = once(proc, "exit");
t.after(async () => {
if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM");
await exited;
});
return {
proc, exited, output: () => output,
async record() {
return until(async () => {
if (proc.exitCode !== null) throw new Error(output);
try { return JSON.parse(await readFile(recordFile, "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; }
});
},
};
}
async function occupyPort(t) {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(() => new Promise((resolve) => server.close(resolve)));
return server;
}
function alive(pid) {
try { process.kill(pid, 0); return true; }
catch (error) { if (error.code === "ESRCH") return false; throw error; }
}
function overrides(record) {
return JSON.parse(record.args.at(-1));
}
+118
View File
@@ -0,0 +1,118 @@
// Real app UI with isolated, deterministic IPC responses. Run against a dev or
// preview URL. Native HTTP/CORS correctness is tested separately in Rust and in
// the real-webview procedure in docs/desktop-session-regression.md.
import assert from "node:assert/strict";
import { chromium, expect } from "@playwright/test";
import { installBrowserFixture } from "../performance/browser-fixture.mjs";
const url = new URL(process.argv[2] || "http://127.0.0.1:5193");
if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) {
throw new Error("Use the local development or preview URL printed by the launcher.");
}
const browser = await chromium.launch({ channel: "chrome", headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await context.addInitScript(installBrowserFixture);
await context.addInitScript(installSessionFixture);
await context.route("**/*", (route) => {
const target = route.request().url();
return target.startsWith(url.origin) || target.startsWith("blob:")
? route.continue() : route.abort();
});
const page = await context.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const home = async () => {
await page.goto(url.href);
await expect(page.getByRole("heading", { name: "What will you craft today?" })).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("button", { name: "42 Credits" })).toBeVisible();
};
const account = async () => {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Accounts", exact: true }).click();
await expect(page.getByText("Session Smoke Account", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Log Out", exact: true })).toBeVisible();
};
await home();
await account();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "My Library" }).click();
await expect(page.getByRole("img", { name: "Session smoke image", exact: true })).toBeVisible();
await expect.poll(() => page.getByRole("img", { name: "Session smoke image", exact: true })
.evaluate((image) => image.complete && image.naturalWidth > 0)).toBe(true);
await expect(page.getByText("Log in to browse and use your library.")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Create Image Generate AI images" }).click();
await expect(page.getByRole("img", { name: "Session smoke image", exact: true })).toBeVisible();
await account();
await page.keyboard.press("Escape");
for (const [card, heading] of [
["Create Video Create video from images", "Create Video"],
["Create Audio Generate music and sound effects", "Create Audio"],
]) {
await home();
await page.getByRole("button", { name: card }).click();
await expect(page.getByRole("heading", { name: heading, exact: true }).first()).toBeVisible();
}
await account();
await page.getByRole("button", { name: "Log Out", exact: true }).click();
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
await page.getByRole("button", { name: "Log in", exact: true }).click();
await page.getByPlaceholder("you@example.com or username").fill("session_smoke");
await page.getByPlaceholder("Min. 8 characters").fill("fixture-password-only");
await page.getByRole("button", { name: "Log in", exact: true }).click();
await expect(page.getByRole("heading", { name: "Welcome back" })).toHaveCount(0, { timeout: 10000 });
await account();
assert.deepEqual(errors, [], "Application runtime errors");
console.log("PASS: credits, Accounts, Library media, deferred image/video/audio pages, logout gate, and sign-in agree.");
} finally {
await browser.close();
}
function installSessionFixture() {
const invoke = window.__TAURI_INTERNALS__.invoke;
let loggedIn = true;
let nextResource = 0;
const responses = new Map();
const user = {
username: "session_smoke", display_name: "Session Smoke Account",
user_token: "u_session_smoke", can_access_studio: true, maybe_feature_flags: [],
};
const image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6mTQAAAAASUVORK5CYII=";
const media = {
token: "m_session_smoke", media_class: "image", media_type: "png",
maybe_title: "Session smoke image", created_at: new Date().toISOString(),
origin_category: "inference", origin: { origin_category: "inference" },
media_links: { cdn_url: image, maybe_thumbnail_template: image, thumbnail_template: image },
stats: { positive_rating_count: 0, bookmark_count: 0 },
};
window.__TAURI_INTERNALS__.invoke = async (command, args) => {
if (command === "storyteller_get_login_session_command") return loggedIn ? user : null;
if (command === "storyteller_password_login_command") { loggedIn = true; return user; }
if (command === "storyteller_purge_credentials_command") { loggedIn = false; return; }
if (command === "get_app_info_command") return { payload: { storyteller_host: "https://api.storyteller.ai" } };
if (command === "storyteller_get_credits_command") return { payload: { free_credits: 0, monthly_credits: 42, banked_credits: 0, sum_total_credits: 42 } };
if (command === "plugin:http|fetch") {
const request = new URL(args.clientConfig.url);
let body = { success: true, results: [], folders: [], tags: [], active_subscriptions: [] };
if (request.pathname === "/v1/session") body = { success: true, logged_in: loggedIn, user: loggedIn ? user : undefined };
if (request.pathname === "/v1/logout") loggedIn = false;
if (request.pathname === "/v1/media_files/list/user/session_smoke") body = { success: true, results: [media], pagination: { has_next: false } };
responses.set(++nextResource, { url: request.href, body });
return nextResource;
}
if (command === "plugin:http|fetch_send") {
const response = responses.get(args.rid);
return { rid: args.rid, status: 200, statusText: "OK", url: response.url, headers: [["content-type", "application/json"]] };
}
if (command === "plugin:http|fetch_read_body") {
const response = responses.get(args.rid);
args.streamChannel.onmessage([...new TextEncoder().encode(JSON.stringify(response.body)), 0]);
args.streamChannel.onmessage([1]);
responses.delete(args.rid);
return;
}
return invoke(command, args);
};
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Start the desktop frontend and Rust/Tauri watcher together on macOS or Linux.
set -euo pipefail
root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
frontend_path="${root_dir}/frontend"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'HELP'
Usage: ./script/artcraft/unix_dev.sh
Starts Vite with React/CSS hot reload and Tauri with Rust rebuild/restart.
Finds and binds a free loopback port starting at ARTCRAFT_DEV_PORT (default: 5193),
then passes that exact URL to Tauri. HMR shares the frontend port.
Rust uses native Tauri IPC and does not need a second HTTP port.
Ctrl-C stops this launcher's frontend, Rust watcher, and desktop app.
Requires Node.js 20+, npm, Rust, cargo-tauri 2, and Tauri platform prerequisites.
Installs frontend dependencies only when the local Vite installation is missing.
Run this command instead of running the two legacy dev scripts together.
HELP
exit 0
fi
if [[ $# -ne 0 ]]; then
echo "Unknown argument: $1 (see --help)" >&2
exit 1
fi
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${frontend_path}"
if ! command -v cargo >/dev/null || ! cargo tauri --version >/dev/null 2>&1; then
echo 'ERROR: Install Rust and the Tauri CLI: cargo install tauri-cli --version "^2" --locked' >&2
exit 1
fi
cd "${frontend_path}"
if [[ ! -f node_modules/vite/package.json ]]; then
# Do not use the legacy install helper: its failure recovery deletes caches.
npm install
fi
exec node "${frontend_path}/scripts/unix-dev.mjs"