Compare commits

..

10 Commits

Author SHA1 Message Date
Brandon Thomas 16c7060dcc note on user agents 2026-09-27 23:40:54 -04:00
Brandon Thomas bfc7c71e50 client identity configs 2026-09-27 23:31:08 -04:00
Brandon Thomas ef43ab679d partial fixes for the API - origin was wrong 2026-09-27 15:21:03 -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
Brandon Thomas c3844acdad
ArtCraft 0.41 (#1957)
- website-based login
2026-09-26 03:43:28 -07:00
Brandon Thomas 8456e2fd06
website based login challenges (#1956) 2026-09-26 03:42:09 -07:00
Brandon Thomas 95f8f659a5
ArtCraft 0.40.0 (#1955) 2026-09-18 16:26:35 -04:00
Brandon Thomas 3c5038c159
Added new models and modes: GPT Image 2.5, Wan, Output Formats (#1954) 2026-09-18 16:23:44 -04:00
89 changed files with 5296 additions and 305 deletions

View File

@ -44,6 +44,7 @@ explicitly authorizes a cache cleanup.
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
- Each Tauri command belongs in its own file module, named after the command. Keep shared state, types, and helpers in separate support modules; see the [Storyteller command conventions](crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md).
### File Layout

9
Cargo.lock generated
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",

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" }

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

View File

@ -0,0 +1,73 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}

View File

@ -2,4 +2,5 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod session_info;
pub mod login_challenges;

View File

@ -1,11 +1,12 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::Serialize;
use serde_derive::{Deserialize, Serialize};
use serde::{Deserialize as DeserializeTrait, Deserializer};
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
@ -14,7 +15,7 @@ pub enum FakeYouPlan {
Pro,
}
#[derive(Serialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
@ -23,7 +24,7 @@ pub enum StorytellerStreamPlan {
Pro,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
@ -41,6 +42,7 @@ pub struct SessionUserInfo {
/// Collection of feature / rollout flags
/// This is the proper place to detect if a user has access to some rollout (non-paywall) feature.
/// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes
#[serde(default, deserialize_with = "deserialize_known_feature_flags")]
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
@ -76,7 +78,7 @@ pub struct SessionUserInfo {
pub can_delete_users: bool,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
@ -91,9 +93,18 @@ pub struct SessionOnboardingState {
pub username_not_customized: bool,
}
#[derive(Serialize, ToSchema)]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
// Server rollouts may introduce flags before a desktop release understands them.
// Ignore only unknown flag names; malformed values and authentication fields
// still fail decoding. Unknown flags never grant a client-side capability.
fn deserialize_known_feature_flags<'de, D>(deserializer: D) -> Result<BTreeSet<UserFeatureFlag>, D::Error>
where D: Deserializer<'de> {
let names = Vec::<String>::deserialize(deserializer)?;
Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect())
}

View File

@ -0,0 +1,27 @@
# artcraft_client
Rust HTTP endpoint bindings used by the ArtCraft Tauri desktop app.
## Endpoint layout
- Each HTTP endpoint binding must live in its own descriptively named `.rs` file.
This applies to both free functions and methods implemented on a shared client.
Do not combine several routes into a catch-all file such as `login_challenges.rs`
or `password_auth.rs`.
- Group related bindings in a directory module. Keep `mod.rs` for module declarations;
it must not contain endpoint implementations. For example:
`endpoints/users/login_challenges/create_login_challenge.rs` and
`endpoints/users/login_challenges/poll_login_challenge.rs`.
- Keep each endpoint's route constant, request construction, and endpoint-specific
request/response types in its file. Keep shared wire types in `artcraft_api_defs`
or a dedicated shared type module; do not duplicate them across endpoint files.
- Put shared HTTP transport, client setup, and common error handling in a separate
utility module. A transport module must not accumulate endpoint bindings.
- Update callers to import types from their new modules when splitting bindings.
Preserve request methods, paths, headers, error behavior, and cookie handling.
## Validation
Run `cargo check --offline -p artcraft_client` for client changes. For native login
bindings, also run `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib
login_bridge_tests`; these tests use local HTTP fixtures and temporary cookie jars.

View File

@ -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

View File

@ -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)))?;

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -7,3 +7,4 @@ pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;

View File

@ -0,0 +1,16 @@
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const CREATE_PATH: &str = "/v1/login_challenges/create";
impl LoginChallengeClient {
pub async fn create(&self) -> Result<CreateLoginChallengeResponse, LoginChallengeClientError> {
let request = self
.request(Method::POST, CREATE_PATH)
.json(&CreateLoginChallengeRequest {});
let (result, _) = self.send(request, CREATE_PATH).await?;
Ok(result)
}
}

View File

@ -0,0 +1,2 @@
pub mod create_login_challenge;
pub mod poll_login_challenge;

View File

@ -0,0 +1,30 @@
use artcraft_api_defs::users::login_challenges::{LoginChallengeResponse, PollLoginChallengeRequest};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const POLL_PATH: &str = "/v1/login_challenges/poll";
// Deliberately no Debug/Serialize: the wire result contains a bearer credential.
pub struct PollLoginChallengeResult {
pub response: LoginChallengeResponse,
pub session_set_cookie: Option<String>,
}
impl LoginChallengeClient {
pub async fn poll(
&self,
device_token: &str,
) -> Result<PollLoginChallengeResult, LoginChallengeClientError> {
let request = self
.request(Method::POST, POLL_PATH)
.json(&PollLoginChallengeRequest {
device_token: device_token.to_owned(),
});
let (response, session_set_cookie) = self.send(request, POLL_PATH).await?;
Ok(PollLoginChallengeResult {
response,
session_set_cookie,
})
}
}

View File

@ -0,0 +1,5 @@
pub mod login_challenges;
pub mod password_auth_response;
pub mod password_login;
pub mod password_signup;
pub mod session_info;

View File

@ -0,0 +1,8 @@
use serde::Deserialize;
// No Debug: the response contains a bearer credential.
#[derive(Deserialize)]
pub struct PasswordAuthResponse {
pub success: bool,
pub signed_session: Option<String>,
}

View File

@ -0,0 +1,20 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const LOGIN_PATH: &str = "/v1/login";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordLoginRequest {
pub username_or_email: String,
pub password: String,
}
impl LoginChallengeClient {
pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, LOGIN_PATH).json(request), LOGIN_PATH).await
}
}

View File

@ -0,0 +1,23 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const SIGNUP_PATH: &str = "/v1/create_account";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordSignupRequest {
pub username: String,
pub email_address: String,
pub password: String,
pub password_confirmation: String,
pub signup_source: String,
}
impl LoginChallengeClient {
pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, SIGNUP_PATH).json(request), SIGNUP_PATH).await
}
}

View File

@ -0,0 +1,19 @@
use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse;
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const SESSION_PATH: &str = "/v1/session";
impl LoginChallengeClient {
pub async fn session(
&self,
signed_session: &str,
) -> Result<SessionInfoSuccessResponse, LoginChallengeClientError> {
let request = self
.request(Method::GET, SESSION_PATH)
.header("Cookie", format!("session={signed_session}"));
let (result, _) = self.send(request, SESSION_PATH).await?;
Ok(result)
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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 {

View File

@ -1,4 +1 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";

View File

@ -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()

View File

@ -0,0 +1,150 @@
use std::time::Duration;
use log::{info, warn};
use reqwest::header::SET_COOKIE;
use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use url::Url;
use crate::utils::api_host::ApiHost;
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.
#[derive(Clone)]
pub struct LoginChallengeClient {
client: Client,
api_host: ApiHost,
}
#[derive(Debug)]
pub struct LoginChallengeClientError {
pub status: Option<u16>,
pub message: &'static str,
}
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
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())
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
client,
api_host: api_host.clone(),
})
}
pub fn api_origin(&self) -> String {
self.api_host.to_api_hostname_and_scheme()
}
pub fn api_url(&self) -> Url {
Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin")
}
pub fn allows_verification_url(&self, value: &str) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
let origin_allowed = match self.api_host {
ApiHost::Localhost { .. } => ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::Storyteller => ["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::FakeYou => false,
};
origin_allowed
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/login/desktop"
&& url.query().is_none()
&& url
.fragment()
.and_then(|f| f.strip_prefix("approval_token="))
.map(|s| {
s.len() == 43
&& s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
.unwrap_or(false)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
self
.client
.request(method, format!("{}{path}", self.api_origin()))
.header("Accept", "application/json")
}
pub(crate) async fn send<T: DeserializeOwned>(
&self,
request: RequestBuilder,
path: &str,
) -> Result<(T, Option<String>), LoginChallengeClientError> {
let origin = self.api_origin();
info!("Website login request: origin={} path={}", origin, path);
let response = request.send().await.map_err(|_| {
warn!(
"Website login network failure: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Unable to reach the login server")
})?;
let status = response.status();
if !status.is_success() {
warn!(
"Website login HTTP failure: origin={} path={} status={}",
origin,
path,
status.as_u16()
);
return Err(LoginChallengeClientError {
status: Some(status.as_u16()),
message: "Login server rejected the request",
});
}
let session_cookie = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| {
cookie::Cookie::parse(*v)
.map(|c| c.name() == "session")
.unwrap_or(false)
})
.map(str::to_owned);
let body = response.json().await.map_err(|_| {
warn!(
"Website login invalid response: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Invalid response from login server")
})?;
Ok((body, session_cookie))
}
}
impl LoginChallengeClientError {
pub fn invalid(message: &'static str) -> Self {
Self {
status: None,
message,
}
}
}
impl std::fmt::Display for LoginChallengeClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (HTTP {:?})", self.message, self.status)
}
}
impl std::error::Error for LoginChallengeClientError {}

View File

@ -4,6 +4,8 @@ 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;
pub mod login_challenge_client;

View File

@ -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)
}

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/

View File

