Compare commits

...
Author SHA1 Message Date
BFlat 9ef9045da9 Use the new ArtCraft icon for the app icon and favicons
- Regenerate the desktop app icons (Windows, macOS, Store, Android,
  iOS) from the new angular ArtCraft mark in the website logo blue
- Replace favicon.ico, the 16/32 favicons, and the apple-touch icon
- Add the SVG icon as the primary favicon, same as the webapp
2026-09-28 13:12:31 +07:00
BFlat 90c7a8a118 Match desktop app styling to the webapp (#1963) 2026-09-28 12:29:54 +07:00
Brandon Thomas 1b27c31531 thumbnail speedup (and fix for non-rendering thumbnails) (#1964) 2026-09-28 01:28:45 -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
Brandon Thomas df707bf8e1 performance changes (#1959) 2026-09-26 17:50:36 -07:00
Brandon Thomas 780e4494e9 fix login video on desktop (#1958) 2026-09-26 17:08:31 -07:00
303 changed files with 6844 additions and 2645 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/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -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.
+224
View File
@@ -0,0 +1,224 @@
# Desktop frontend performance experiments
Measured September 26, 2026 on an Apple M4 Pro, macOS arm64, Chrome
153.0.8010.53, Node 24.13.0. These are production frontend measurements in
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
browser context at 1440 × 1000 with device scale 1 and browser HTTP cache
disabled. The browser process stays alive between samples. A reload in the
same context is measured separately. There is no CPU/network throttling.
External requests, including analytics, fonts, and media, are blocked in both
versions. The login fixture is signed in; the bundled model overlay is used.
| Measurement | Before | After | Reduction |
|-----------------------------|-----------|-----------|-----------|
| Startup to frame | 566.40 ms | 350.60 ms | 38.1% |
| Refresh to frame | 248.60 ms | 198.10 ms | 20.3% |
| Startup JavaScript | 9.05 MB | 6.47 MB | 28.6% |
| Startup long tasks (sum) | 336.00 ms | 184.00 ms | 45.2% |
| First drawing page to frame | 120.90 ms | 118.20 ms | 2.2% |
| Drawing to home: dispatch | 23.40 ms | 9.00 ms | 61.5% |
| Drawing to home: frame | 29.50 ms | 23.00 ms | 22.0% |
| Home to drawing: dispatch | 42.10 ms | 18.80 ms | 55.3% |
| Home to drawing: frame | 105.70 ms | 86.50 ms | 18.2% |
The 2.2% first-open difference is small enough to treat as unchanged. These
local measurements are evidence of reduced frontend work, not predictions
of identical percentage gains on every user's machine. Raw samples, ranges,
environment, fixture sizes, browser errors, and build provenance are in
[before.json](performance/2026-09-26-before.json) and
[after.json](performance/2026-09-26-after.json).
Three changes produced these results:
1. `MainApp.tsx` imported every editor at launch. Fifteen pages now use
`React.lazy` behind a Suspense boundary below the persistent shell. Their
code loads when selected; the top bar stays available during loading.
The default Apps page remains eager. Drawing also remains eager: its
shared code is already part of the shell, and experimentally deferring
its small host adapter made first-open latency worse for little saving.
First-open latency for the other deferred editors is a remaining tradeoff
to measure, especially on slower disks/CPUs. All 15 deferred modules were
imported successfully in the browser, but this is not an end-to-end test
of every editor's features.
2. `TabState.ts` exported the drawing scene to JSON when leaving and imported
it when returning. The Zustand scene store already survives page unmount.
With four imported images, the old path performed four FileReader reads
on exit and four more on return, allocated base64 copies, rebuilt image
nodes, cleared the selection, and appended an undo-history entry. Tab
changes now keep the live store. The measured round trip performs **zero
image-file reads**, preserves the selected node, and keeps history length
at one instead of growing to two. Explicit file export/import remains
available. Tests cover image identity, model metadata, selection, redo,
gallery reset, rapid navigation, and portable file export.
3. The shell ran `getGPUTier()` at startup and stored its result in an unread
React state value. Removing it eliminates **one startup WebGL context
request** and its associated detection work. The real 3D renderer still
creates its context when used. This experiment reports combined timing
gains, not an isolated timing attribution for each individual change.
## Repeat the experiment
From the repository root:
```sh
cd frontend
npm ci
npm run perf:desktop -- --output /tmp/artcraft-perf-current --smoke-imports
```
The default browser is an installed Google Chrome (`--channel chrome`). The
runner requires permission to launch a browser and bind a loopback server.
It never uses your regular browser profile, account, or native app data.
`@playwright/test` is pinned as a development dependency. Artifacts are written
outside the repository by default. Use a dedicated output directory: its
`dist` subdirectory is rebuilt by Vite.
For this particular two-module change, reproduce the original implementation
without replacing local source files:
```sh
npm run perf:desktop -- --output /tmp/artcraft-perf-before \
--baseline-ref 780e4494e995163fed1d677b88c3cf03b3fca63c
npm run perf:desktop -- --output /tmp/artcraft-perf-after --smoke-imports
node tools/performance/compare.mjs \
/tmp/artcraft-perf-before/results.json \
/tmp/artcraft-perf-after/results.json
```
`--baseline-ref` overlays **only** `MainApp.tsx` and `pages/Stores/TabState.ts`
from that Git revision in the benchmark build. All other source files and
dependencies remain current. It reproduces the two production files changed
in this experiment; it is not a general historical-checkout benchmark. For
future changes, capture a normal baseline **before** editing, use a new output
directory for the changed version, and keep the runner, fixture, dependencies,
browser, and machine identical. Run sequentially while the machine is idle;
do not build or run other CPU-heavy tasks during samples. Repeat in reverse
order if results are close or noisy.
Useful options:
```sh
# Build in a restricted shell, then run where browser/server use is allowed.
npm run perf:desktop -- --output /tmp/artcraft-perf-current --build-only
npm run perf:desktop -- --output /tmp/artcraft-perf-current --skip-build
# More samples; seven is the default.
npm run perf:desktop -- --output /tmp/artcraft-perf-current --runs 15
# Correctness checks for the navigation change.
npx vitest run --config apps/artcraft/vitest.config.ts
```
Do not use `--skip-build` after changing app code; it intentionally measures
the existing artifact. `build-info.json` records the revision, changed-file
names, baseline overlay, and build time. The benchmark plugin alone exposes
the stores needed to seed the scene. It is not part of the normal Vite/Tauri
build, and no mock IPC or benchmark global is added to the shipped app.
The runner builds from the app's working directory, matching its normal
build command. This matters because Tailwind resolves its config and content
relative to that directory. It rejects a run without working Tailwind styles
or with uncaught browser errors. `drawing.png` provides a visual check after
the measured operations. The initial unstyled exploratory runs were discarded.
## What the measurements mean
The fixture creates four deterministic 1024 × 1024 PNG image nodes and 80
strokes of 200 coordinates each, with one selected image. On the recorded
browser each PNG is 2,356,706 bytes. Fixture construction, image decoding,
and a one-second settling period are outside the tab-transition timings.
Each sample follows the same sequence:
1. Navigate to the production app, wait for the Apps heading and for the
signed-in login overlay to disappear, then wait two animation frames.
2. Reload that page and repeat the same readiness checks.
3. Seed the scene while on Apps, open drawing, and wait for its canvas and
two animation frames. Allow a second for its background bake to settle.
4. Switch to Apps and then back to drawing, measuring each separately.
5. After a settling period, inspect node count, selection, history, and IPC
counters. Save a screenshot on the first run. Optionally evaluate all
deferred page modules after the final timed operation.
`readyMs` is `performance.now()` after readiness checks and two animation
frames; its origin is navigation start. It includes browser loading and
automation readiness-observation overhead. `jsBytes` sums decoded resource
sizes of loaded JavaScript; it is uncompressed bytes, not all packaged files.
`longTaskMs` sums observed startup tasks longer than 50 ms, not total CPU time
or total blocking time. Long Tasks support is browser-dependent.
`stateReadyMs` measures the awaited `setActiveTab()` action, including React
work synchronously flushed by its store subscribers. `paintMs` continues
until the destination heading/canvas exists and two animation frames have
passed. This is a **rendering-opportunity proxy**, not proof of GPU presentation
or of every asynchronous image decode completing. In particular, the old
return path could still be decoding images after the canvas first appeared.
Tab measurements also create `artcraft:tab:APPS` and `artcraft:tab:2D` User
Timing entries in the benchmark page.
The runner calls the same store action used by navigation UI. It excludes
pointer-event dispatch, menu animations, and physical click-to-screen latency.
It does not automate paid generations, sign in to a real account, or measure
remote services. Save the raw JSON as well as the summary; a single minimum
time is not a reliable comparison.
`dist/performance-bundle.json` ranks the largest retained modules per chunk
and records chunk imports. Module `renderedBytes` is Rollup's intermediate
module size; it is useful for attribution but should not be summed as final
minified/gzipped size. Use actual resource bytes for the startup total.
## Remaining investigation
- The shell still loads about 6.47 MB of JavaScript. Shared imports through
drawing, scene, gallery, and settings entry points need a deeper dependency
audit. Separate lightweight store/type imports from editor code, then use
the bundle report and the same startup experiment to verify what actually
leaves the initial graph. The report identifies Spark, Three.js core and
renderer, the inline FBX-to-GLB conversion worker, PostHog, and Mediabunny
among the largest retained modules. Their presence is measured; the runtime
benefit of deferring each individually has not yet been measured.
- Returning to the populated canvas still takes about 86 ms. The editor
remount, Konva drawing, image/transformer setup, and composite bake need a
timeline profile before choosing the next change. Keep scene restoration,
undo, selection, and background generation behavior in correctness checks.
- `TaskQueue.tsx` polls every five seconds and rebuilds task arrays even when
the popover is closed. The benchmark uses an empty queue, so it does not
establish the cost for a large real queue. A fixed, representative task
fixture plus idle CPU/render counts is the next useful experiment there.
- `useStageSnapshot` looks expensive in isolation, but its only call is
commented out in `PaintSurface.tsx`. It was excluded as a current bottleneck.
Validation completed: normal production Vite build, all 10 app tests and 20
login tests, all 15 deferred page-module imports, visual canvas inspection,
and a check that the normal build contains no benchmark globals or fixtures.
The broad TypeScript check remains blocked by existing workspace declaration/
project-reference errors and Remix/Deno types. Comparing diagnostics with the
two original modules showed the same diagnostic messages (some import traces
and reporting locations changed); this is not a clean typecheck claim.
## Validate in the native app
Before claiming native launch or interaction gains, run matching release
Tauri builds on the same machine and WebView version. Keep account, window
size, network conditions, and canvas artwork fixed. Record the Web Inspector
timeline for launch/reload, opening a tool for the first time, drawing → Apps,
and Apps → drawing. Repeat at least seven times and retain the traces with
build IDs and medians. Separate native process launch from page reload; do
not mix dev/HMR traces with production measurements.
For canvas tests, create a blank 1024-square canvas, import the same four
images, add the same strokes, select an image, and wait for background work
before switching. Check image content, selection, undo/redo, and opening a new
gallery image afterward. Record actual click-to-visible-frame latency in
the inspector as well as JavaScript tasks. The browser results above justify
the code changes but do not replace that native validation.
+527
View File
@@ -0,0 +1,527 @@
{
"timestamp": "2026-09-27T00:32:54.524Z",
"build": {
"builtAt": "2026-09-27T00:30:35.760Z",
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
"baselineOverlay": null,
"changedFiles": [
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
"frontend/package-lock.json",
"frontend/package.json"
]
},
"environment": {
"platform": "darwin",
"arch": "arm64",
"cpu": "Apple M4 Pro",
"browser": "153.0.8010.53",
"node": "v24.13.0",
"runs": 7,
"viewport": "1440x1000@1",
"ipc": "mocked",
"network": "local assets only, browser cache disabled"
},
"summary": {
"startup": {
"readyMs": {
"median": 350.60000002384186,
"min": 348.7000000476837,
"max": 457.39999997615814
},
"jsBytes": {
"median": 6466606,
"min": 6466606,
"max": 6466606
},
"longTaskMs": {
"median": 184,
"min": 179,
"max": 284
},
"webglContexts": {
"median": 0,
"min": 0,
"max": 0
}
},
"refresh": {
"readyMs": {
"median": 198.10000002384186,
"min": 197.10000002384186,
"max": 198.80000007152557
},
"jsBytes": {
"median": 6466606,
"min": 6466606,
"max": 6466606
},
"longTaskMs": {
"median": 0,
"min": 0,
"max": 0
},
"webglContexts": {
"median": 0,
"min": 0,
"max": 0
}
},
"firstDraw": {
"stateReadyMs": {
"median": 38.300000071525574,
"min": 37.60000002384186,
"max": 40.299999952316284
},
"paintMs": {
"median": 118.19999992847443,
"min": 115.30000007152557,
"max": 120
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"leaveDraw": {
"stateReadyMs": {
"median": 9,
"min": 7.5,
"max": 11
},
"paintMs": {
"median": 23,
"min": 19.600000023841858,
"max": 26.600000023841858
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"returnDraw": {
"stateReadyMs": {
"median": 18.800000071525574,
"min": 15.799999952316284,
"max": 20.700000047683716
},
"paintMs": {
"median": 86.5,
"min": 78.79999995231628,
"max": 90.10000002384186
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
}
},
"samples": [
{
"startup": {
"readyMs": 457.39999997615814,
"jsBytes": 6466606,
"longTaskMs": 284,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.80000007152557,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38.800000071525574,
"paintMs": 119.5,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 7.600000023841858,
"paintMs": 20.399999976158142,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 17.799999952316284,
"paintMs": 87.79999995231628,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 365.7999999523163,
"jsBytes": 6466606,
"longTaskMs": 185,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.10000002384186,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 37.60000002384186,
"paintMs": 116,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 7.5,
"paintMs": 26.600000023841858,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 15.799999952316284,
"paintMs": 78.79999995231628,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 366.59999990463257,
"jsBytes": 6466606,
"longTaskMs": 193,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.39999997615814,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 40.299999952316284,
"paintMs": 118.19999992847443,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 9,
"paintMs": 23,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 19.699999928474426,
"paintMs": 86.5,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 348.7000000476837,
"jsBytes": 6466606,
"longTaskMs": 184,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.89999997615814,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 37.700000047683716,
"paintMs": 115.60000002384186,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 9.300000071525574,
"paintMs": 26.40000009536743,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 18.700000047683716,
"paintMs": 84.90000009536743,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 349.60000002384186,
"jsBytes": 6466606,
"longTaskMs": 179,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.10000002384186,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38.300000071525574,
"paintMs": 118.5,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 11,
"paintMs": 19.600000023841858,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 20.700000047683716,
"paintMs": 90.10000002384186,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 350.60000002384186,
"jsBytes": 6466606,
"longTaskMs": 179,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.20000004768372,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 39.60000002384186,
"paintMs": 120,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 11,
"paintMs": 22.199999928474426,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 20,
"paintMs": 87,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 349.89999997615814,
"jsBytes": 6466606,
"longTaskMs": 180,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.80000007152557,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38,
"paintMs": 115.30000007152557,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 8.200000047683716,
"paintMs": 24.899999976158142,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 18.800000071525574,
"paintMs": 84.5,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
}
],
"importedPages": [
"src/pages/PageAngles/index.ts",
"src/pages/PageAudio/CreateAudio.tsx",
"src/pages/PageBackgroundChange/index.ts",
"src/pages/PageImage/TextToImage.tsx",
"src/pages/PageImageTo3DObject/index.ts",
"src/pages/PageImageTo3DWorld/index.ts",
"src/pages/PageImageWatermarkRemover/index.ts",
"src/pages/PageMoodboard/index.ts",
"src/pages/PageRemoveBackground/index.ts",
"src/pages/PageScene/index.tsx",
"src/pages/PageStoryboard/index.ts",
"src/pages/PageVideo/ImageToVideo.tsx",
"src/pages/PageVideoEditor/index.tsx",
"src/pages/PageVideoFrameExtractor/index.ts",
"src/pages/PageVideoWatermarkRemover/index.ts"
],
"errors": []
}
+510
View File
@@ -0,0 +1,510 @@
{
"timestamp": "2026-09-27T00:30:21.217Z",
"build": {
"builtAt": "2026-09-27T00:28:39.209Z",
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
"baselineOverlay": "HEAD",
"changedFiles": [
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
"frontend/package-lock.json",
"frontend/package.json"
]
},
"environment": {
"platform": "darwin",
"arch": "arm64",
"cpu": "Apple M4 Pro",
"browser": "153.0.8010.53",
"node": "v24.13.0",
"runs": 7,
"viewport": "1440x1000@1",
"ipc": "mocked",
"network": "local assets only, browser cache disabled"
},
"summary": {
"startup": {
"readyMs": {
"median": 566.3999999761581,
"min": 532.3999999761581,
"max": 847.7999999523163
},
"jsBytes": {
"median": 9052991,
"min": 9052991,
"max": 9052991
},
"longTaskMs": {
"median": 336,
"min": 314,
"max": 407
},
"webglContexts": {
"median": 1,
"min": 1,
"max": 1
}
},
"refresh": {
"readyMs": {
"median": 248.60000002384186,
"min": 247.59999990463257,
"max": 351.1999999284744
},
"jsBytes": {
"median": 9052991,
"min": 9052991,
"max": 9052991
},
"longTaskMs": {
"median": 0,
"min": 0,
"max": 0
},
"webglContexts": {
"median": 1,
"min": 1,
"max": 1
}
},
"firstDraw": {
"stateReadyMs": {
"median": 40.40000009536743,
"min": 37.299999952316284,
"max": 43.299999952316284
},
"paintMs": {
"median": 120.90000009536743,
"min": 117.89999997615814,
"max": 126.79999995231628
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"leaveDraw": {
"stateReadyMs": {
"median": 23.399999976158142,
"min": 20.100000023841858,
"max": 26
},
"paintMs": {
"median": 29.5,
"min": 21.200000047683716,
"max": 35.5
},
"fileReads": {
"median": 4,
"min": 4,
"max": 4
}
},
"returnDraw": {
"stateReadyMs": {
"median": 42.10000002384186,
"min": 36.59999990463257,
"max": 45.200000047683716
},
"paintMs": {
"median": 105.69999992847443,
"min": 100.89999997615814,
"max": 109.20000004768372
},
"fileReads": {
"median": 4,
"min": 4,
"max": 4
}
}
},
"samples": [
{
"startup": {
"readyMs": 847.7999999523163,
"jsBytes": 9052991,
"longTaskMs": 407,
"webglContexts": 1
},
"refresh": {
"readyMs": 351.1999999284744,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 43.299999952316284,
"paintMs": 126.79999995231628,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 25.100000023841858,
"paintMs": 35.5,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 36.59999990463257,
"paintMs": 100.89999997615814,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 539.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 317,
"webglContexts": 1
},
"refresh": {
"readyMs": 264.60000002384186,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 39.39999997615814,
"paintMs": 120.20000004768372,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 20.100000023841858,
"paintMs": 21.200000047683716,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 41.60000002384186,
"paintMs": 104.60000002384186,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 561.9000000953674,
"jsBytes": 9052991,
"longTaskMs": 329,
"webglContexts": 1
},
"refresh": {
"readyMs": 248.19999992847443,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 40.40000009536743,
"paintMs": 120.90000009536743,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.399999976158142,
"paintMs": 33.39999997615814,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 37.299999952316284,
"paintMs": 101.10000002384186,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 569.5,
"jsBytes": 9052991,
"longTaskMs": 336,
"webglContexts": 1
},
"refresh": {
"readyMs": 248,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 38,
"paintMs": 119.30000007152557,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 26,
"paintMs": 34.799999952316284,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 45.200000047683716,
"paintMs": 109.20000004768372,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 567.5,
"jsBytes": 9052991,
"longTaskMs": 339,
"webglContexts": 1
},
"refresh": {
"readyMs": 248.60000002384186,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 40.89999997615814,
"paintMs": 122.10000002384186,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.200000047683716,
"paintMs": 29.5,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 42.10000002384186,
"paintMs": 105.69999992847443,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 566.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 336,
"webglContexts": 1
},
"refresh": {
"readyMs": 281.39999997615814,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 43.09999990463257,
"paintMs": 124.69999992847443,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.399999976158142,
"paintMs": 26.199999928474426,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 42.799999952316284,
"paintMs": 107.19999992847443,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 532.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 314,
"webglContexts": 1
},
"refresh": {
"readyMs": 247.59999990463257,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 37.299999952316284,
"paintMs": 117.89999997615814,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 21.5,
"paintMs": 22.899999976158142,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 44.09999990463257,
"paintMs": 108.39999997615814,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
}
],
"errors": []
}
+80
View File
@@ -0,0 +1,80 @@
# Website login from the desktop app
The React login/signup dialog calls Tauri commands only, including its startup
session check and password forms. Website and QR actions appear only in login
mode. Tauri never connects to MySQL; the desktop uses Rust HTTP API bindings. Creating/polling login challenges and
verifying `/v1/session` use `artcraft_client::endpoints::users::login_challenges`.
The Rust command uses `AppEnvConfigs.storyteller_host` for every request, keeps the
private device token in native memory, and returns only a local challenge handle,
approval URL/code/deadline, status, and the verified user to JavaScript. The signed
session cookie never crosses IPC or gets written to JavaScript local storage.
After redemption, Rust verifies the signed session against the same API host,
installs the server's cookie into the native HTTP jar and credential manager, and
persists the jar. Closing the dialog removes the local handle; a cancellation
while HTTP is in flight prevents installing the late response. Retries retrieve
the same server session. Neither creation nor website approval pre-creates one.
## Session response compatibility
A server feature flag added after a desktop release must not prevent login. The
session decoder keeps known feature flags and ignores unknown flag names, such
as `use_qt` on older clients. Malformed flag values and required authentication
fields still fail validation. Tests cover a real Rust HTTP session response with
both known and future flags, followed by native cookie installation/persistence.
A late startup session check cannot reset an active or completed website login.
## Diagnosing 401 responses
`storyteller_activity_thread` reports errors from `/v1/analytics/active_user_v2`.
This is the background activity ping, not the login-challenge endpoint. It requires
a user session. An anonymous `visitor` cookie alone is now skipped. Expired or
revoked sessions can still produce a 401 and require signing in again.
Activity failures log the configured API origin (including port), endpoint, and
error. Native website-login requests/failures log origin, endpoint, and HTTP
status. Request bodies, device tokens, approval URLs, and signed cookies are not
logged by the new client, including at debug level.
## API environment
`~/Artcraft/settings/env_configs.json` controls the native API host:
```json
{"storyteller_host":"localhost","storyteller_port":12345}
```
Local mode uses `http://localhost:12345` and a local approval website at
`http://localhost:4201/login/desktop`. Run storyteller-web with its migrations
applied, and the services webapp with `USE_LOCAL_API=1`. The local website must
also use the local API. A challenge in a local database cannot be approved on the
production website, even when that browser already has a production session.
For the production API and production website, use `"storyteller_host":"production"`
(or omit the override), then restart ArtCraft. API host configuration is not
silently changed by login. Local and production approval URLs cannot be mixed.
Localhost development URLs are for the same computer; mobile QR testing needs
a shared/deployed environment.
## Tests
From the desktop repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
cd frontend
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
```
Native tests use only ephemeral loopback HTTP servers and temporary cookie stores.
They exercise the real API bindings and the command implementation, including
cookie verification/persistence, rejection, expiry, retries, in-flight cancellation,
origin separation, no redirects, redacted errors, visitor-only telemetry, future
feature flags, malformed session data, and password login/signup/session checks.
UI tests prohibit JavaScript HTTP and runtime API imports. They verify the IPC
contract, native browser opening, QR/code display, status, backoff, cleanup,
login-only button placement, native password forms, and stale session responses.
The services repository also has real MySQL integration tests for approval,
redemption, rejection, concurrency, and downstream authentication, using isolated
local databases. No test contacts a production API or database.
+2 -1
View File
@@ -28,10 +28,11 @@
</script>
<base href="/" />
<link rel="icon" type="image/svg+xml" href="/artcraft-icon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
rel="stylesheet" />
<!-- <link rel="stylesheet" href="/src/styles.css" /> -->
</head>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -191,11 +191,11 @@ export function GlobalFileDropHandler() {
return (
<>
{isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm">
<Maximize2Icon className="text-4xl opacity-80" />
<div className="text-xl font-semibold">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div>
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/70">
<div className="flex flex-col items-center gap-3 border border-dashed border-white/60 bg-ui-panel px-16 py-12 text-white">
<Maximize2Icon className="text-3xl opacity-60" />
<div className="font-display text-2xl tracking-tight">Drop to Upload</div>
<div className="hud-label text-white/50">GLB, PNG, JPG, JPEG, SPZ</div>
</div>
</div>
)}
@@ -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";
@@ -36,6 +37,7 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -549,9 +551,9 @@ export const ImageTo3DExperience = ({
role="button"
tabIndex={0}
className={twMerge(
"flex flex-col items-center justify-center rounded-2xl border-[3px] border-dashed border-primary/40 bg-primary/5 text-center text-xs transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40",
"flex flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] text-center text-xs transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
dragActive && "border-primary bg-primary/10",
dragActive && "border-white/60 bg-white/5",
)}
onDragEnter={(event) => {
event.preventDefault();
@@ -581,11 +583,11 @@ export const ImageTo3DExperience = ({
<PlusIcon
className={twMerge(
"text-base-fg opacity-90 drop-shadow",
"text-base-fg opacity-80",
hasResults ? "text-2xl" : "text-4xl",
)} />
{!hasResults && (
<span className="mt-3 text-[15px] font-medium text-base-fg opacity-60">
<span className="mt-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Add Image
</span>
)}
@@ -598,7 +600,7 @@ export const ImageTo3DExperience = ({
{uploadedPreview ? (
<div
className={twMerge(
"group relative cursor-pointer overflow-hidden rounded-2xl border-[3px] border-primary/40 bg-black/30 transition-all",
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
)}
onClick={() => !isUploading && fileInputRef.current?.click()}
@@ -613,13 +615,13 @@ export const ImageTo3DExperience = ({
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-primary" />
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-white" />
</div>
)}
{!isUploading && (
<button
type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-all hover:bg-black group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
setUploadedPreview(null);
@@ -645,7 +647,7 @@ export const ImageTo3DExperience = ({
ref={textareaRef}
id={promptInputId}
rows={1}
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded-[3px] bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
placeholder="Describe any object you want to generate from scratch..."
onChange={(event) => setPrompt(event.target.value)}
@@ -691,8 +693,8 @@ export const ImageTo3DExperience = ({
role="button"
tabIndex={0}
className={twMerge(
"flex h-32 w-32 flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary/40 bg-primary/5 transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40",
dragActive && "border-primary bg-primary/10",
"flex h-32 w-32 flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
dragActive && "border-white/60 bg-white/5",
)}
onDragEnter={(e) => {
e.preventDefault();
@@ -730,8 +732,8 @@ export const ImageTo3DExperience = ({
>
<PlusIcon
className="text-3xl text-base-fg opacity-90 drop-shadow" />
<span className="mt-2 text-sm font-medium text-base-fg/50">
className="text-3xl text-base-fg opacity-80" />
<span className="mt-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Add Images
</span>
</div>
@@ -740,7 +742,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80"
className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -754,7 +756,7 @@ export const ImageTo3DExperience = ({
<div
key={img.id}
className={twMerge(
"glass group relative aspect-square overflow-hidden rounded-lg border-2 border-white/30 transition-all",
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
hasResults ? "w-10" : "w-auto",
img.isUploading
? "cursor-default"
@@ -772,13 +774,13 @@ export const ImageTo3DExperience = ({
/>
{img.isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-primary" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
)}
{!img.isUploading && (
<button
type="button"
className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-red/70 group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-colors hover:bg-red/70 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeWorldImage(img.id);
@@ -820,7 +822,7 @@ export const ImageTo3DExperience = ({
<Button
variant="action"
className={twMerge(
"aspect-square overflow-hidden rounded-lg border-2 border-dashed border-black/5 bg-ui-controls/40 transition-all hover:bg-ui-controls/60 dark:border-white/25",
"aspect-square overflow-hidden rounded-[3px] border border-dashed border-black/15 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10 dark:border-white/25",
hasResults ? "w-10" : "w-14",
)}
onClick={() => worldFileInputRef.current?.click()}
@@ -836,7 +838,7 @@ export const ImageTo3DExperience = ({
);
const imageCountRow = (
<div className="flex items-center justify-center gap-2 text-xs text-base-fg/40">
<div className="flex items-center justify-center gap-2 font-mono text-[11px] tabular-nums text-base-fg/40">
<span>
{worldImages.length}/{MAX_WORLD_IMAGES} images
</span>
@@ -865,7 +867,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={1}
className="flex-1 resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
className="flex-1 resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
value={worldPrompt}
placeholder="Describe world (optional)..."
onChange={(e) => setWorldPrompt(e.target.value)}
@@ -887,7 +889,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -907,7 +909,7 @@ export const ImageTo3DExperience = ({
results.find((r) => r.id === selectedResultId) || results[0];
return (
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full bg-ui-panel text-base-fg">
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-base-fg">
{backgroundImage && !hasResults && (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
@@ -928,9 +930,12 @@ export const ImageTo3DExperience = ({
<div className="relative z-10 h-full w-full p-8">
{!hasResults && (
<div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] w-full text-center">
<h1 className="mb-3 text-7xl font-bold tracking-tight">{title}</h1>
<p className="text-xl text-base-fg/70">{subtitle}</p>
<div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] flex w-full justify-center">
<CreateEmptyState
title={title}
subtitle={subtitle}
className="py-8 sm:py-8"
/>
</div>
)}
@@ -944,7 +949,7 @@ export const ImageTo3DExperience = ({
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
{/* Left: Viewer */}
<div className="glass relative h-full overflow-hidden rounded-xl border border-ui-panel-border">
<div className="relative h-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<Viewer3D
key={activeResult?.id}
modelUrl={activeResult?.modelUrl}
@@ -1002,13 +1007,15 @@ export const ImageTo3DExperience = ({
</div>
{/* Right: History List */}
<div className="glass flex h-full flex-col overflow-hidden rounded-xl border border-ui-panel-border">
<div className="flex items-center justify-between p-4">
<h3 className="font-semibold text-base-fg/80">History</h3>
<div className="flex h-full flex-col overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between border-b border-ui-panel-border px-4 py-3">
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
History
</h3>
{results.length > 0 && (
<button
onClick={resetResults}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
className="rounded-[3px] border border-red/40 bg-red/10 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/20"
>
Clear Session
</button>
@@ -1024,13 +1031,13 @@ export const ImageTo3DExperience = ({
key={result.id}
onClick={() => setSelectedResultId(result.id)}
className={twMerge(
"group flex w-full items-center gap-3 rounded-xl border p-2 text-left transition-all hover:bg-ui-controls/40",
"group flex w-full items-center gap-3 rounded-none border p-2 text-left transition-all hover:bg-white/5",
isSelected
? "border-primary/50 bg-primary/10"
: "border-transparent bg-ui-controls/20",
? "border-white bg-white/10"
: "border-white/10 bg-transparent hover:border-white/30",
)}
>
<div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-black/30 ring-1 ring-white/5">
<div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden border border-white/10 bg-black/30">
{result.previewUrl ? (
<img
src={result.previewUrl}
@@ -1049,7 +1056,7 @@ export const ImageTo3DExperience = ({
)}
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-primary" />
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
)}
</div>
@@ -1063,7 +1070,7 @@ export const ImageTo3DExperience = ({
Generating…
</span>
) : (
<span className="text-base-fg/45">
<span className="font-mono tabular-nums text-base-fg/45">
{formatTime(result.timestamp)}
</span>
)}
@@ -1093,7 +1100,7 @@ export const ImageTo3DExperience = ({
<div ref={promptContentRef}>
<div
className={twMerge(
"glass w-full rounded-xl shadow-2xl ring-1 ring-white/10",
"glass w-full",
hasResults ? "p-3" : "p-5",
)}
>
@@ -1138,7 +1145,7 @@ export const ImageTo3DExperience = ({
activeTab={activeMode}
onTabChange={(tabId) => setActiveMode(tabId as Mode)}
className="w-fit"
indicatorClassName="bg-primary/25"
indicatorClassName="bg-white/15"
/>
</div>
)}
@@ -1195,12 +1202,12 @@ export const ImageTo3DExperience = ({
{previewImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)}
>
<button
type="button"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-[3px] border border-white/15 bg-black/60 text-white transition-colors hover:border-white/30 hover:bg-black"
onClick={() => setPreviewImage(null)}
>
<XIcon className="h-4 w-4" />
@@ -1208,7 +1215,7 @@ export const ImageTo3DExperience = ({
<img
src={previewImage}
alt="Preview"
className="max-h-[80vh] max-w-[80vw] rounded-xl object-contain shadow-2xl"
className="max-h-[80vh] max-w-[80vw] border border-white/15 object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -0,0 +1,56 @@
import { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
const CORNERS = [
"top-0 left-0",
"top-0 right-0",
"bottom-0 left-0",
"bottom-0 right-0",
];
interface CreateEmptyStateProps {
title: string;
subtitle: string;
children?: ReactNode;
compact?: boolean;
className?: string;
}
/** A quiet editorial section with a fine border and corner registration marks. */
export function CreateEmptyState({
title,
subtitle,
children,
compact = false,
className,
}: CreateEmptyStateProps) {
return (
<section
className={twMerge(
"create-empty-section relative w-full max-w-3xl px-6 text-left sm:px-10",
compact ? "py-8" : "py-10 sm:py-14",
className,
)}
>
<h1
className={twMerge(
"text-balance font-display leading-[1.05] tracking-[-0.035em] text-ui-ink",
compact ? "text-2xl" : "text-3xl md:text-5xl",
)}
>
{title}
</h1>
<p className="mt-4 max-w-xl text-pretty text-sm leading-relaxed text-white/60 sm:text-base">
{subtitle}
</p>
{children && <div className="mt-6">{children}</div>}
{CORNERS.map((corner) => (
<span
key={corner}
aria-hidden="true"
className={`frame-corner-mark ${corner}`}
/>
))}
</section>
);
}
@@ -1,15 +1,16 @@
import { useEffect, useState, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { CreateEmptyState } from "./CreateEmptyState";
// Shared page chrome for the desktop Generate Image / Generate Video pages,
// mirroring the webapp's CreateMediaPageShell: a centered hero over the
// decorative background, and a scrollable generation feed (padded past the
// fixed promptbox) once there is anything to show.
// mirroring the webapp's CreateMediaPageShell: a centered empty-state panel,
// and a scrollable generation feed (padded past the fixed promptbox) once
// there is anything to show.
//
// The hero + background behave like a splash screen: they stay up while the
// feed is still loading (pages remount on tab switches, so this covers the
// reload) and fade out once content arrives. With a genuinely empty library
// they simply stay — they ARE the empty state.
// The empty state (and optional background) behave like a splash screen:
// they stay up while the feed is still loading (pages remount on tab
// switches, so this covers the reload) and fade out once content arrives.
// With a genuinely empty library they simply stay.
const SPLASH_FADE_MS = 700;
@@ -17,7 +18,8 @@ interface DesktopCreatePageShellProps {
hasContent: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
/** Decorative page background (fixed-position); fades out with the hero. */
/** Optional decorative page background (fixed-position); fades out with
* the empty state. */
background?: ReactNode;
/** Height of the fixed promptbox area, used to pad the feed's bottom. */
bottomOffset: number;
@@ -55,11 +57,11 @@ export function DesktopCreatePageShell({
}, [hasContent, splashState]);
return (
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background">
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-white">
<div className="relative h-full w-full">
{hasContent && (
<div
className="h-full w-full overflow-y-auto pt-4"
className="h-full w-full overflow-y-auto pt-0.5"
style={{ paddingBottom: bottomOffset + 24 }}
>
{listContent}
@@ -73,15 +75,14 @@ export function DesktopCreatePageShell({
splashState === "fading" ? "opacity-0" : "opacity-100",
)}
>
<div className="flex h-full w-full flex-col items-center justify-center pb-52">
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl">
<h1 className="text-7xl font-bold text-base-fg">
{emptyStateTitle}
</h1>
<span className="pt-2 text-xl text-base-fg opacity-80">
{emptyStateSubtitle}
</span>
</div>
<div
className="relative z-20 flex h-full w-full items-center justify-center px-6"
style={{ paddingBottom: bottomOffset }}
>
<CreateEmptyState
title={emptyStateTitle}
subtitle={emptyStateSubtitle}
/>
</div>
{background}
</div>
@@ -92,7 +93,7 @@ export function DesktopCreatePageShell({
{hasContent && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-48 bg-gradient-to-t from-ui-background to-transparent"
/>
)}
@@ -180,7 +180,7 @@ function SelectionDownloadBar({
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
className="flex items-center gap-2 border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:opacity-60"
>
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
@@ -226,8 +226,8 @@ function ItemActions({
const buttonClass =
variant === "card"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
: "flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
? "flex h-7 w-7 items-center justify-center text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
: "flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
const handleRecreate = useCallback(
async (e: React.MouseEvent) => {
@@ -305,7 +305,7 @@ function ItemActions({
aria-label="Make Video"
className={buttonClass}
>
<VideoIcon className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -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" {
@@ -106,8 +106,8 @@ export const UploadEntryCard = ({
return (
<div
className={twMerge(
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors",
isDragActive && "border-primary/80 bg-primary/5",
"relative flex h-full flex-col items-center justify-center gap-6 overflow-hidden rounded-[3px] border border-dashed border-white/15 bg-ui-background p-8 text-center transition-colors hover:border-white/40",
isDragActive && "border-white bg-white/10 hover:border-white",
disabled && "pointer-events-none opacity-60",
)}
onDragEnter={handleDragEnter}
@@ -124,35 +124,28 @@ export const UploadEntryCard = ({
onChange={handleFileChange}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-6">
<div className="relative">
<div
className={twMerge(
"relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 shadow-xl backdrop-blur-sm",
accentBackgroundClass,
accentBorderClass,
)}
>
<DynamicIcon
icon={icon}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
<div className="flex flex-col items-center gap-4">
<div
className={twMerge(
"flex h-14 w-14 items-center justify-center border",
accentBackgroundClass,
accentBorderClass,
)}
>
<DynamicIcon icon={icon} className="text-xl text-white" />
</div>
<div className="space-y-3">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
{title}
</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
<div>
<h3 className="text-lg font-semibold text-base-fg">{title}</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-base-fg/60">
{description}
</p>
</div>
<div className="mt-4 flex flex-wrap justify-center gap-4">
<div className="mt-2 flex flex-wrap justify-center gap-2">
<Button
variant="primary"
icon={primaryIcon}
onClick={handlePrimaryClick}
className="px-8 py-3 text-base font-semibold shadow-lg"
className="px-3 py-2"
disabled={disabled}
>
{primaryLabel}
@@ -162,7 +155,7 @@ export const UploadEntryCard = ({
variant="action"
icon={secondaryIcon}
onClick={onSecondaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
className="px-3 py-2"
disabled={disabled}
>
{secondaryLabel}
@@ -173,7 +166,7 @@ export const UploadEntryCard = ({
variant="action"
icon={tertiaryIcon}
onClick={onTertiaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
className="px-3 py-2"
disabled={disabled}
>
{tertiaryLabel}

Some files were not shown because too many files have changed in this diff Show More