@ -1,10 +1,10 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_startup_time::AppStartupTime;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use chrono::TimeDelta;
use errors::AnyhowResult;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@ -13,24 +13,13 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_http::reqwest_cookie_store::CookieStore;
use tauri_plugin_http::Http;
const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/";
static STORYTELLER_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
const AVT_COOKIE_NAME : &str = "visitor";
const SESSION_COOKIE_NAME : &str = "session";
const AVT_COOKIE_NAME: &str = "visitor";
const SESSION_COOKIE_NAME: &str = "session";
/// There's some kind of race condition that we need to wait for before inspecting cookies.
const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000);
pub async fn persist_storyteller_cookies_task(
app: &AppHandle,
storyteller_credential_manager: &StorytellerCredentialManager,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
pub async fn persist_storyteller_cookies_task(app: &AppHandle, storyteller_credential_manager: &StorytellerCredentialManager, app_startup_time: &AppStartupTime) -> AnyhowResult<()> {
//if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME {
// // NB: There's an issue when the "main window thread" inquires about the
// // webview cookies shortly after app startup. When it attempts to dump the
@ -41,33 +30,30 @@ pub async fn persist_storyteller_cookies_task(
//}
let maybe_http = app.try_state::<Http>();
let http = match maybe_http {
None => {
error!("No HTTP plugin found");
return Err(anyhow!("No HTTP plugin found"));
}
},
Some(http) => http,
};
match http.cookies_jar.store.lock() {
Err(err) => {
error!("Failed to lock cookie jar: {:?}", err);
return Err(anyhow!("Failed to lock cookie jar"));
}
},
Ok(cookie_jar) => {
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?;
}
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager, &storyteller_cookie_url(app)?)?;
},
}
Ok(())
}
fn sync_tauri_credentials(
cookie_store: &CookieStore,
storyteller_credential_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?;
pub(crate) fn sync_tauri_credentials(cookie_store: &CookieStore, storyteller_credential_manager: &StorytellerCredentialManager, api_url: &Url) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store, api_url)?;
let mut replace_credentials = true;
@ -78,31 +64,80 @@ fn sync_tauri_credentials(
replace_credentials = false;
}
}
if replace_credentials {
info!("Syncing ArtCraft credentials ...");
storyteller_credential_manager.set_credentials(&current_http_plugin_credentials)?;
// NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now.
//storyteller_credential_manager.persist_all_to_disk()?;
}
Ok(())
}
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult<StorytellerCredentialSet> {
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore, api_url: &Url) -> AnyhowResult<StorytellerCredentialSet> {
let mut avt_cookie = None;
let mut session_cookie = None;
for cookie in cookie_jar.iter_unexpired() {
if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) {
avt_cookie = Some(avt);
} else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) {
session_cookie = Some(session);
// Only cookies applicable to our API origin can become ArtCraft credentials.
for (name, value) in cookie_jar.get_request_values(api_url) {
match name {
AVT_COOKIE_NAME => avt_cookie = Some(StorytellerAvtCookie::new(value.to_owned())),
SESSION_COOKIE_NAME => session_cookie = Some(StorytellerSessionCookie::new(value.to_owned())),
_ => {},
}
}
Ok(StorytellerCredentialSet::initialize(
avt_cookie,
session_cookie,
))
Ok(StorytellerCredentialSet::initialize(avt_cookie, session_cookie))
}
pub(crate) fn storyteller_cookie_url(app: &AppHandle) -> AnyhowResult<Url> {
let config = app.try_state::<AppEnvConfigs>().ok_or_else(|| anyhow!("API configuration unavailable"))?;
Ok(Url::parse(&format!("{}/", config.storyteller_host.to_api_hostname_and_scheme()))?)
}
#[cfg(test)]
mod login_bridge_tests {
use super::*;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use tauri_plugin_http::reqwest_cookie_store::RawCookie;
#[test]
fn approved_response_cookie_reaches_native_credentials() {
let directory = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(directory.path()).unwrap();
let manager = StorytellerCredentialManager::initialize_empty(&root);
let mut jar = CookieStore::default();
let url = Url::parse("https://api.storyteller.ai/v1/login_challenges/poll").unwrap();
jar.store_response_cookies([RawCookie::parse("session=signed_backend_cookie; Secure; HttpOnly; Path=/; Max-Age=3600").unwrap().into_owned(), RawCookie::parse("visitor=existing_visitor; Secure; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &url);
sync_tauri_credentials(&jar, &manager, &url).unwrap();
let credentials = manager.get_credentials_required().unwrap();
assert_eq!(credentials.session.unwrap().as_str(), "signed_backend_cookie");
assert_eq!(credentials.avt.unwrap().as_str(), "existing_visitor");
// Retrying the same successful poll is idempotent for native credentials too.
sync_tauri_credentials(&jar, &manager, &url).unwrap();
assert_eq!(manager.get_credentials_required().unwrap().session.unwrap().as_str(), "signed_backend_cookie");
}
#[test]
fn unrelated_and_expired_cookies_cannot_become_artcraft_sessions() {
let url = Url::parse("https://api.storyteller.ai/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=unrelated; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &Url::parse("https://unrelated.example/").unwrap());
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=expired; Secure; Path=/; Max-Age=0").unwrap().into_owned()].into_iter(), &url);
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
}
#[test]
fn configured_local_api_does_not_import_production_cookies() {
let production = Url::parse("https://api.storyteller.ai/").unwrap();
let local = Url::parse("http://localhost:12345/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=production; Secure; Path=/").unwrap().into_owned()].into_iter(), &production);
assert!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=development; Path=/").unwrap().into_owned()].into_iter(), &local);
assert_eq!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.unwrap().as_str(), "development");
assert_eq!(get_credentials_from_cookie_store(&jar, &production).unwrap().session.unwrap().as_str(), "production");
}
}

View File

@ -63,6 +63,13 @@ use crate::services::sora::commands::sora_logout_command::sora_logout_command;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread;
use crate::services::storyteller::commands::login_bridge::DesktopLoginBridgeState;
use crate::services::storyteller::commands::storyteller_cancel_login_challenge_command::storyteller_cancel_login_challenge_command;
use crate::services::storyteller::commands::storyteller_create_login_challenge_command::storyteller_create_login_challenge_command;
use crate::services::storyteller::commands::storyteller_get_login_session_command::storyteller_get_login_session_command;
use crate::services::storyteller::commands::storyteller_password_login_command::storyteller_password_login_command;
use crate::services::storyteller::commands::storyteller_password_signup_command::storyteller_password_signup_command;
use crate::services::storyteller::commands::storyteller_poll_login_challenge_command::storyteller_poll_login_challenge_command;
use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command;
use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command;
use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command;
@ -201,6 +208,7 @@ pub fn run() {
})
.manage(app_data_root)
.manage(app_env_configs)
.manage(DesktopLoginBridgeState::default())
.manage(app_preferences)
.manage(artcraft_platform_info)
.manage(artcraft_usage_tracker)
@ -259,6 +267,12 @@ pub fn run() {
set_provider_order_command,
sora_get_credential_info_command,
sora_logout_command,
storyteller_get_login_session_command,
storyteller_password_login_command,
storyteller_password_signup_command,
storyteller_create_login_challenge_command,
storyteller_poll_login_challenge_command,
storyteller_cancel_login_challenge_command,
storyteller_get_credits_command,
storyteller_get_subscription_command,
storyteller_open_credits_purchase_command,

View File

@ -0,0 +1,17 @@
# Tauri Commands
- Put each `#[tauri::command]` function in its own `.rs` file module named after
that command, such as `storyteller_create_login_challenge_command.rs`. Do not
collect multiple commands in a catch-all `*_commands.rs` file.
- Keep `mod.rs` for module declarations. Put shared state, response/error types,
and implementation helpers in separate support modules, with the narrowest
visibility needed by their callers.
- Update the imports used by `tauri::generate_handler!` when moving commands.
Preserve IPC command names and arguments unless changing that contract is
explicitly part of the task.
- Authentication HTTP requests run in Rust through `artcraft_client` endpoint
bindings. The frontend invokes Tauri commands; it does not call the API
directly, and desktop commands do not connect to the backend MySQL database.
- For login bridge changes, run the existing native fixture tests with
`SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests`.
These tests use loopback HTTP servers and temporary cookie stores.

View File

@ -0,0 +1,340 @@
use std::collections::HashMap;
use std::sync::Arc;
use artcraft_api_defs::users::login_challenges::{LoginChallengeFailure, LoginChallengeState};
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_client::utils::login_challenge_client::{
LoginChallengeClient, LoginChallengeClientError,
};
use chrono::{DateTime, Utc};
use log::{info, warn};
use serde::Serialize;
use tauri::{AppHandle, Manager};
use tauri_plugin_http::reqwest_cookie_store::{CookieStoreMutex, RawCookie};
use tauri_plugin_http::Http;
use tokio::sync::Mutex;
use uuid_utils::uuid::generate_random_uuid;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::get_credentials_from_cookie_store;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
const MAX_PENDING_CHALLENGES: usize = 8;
/// All bearer credentials stay in Rust. The UI holds only a local lookup handle.
#[derive(Default)]
pub struct DesktopLoginBridgeState {
pending: Mutex<HashMap<String, PendingChallenge>>,
}
#[derive(Clone)]
struct PendingChallenge {
device_token: String,
expires_at: DateTime<Utc>,
client: LoginChallengeClient,
}
#[derive(Serialize)]
pub struct DesktopLoginChallenge {
pub challenge_id: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Serialize)]
pub struct DesktopLoginOutcome {
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub maybe_user: Option<SessionUserInfo>,
}
#[derive(Debug, Serialize)]
pub struct DesktopLoginError {
pub status: Option<u16>,
pub message: String,
pub retryable: bool,
}
impl DesktopLoginError {
fn local(message: &str) -> Self {
Self {
status: None,
message: message.to_owned(),
retryable: false,
}
}
pub(super) fn from_client(origin: &str, error: LoginChallengeClientError) -> Self {
warn!(
"Website login failed: origin={} status={:?} reason={}",
origin, error.status, error.message
);
Self {
retryable: error
.status
.map(|s| s == 429 || s >= 500)
.unwrap_or(error.message == "Unable to reach the login server"),
status: error.status,
message: format!("{}: {} (HTTP {:?})", origin, error.message, error.status),
}
}
}
pub(super) async fn current_login_session(
client: &LoginChallengeClient,
jar: &Arc<CookieStoreMutex>,
) -> Result<Option<SessionUserInfo>, DesktopLoginError> {
let credentials = {
let store = jar
.store
.lock()
.map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?;
get_credentials_from_cookie_store(&store, &client.api_url())
.map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?
};
let Some(signed) = credentials.session else {
return Ok(None);
};
let session = match client.session(signed.as_str()).await {
Ok(session) => session,
Err(error) if error.status == Some(401) => return Ok(None),
Err(error) => return Err(DesktopLoginError::from_client(&client.api_origin(), error)),
};
Ok(
session
.user
.filter(|_| session.success && session.logged_in),
)
}
pub(super) async fn password_auth(
host: &ApiHost,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
login: Option<PasswordLoginRequest>,
signup: Option<PasswordSignupRequest>,
) -> Result<SessionUserInfo, DesktopLoginError> {
let client = LoginChallengeClient::new(host)
.map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?;
let response = match (login, signup) {
(Some(request), None) => client.password_login(&request).await,
(None, Some(request)) => client.password_signup(&request).await,
_ => return Err(DesktopLoginError::local("Invalid authentication request")),
}
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
let (response, cookie) = response;
if !response.success {
return Err(DesktopLoginError::local(
"Sign-in failed. Check your account details and try again.",
));
}
let signed = response
.signed_session
.ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?;
let cookie =
cookie.ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?;
let (cookie, user) = verify_session(&client, &signed, cookie).await?;
install_session_cookie(&client, &signed, cookie, manager, jar)?;
persist_session_cookie(jar).await?;
Ok(user)
}
pub(super) fn http_cookie_jar(app: &AppHandle) -> Result<Arc<CookieStoreMutex>, DesktopLoginError> {
Ok(
app
.try_state::<Http>()
.ok_or_else(|| DesktopLoginError::local("HTTP cookie store unavailable"))?
.cookies_jar
.clone(),
)
}
pub(super) async fn begin_challenge(
host: &ApiHost,
state: &DesktopLoginBridgeState,
) -> Result<DesktopLoginChallenge, DesktopLoginError> {
let client = LoginChallengeClient::new(host)
.map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?;
let created = client
.create()
.await
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
if !created.success
|| !client.allows_verification_url(&created.verification_url)
|| created.device_token.len() != 43
|| created.expires_at <= Utc::now()
{
return Err(DesktopLoginError::local(
"Invalid login challenge or website/API environment mismatch",
));
}
let mut pending = state.pending.lock().await;
pending.retain(|_, value| value.expires_at > Utc::now());
if pending.len() >= MAX_PENDING_CHALLENGES {
return Err(DesktopLoginError::local("Too many pending login attempts"));
}
let challenge_id = generate_random_uuid();
pending.insert(
challenge_id.clone(),
PendingChallenge {
device_token: created.device_token,
expires_at: created.expires_at,
client,
},
);
Ok(DesktopLoginChallenge {
challenge_id,
verification_url: created.verification_url,
confirmation_code: created.confirmation_code,
expires_at: created.expires_at,
poll_interval_seconds: created.poll_interval_seconds.max(5),
})
}
pub(super) async fn poll_challenge(
state: &DesktopLoginBridgeState,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
challenge_id: &str,
) -> Result<DesktopLoginOutcome, DesktopLoginError> {
let challenge = {
let mut pending = state.pending.lock().await;
let challenge = pending.get(challenge_id).cloned().ok_or_else(|| {
DesktopLoginError::local("Login attempt was cancelled or is no longer available")
})?;
if challenge.expires_at <= Utc::now() {
pending.remove(challenge_id);
return Ok(expired());
}
challenge
};
let origin = challenge.client.api_origin();
let polled = challenge
.client
.poll(&challenge.device_token)
.await
.map_err(|e| DesktopLoginError::from_client(&origin, e))?;
if !polled.response.success || polled.response.status == LoginChallengeState::Unknown {
return Err(DesktopLoginError::local("Unrecognized login response"));
}
if polled.response.status != LoginChallengeState::Redeemed {
if polled.response.status == LoginChallengeState::Failed {
state.pending.lock().await.remove(challenge_id);
}
return Ok(DesktopLoginOutcome {
status: polled.response.status,
maybe_failure_type: polled.response.maybe_failure_type,
maybe_user: None,
});
}
let signed = polled
.response
.maybe_signed_session
.ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?;
let set_cookie = polled
.session_set_cookie
.ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?;
let (cookie, user) = verify_session(&challenge.client, &signed, set_cookie).await?;
{
let pending = state.pending.lock().await;
// A cancellation during either HTTP request must prevent local installation.
if !pending.contains_key(challenge_id) {
return Err(DesktopLoginError::local("Login attempt was cancelled"));
}
if challenge.expires_at <= Utc::now() {
return Ok(expired());
}
install_session_cookie(&challenge.client, &signed, cookie, manager, jar)?;
}
// Keep the challenge for idempotent IPC retries; it expires on its original deadline.
persist_session_cookie(jar).await?;
info!("Website login complete: origin={}", origin);
Ok(DesktopLoginOutcome {
status: LoginChallengeState::Redeemed,
maybe_failure_type: None,
maybe_user: Some(user),
})
}
pub(super) async fn cancel_challenge(state: &DesktopLoginBridgeState, challenge_id: &str) {
state.pending.lock().await.remove(challenge_id);
}
async fn verify_session(
client: &LoginChallengeClient,
signed: &str,
set_cookie: String,
) -> Result<(RawCookie<'static>, SessionUserInfo), DesktopLoginError> {
let cookie = RawCookie::parse(set_cookie)
.map_err(|_| DesktopLoginError::local("Invalid session cookie"))?
.into_owned();
if cookie.name() != "session" || cookie.value() != signed {
return Err(DesktopLoginError::local(
"Session cookie did not match the redeemed session",
));
}
// Validate on the SAME native API host before publishing any new credentials.
let session = client
.session(&signed)
.await
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
let user = session
.user
.filter(|_| session.success && session.logged_in)
.ok_or_else(|| DesktopLoginError::local("The redeemed session could not authenticate"))?;
Ok((cookie, user))
}
fn install_session_cookie(
client: &LoginChallengeClient,
signed: &str,
cookie: RawCookie<'static>,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
) -> Result<(), DesktopLoginError> {
let mut store = jar
.store
.lock()
.map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?;
let mut candidate = store.clone();
candidate.store_response_cookies([cookie].into_iter(), &client.api_url());
let credentials = get_credentials_from_cookie_store(&candidate, &client.api_url())
.map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?;
if credentials.session.as_ref().map(|c| c.as_str()) != Some(signed) {
return Err(DesktopLoginError::local(
"Session cookie does not apply to the configured API host",
));
}
manager
.set_credentials(&credentials)
.map_err(|_| DesktopLoginError::local("Unable to store native login credentials"))?;
*store = candidate;
Ok(())
}
async fn persist_session_cookie(jar: &Arc<CookieStoreMutex>) -> Result<(), DesktopLoginError> {
let saved = jar
.request_save()
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;
tokio::task::spawn_blocking(move || saved.recv())
.await
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;
Ok(())
}
fn expired() -> DesktopLoginOutcome {
DesktopLoginOutcome {
status: LoginChallengeState::Failed,
maybe_failure_type: Some(LoginChallengeFailure::Expired),
maybe_user: None,
}
}
#[cfg(test)]
#[path = "storyteller_login_bridge_tests.rs"]
mod login_bridge_tests;

View File

@ -2,4 +2,11 @@ pub mod storyteller_purge_credentials_command;
pub mod storyteller_get_credits_command;
pub mod storyteller_get_subscription_command;
pub mod stripe_checkout;
pub mod stripe_customer_portal;
pub mod stripe_customer_portal;
pub(crate) mod login_bridge;
pub mod storyteller_cancel_login_challenge_command;
pub mod storyteller_create_login_challenge_command;
pub mod storyteller_get_login_session_command;
pub mod storyteller_password_login_command;
pub mod storyteller_password_signup_command;
pub mod storyteller_poll_login_challenge_command;

View File

@ -0,0 +1,14 @@
use tauri::State;
use crate::services::storyteller::commands::login_bridge::{
cancel_challenge, DesktopLoginBridgeState, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_cancel_login_challenge_command(
state: State<'_, DesktopLoginBridgeState>,
challenge_id: String,
) -> Result<(), DesktopLoginError> {
cancel_challenge(&state, &challenge_id).await;
Ok(())
}

View File

@ -0,0 +1,14 @@
use tauri::State;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
begin_challenge, DesktopLoginBridgeState, DesktopLoginChallenge, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_create_login_challenge_command(
config: State<'_, AppEnvConfigs>,
state: State<'_, DesktopLoginBridgeState>,
) -> Result<DesktopLoginChallenge, DesktopLoginError> {
begin_challenge(&config.storyteller_host, &state).await
}

View File

@ -0,0 +1,19 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::utils::login_challenge_client::LoginChallengeClient;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
current_login_session, http_cookie_jar, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_get_login_session_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
) -> Result<Option<SessionUserInfo>, DesktopLoginError> {
let client = LoginChallengeClient::new(&config.storyteller_host).map_err(|error| {
DesktopLoginError::from_client(&config.storyteller_host.to_api_hostname_and_scheme(), error)
})?;
current_login_session(&client, &http_cookie_jar(&app)?).await
}

View File

@ -0,0 +1,436 @@
//! Commands' native implementation + real ArtCraft bindings + loopback HTTP.
//! All listeners bind to an ephemeral local port; no production host is contacted.
use super::*;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use serde_json::{json, Value};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
use tauri_plugin_http::reqwest_cookie_store::CookieStore;
const SIGNED: &str = "signed_native_session_fixture";
const DEVICE: &str = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD";
#[tokio::test]
async fn approved_session_is_verified_installed_persisted_and_retried_entirely_in_rust() {
let server = server(vec![
create(),
pending(),
redeemed(),
session(),
redeemed(),
session(),
]);
let h = harness();
// Native installation must preserve visitor cookies and avoid importing other origins.
h.jar.store.lock().unwrap().store_response_cookies(
[RawCookie::parse("visitor=existing; Path=/; Max-Age=3600")
.unwrap()
.into_owned()]
.into_iter(),
&server_url(&server),
);
h.manager
.set_credentials(&StorytellerCredentialSet::initialize_with_just_avt(
StorytellerAvtCookie::new("existing".into()),
))
.unwrap();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let public = serde_json::to_string(&created).unwrap();
assert!(!public.contains(DEVICE));
assert!(!public.contains("device_token"));
assert!(h
.manager
.get_credentials_required()
.unwrap()
.session
.is_none());
let wait = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(wait.status, LoginChallengeState::Pending);
assert!(h
.manager
.get_credentials_required()
.unwrap()
.session
.is_none());
for _ in 0..2 {
let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(result.status, LoginChallengeState::Redeemed);
assert_eq!(result.maybe_user.as_ref().unwrap().username, "google_user");
// This was the real failure: use_qt exists on the server, not this desktop.
let flags = &result.maybe_user.as_ref().unwrap().maybe_feature_flags;
assert_eq!(flags.len(), 1);
assert_eq!(flags.first().unwrap().to_str(), "studio");
assert!(!serde_json::to_string(&result).unwrap().contains(SIGNED));
let credentials = h.manager.get_credentials_required().unwrap();
assert_eq!(credentials.session.unwrap().as_str(), SIGNED);
assert_eq!(credentials.avt.unwrap().as_str(), "existing");
assert!(std::fs::read_to_string(&h.jar.path)
.unwrap()
.contains(SIGNED));
}
server.thread.join().unwrap();
}
#[tokio::test]
async fn rejection_and_timeout_never_install_a_session() {
for expired in [false, true] {
let mut steps = vec![create()];
if !expired {
steps.push(step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"failed","maybe_failure_type":"user_declined"}),
));
}
let server = server(steps);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
if expired {
h.state
.pending
.lock()
.await
.get_mut(&created.challenge_id)
.unwrap()
.expires_at = Utc::now();
}
let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(result.status, LoginChallengeState::Failed);
assert_eq!(
result.maybe_failure_type,
Some(if expired {
LoginChallengeFailure::Expired
} else {
LoginChallengeFailure::UserDeclined
})
);
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn invalid_downstream_session_is_not_installed_and_error_has_host_without_credentials() {
let mut invalid = session();
invalid.status = 401;
invalid.body = json!({"echo": SIGNED});
let server = server(vec![create(), redeemed(), invalid]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.err()
.unwrap();
assert_eq!(error.status, Some(401));
assert!(!error.retryable);
assert!(error
.message
.contains(&server.host.to_api_hostname_and_scheme()));
assert!(!format!("{error:?}").contains(SIGNED));
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
#[tokio::test]
async fn cancelling_during_http_prevents_late_cookie_installation() {
let mut delayed = redeemed();
delayed.delay = Duration::from_millis(150);
let server = server(vec![create(), delayed, session()]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let (result, _) = tokio::join!(
poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id),
async {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel_challenge(&h.state, &created.challenge_id).await;
}
);
assert!(result.is_err());
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
#[tokio::test]
async fn unknown_states_and_redirects_fail_closed() {
for redirect in [false, true] {
let mut result = step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"future_state","maybe_signed_session":SIGNED}),
);
if redirect {
result.status = 302;
result.headers = "Location: https://must-never-be-contacted.invalid/\r\n".into();
}
let server = server(vec![create(), result]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.err()
.unwrap();
assert!(!error.retryable);
if redirect {
assert_eq!(error.status, Some(302));
}
assert!(h.manager.get_credentials().unwrap().is_none());
server.thread.join().unwrap();
}
}
#[test]
fn website_origin_must_match_native_api_environment() {
let local = LoginChallengeClient::new(&ApiHost::Localhost { port: 12345 }).unwrap();
let prod = LoginChallengeClient::new(&ApiHost::Storyteller).unwrap();
let fragment = format!("#approval_token={}", "A".repeat(43));
assert!(local.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}")));
assert!(!local.allows_verification_url(&format!(
"https://app.getartcraft.com/login/desktop{fragment}"
)));
assert!(prod.allows_verification_url(&format!(
"https://app.getartcraft.com/login/desktop{fragment}"
)));
assert!(!prod.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}")));
}
#[tokio::test]
async fn malformed_session_data_still_cannot_install_credentials() {
for (field, value) in [("maybe_feature_flags", json!([42])), ("username", Value::Null)] {
let mut invalid = session();
invalid.body["user"][field] = value;
let server = server(vec![create(), redeemed(), invalid]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
assert!(poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id).await.is_err());
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h.jar.store.lock().unwrap().iter_unexpired().next().is_none());
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn password_login_and_signup_verify_persist_and_recheck_via_native_api() {
for signup in [false, true] {
let mut auth = redeemed();
auth.path = if signup { "/v1/create_account" } else { "/v1/login" };
auth.body = json!({"success":true,"signed_session":SIGNED});
let server = server(vec![auth, session(), session()]);
let h = harness();
let (login, signup) = if signup {
(None, Some(PasswordSignupRequest { username: "google_user".into(), email_address: "user@example.test".into(), password: "fixture_password".into(), password_confirmation: "fixture_password".into(), signup_source: "artcraft".into() }))
} else {
(Some(PasswordLoginRequest { username_or_email: "google_user".into(), password: "fixture_password".into() }), None)
};
let user = password_auth(&server.host, &h.manager, &h.jar, login, signup).await.unwrap();
assert_eq!(user.username, "google_user");
assert_eq!(h.manager.get_credentials_required().unwrap().session.unwrap().as_str(), SIGNED);
assert!(std::fs::read_to_string(&h.jar.path).unwrap().contains(SIGNED));
let client = LoginChallengeClient::new(&server.host).unwrap();
assert_eq!(current_login_session(&client, &h.jar).await.unwrap().unwrap().username, "google_user");
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn empty_native_cookie_jar_needs_no_session_http_request() {
let h = harness();
let client = LoginChallengeClient::new(&ApiHost::Localhost { port: 1 }).unwrap();
assert!(current_login_session(&client, &h.jar).await.unwrap().is_none());
}
struct Harness {
_directory: tempfile::TempDir,
state: DesktopLoginBridgeState,
manager: StorytellerCredentialManager,
jar: Arc<CookieStoreMutex>,
}
struct Server {
host: ApiHost,
thread: thread::JoinHandle<()>,
}
struct Step {
path: &'static str,
status: u16,
body: Value,
headers: String,
delay: Duration,
}
fn harness() -> Harness {
let directory = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(directory.path()).unwrap();
let manager = StorytellerCredentialManager::initialize_empty(&root);
let jar = Arc::new(CookieStoreMutex::new(
directory.path().join("cookies.json"),
CookieStore::default(),
));
Harness {
_directory: directory,
state: DesktopLoginBridgeState::default(),
manager,
jar,
}
}
fn server_url(server: &Server) -> reqwest::Url {
reqwest::Url::parse(&format!("{}/", server.host.to_api_hostname_and_scheme())).unwrap()
}
fn server(steps: Vec<Step>) -> Server {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let thread = thread::spawn(move || {
for step in steps {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut data = Vec::new();
let mut buffer = [0; 4096];
loop {
let count = stream.read(&mut buffer).unwrap();
assert!(count > 0);
data.extend_from_slice(&buffer[..count]);
if let Some(end) = data.windows(4).position(|v| v == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&data[..end]).to_lowercase();
let length: usize = headers
.lines()
.find_map(|s| s.strip_prefix("content-length:"))
.map(|s| s.trim().parse().unwrap())
.unwrap_or(0);
if data.len() >= end + 4 + length {
break;
}
}
}
let request = String::from_utf8(data).unwrap();
assert_eq!(request.split_whitespace().nth(1), Some(step.path));
assert!(request
.to_lowercase()
.contains(&format!("host: localhost:{port}")));
if step.path == "/v1/session" {
assert!(request.starts_with("GET "));
assert!(request.contains(&format!("session={SIGNED}")));
} else {
assert!(request.starts_with("POST "));
assert!(!request.to_lowercase().contains("cookie:"));
if step.path.ends_with("/poll") {
let body: Value =
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body, json!({"device_token":DEVICE}));
}
}
thread::sleep(step.delay);
let body = step.body.to_string();
write!(stream, "HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", step.status, body.len(), step.headers, body).unwrap();
}
});
Server {
host: ApiHost::Localhost { port: port.into() },
thread,
}
}
fn step(path: &'static str, body: Value) -> Step {
Step {
path,
body,
status: 200,
headers: String::new(),
delay: Duration::ZERO,
}
}
fn create() -> Step {
step(
"/v1/login_challenges/create",
json!({"success":true,"device_token":DEVICE,"verification_url":format!("http://localhost:4201/login/desktop#approval_token={}","A".repeat(43)),"confirmation_code":"WDJBMJHT","expires_at":Utc::now()+chrono::Duration::minutes(20),"poll_interval_seconds":5}),
)
}
fn pending() -> Step {
step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"pending","maybe_failure_type":null}),
)
}
fn redeemed() -> Step {
let mut response = step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"redeemed","maybe_failure_type":null,"maybe_signed_session":SIGNED}),
);
response.headers = format!("Set-Cookie: session={SIGNED}; HttpOnly; Path=/; Max-Age=3600\r\n");
response
}
fn session() -> Step {
let mut user = json!({
"user_token":"u_native_test", "username":"google_user", "display_name":"Google User", "email_gravatar_hash":"fixture",
"core_info":{"user_token":"u_native_test","username":"google_user","display_name":"Google User","gravatar_hash":"fixture","default_avatar":{"image_index":1,"color_index":1}},
"onboarding":{"email_not_set":false,"email_not_confirmed":false,"password_not_set":true,"username_not_customized":false},
"maybe_feature_flags":["studio", "use_qt", "future_server_flag"],"fakeyou_plan":"free","storyteller_stream_plan":"free"
});
for field in [
"can_access_studio",
"can_use_tts",
"can_use_w2l",
"can_delete_own_tts_results",
"can_delete_own_w2l_results",
"can_delete_own_account",
"can_upload_tts_models",
"can_upload_w2l_templates",
"can_delete_own_tts_models",
"can_delete_own_w2l_templates",
"can_approve_w2l_templates",
"can_edit_other_users_profiles",
"can_edit_other_users_tts_models",
"can_edit_other_users_w2l_templates",
"can_delete_other_users_tts_models",
"can_delete_other_users_w2l_templates",
"can_delete_other_users_tts_results",
"can_delete_other_users_w2l_results",
"can_ban_users",
"can_delete_users",
] {
user[field] = json!(false);
}
step(
"/v1/session",
json!({"success":true,"logged_in":true,"user":user}),
)
}

View File

@ -0,0 +1,26 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, password_auth, DesktopLoginError,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_password_login_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
manager: State<'_, StorytellerCredentialManager>,
request: PasswordLoginRequest,
) -> Result<SessionUserInfo, DesktopLoginError> {
password_auth(
&config.storyteller_host,
&manager,
&http_cookie_jar(&app)?,
Some(request),
None,
)
.await
}

View File

@ -0,0 +1,26 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, password_auth, DesktopLoginError,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_password_signup_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
manager: State<'_, StorytellerCredentialManager>,
request: PasswordSignupRequest,
) -> Result<SessionUserInfo, DesktopLoginError> {
password_auth(
&config.storyteller_host,
&manager,
&http_cookie_jar(&app)?,
None,
Some(request),
)
.await
}

View File

@ -0,0 +1,17 @@
use tauri::{AppHandle, State};
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, poll_challenge, DesktopLoginBridgeState, DesktopLoginError, DesktopLoginOutcome,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_poll_login_challenge_command(
app: AppHandle,
state: State<'_, DesktopLoginBridgeState>,
manager: State<'_, StorytellerCredentialManager>,
challenge_id: String,
) -> Result<DesktopLoginOutcome, DesktopLoginError> {
let jar = http_cookie_jar(&app)?;
poll_challenge(&state, &manager, &jar, &challenge_id).await
}

View File

@ -3,9 +3,10 @@ use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest;
use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LOG_ACTIVE_USER_V2_PATH};
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::error::storyteller_error::StorytellerError;
use errors::AnyhowResult;
use log::{debug, error, info};
@ -38,7 +39,7 @@ pub async fn storyteller_activity_thread(
&app_session_token,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
error!("Activity request failed: origin={} path={} error={:?}", app_env_configs.storyteller_host.to_api_hostname_and_scheme(), LOG_ACTIVE_USER_V2_PATH, err);
}
// NB: Sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await;
@ -62,7 +63,7 @@ async fn polling_loop(
continue;
}
Some(creds) => {
if creds.is_empty() {
if !has_activity_session(&creds) {
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
continue;
}
@ -123,7 +124,31 @@ async fn polling_loop(
};
// Wait at least a minute, no matter what the server tells us.
let wait_millis = std::cmp::min(wait_millis, 60_000);
let wait_millis = std::cmp::max(wait_millis, 60_000);
tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await;
}
}
// Anonymous visitor tracking is not authentication for active_user_v2.
fn has_activity_session(creds: &StorytellerCredentialSet) -> bool {
creds.session.as_ref().map(|session| !session.as_str().is_empty()).unwrap_or(false)
}
#[cfg(test)]
mod login_bridge_tests {
use super::has_activity_session;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
#[test]
fn anonymous_visitor_cookie_does_not_authorize_activity_requests() {
assert!(!has_activity_session(&StorytellerCredentialSet::empty()));
let visitor_only = StorytellerCredentialSet::initialize_with_just_avt(StorytellerAvtCookie::new("visitor_fixture".into()));
assert!(!has_activity_session(&visitor_only));
let empty_session = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new(String::new()));
assert!(!has_activity_session(&empty_session));
let signed_in = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new("signed_session_fixture".into()));
assert!(has_activity_session(&signed_in));
}
}

View File

@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.39.0";
pub const ARTCRAFT_VERSION: &str = "0.41.0";

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.39.0",
"version": "0.41.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.39.0",
"version": "0.41.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",

View File

@ -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)

View File

@ -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())
}
}

View File

@ -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;

View File

@ -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";

View File

@ -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" },
];

View File

@ -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)";

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 }

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())
}
}

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);

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;

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
docs/performance.md Normal file
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.

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": []
}

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
docs/website-login.md Normal file
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.

View File

@ -596,6 +596,7 @@ export const TopBar = ({ pageName }: Props) => {
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
aria-label="Settings"
variant="secondary"
icon={SettingsIcon}
iconClassName="h-5 w-5 shrink-0"

View File

@ -10,8 +10,7 @@
// siblings, never wrapped by EngineProvider, so they have no
// dependency on the 3D engine being present.
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { lazy, Suspense, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
@ -58,26 +57,55 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
import PageDraw from "./PageDraw/PageDraw";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import CreateAudio from "./PageAudio/CreateAudio";
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import { PageVideoEditor } from "./PageVideoEditor";
import { PageMoodboard } from "./PageMoodboard";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
// Load editors when selected, keeping their code off the launch path. Drawing
// stays eager: its shared store/UI is already needed by the shell, so deferring
// its small host adapter adds first-open latency for very little byte saving.
// Declare lazy components once so navigating does not recreate their identity.
const TextToImage = lazy(() => import("./PageImage/TextToImage"));
const ImageToVideo = lazy(() => import("./PageVideo/ImageToVideo"));
const CreateAudio = lazy(() => import("./PageAudio/CreateAudio"));
const VideoFrameExtractor = lazy(() =>
import("./PageVideoFrameExtractor").then((m) => ({ default: m.VideoFrameExtractor })),
);
const VideoWatermarkRemover = lazy(() =>
import("./PageVideoWatermarkRemover").then((m) => ({ default: m.VideoWatermarkRemover })),
);
const ImageWatermarkRemover = lazy(() =>
import("./PageImageWatermarkRemover").then((m) => ({ default: m.ImageWatermarkRemover })),
);
const ImageTo3DObject = lazy(() =>
import("./PageImageTo3DObject").then((m) => ({ default: m.ImageTo3DObject })),
);
const ImageTo3DWorld = lazy(() =>
import("./PageImageTo3DWorld").then((m) => ({ default: m.ImageTo3DWorld })),
);
const RemoveBackground = lazy(() =>
import("./PageRemoveBackground").then((m) => ({ default: m.RemoveBackground })),
);
const Angles = lazy(() =>
import("./PageAngles").then((m) => ({ default: m.Angles })),
);
const Storyboard = lazy(() =>
import("./PageStoryboard").then((m) => ({ default: m.Storyboard })),
);
const PageBackgroundChange = lazy(() =>
import("./PageBackgroundChange").then((m) => ({ default: m.PageBackgroundChange })),
);
const PageScene = lazy(() =>
import("./PageScene").then((m) => ({ default: m.PageScene })),
);
const PageVideoEditor = lazy(() =>
import("./PageVideoEditor").then((m) => ({ default: m.PageVideoEditor })),
);
const PageMoodboard = lazy(() =>
import("./PageMoodboard").then((m) => ({ default: m.PageMoodboard })),
);
interface Props {
sceneToken?: string;
}
@ -121,8 +149,7 @@ export const MainApp = ({ sceneToken }: Props) => {
toast.error("File deleted.");
});
// Session probe (runs once per shell mount) and GPU detection.
// Both are app-wide concerns, not 3D-only.
// Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
@ -132,20 +159,6 @@ export const MainApp = ({ sceneToken }: Props) => {
});
}, []);
const [, setValidGpu] = useState("unknown");
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) isValid = true;
if (fps > 15) isValid = true;
if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
setValidGpu(isValid ? "valid" : "error");
});
}, []);
const { triggerRecheck } = useLoginModalStore();
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
@ -179,7 +192,18 @@ export const MainApp = ({ sceneToken }: Props) => {
}}
/>
<TabBody sceneToken={sceneToken} />
<Suspense
fallback={
<div
role="status"
className="flex h-[calc(100vh-56px)] items-center justify-center text-base-fg/60"
>
Loading…
</div>
}
>
<TabBody sceneToken={sceneToken} />
</Suspense>
<GalleryDragComponent />
<ErrorDialog />
@ -210,7 +234,7 @@ export const MainApp = ({ sceneToken }: Props) => {
};
const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
const tabStore = useTabStore();
const activeTabId = useTabStore((s) => s.activeTabId);
const storyboardPageEnabled = useStoryboardPageEnabled();
// The 3D case stays unwrapped because Stage3DBody (lib) already
@ -218,7 +242,7 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
// top-level children may use position: fixed (e.g. PageDraw); the
// wrapping <div> scopes them so they don't stack as siblings of
// the TopBar at the MainApp root.
switch (tabStore.activeTabId) {
switch (activeTabId) {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":

View File

@ -0,0 +1,84 @@
import { beforeEach, expect, it } from "vitest";
import { useTabStore } from "./TabState";
import { useSceneStore } from "../../../../../../libs/components/pagedraw/src/lib/stores/SceneState";
import { Node } from "../../../../../../libs/components/pagedraw/src/lib/Node";
const initialScene = useSceneStore.getInitialState();
const initialTabs = useTabStore.getInitialState();
beforeEach(() => {
useSceneStore.setState(initialScene, true);
useTabStore.setState(initialTabs, true);
});
it("retains decoded images, selection, model metadata, and history across tab changes", async () => {
const image = new Node({
id: "image", type: "image", x: 10, y: 20, width: 512, height: 512,
fill: "transparent", imageFile: new File(["pixels"], "artwork.png"),
imageElement: new Image(), modelUrl: "blob:model",
});
useSceneStore.setState({ drawNodes: [image], selectedNodeIds: [image.id] });
useSceneStore.getState().saveState();
const before = useSceneStore.getState();
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("APPS");
await useTabStore.getState().setActiveTab("2D");
const after = useSceneStore.getState();
expect(after.drawNodes).toBe(before.drawNodes);
expect(after.drawNodes[0]).toBe(image);
expect(after.selectedNodeIds).toEqual([image.id]);
expect(after.history).toBe(before.history);
expect(after.historyIndex).toBe(before.historyIndex);
});
it("keeps redo available when navigating away after undo", async () => {
const scene = useSceneStore.getState();
useSceneStore.setState({ drawNodes: [rectangle("first")] });
scene.saveState();
useSceneStore.setState({ drawNodes: [rectangle("first"), rectangle("second")] });
scene.saveState();
await scene.undo();
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("IMAGE");
await useTabStore.getState().setActiveTab("2D");
await useSceneStore.getState().redo();
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["first", "second"]);
});
it("keeps edits made while away and does not restore an old scene after a gallery reset", async () => {
useSceneStore.setState({ drawNodes: [rectangle("old")] });
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("APPS");
useSceneStore.getState().RESET();
useSceneStore.setState({ drawNodes: [rectangle("new")] });
await useTabStore.getState().setActiveTab("2D");
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["new"]);
});
it("honors the latest rapid navigation request", async () => {
await useTabStore.getState().setActiveTab("2D");
const results = await Promise.all([
useTabStore.getState().setActiveTab("IMAGE"),
useTabStore.getState().setActiveTab("VIDEO"),
]);
expect(results).toEqual([true, true]);
expect(useTabStore.getState().activeTabId).toBe("VIDEO");
});
it("still exports a portable scene when explicitly requested", async () => {
const image = new Node({
id: "image", type: "image", x: 0, y: 0, width: 1, height: 1,
fill: "transparent", imageFile: new File(["pixels"], "image.png", { type: "image/png" }),
});
useSceneStore.setState({ drawNodes: [image] });
const exported = JSON.parse(await useSceneStore.getState().exportSceneAsJson());
expect(exported.drawNodes[0].imageDataUrl).toBe("data:image/png;base64,cGl4ZWxz");
});
function rectangle(id: string) {
return new Node({ id, type: "rectangle", x: 0, y: 0, width: 100, height: 100, fill: "red" });
}

View File

@ -1,5 +1,4 @@
import { create } from "zustand";
import { useSceneStore } from "@storyteller/ui-pagedraw";
export type TabId =
| "2D"
@ -42,40 +41,14 @@ export const useTabStore = create<TabState>((set, get) => ({
tabData: {},
setActiveTab: async (newTabId) => {
const currentTabId = get().activeTabId;
// Don't do anything if we're already on this tab
if (currentTabId === newTabId) return true;
if (get().activeTabId === newTabId) return true;
try {
// Save current 2D state if we're leaving 2D tab
if (currentTabId === "2D") {
const sceneStore = useSceneStore.getState();
const sceneState = await sceneStore.serializeSceneToString();
set((state) => ({
tabData: {
...state.tabData,
"2D": sceneState,
},
}));
}
// Load 2D state if we're entering 2D tab
if (newTabId === "2D") {
const savedState = get().tabData["2D"];
if (savedState) {
const sceneStore = useSceneStore.getState();
sceneStore.loadSceneFromString(savedState);
}
}
// Update active tab
set({ activeTabId: newTabId });
return true;
} catch (error) {
console.error("Error during tab change:", error);
return false;
}
// The drawing store outlives its React page. Keep its live image objects,
// selection, and undo history when switching tabs. Export/import is for
// files; round-tripping here reread every image and rebuilt the scene.
set({ activeTabId: newTabId });
return true;
},
updateTabData: (tabId, data) => {

View File

@ -1,4 +1,5 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
import { resolve } from "node:path";
@ -58,6 +59,7 @@ export default defineConfig({
},
},
plugins: [
react(),
sparkWasmDataUrlFix(),
tsconfigPaths({
// Resolve workspace packages to source for every importer, including shared

View File

@ -0,0 +1,167 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DesktopLoginBridge } from "./DesktopLoginBridge";
const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbiddenFetch: vi.fn(() => { throw new Error("JavaScript must never call HTTP for login"); }) }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: native }));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch }));
vi.mock("@storyteller/api", () => { throw new Error("Login UI must only import API types"); });
vi.mock("qrcode.react", () => ({ QRCodeSVG: ({ value }: { value: string }) => <svg role="img" aria-label="Login QR" data-value={value} /> }));
const CHALLENGE_ID = "local-handle";
const APPROVAL_URL = `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`;
const USER = { user_token: "user_fixture", username: "google_user" };
let outcome: Record<string, unknown>;
let expiresIn: number;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
vi.stubGlobal("fetch", forbiddenFetch);
localStorage.clear();
native.mockReset();
forbiddenFetch.mockClear();
expiresIn = 1_200_000;
outcome = { status: "pending", maybe_failure_type: null, maybe_user: null };
native.mockImplementation(async (command: string) => {
if (command === "storyteller_create_login_challenge_command") return {
challenge_id: CHALLENGE_ID, verification_url: APPROVAL_URL,
confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + expiresIn).toISOString(), poll_interval_seconds: 5,
};
if (command === "storyteller_poll_login_challenge_command") return outcome;
if (command === "storyteller_cancel_login_challenge_command" || command === "plugin:opener|open_url") return;
throw new Error(`Unexpected native command: ${command}`);
});
});
afterEach(() => {
cleanup();
expect(forbiddenFetch).not.toHaveBeenCalled();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("desktop native login bridge integration", () => {
it("creates through IPC and opens the native browser with the matching QR/code", async () => {
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Login with Website");
expect(native).toHaveBeenCalledWith("storyteller_create_login_challenge_command");
expect(native).toHaveBeenCalledWith("plugin:opener|open_url", { url: APPROVAL_URL });
expect(screen.getByRole("img", { name: "Login QR" }).getAttribute("data-value")).toBe(APPROVAL_URL);
expect(screen.getByText("WDJB-MJHT")).toBeTruthy();
});
it("polls with only a local handle and accepts the natively verified user without seeing credentials", async () => {
const success = vi.fn();
render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
await tick(5000);
expect(success).not.toHaveBeenCalled();
outcome = { status: "redeemed", maybe_failure_type: null, maybe_user: USER };
await tick(5000);
expect(native).toHaveBeenCalledWith("storyteller_poll_login_challenge_command", { challengeId: CHALLENGE_ID });
expect(success).toHaveBeenCalledWith(USER);
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID });
const count = calls("storyteller_poll_login_challenge_command");
await tick(30_000);
expect(calls("storyteller_poll_login_challenge_command")).toBe(count);
});
it("stops after decline", async () => {
const success = vi.fn();
outcome = { status: "failed", maybe_failure_type: "user_declined" };
render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("Login was declined on the website.")).toBeTruthy();
expect(success).not.toHaveBeenCalled();
await tick(60_000);
expect(calls("storyteller_poll_login_challenge_command")).toBe(1);
});
it("expires locally and cancels the native attempt without creating another", async () => {
expiresIn = 5000;
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("Login request expired. Start a new request.")).toBeTruthy();
expect(calls("storyteller_create_login_challenge_command")).toBe(1);
expect(calls("storyteller_poll_login_challenge_command")).toBe(0);
expect(calls("storyteller_cancel_login_challenge_command")).toBe(1);
});
it("backs off a native network failure and retries the same local handle", async () => {
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
native.mockRejectedValueOnce({ message: "Disconnected", status: null, retryable: true });
await tick(5000);
expect(screen.getByText("Connection interrupted. Retrying…")).toBeTruthy();
await tick(9999);
expect(calls("storyteller_poll_login_challenge_command")).toBe(1);
await tick(1);
expect(calls("storyteller_poll_login_challenge_command")).toBe(2);
});
it("cancels on unmount and ignores a late command result", async () => {
let resolve: (value: unknown) => void = () => {};
const success = vi.fn();
const view = render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
await tick(5000);
view.unmount();
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID });
await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); });
expect(success).not.toHaveBeenCalled();
});
it("ignores an in-flight redemption after Back and can start a new challenge", async () => {
let resolve: (value: unknown) => void = () => {};
const success = vi.fn();
render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
await tick(5000);
await start("Back");
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID });
await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); });
expect(success).not.toHaveBeenCalled();
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
await tick(30_000);
expect(calls("storyteller_poll_login_challenge_command")).toBe(1);
await start("Scan to Login");
expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
outcome = { status: "redeemed", maybe_user: USER };
await tick(5000);
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
});
it("fails closed on a future status", async () => {
outcome = { status: "future_state", maybe_user: USER };
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("This login request could not be completed. Start a new request.")).toBeTruthy();
});
it("shows the native host/status diagnostic and stops on terminal HTTP errors", async () => {
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
native.mockRejectedValueOnce({ message: "http://localhost:12345: Login server rejected the request (HTTP 401)", status: 401, retryable: false });
await tick(5000);
expect(screen.getByText(/localhost:12345.*401/)).toBeTruthy();
await tick(60_000);
expect(calls("storyteller_poll_login_challenge_command")).toBe(1);
});
});
async function start(label: string) {
await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); });
}
async function tick(ms: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
}
function calls(command: string) {
return native.mock.calls.filter(([name]) => name === command).length;
}

View File

@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { invoke } from "@tauri-apps/api/core";
import { ArrowLeftIcon } from "lucide-react";
import type { UserInfo } from "@storyteller/api";
import { createDesktopLoginChallenge, pollDesktopLoginChallenge, cancelDesktopLoginChallenge, isDesktopLoginError, type DesktopLoginChallenge } from "./NativeLoginBridge";
const OpenUrl = (url: string) => invoke("plugin:opener|open_url", { url });
export function DesktopLoginBridge({ onSuccess, onStart, onActiveChange }: { onSuccess: (user: UserInfo) => void; onStart?: () => void; onActiveChange?: (active: boolean) => void }) {
const [challenge, setChallenge] = useState<DesktopLoginChallenge | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [now, setNow] = useState(Date.now);
const generation = useRef(0);
const success = useRef(onSuccess);
success.current = onSuccess;
const isActive = busy || challenge !== null;
useEffect(() => { onActiveChange?.(isActive); }, [isActive, onActiveChange]);
useEffect(() => () => { generation.current += 1; }, []);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [challenge]);
useEffect(() => {
if (!challenge) return;
let active = true;
const current = generation.current;
let timer: ReturnType<typeof setTimeout>;
let delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
const deadline = Date.parse(challenge.expires_at);
const finish = (text: string) => {
setChallenge(null);
setMessage(text);
};
const poll = async () => {
if (!active || current !== generation.current) return;
if (Date.now() >= deadline) { finish("Login request expired. Start a new request."); return; }
try {
const result = await pollDesktopLoginChallenge(challenge.challenge_id);
if (!active || current !== generation.current) return;
if (result.status === "redeemed") {
if (!result.maybe_user) throw new Error("Native session verification did not return a user");
active = false;
setChallenge(null);
setMessage("Signed in successfully.");
success.current(result.maybe_user);
return;
}
if (result.status === "failed") {
finish(result.maybe_failure_type === "user_declined" ? "Login was declined on the website." : result.maybe_failure_type === "expired" ? "Login request expired. Start a new request." : "Login failed. Start a new request.");
return;
}
if (!["pending", "approved"].includes(result.status)) {
finish("This login request could not be completed. Start a new request.");
return;
}
delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
setMessage("Waiting for your confirmation on the website…");
} catch (error) {
if (!active || current !== generation.current) return;
if (isDesktopLoginError(error) && !error.retryable) {
finish(error.message);
return;
}
delay = Math.min(delay * 2, 30_000);
setMessage("Connection interrupted. Retrying…");
}
if (active) timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now())));
};
timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now())));
return () => {
active = false;
clearTimeout(timer);
void cancelDesktopLoginChallenge(challenge.challenge_id).catch(() => {});
};
}, [challenge]);
const start = async (openBrowser: boolean) => {
if (busy) return;
onStart?.();
const current = ++generation.current;
setBusy(true);
setMessage("");
try {
const created = await createDesktopLoginChallenge();
if (current !== generation.current) {
void cancelDesktopLoginChallenge(created.challenge_id).catch(() => {});
return;
}
// Rust validates the approval URL against the configured API environment.
setNow(Date.now());
setChallenge(created);
setMessage("Waiting for your confirmation on the website…");
if (openBrowser) await OpenUrl(created.verification_url);
} catch (error) {
if (current === generation.current) setMessage(isDesktopLoginError(error) ? error.message : "Unable to open website login. Try again or scan the QR code.");
} finally {
if (current === generation.current) setBusy(false);
}
};
const back = () => {
generation.current += 1;
setChallenge(null);
setBusy(false);
setMessage("");
};
const remaining = challenge ? Math.max(0, Math.ceil((Date.parse(challenge.expires_at) - now) / 1000)) : 0;
return <section aria-label="Website login" className="mb-6 rounded-xl border border-white/15 p-4 text-center">
{isActive && <div className="mb-4 text-left">
<button type="button" className="inline-flex items-center gap-2 text-sm text-white/70 hover:text-white" onClick={back}>
<ArrowLeftIcon size={16} aria-hidden="true" /> Back
</button>
</div>}
{challenge ? <>
<p className="mb-3 text-sm text-white/70">Scan with your phone or approve in your browser.</p>
<QRCodeSVG value={challenge.verification_url} size={192} marginSize={4} title="Scan to approve desktop login" className="mx-auto rounded-lg" />
<p className="my-3 font-mono text-2xl tracking-widest">{challenge.confirmation_code.slice(0, 4)}-{challenge.confirmation_code.slice(4)}</p>
<p className="mb-3 text-xs text-white/60">Verify this code on the website. Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}</p>
<button type="button" className="rounded-lg border border-white/30 px-4 py-2" onClick={() => OpenUrl(challenge.verification_url).catch(() => setMessage("Unable to open the browser. Scan the QR code instead."))}>Open website</button>
</> : busy ? <p className="text-sm text-white/70" role="status">Preparing login…</p> : <div className="flex flex-col gap-2">
<button type="button" className="rounded-lg bg-white px-4 py-3 font-medium text-black" onClick={() => start(true)}>Login with Website</button>
<button type="button" className="rounded-lg border border-white/30 px-4 py-2" onClick={() => start(false)}>Scan to Login</button>
<p className="text-xs text-white/50">Use Google or any account already signed in on the website.</p>
</div>}
{message && <p className="mt-3 text-sm text-white/70" role="status">{message}</p>}
</section>;
}

View File

@ -0,0 +1,86 @@
.panel {
position: relative;
isolation: isolate;
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
overflow: hidden;
padding: 56px 32px;
text-align: center;
background: radial-gradient(ellipse at 50% 0%, rgb(94 234 212 / 9%), transparent 70%);
animation: arrive 450ms ease-out both;
}
.glow {
position: absolute;
z-index: -1;
top: -52px;
left: calc(50% - 144px);
width: 288px;
height: 288px;
border-radius: 50%;
background: radial-gradient(circle, rgb(94 234 212 / 24%), rgb(129 140 248 / 12%) 45%, transparent 70%);
filter: blur(24px);
pointer-events: none;
animation: glow 2400ms ease-in-out infinite;
}
.badge {
display: flex;
width: 76px;
height: 76px;
align-items: center;
justify-content: center;
margin-bottom: 28px;
border: 1px solid rgb(153 246 228 / 35%);
border-radius: 50%;
color: #b4f7de;
background: linear-gradient(145deg, rgb(153 246 228 / 16%), rgb(94 234 212 / 4%));
box-shadow: 0 0 0 8px rgb(94 234 212 / 4%), 0 0 44px rgb(94 234 212 / 18%), inset 0 1px 0 rgb(255 255 255 / 12%);
}
.heading {
margin: 0;
max-width: 100%;
color: rgb(255 255 255 / 80%);
font-size: 18px;
font-weight: 500;
line-height: 1.5;
}
.username {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
color: #b4f7de;
background: linear-gradient(100deg, #d4ffec, #99f6e4 55%, #c7d2fe);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.25;
}
.caption {
margin: 14px 0 0;
color: rgb(255 255 255 / 50%);
font-size: 14px;
line-height: 1.5;
}
@keyframes arrive {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes glow {
0%, 100% { opacity: 0.7; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
.panel, .glow { animation: none; }
}

View File

@ -0,0 +1,18 @@
import { CheckIcon } from "lucide-react";
import styles from "./LoginSuccess.module.css";
export function LoginSuccess({ username }: { username: string }) {
return (
<div className={styles.panel} role="status" aria-live="polite" aria-atomic="true">
<div className={styles.glow} aria-hidden="true" />
<div className={styles.badge} aria-hidden="true">
<CheckIcon size={34} strokeWidth={2} />
</div>
<h2 className={styles.heading}>
Logged in as{" "}
<span className={styles.username}>{username}</span>
</h2>
<p className={styles.caption}>You're ready to create.</p>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { invoke } from "@tauri-apps/api/core";
import type { UserInfo } from "@storyteller/api";
export interface DesktopLoginChallenge {
challenge_id: string;
verification_url: string;
confirmation_code: string;
expires_at: string;
poll_interval_seconds: number;
}
export interface DesktopLoginOutcome {
status: string;
maybe_failure_type: string | null;
maybe_user: UserInfo | null;
}
export interface DesktopLoginError {
status: number | null;
message: string;
retryable: boolean;
}
export const createDesktopLoginChallenge = () => invoke<DesktopLoginChallenge>("storyteller_create_login_challenge_command");
export const pollDesktopLoginChallenge = (challengeId: string) => invoke<DesktopLoginOutcome>("storyteller_poll_login_challenge_command", { challengeId });
export const cancelDesktopLoginChallenge = (challengeId: string) => invoke<void>("storyteller_cancel_login_challenge_command", { challengeId });
export function isDesktopLoginError(error: unknown): error is DesktopLoginError {
return typeof error === "object" && error !== null && "message" in error && "retryable" in error;
}
export const getNativeLoginSession = () => invoke<UserInfo | null>("storyteller_get_login_session_command");
export const passwordLogin = (usernameOrEmail: string, password: string) => invoke<UserInfo>("storyteller_password_login_command", {
request: { username_or_email: usernameOrEmail, password },
});
export const passwordSignup = (username: string, email: string, password: string, passwordConfirmation: string) => invoke<UserInfo>("storyteller_password_signup_command", {
request: { username, email_address: email, password, password_confirmation: passwordConfirmation, signup_source: "artcraft" },
});

View File

@ -1,12 +1,182 @@
import { render } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LoginModal } from "./login-modal";
import { useLoginModalStore } from "./useLoginModalStore";
import LoginModal from './login-modal';
const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbiddenFetch: vi.fn(() => { throw new Error("Authentication HTTP must run in Rust"); }) }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: native }));
vi.mock("@storyteller/api", () => { throw new Error("Authentication UI must not import API clients"); });
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch }));
vi.mock("@headlessui/react", () => ({ Transition: ({ show, children }: any) => show ? children : null, TransitionChild: ({ children }: any) => children }));
vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null, DiscordIcon: () => null }));
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant, icon, iconFlip, ...props }: any) => <button {...props}>{children}</button> }));
vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => <input {...props} /> }));
vi.mock("qrcode.react", () => ({ QRCodeSVG: () => <svg role="img" aria-label="Login QR" /> }));
describe('LoginModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<LoginModal />);
expect(baseElement).toBeTruthy();
const USER = { username: "google_user", user_token: "u_test" };
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("fetch", forbiddenFetch);
forbiddenFetch.mockClear();
localStorage.clear();
useLoginModalStore.setState({ isOpen: true, recheckTrigger: 0 });
native.mockReset().mockImplementation(async (command: string) => {
if (command === "storyteller_get_login_session_command") return null;
if (command === "storyteller_create_login_challenge_command") return {
challenge_id: "native_handle", verification_url: `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`,
confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + 1_200_000).toISOString(), poll_interval_seconds: 5,
};
if (command === "storyteller_poll_login_challenge_command") return { status: "redeemed", maybe_user: USER };
if (command === "storyteller_password_login_command" || command === "storyteller_password_signup_command") return USER;
if (command === "storyteller_cancel_login_challenge_command" || command === "plugin:opener|open_url") return;
throw new Error(`Unexpected command: ${command}`);
});
});
afterEach(() => {
cleanup();
expect(forbiddenFetch).not.toHaveBeenCalled();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("native login modal integration", () => {
it("shows website and QR login only on the login screen", async () => {
await act(async () => { render(<LoginModal />); });
expect(screen.getByText("Create your account")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Login with Website" })).toBeNull();
expect(screen.queryByRole("button", { name: "Scan to Login" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Log in" }));
expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Sign up" }));
expect(screen.queryByRole("button", { name: "Login with Website" })).toBeNull();
});
it.each(["Login with Website", "Scan to Login"])("hides password login during %s and restores it with Back", async (label) => {
const success = vi.fn();
await act(async () => { render(<LoginModal isSignUp={false} onArtCraftAuthSuccess={success} />); });
expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); });
expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
expect(screen.queryByPlaceholderText("you@example.com or username")).toBeNull();
expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
expect(screen.queryByRole("button", { name: "Sign up" })).toBeNull();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();
expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy();
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "native_handle" });
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(native.mock.calls.filter(([command]) => command === "storyteller_poll_login_challenge_command")).toHaveLength(0);
expect(success).not.toHaveBeenCalled();
});
it("hides password login while preparing a challenge and cancels a late creation after Back", async () => {
let resolve: (value: unknown) => void = () => {};
await act(async () => { render(<LoginModal isSignUp={false} />); });
native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); });
expect(screen.getByText("Preparing login…")).toBeTruthy();
expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
await act(async () => { resolve({ challenge_id: "late_handle" }); });
expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "late_handle" });
expect(native.mock.calls.filter(([command]) => command === "plugin:opener|open_url")).toHaveLength(0);
});
it.each([true, false])("keeps the completed native login when a stale session check resolves (before completion: %s)", async (beforeCompletion) => {
let resolveSession: (value: unknown) => void = () => {};
native.mockImplementationOnce(() => new Promise((resolve) => { resolveSession = resolve; }));
const success = vi.fn();
await act(async () => { render(<LoginModal isSignUp={false} onArtCraftAuthSuccess={success} />); });
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Scan to Login" })); });
if (beforeCompletion) await act(async () => { resolveSession(null); });
expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
if (!beforeCompletion) await act(async () => { resolveSession(null); });
expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy();
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
expect(useLoginModalStore.getState().isOpen).toBe(true);
await act(async () => { await vi.advanceTimersByTimeAsync(2999); });
expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy();
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
expect(useLoginModalStore.getState().isOpen).toBe(false);
expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull();
expect(screen.queryByText("Create your account")).toBeNull();
expect(native).toHaveBeenCalledWith("storyteller_poll_login_challenge_command", { challengeId: "native_handle" });
});
it.each([false, true])("submits password authentication only through Rust (signup: %s)", async (signup) => {
const success = vi.fn();
await act(async () => { render(<LoginModal isSignUp={signup} onArtCraftAuthSuccess={success} />); });
if (signup) {
fireEvent.change(screen.getByPlaceholderText("Username"), { target: { value: "native_user" } });
fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByPlaceholderText("Re-enter password"), { target: { value: "password123" } });
} else {
fireEvent.change(screen.getByPlaceholderText("you@example.com or username"), { target: { value: "native_user" } });
}
fireEvent.change(screen.getByPlaceholderText("Min. 8 characters"), { target: { value: "password123" } });
const submit = screen.getAllByRole("button", { name: signup ? "Sign up" : "Log in" }).find((button) => button.getAttribute("type") === "submit")!;
await act(async () => { fireEvent.click(submit); });
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
expect(native).toHaveBeenCalledWith(signup ? "storyteller_password_signup_command" : "storyteller_password_login_command", {
request: signup ? { username: "native_user", email_address: "user@example.com", password: "password123", password_confirmation: "password123", signup_source: "artcraft" } : { username_or_email: "native_user", password: "password123" },
});
if (!signup) {
expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy();
expect(screen.queryByText("Join Our Community")).toBeNull();
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(useLoginModalStore.getState().isOpen).toBe(false);
}
});
it("dismisses the confirmation once even when callbacks change", async () => {
const firstClose = vi.fn();
const latestClose = vi.fn();
const success = vi.fn();
const view = render(<LoginModal isSignUp={false} onClose={firstClose} onArtCraftAuthSuccess={success} />);
await act(async () => {});
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); });
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
expect(screen.getByRole("status").textContent).toContain("Logged in as google_user");
await act(async () => { await vi.advanceTimersByTimeAsync(1500); });
view.rerender(<LoginModal isSignUp={false} onClose={latestClose} onArtCraftAuthSuccess={success} />);
await act(async () => { await vi.advanceTimersByTimeAsync(1500); });
expect(useLoginModalStore.getState().isOpen).toBe(false);
expect(firstClose).not.toHaveBeenCalled();
expect(latestClose).toHaveBeenCalledTimes(1);
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
expect(latestClose).toHaveBeenCalledTimes(1);
});
it("cleans up the confirmation timer on unmount", async () => {
const onClose = vi.fn();
const view = render(<LoginModal isSignUp={false} onClose={onClose} />);
await act(async () => {});
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Scan to Login" })); });
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy();
view.unmount();
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
expect(onClose).not.toHaveBeenCalled();
});
it("restores an existing session without showing the login confirmation", async () => {
native.mockResolvedValueOnce(USER);
const success = vi.fn();
await act(async () => { render(<LoginModal onArtCraftAuthSuccess={success} />); });
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
expect(useLoginModalStore.getState().isOpen).toBe(false);
expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull();
});
});

View File

@ -1,24 +1,26 @@
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { ArrowRightIcon } from "lucide-react";
import { DiscordIcon } from "@storyteller/icons";
import type { UserInfo } from "@storyteller/api";
import { DesktopLoginBridge } from "./DesktopLoginBridge";
import { LoginSuccess } from "./LoginSuccess";
import { ArtCraftSignUp } from "./artcraft-signup";
import { UsersApi } from "@storyteller/api";
import { getNativeLoginSession, passwordLogin, passwordSignup, isDesktopLoginError } from "./NativeLoginBridge";
import { useLoginModalStore } from "./useLoginModalStore";
const SIGNUP_SOURCE_ARTCRAFT = "artcraft";
// Webapp auth-showcase video (swap by passing `videoUrl`).
const DEFAULT_SHOWCASE_VIDEO =
"https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1";
"https://frontend-cdn.fakeyou.com/videos/knight-video.mp4";
const LOGIN_SUCCESS_DURATION_MS = 3000;
interface LoginModalProps {
onClose?: () => void;
onOpenChange?: (isOpen: boolean) => void;
onArtCraftAuthSuccess?: (userInfo: any) => void;
isSignUp?: boolean;
/** Optional override for the right-pane showcase video. */
/** Optional direct media URL for the right-pane showcase video. */
videoUrl?: string;
// Accepted for backwards compatibility with existing call sites (MainApp
// passes these); no longer used now that the showcase is a single video.
@ -40,39 +42,56 @@ export function LoginModal({
const [errorMessage, setErrorMessage] = useState("");
const [showDiscord, setShowDiscord] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [isChallengeActive, setIsChallengeActive] = useState(false);
const [loggedInUsername, setLoggedInUsername] = useState<string | null>(null);
const checkArtCraftLogin = async () => {
const usersApi = new UsersApi();
const session = await usersApi.GetSession();
const loggedIn = session.data?.loggedIn;
return loggedIn;
};
const authGeneration = useRef(0);
const authSuccess = useRef(onArtCraftAuthSuccess);
authSuccess.current = onArtCraftAuthSuccess;
const afterClose = useRef(onClose);
afterClose.current = onClose;
// Check session on mount and when recheckTrigger changes.
// A stale startup/recheck response must never reopen the modal after login.
useEffect(() => {
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
const generation = ++authGeneration.current;
let active = true;
getNativeLoginSession().then((user) => {
if (!active || generation !== authGeneration.current) return;
if (user) {
setIsLoggedInArtCraft(true);
authSuccess.current?.(user);
closeModal();
} else {
// Reset modal state to initial values.
setIsLoading(false);
setIsSignUp(initialIsSignUp);
setErrorMessage("");
setShowDiscord(false);
setShowSuccess(false);
setLoggedInUsername(null);
setIsLoggedInArtCraft(false);
const { openModal } = useLoginModalStore.getState();
openModal();
useLoginModalStore.getState().openModal();
}
}).catch((error) => {
if (!active || generation !== authGeneration.current) return;
setErrorMessage(isDesktopLoginError(error) ? error.message : "Unable to check your account. Please sign in again.");
useLoginModalStore.getState().openModal();
});
return () => { active = false; };
}, [recheckTrigger, closeModal, initialIsSignUp]);
useEffect(() => {
if (onOpenChange) onOpenChange(isOpen);
}, [isOpen, onOpenChange]);
useEffect(() => {
if (loggedInUsername === null || !isOpen) return;
const timer = window.setTimeout(() => {
closeModal();
afterClose.current?.();
}, LOGIN_SUCCESS_DURATION_MS);
return () => window.clearTimeout(timer);
}, [loggedInUsername, isOpen, closeModal]);
const handleClose = () => {
closeModal();
onClose?.();
@ -84,6 +103,15 @@ export function LoginModal({
setShowSuccess(true);
};
const handleLoginSuccess = (user: UserInfo) => {
authGeneration.current += 1;
setIsLoading(false);
setIsLoggedInArtCraft(true);
setIsChallengeActive(false);
setLoggedInUsername(user.username);
authSuccess.current?.(user);
};
const handleAuthSubmit = async (
username: string,
email: string,
@ -91,56 +119,24 @@ export function LoginModal({
passwordConfirmation: string
) => {
setIsLoading(true);
const usersApi = new UsersApi();
setErrorMessage("");
const generation = ++authGeneration.current;
try {
let signupResponse, loginResponse;
const user = isSignUp
? await passwordSignup(username, email, password, passwordConfirmation)
: await passwordLogin(username || email, password);
if (generation !== authGeneration.current) return;
if (isSignUp) {
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
signupSource: SIGNUP_SOURCE_ARTCRAFT,
});
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage || "Signup failed, please try again."
);
setIsLoading(false);
return;
}
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
setIsLoggedInArtCraft(true);
authSuccess.current?.(user);
setShowDiscord(true);
} else {
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
handleLoginSuccess(user);
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage || "Login failed, please try again."
);
setIsLoading(false);
return;
}
setIsLoggedInArtCraft(true);
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setShowDiscord(true); // Onboarding step after successful auth.
} catch (e) {
console.error(e);
setErrorMessage("An unexpected error occurred. Please try again.");
} catch (error) {
if (generation === authGeneration.current) setErrorMessage(isDesktopLoginError(error) ? error.message : "An unexpected error occurred. Please try again.");
} finally {
setIsLoading(false);
if (generation === authGeneration.current) setIsLoading(false);
}
};
@ -204,7 +200,7 @@ export function LoginModal({
const inOnboarding = showDiscord || showSuccess;
return (
<Transition appear show={isOpen}>
<Transition appear show={isOpen} afterLeave={() => setLoggedInUsername(null)}>
<div className="fixed inset-0 z-[100]">
<TransitionChild
enter="ease-out duration-300"
@ -226,10 +222,12 @@ export function LoginModal({
leaveTo="opacity-0 scale-95"
>
<div
className="relative flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] text-white shadow-2xl lg:min-h-[640px]"
className={`relative flex w-full overflow-hidden rounded-3xl border bg-[#1C1C20] text-white shadow-2xl ${loggedInUsername !== null ? "max-w-md border-emerald-200/20" : "max-w-5xl border-white/[4%] lg:min-h-[640px]"}`}
onClick={(e) => e.stopPropagation()}
>
{inOnboarding ? (
{loggedInUsername !== null ? (
<LoginSuccess username={loggedInUsername} />
) : inOnboarding ? (
renderOnboarding()
) : (
<>
@ -254,13 +252,14 @@ export function LoginModal({
</p>
</div>
<ArtCraftSignUp
{!isSignUp && <DesktopLoginBridge onActiveChange={setIsChallengeActive} onStart={() => { authGeneration.current += 1; }} onSuccess={handleLoginSuccess} />}
{!isChallengeActive && <ArtCraftSignUp
onSubmit={handleAuthSubmit}
isSignUp={isSignUp}
onToggleMode={() => setIsSignUp((prev) => !prev)}
errorMessage={errorMessage}
isLoading={isLoading}
/>
/>}
</div>
</div>
@ -284,24 +283,19 @@ export function LoginModal({
);
}
// Right-pane video showcase — Vimeo background embed scaled to cover the pane,
// Right-pane video showcase — muted background video cropped to cover the pane,
// with a legibility gradient + caption. Mirrors the webapp auth-showcase.
function LoginShowcase({ videoUrl }: { videoUrl: string }) {
return (
<div
className="absolute inset-2 overflow-hidden rounded-2xl bg-black"
style={{ containerType: "size" }}
>
<iframe
<div className="absolute inset-2 overflow-hidden rounded-2xl bg-black">
<video
src={videoUrl}
title="ArtCraft"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{
width: "max(100cqw, calc(100cqh * 16 / 9))",
height: "max(100cqh, calc(100cqw * 9 / 16))",
}}
autoPlay
muted
loop
playsInline
preload="auto"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<div

View File

@ -54,6 +54,9 @@
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
}
]
}

View File

@ -49,6 +49,7 @@
"mediabunny": "^1.29.1",
"opencut-wasm": "^0.2.10",
"posthog-js": "^1.141.0",
"qrcode.react": "4.2.0",
"radix-ui": "^1.4.3",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.1.0",
@ -78,6 +79,7 @@
"@nx/vite": "21.2.3",
"@nx/web": "21.2.3",
"@nx/workspace": "21.2.3",
"@playwright/test": "1.63.0",
"@remix-run/node": "^2.16.5",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
@ -5601,6 +5603,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -19976,6 +19994,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
@ -20479,6 +20526,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",

View File

@ -2,7 +2,10 @@
"name": "@frontend/source",
"version": "0.0.0",
"license": "MIT",
"scripts": {},
"scripts": {
"test:desktop-session": "node tools/testing/desktop-session.mjs",
"perf:desktop": "node tools/performance/run.mjs"
},
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -38,6 +41,7 @@
"mediabunny": "^1.29.1",
"opencut-wasm": "^0.2.10",
"posthog-js": "^1.141.0",
"qrcode.react": "4.2.0",
"radix-ui": "^1.4.3",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.1.0",
@ -67,6 +71,7 @@
"@nx/vite": "21.2.3",
"@nx/web": "21.2.3",
"@nx/workspace": "21.2.3",
"@playwright/test": "1.63.0",
"@remix-run/node": "^2.16.5",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",

View File

@ -0,0 +1,149 @@
// Invoked by script/artcraft/unix_dev.sh. Adapted from ArtcraftX's launcher.
// Living under frontend lets Node resolve the local Vite installation.
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const frontendDir = path.join(rootDir, "frontend");
const appDir = path.join(frontendDir, "apps/artcraft");
const rustDir = path.join(rootDir, "crates/desktop/artcraft");
const host = "127.0.0.1";
const startingPort = Number(process.env.ARTCRAFT_DEV_PORT ?? "5193");
let vite;
let rust;
let stopping = false;
async function main() {
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
throw new Error("ARTCRAFT_DEV_PORT must be an integer between 1024 and 65535.");
}
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
// Match the app build's cwd, including Tailwind config/content resolution.
process.chdir(appDir);
vite = await createServer({
configFile: path.join(appDir, "vite.config.ts"),
clearScreen: false,
server: {
host,
port: startingPort,
// Vite retries occupied ports while binding the real server. There is no
// port probe/release/rebind race, and no existing process is terminated.
strictPort: false,
open: false,
hmr: true,
},
});
if (stopping) return;
await vite.listen();
const { port } = vite.httpServer.address();
const devUrl = `http://${host}:${port}`;
// Only initial startup may choose another port. On a Vite config restart,
// rebind this port or fail visibly so Tauri never follows a silent port hop.
for (const options of [vite.config.server, vite.config.inlineConfig.server]) {
options.port = port;
options.strictPort = true;
}
const configPath = path.join(rustDir, "tauri-dev-hot-reload.conf.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
if (stopping) return;
const csp = config.app.security.csp;
const overrides = {
build: { devUrl, beforeDevCommand: "" },
app: {
security: {
devCsp: {
...csp,
"connect-src": [...csp["connect-src"], devUrl, `ws://${host}:${port}`],
},
},
},
};
console.log(`\n[artcraft] Frontend + HMR: ${devUrl}`);
console.log("[artcraft] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
console.log("[artcraft] Press Ctrl-C to stop both.\n");
rust = spawn("cargo", [
"tauri", "dev",
"--no-dev-server",
// Tauri watches the desktop crate and its workspace dependencies itself.
// SQLx's external metadata and migrations also affect the Rust build.
"--additional-watch-folders", path.join(rootDir, ".sqlx"),
"--additional-watch-folders", path.join(rootDir, "_database/sql/artcraft_migrations"),
"--config", configPath,
"--config", JSON.stringify(overrides),
], {
cwd: rootDir,
// All cargo/rustc/app descendants inherit this private Unix process group.
detached: true,
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
TAURI_FRONTEND_PATH: frontendDir,
TAURI_APP_PATH: rustDir,
SQLX_OFFLINE: process.env.SQLX_OFFLINE ?? "true",
RUSTFLAGS: process.env.RUSTFLAGS ?? "-Awarnings",
WEBKIT_DISABLE_DMABUF_RENDERER: process.env.WEBKIT_DISABLE_DMABUF_RENDERER ?? "1",
WEBKIT_DISABLE_COMPOSITING_MODE: process.env.WEBKIT_DISABLE_COMPOSITING_MODE ?? "1",
},
});
rust.on("error", fail);
rust.on("exit", (code, signal) => {
if (!stopping) {
console.log(`[artcraft] Rust/Tauri exited (${signal ?? code}).`);
void stop(code ?? 1);
}
});
}
async function stop(exitCode) {
if (stopping) return;
stopping = true;
console.log("\n[artcraft] Stopping frontend and Rust/Tauri...");
const deadline = setTimeout(() => {
signalRustGroup("SIGKILL");
process.exit(exitCode);
}, 5000);
signalRustGroup("SIGTERM");
await Promise.allSettled([
vite?.close(),
(async () => {
while (signalRustGroup(0)) await delay(50);
})(),
]);
clearTimeout(deadline);
process.exit(exitCode);
}
function signalRustGroup(signal) {
if (!rust?.pid) return false;
try {
process.kill(-rust.pid, signal);
return true;
} catch (error) {
if (error.code !== "ESRCH") throw error;
return false;
}
}
function fail(error) {
console.error(`[artcraft] ${error.stack ?? error}`);
void stop(1);
}
process.on("SIGINT", () => void stop(130));
process.on("SIGTERM", () => void stop(143));
process.on("SIGHUP", () => void stop(129));
process.on("uncaughtException", fail);
process.on("unhandledRejection", fail);
main().catch(fail);

View File

@ -0,0 +1,188 @@
// Integration tests: real Vite sockets + a fake cargo process tree, no GUI/build.
// Run: node --test frontend/scripts/unix-dev.test.mjs
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { test } from "node:test";
import WebSocket from "ws";
import { fileURLToPath } from "node:url";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const launcher = path.join(rootDir, "script/artcraft/unix_dev.sh");
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const startingPort = occupied.address().port;
const run = await fixture(t, { ARTCRAFT_DEV_PORT: String(startingPort) });
const record = await run.record();
const config = overrides(record);
const url = config.build.devUrl;
assert.ok(Number(new URL(url).port) > startingPort);
assert.equal(new URL(url).hostname, "127.0.0.1");
assert.equal(config.build.beforeDevCommand, "");
assert.ok(!record.args.includes("--no-watch"));
assert.ok(!record.args.includes("--no-dev-server-wait"));
assert.equal(record.env.TAURI_APP_PATH, path.join(rootDir, "crates/desktop/artcraft"));
assert.equal(record.env.TAURI_FRONTEND_PATH, path.join(rootDir, "frontend"));
assert.ok(config.app.security.devCsp["connect-src"].includes(url.replace("http:", "ws:")));
assert.match(await (await fetch(url)).text(), /\/src\/index\.tsx/);
assert.match(await (await fetch(`${url}/@vite/client`)).text(), /WebSocket/);
run.proc.kill("SIGINT");
assert.deepEqual(await run.exited, [130, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(url));
assert.equal(occupied.listening, true, "must not terminate the existing listener");
});
test("Tauri failure propagates and stops Vite and leftover descendants", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { TEST_EXIT_CODE: "17" });
const record = await run.record();
assert.deepEqual(await run.exited, [17, null], run.output());
await until(() => !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("SIGTERM shuts down the complete process tree", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
run.proc.kill("SIGTERM");
assert.deepEqual(await run.exited, [143, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("invalid ports fail before starting Tauri", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { ARTCRAFT_DEV_PORT: "65536" });
assert.deepEqual(await run.exited, [1, null]);
assert.match(run.output(), /ARTCRAFT_DEV_PORT must be an integer/);
});
test("concurrent launchers use different ports without replacing either frontend", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const env = { ARTCRAFT_DEV_PORT: String(occupied.address().port) };
const first = await fixture(t, env);
const second = await fixture(t, env);
const [a, b] = await Promise.all([first.record(), second.record()]);
const firstUrl = overrides(a).build.devUrl;
const secondUrl = overrides(b).build.devUrl;
assert.notEqual(firstUrl, secondUrl);
assert.equal((await fetch(firstUrl)).status, 200);
assert.equal((await fetch(secondUrl)).status, 200);
first.proc.kill("SIGINT");
await first.exited;
assert.equal((await fetch(secondUrl)).status, 200);
assert.ok(alive(b.pid));
});
test("a React source edit sends HMR on the selected port without restarting Rust", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
const url = overrides(record).build.devUrl;
const probeDir = await mkdtemp(path.join(rootDir, "frontend/apps/artcraft/app/src/dev-probe-"));
t.after(() => rm(probeDir, { recursive: true, force: true }));
const probe = path.join(probeDir, "Probe.tsx");
const moduleUrl = `/src/${path.basename(probeDir)}/Probe.tsx`;
await writeFile(probe, "export default function Probe() { return <div>before</div>; }\n");
const code = await (await fetch(`${url}${moduleUrl}`)).text();
assert.match(code, /\$RefreshReg\$/);
const client = await (await fetch(`${url}/@vite/client`)).text();
const token = client.match(/const wsToken = "([^"]+)"/)[1];
const ws = new WebSocket(`${url.replace("http:", "ws:")}/?token=${token}`, "vite-hmr");
t.after(() => ws.terminate());
const messages = [];
ws.on("message", (data) => messages.push(JSON.parse(data.toString())));
await once(ws, "open");
await writeFile(probe, "export default function Probe() { return <div>after</div>; }\n");
await until(() => messages.some((m) => m.type === "update" && m.updates.some((u) => u.path === moduleUrl)));
assert.match(await (await fetch(`${url}${moduleUrl}`)).text(), /after/);
assert.ok(alive(record.pid), "JS edits must leave the Rust watcher running");
assert.equal((await run.record()).pid, record.pid);
});
test("Rust watcher arguments include SQLx inputs and preserve caller overrides", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { SQLX_OFFLINE: "false", RUSTFLAGS: "-C debuginfo=1" });
const record = await run.record();
assert.equal(record.env.SQLX_OFFLINE, "false");
assert.equal(record.env.RUSTFLAGS, "-C debuginfo=1");
assert.ok(record.args.includes(path.join(rootDir, ".sqlx")));
assert.ok(record.args.includes(path.join(rootDir, "_database/sql/artcraft_migrations")));
const ignores = await readFile(path.join(rootDir, "crates/desktop/artcraft/.taurignore"), "utf8");
assert.doesNotMatch(ignores, /^(crates\/|Cargo\.toml|Cargo\.lock)$/m);
});
async function until(check) {
for (let attempt = 0; attempt < 200; attempt++) {
const result = await check();
if (result) return result;
await delay(50);
}
throw new Error("Timed out waiting for dev launcher");
}
async function fixture(t, env = {}) {
const dir = await mkdtemp(path.join(os.tmpdir(), "artcraft-dev-test-"));
t.after(() => rm(dir, { recursive: true, force: true }));
await writeFile(path.join(dir, "cargo"), `#!/usr/bin/env node
const fs = require("node:fs");
const { spawn } = require("node:child_process");
if (process.argv.includes("--version")) process.exit(0);
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(process.env.TEST_RECORD, JSON.stringify({
args: process.argv.slice(2), cwd: process.cwd(),
env: Object.fromEntries(["TAURI_APP_PATH", "TAURI_FRONTEND_PATH", "SQLX_OFFLINE", "RUSTFLAGS"].map(key => [key, process.env[key]])),
pid: process.pid, childPid: child.pid,
}));
if (process.env.TEST_EXIT_CODE) setTimeout(() => process.exit(Number(process.env.TEST_EXIT_CODE)), 200);
setInterval(() => {}, 1000);
`, { mode: 0o755 });
const recordFile = path.join(dir, "record.json");
const proc = spawn(launcher, [], {
// Prove the launcher is independent of the invoking working directory.
cwd: dir,
env: { ...process.env, ...env, PATH: `${dir}:${process.env.PATH}`, TEST_RECORD: recordFile },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
proc.stdout.on("data", (chunk) => { output += chunk; });
proc.stderr.on("data", (chunk) => { output += chunk; });
const exited = once(proc, "exit");
t.after(async () => {
if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM");
await exited;
});
return {
proc, exited, output: () => output,
async record() {
return until(async () => {
if (proc.exitCode !== null) throw new Error(output);
try { return JSON.parse(await readFile(recordFile, "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; }
});
},
};
}
async function occupyPort(t) {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(() => new Promise((resolve) => server.close(resolve)));
return server;
}
function alive(pid) {
try { process.kill(pid, 0); return true; }
catch (error) { if (error.code === "ESRCH") return false; throw error; }
}
function overrides(record) {
return JSON.parse(record.args.at(-1));
}

View File

@ -0,0 +1,54 @@
import { execFileSync } from "node:child_process";
import { relative } from "node:path";
// Benchmark-only instrumentation. The normal desktop build never uses this plugin.
export function benchmarkPlugin({ baselineRef, frontend } = {}) {
// Reproduce this experiment without checking out over local work. This is a
// two-module overlay, not a build of an entire historical repository.
const baseline = new Map();
if (baselineRef) {
for (const path of ["pages/MainApp.tsx", "pages/Stores/TabState.ts"]) {
baseline.set(`/apps/artcraft/app/src/${path}`, execFileSync("git", ["show", `${baselineRef}:frontend/apps/artcraft/app/src/${path}`], { cwd: frontend, encoding: "utf8" }));
}
}
const exports = new Map([
["/pages/Stores/TabState.ts", ["tabs", "useTabStore"]],
["/pagedraw/src/lib/stores/SceneState.ts", ["scene", "useSceneStore"]],
["/pagedraw/src/lib/Node.ts", ["Node", "Node"]],
]);
return {
name: "artcraft-performance-fixtures",
enforce: "pre",
transform(code, id) {
const source = [...baseline].find(([suffix]) => id.endsWith(suffix));
if (source) code = source[1];
for (const [suffix, [key, value]] of exports) {
if (id.endsWith(suffix)) {
return `${code}\nwindow.__ARTCRAFT_BENCH__ ??= {};\nwindow.__ARTCRAFT_BENCH__.${key} = ${value};\n`;
}
}
if (source) return code;
},
generateBundle: {
order: "post",
handler(_options, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === "chunk");
this.emitFile({
type: "asset",
fileName: "performance-bundle.json",
source: JSON.stringify(chunks.map((chunk) => ({
file: chunk.fileName,
entry: chunk.isEntry,
dynamicEntry: chunk.isDynamicEntry,
bytes: Buffer.byteLength(chunk.code),
imports: chunk.imports,
largestModules: Object.entries(chunk.modules)
.sort((a, b) => b[1].renderedLength - a[1].renderedLength)
.slice(0, 25)
.map(([id, module]) => ({ module: relative(frontend, id), renderedBytes: module.renderedLength })),
})), null, 2),
});
},
},
};
}

View File

@ -0,0 +1,103 @@
// Runs before any application script. No real account, Rust IPC, or remote API.
export function installBrowserFixture() {
const counters = { commands: {}, fileReads: 0, webglContexts: 0, longTasks: [] };
window.__ARTCRAFT_COUNTERS__ = counters;
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
if (type === "webgl" || type === "webgl2" || type === "experimental-webgl") counters.webglContexts++;
return getContext.call(this, type, ...args);
};
const readAsDataURL = FileReader.prototype.readAsDataURL;
FileReader.prototype.readAsDataURL = function (...args) {
counters.fileReads++;
return readAsDataURL.apply(this, args);
};
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
new PerformanceObserver((list) => {
counters.longTasks.push(...list.getEntries().map(({ startTime, duration }) => ({ startTime, duration })));
}).observe({ type: "longtask", buffered: true });
}
let callbackId = 0;
const callbacks = new Map();
window.__TAURI_INTERNALS__ = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } },
transformCallback(callback) { callbacks.set(++callbackId, callback); return callbackId; },
unregisterCallback(id) { callbacks.delete(id); },
convertFileSrc(path) { return path; },
async invoke(command, args) {
counters.commands[command] = (counters.commands[command] ?? 0) + 1;
if (command === "plugin:event|listen") return args.handler;
if (command === "plugin:window|inner_size") return { width: 1440, height: 1000 };
if (command === "plugin:window|scale_factor") return 1;
if (command === "storyteller_get_login_session_command") return {
username: "performance_fixture", user_token: "u_performance_fixture",
};
if (command === "get_task_queue_command") return { payload: { tasks: [] } };
// Use the bundled model overlay, as the app does when the listing is unavailable.
if (command === "list_image_models_command" || command === "list_video_models_command") throw new Error("Offline model listing fixture");
if (command === "plugin:app|version") return "0.0.0-benchmark";
if (command === "plugin:app|name") return "ArtCraft Benchmark";
if (command === "plugin:os|platform") return "macos";
if (command.startsWith("plugin:http|")) throw new Error("Network disabled in performance fixture");
return { success: true, payload: {} };
},
};
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
}
export async function seedDrawingScene() {
const { scene, Node } = window.__ARTCRAFT_BENCH__;
scene.getState().RESET();
scene.getState().setBaseImageInfo({ url: "", mediaToken: "fixture-blank", isBlankCanvas: true, blankCanvasWidth: 1024, blankCanvasHeight: 1024 });
// Deterministic noisy pixels resist PNG compression, like imported artwork.
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1024;
const context = canvas.getContext("2d");
const pixels = context.createImageData(1024, 1024);
let random = 12345;
for (let i = 0; i < pixels.data.length; i += 4) {
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
pixels.data[i] = random & 255;
pixels.data[i + 1] = (random >>> 8) & 255;
pixels.data[i + 2] = (random >>> 16) & 255;
pixels.data[i + 3] = 255;
}
context.putImageData(pixels, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve));
const drawNodes = [];
for (let i = 0; i < 4; i++) {
const node = new Node({ id: `fixture-image-${i}`, type: "image", x: i * 220, y: i * 100, width: 400, height: 400, fill: "transparent" });
await node.setImageFromFile(new File([blob], `fixture-${i}.png`, { type: "image/png" }));
drawNodes.push(node);
}
for (let i = 0; i < 80; i++) {
drawNodes.push({
id: `fixture-line-${i}`, type: "line", draggable: true, stroke: "#7766ee", strokeWidth: 3,
points: Array.from({ length: 200 }, (_, j) => j % 2 === 0 ? j * 4 : i * 10 + Math.sin(j) * 30),
});
}
scene.setState({ drawNodes, selectedNodeIds: [drawNodes[0].id] });
scene.getState().saveState();
return { images: 4, imageSize: 1024, imageBytesEach: blob.size, strokes: 80, coordinatesPerStroke: 200 };
}
export async function measureTabChange(target) {
const counters = window.__ARTCRAFT_COUNTERS__;
const readsBefore = counters.fileReads;
const start = performance.now();
const success = await window.__ARTCRAFT_BENCH__.tabs.getState().setActiveTab(target);
if (!success) throw new Error(`Tab change to ${target} failed`);
const stateReadyMs = performance.now() - start;
const ready = () => target === "2D"
? !!document.querySelector(".konvajs-content canvas")
: !!Array.from(document.querySelectorAll("h1")).find((h) => h.textContent.includes("craft"));
while (!ready()) {
if (performance.now() - start > 15000) throw new Error(`Tab ${target} never rendered`);
await new Promise(requestAnimationFrame);
}
// A rendering opportunity after the target DOM committed, not GPU completion.
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const paintMs = performance.now() - start;
performance.measure(`artcraft:tab:${target}`, { start, end: performance.now() });
return { stateReadyMs, paintMs, fileReads: counters.fileReads - readsBefore };
}

View File

@ -0,0 +1,33 @@
import { readFile } from "node:fs/promises";
const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) throw new Error("Usage: node tools/performance/compare.mjs before.json after.json");
const before = JSON.parse(await readFile(beforePath, "utf8"));
const after = JSON.parse(await readFile(afterPath, "utf8"));
for (const field of ["platform", "arch", "cpu", "browser", "viewport", "ipc", "network", "runs"]) {
if (before.environment[field] !== after.environment[field]) throw new Error(`Different ${field}; do not compare these runs`);
}
if (before.errors.length || after.errors.length) throw new Error("A run contains browser errors");
if (JSON.stringify(before.samples[0].fixture) !== JSON.stringify(after.samples[0].fixture)) throw new Error("Different scene fixtures");
const rows = [["Measurement", "Before", "After", "Reduction"]];
for (const [label, event, metric, unit, scale] of [
["Startup to frame", "startup", "readyMs", "ms", 1],
["Refresh to frame", "refresh", "readyMs", "ms", 1],
["Startup JavaScript", "startup", "jsBytes", "MB", 1e6],
["Startup long tasks (sum)", "startup", "longTaskMs", "ms", 1],
["First drawing page to frame", "firstDraw", "paintMs", "ms", 1],
["Drawing to home: dispatch", "leaveDraw", "stateReadyMs", "ms", 1],
["Drawing to home: frame", "leaveDraw", "paintMs", "ms", 1],
["Home to drawing: dispatch", "returnDraw", "stateReadyMs", "ms", 1],
["Home to drawing: frame", "returnDraw", "paintMs", "ms", 1],
]) {
const a = before.summary[event][metric].median;
const b = after.summary[event][metric].median;
rows.push([label, `${(a / scale).toFixed(2)} ${unit}`, `${(b / scale).toFixed(2)} ${unit}`, `${((a - b) / a * 100).toFixed(1)}%`]);
}
const widths = rows[0].map((_, col) => Math.max(...rows.map((row) => row[col].length)));
const format = (row) => `| ${row.map((cell, col) => cell.padEnd(widths[col])).join(" | ")} |`;
console.log(format(rows[0]));
console.log(`|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`);
for (const row of rows.slice(1)) console.log(format(row));

View File

@ -0,0 +1,125 @@
import { build, preview } from "vite";
import { chromium } from "@playwright/test";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
import { benchmarkPlugin } from "./benchmark-plugin.mjs";
import { installBrowserFixture, seedDrawingScene, measureTabChange } from "./browser-fixture.mjs";
const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const { values } = parseArgs({ options: {
output: { type: "string", default: process.env.ARTCRAFT_PERF_DIR || "/tmp/artcraft-performance" },
runs: { type: "string", default: process.env.ARTCRAFT_PERF_RUNS || "7" },
"skip-build": { type: "boolean", default: !!process.env.ARTCRAFT_PERF_SKIP_BUILD },
"build-only": { type: "boolean", default: false },
channel: { type: "string", default: "chrome" },
"baseline-ref": { type: "string" },
"smoke-imports": { type: "boolean", default: false },
} });
const output = resolve(values.output);
const runs = Number(values.runs);
if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer");
const configFile = resolve(frontend, "apps/artcraft/vite.config.ts");
// Match `npm run build` in the app: Tailwind resolves its config/content from cwd.
process.chdir(dirname(configFile));
await mkdir(output, { recursive: true });
if (!values["skip-build"]) {
await build({ configFile, plugins: [benchmarkPlugin({ baselineRef: values["baseline-ref"], frontend })], build: { outDir: resolve(output, "dist"), manifest: true } });
await writeFile(resolve(output, "build-info.json"), JSON.stringify({
builtAt: new Date().toISOString(),
revision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: frontend, encoding: "utf8" }).trim(),
baselineOverlay: values["baseline-ref"] || null,
changedFiles: execFileSync("git", ["diff", "--name-only"], { cwd: frontend, encoding: "utf8" }).trim().split("\n"),
}, null, 2));
}
if (values["build-only"]) process.exit(0);
const server = await preview({ configFile, build: { outDir: resolve(output, "dist") }, preview: { host: "127.0.0.1", port: 0, strictPort: false } });
let browser;
try {
browser = await chromium.launch({ channel: values.channel, headless: true });
const url = server.resolvedUrls.local[0];
const samples = [];
const errors = [];
const importedPages = [];
for (let run = 0; run < runs; run++) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
await context.addInitScript(installBrowserFixture);
const page = await context.newPage();
page.on("pageerror", (error) => { errors.push(error.message); console.error("Browser error:", error.message); });
// No analytics, external media, or authenticated API traffic in this fixture.
await context.route("**/*", (route) => route.request().url().startsWith(url) || route.request().url().startsWith("blob:")
? route.continue() : route.abort());
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
await page.waitForFunction(() => !document.querySelector("video"));
if (!await page.evaluate(() => getComputedStyle(document.querySelector(".fixed")).position === "fixed")) {
throw new Error("Tailwind styles are missing; benchmark layout is invalid");
}
const collectStartup = async () => page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
readyMs: performance.now(),
jsBytes: performance.getEntriesByType("resource").filter((r) => r.name.endsWith(".js")).reduce((sum, r) => sum + r.decodedBodySize, 0),
longTaskMs: window.__ARTCRAFT_COUNTERS__.longTasks.reduce((sum, t) => sum + t.duration, 0),
webglContexts: window.__ARTCRAFT_COUNTERS__.webglContexts,
};
});
const startup = await collectStartup();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
await page.waitForFunction(() => !document.querySelector("video"));
const refresh = await collectStartup();
const fixture = await page.evaluate(seedDrawingScene);
const firstDraw = await page.evaluate(measureTabChange, "2D");
// Allow image decoding and the canvas bake debounce to settle outside timing.
await page.waitForTimeout(1000);
const leaveDraw = await page.evaluate(measureTabChange, "APPS");
const returnDraw = await page.evaluate(measureTabChange, "2D");
await page.waitForTimeout(1000);
const afterReturn = await page.evaluate(() => ({
nodeCount: window.__ARTCRAFT_BENCH__.scene.getState().drawNodes.length,
selectedCount: window.__ARTCRAFT_BENCH__.scene.getState().selectedNodeIds.length,
historyLength: window.__ARTCRAFT_BENCH__.scene.getState().history.length,
commands: window.__ARTCRAFT_COUNTERS__.commands,
}));
if (run === 0) await page.screenshot({ path: resolve(output, "drawing.png") });
samples.push({ startup, refresh, firstDraw, leaveDraw, returnDraw, fixture, afterReturn });
console.log(JSON.stringify({ run: run + 1, startup, refresh, firstDraw, leaveDraw, returnDraw }));
// Validate deferred module evaluation separately from all timed operations.
if (values["smoke-imports"] && run === runs - 1) {
const manifest = JSON.parse(await readFile(resolve(output, "dist/.vite/manifest.json"), "utf8"));
for (const [source, chunk] of Object.entries(manifest)) {
if (source.startsWith("src/pages/") && chunk.isDynamicEntry) {
await page.evaluate(async (file) => { await import(/* @vite-ignore */ `/${file}`); }, chunk.file);
importedPages.push(source);
}
}
}
await context.close();
}
const summary = {};
for (const event of ["startup", "refresh", "firstDraw", "leaveDraw", "returnDraw"]) {
summary[event] = {};
for (const metric of Object.keys(samples[0][event])) {
const values = samples.map((s) => s[event][metric]).sort((a, b) => a - b);
summary[event][metric] = { median: values[Math.floor(values.length / 2)], min: values[0], max: values.at(-1) };
}
}
const result = {
timestamp: new Date().toISOString(), build: JSON.parse(await readFile(resolve(output, "build-info.json"), "utf8")),
environment: { platform: os.platform(), arch: os.arch(), cpu: os.cpus()[0].model, browser: browser.version(), node: process.version, runs, viewport: "1440x1000@1", ipc: "mocked", network: "local assets only, browser cache disabled" },
summary, samples, importedPages, errors: [...new Set(errors)],
};
await writeFile(resolve(output, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ summary, errors: result.errors, output }, null, 2));
if (errors.length) throw new Error("Browser errors invalidate this run; see results.json");
} finally {
await browser?.close();
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
}

View File

@ -0,0 +1,118 @@
// Real app UI with isolated, deterministic IPC responses. Run against a dev or
// preview URL. Native HTTP/CORS correctness is tested separately in Rust and in
// the real-webview procedure in docs/desktop-session-regression.md.
import assert from "node:assert/strict";
import { chromium, expect } from "@playwright/test";
import { installBrowserFixture } from "../performance/browser-fixture.mjs";
const url = new URL(process.argv[2] || "http://127.0.0.1:5193");
if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) {
throw new Error("Use the local development or preview URL printed by the launcher.");
}
const browser = await chromium.launch({ channel: "chrome", headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await context.addInitScript(installBrowserFixture);
await context.addInitScript(installSessionFixture);
await context.route("**/*", (route) => {
const target = route.request().url();
return target.startsWith(url.origin) || target.startsWith("blob:")
? route.continue() : route.abort();
});
const page = await context.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const home = async () => {
await page.goto(url.href);
await expect(page.getByRole("heading", { name: "What will you craft today?" })).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("button", { name: "42 Credits" })).toBeVisible();
};
const account = async () => {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Accounts", exact: true }).click();
await expect(page.getByText("Session Smoke Account", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Log Out", exact: true })).toBeVisible();
};
await home();
await account();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "My Library" }).click();
await expect(page.getByRole("img", { name: "Session smoke image", exact: true })).toBeVisible();
await expect.poll(() => page.getByRole("img", { name: "Session smoke image", exact: true })
.evaluate((image) => image.complete && image.naturalWidth > 0)).toBe(true);
await expect(page.getByText("Log in to browse and use your library.")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Create Image Generate AI images" }).click();
await expect(page.getByRole("img", { name: "Session smoke image", exact: true })).toBeVisible();
await account();
await page.keyboard.press("Escape");
for (const [card, heading] of [
["Create Video Create video from images", "Create Video"],
["Create Audio Generate music and sound effects", "Create Audio"],
]) {
await home();
await page.getByRole("button", { name: card }).click();
await expect(page.getByRole("heading", { name: heading, exact: true }).first()).toBeVisible();
}
await account();
await page.getByRole("button", { name: "Log Out", exact: true }).click();
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
await page.getByRole("button", { name: "Log in", exact: true }).click();
await page.getByPlaceholder("you@example.com or username").fill("session_smoke");
await page.getByPlaceholder("Min. 8 characters").fill("fixture-password-only");
await page.getByRole("button", { name: "Log in", exact: true }).click();
await expect(page.getByRole("heading", { name: "Welcome back" })).toHaveCount(0, { timeout: 10000 });
await account();
assert.deepEqual(errors, [], "Application runtime errors");
console.log("PASS: credits, Accounts, Library media, deferred image/video/audio pages, logout gate, and sign-in agree.");
} finally {
await browser.close();
}
function installSessionFixture() {
const invoke = window.__TAURI_INTERNALS__.invoke;
let loggedIn = true;
let nextResource = 0;
const responses = new Map();
const user = {
username: "session_smoke", display_name: "Session Smoke Account",
user_token: "u_session_smoke", can_access_studio: true, maybe_feature_flags: [],
};
const image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6mTQAAAAASUVORK5CYII=";
const media = {
token: "m_session_smoke", media_class: "image", media_type: "png",
maybe_title: "Session smoke image", created_at: new Date().toISOString(),
origin_category: "inference", origin: { origin_category: "inference" },
media_links: { cdn_url: image, maybe_thumbnail_template: image, thumbnail_template: image },
stats: { positive_rating_count: 0, bookmark_count: 0 },
};
window.__TAURI_INTERNALS__.invoke = async (command, args) => {
if (command === "storyteller_get_login_session_command") return loggedIn ? user : null;
if (command === "storyteller_password_login_command") { loggedIn = true; return user; }
if (command === "storyteller_purge_credentials_command") { loggedIn = false; return; }
if (command === "get_app_info_command") return { payload: { storyteller_host: "https://api.storyteller.ai" } };
if (command === "storyteller_get_credits_command") return { payload: { free_credits: 0, monthly_credits: 42, banked_credits: 0, sum_total_credits: 42 } };
if (command === "plugin:http|fetch") {
const request = new URL(args.clientConfig.url);
let body = { success: true, results: [], folders: [], tags: [], active_subscriptions: [] };
if (request.pathname === "/v1/session") body = { success: true, logged_in: loggedIn, user: loggedIn ? user : undefined };
if (request.pathname === "/v1/logout") loggedIn = false;
if (request.pathname === "/v1/media_files/list/user/session_smoke") body = { success: true, results: [media], pagination: { has_next: false } };
responses.set(++nextResource, { url: request.href, body });
return nextResource;
}
if (command === "plugin:http|fetch_send") {
const response = responses.get(args.rid);
return { rid: args.rid, status: 200, statusText: "OK", url: response.url, headers: [["content-type", "application/json"]] };
}
if (command === "plugin:http|fetch_read_body") {
const response = responses.get(args.rid);
args.streamChannel.onmessage([...new TextEncoder().encode(JSON.stringify(response.body)), 0]);
args.streamChannel.onmessage([1]);
responses.delete(args.rid);
return;
}
return invoke(command, args);
};
}

44
script/artcraft/unix_dev.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Start the desktop frontend and Rust/Tauri watcher together on macOS or Linux.
set -euo pipefail
root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
frontend_path="${root_dir}/frontend"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'HELP'
Usage: ./script/artcraft/unix_dev.sh
Starts Vite with React/CSS hot reload and Tauri with Rust rebuild/restart.
Finds and binds a free loopback port starting at ARTCRAFT_DEV_PORT (default: 5193),
then passes that exact URL to Tauri. HMR shares the frontend port.
Rust uses native Tauri IPC and does not need a second HTTP port.
Ctrl-C stops this launcher's frontend, Rust watcher, and desktop app.
Requires Node.js 20+, npm, Rust, cargo-tauri 2, and Tauri platform prerequisites.
Installs frontend dependencies only when the local Vite installation is missing.
Run this command instead of running the two legacy dev scripts together.
HELP
exit 0
fi
if [[ $# -ne 0 ]]; then
echo "Unknown argument: $1 (see --help)" >&2
exit 1
fi
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${frontend_path}"
if ! command -v cargo >/dev/null || ! cargo tauri --version >/dev/null 2>&1; then
echo 'ERROR: Install Rust and the Tauri CLI: cargo install tauri-cli --version "^2" --locked' >&2
exit 1
fi
cd "${frontend_path}"
if [[ ! -f node_modules/vite/package.json ]]; then
# Do not use the legacy install helper: its failure recovery deletes caches.
npm install
fi
exec node "${frontend_path}/scripts/unix-dev.mjs"