mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 09:39:31 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c7060dcc | ||
|
|
bfc7c71e50 | ||
|
|
ef43ab679d | ||
|
|
92e6f438c3 |
Generated
+9
@@ -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",
|
||||
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/api_clients/worldlabs_api_client",
|
||||
"crates/api_clients/worldlabs_consumer_client",
|
||||
"crates/desktop/artcraft",
|
||||
"crates/lib/artcraft_client_identity",
|
||||
"crates/lib/browser_emulation",
|
||||
"crates/lib/build_metadata",
|
||||
"crates/lib/caching/memory_store",
|
||||
@@ -49,6 +50,7 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
browser_emulation = { path = "crates/lib/browser_emulation" }
|
||||
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }
|
||||
|
||||
+53
-7
@@ -9,21 +9,67 @@ To set up the ArtCraft development environment, install the following:
|
||||
|
||||
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
|
||||
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
|
||||
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
|
||||
3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher.
|
||||
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
|
||||
|
||||
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
|
||||
|
||||
**Mac and Linux Development**
|
||||
|
||||
```bash
|
||||
# Run the frontend dev server
|
||||
./script/artcraft/unix_frontend_dev.sh
|
||||
# Start the frontend and Rust/Tauri together in one terminal
|
||||
./script/artcraft/unix_dev.sh
|
||||
|
||||
# Run the Tauri Rust application
|
||||
./script/artcraft/unix_rust_dev.sh
|
||||
# Optionally start the free-port search at another port
|
||||
ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh
|
||||
```
|
||||
|
||||
Use the combined launcher instead of starting the two legacy dev scripts. It
|
||||
works from any working directory when invoked by its path and installs frontend
|
||||
dependencies if the local Vite installation is missing. Node.js 20+ is required.
|
||||
|
||||
The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying
|
||||
successive ports until one is available. It leaves existing listeners alone.
|
||||
Only after the real frontend socket is bound does it start Tauri with that exact
|
||||
`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates
|
||||
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
|
||||
are passed in memory, without changing the checked-in Tauri config.
|
||||
|
||||
The native HTTP bridge normalizes loopback development origins for first-party
|
||||
API requests so an automatically selected port does not break login or media
|
||||
loading. See the [desktop session regression checks](../docs/desktop-session-regression.md)
|
||||
when changing ports, the launcher, or authentication.
|
||||
|
||||
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
|
||||
React component edits use Fast Refresh. Changes that cannot be hot-replaced
|
||||
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
|
||||
restart**, including dependent workspace crates. SQLx metadata and SQLite
|
||||
migrations are also watched. Rust reload is a process restart, so it does not
|
||||
preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes).
|
||||
|
||||
After startup, a Vite configuration restart must reuse the chosen port or report
|
||||
an error; it cannot silently switch to another port while Tauri uses an old URL.
|
||||
Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app.
|
||||
If the Rust watcher exits, the launcher closes Vite and propagates the exit code.
|
||||
It never finds or terminates another app by name or port.
|
||||
|
||||
The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`,
|
||||
`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment
|
||||
overrides are preserved. It uses the normal Artcraft app data and Cargo target
|
||||
directory; port separation does not create an isolated account/data profile, and
|
||||
concurrent Rust builds can wait on Cargo's build lock.
|
||||
|
||||
To verify the launcher without compiling Rust or opening a desktop window:
|
||||
|
||||
```bash
|
||||
node --test frontend/scripts/unix-dev.test.mjs
|
||||
```
|
||||
|
||||
The tests use real Vite listeners and HMR, with a test-owned fake Cargo process
|
||||
tree. They cover occupied ports, simultaneous launches, React edits, shutdown,
|
||||
Rust-process failure, and watcher arguments. To verify native Rust reload, run
|
||||
the launcher, wait for the desktop window, make a Rust source edit, and check for
|
||||
a rebuild/restart in the same terminal. Frontend edits should update the webview
|
||||
without restarting the Rust process.
|
||||
|
||||
**Windows Development**
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -19,6 +19,7 @@ path = "src/lib.rs"
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_client_identity.workspace = true
|
||||
cloudflare_errors.workspace = true
|
||||
enums.workspace = true
|
||||
errors.workspace = true
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let response = http_get_anonymous(url).await?;
|
||||
let response = http_get_anonymous(api_host, url).await?;
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
+3
-5
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -56,8 +56,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
|
||||
//let mut file = File::open(path)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
@@ -86,7 +85,6 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
+3
-5
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -11,7 +12,6 @@ use chrono::{DateTime, Utc};
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
@@ -72,8 +72,7 @@ pub async fn upload_image_media_file_from_bytes(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
@@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_bytes(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
+3
-5
@@ -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 {
|
||||
|
||||
+3
-5
@@ -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 {
|
||||
|
||||
+3
-5
@@ -1,7 +1,8 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -58,8 +58,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())?;
|
||||
@@ -78,7 +77,6 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
|
||||
|
||||
const HTTP_SCHEME: &str = "http";
|
||||
|
||||
const HTTPS_SCHEME: &str = "https";
|
||||
@@ -27,6 +29,15 @@ impl ApiHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Origin` the desktop client declares to this API.
|
||||
pub fn request_origin(&self) -> &'static str {
|
||||
match self {
|
||||
ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN,
|
||||
ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN,
|
||||
ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scheme(&self) -> &'static str {
|
||||
match self {
|
||||
ApiHost::Storyteller => HTTPS_SCHEME,
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -21,13 +21,11 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
|
||||
info!("DELETE {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.delete(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON)
|
||||
.header("Content-Type", APPLICATION_JSON);
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -20,13 +20,11 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -30,13 +30,11 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
info!("Requesting {:?}", &url);
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON)
|
||||
.header("Content-Type", APPLICATION_JSON);
|
||||
|
||||
|
||||
+3
-5
@@ -3,11 +3,11 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use errors::AnyhowResult;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -26,13 +26,11 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
|
||||
pub const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
pub const APPLICATION_JSON : &str = "application/json";
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use crate::error::api_error::ApiError;
|
||||
use reqwest::Client;
|
||||
const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
|
||||
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
//.header("Accept-Encoding", "gzip, deflate, br")
|
||||
.send()
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::de::DeserializeOwned;
|
||||
use url::Url;
|
||||
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::USER_AGENT;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
|
||||
/// Native transport for the session bridge. No request/response bodies, cookies,
|
||||
/// or challenge credentials are logged, even at debug level. Never follows redirects.
|
||||
@@ -25,14 +25,13 @@ pub struct LoginChallengeClientError {
|
||||
|
||||
impl LoginChallengeClient {
|
||||
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
|
||||
let mut builder = Client::builder();
|
||||
let mut builder = storyteller_client_builder(api_host);
|
||||
if matches!(api_host, ApiHost::Localhost { .. }) {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let client = builder
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
|
||||
Ok(Self {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod basic_json_post_request;
|
||||
pub (crate) mod basic_query_string_post_request;
|
||||
pub (crate) mod filter_bad_response;
|
||||
pub (crate) mod http_get_anonymous;
|
||||
pub (crate) mod storyteller_client_builder;
|
||||
pub mod api_host;
|
||||
pub mod constants;
|
||||
pub mod status_codes;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ORIGIN};
|
||||
use reqwest::{Client, ClientBuilder};
|
||||
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
/// Every request to our API is built from this so the desktop client identifies itself
|
||||
/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs.
|
||||
pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin()));
|
||||
|
||||
Client::builder()
|
||||
.user_agent(ARTCRAFT_DESKTOP_USER_AGENT)
|
||||
.default_headers(headers)
|
||||
.gzip(true)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
# Don't restart the dev server on Rust changes
|
||||
crates/
|
||||
Cargo.toml
|
||||
Cargo.lock
|
||||
# Rust sources and Cargo manifests must remain watched for `cargo tauri dev`.
|
||||
# Vite owns frontend reloads; build artifacts must not trigger Rust rebuilds.
|
||||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "artcraft_client_identity"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "artcraft_client_identity"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# External
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# (None)
|
||||
@@ -0,0 +1,166 @@
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
|
||||
use crate::third_party_provider::{ThirdPartyProvider, THIRD_PARTY_PROVIDERS};
|
||||
|
||||
/// Hostnames of our deployed APIs (HTTPS only).
|
||||
const ARTCRAFT_API_HOSTNAMES: [&str; 3] = [
|
||||
"api.storyteller.ai",
|
||||
"api.getartcraft.com",
|
||||
"api.fakeyou.com",
|
||||
];
|
||||
|
||||
/// Domains we own. Subdomains match too (CDNs, uploads, websites).
|
||||
const ARTCRAFT_DOMAINS: [&str; 4] = [
|
||||
"storyteller.ai",
|
||||
"getartcraft.com",
|
||||
"fakeyou.com",
|
||||
"artcraft.ai",
|
||||
];
|
||||
|
||||
/// Who a request is going to, which decides how the desktop app identifies itself.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
/// Our HTTP API, deployed or running locally, and the `Origin` to declare to it.
|
||||
ArtcraftApi { origin: &'static str },
|
||||
/// Our other hosts: CDNs, uploads, websites.
|
||||
ArtcraftHost,
|
||||
/// A known third-party provider.
|
||||
ThirdParty(&'static ThirdPartyProvider),
|
||||
/// Anything else.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Destination {
|
||||
pub fn classify(destination: &Url) -> Self {
|
||||
if is_local_development_api(destination) {
|
||||
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN };
|
||||
}
|
||||
let Some(host) = destination.host_str() else {
|
||||
return Self::Unknown;
|
||||
};
|
||||
if destination.scheme() == "https" && ARTCRAFT_API_HOSTNAMES.contains(&host) {
|
||||
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_ORIGIN };
|
||||
}
|
||||
if ARTCRAFT_DOMAINS.iter().any(|domain| is_domain_or_subdomain(host, domain)) {
|
||||
return Self::ArtcraftHost;
|
||||
}
|
||||
THIRD_PARTY_PROVIDERS
|
||||
.iter()
|
||||
.find(|provider| provider.domains.iter().any(|domain| is_domain_or_subdomain(host, domain)))
|
||||
.map_or(Self::Unknown, Self::ThirdParty)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_local_development_api(destination: &Url) -> bool {
|
||||
if !matches!(destination.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match destination.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_domain_or_subdomain(host: &str, domain: &str) -> bool {
|
||||
host == domain
|
||||
|| host
|
||||
.strip_suffix(domain)
|
||||
.is_some_and(|prefix| prefix.ends_with('.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod artcraft {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployed_apis_get_desktop_origin() {
|
||||
for destination in [
|
||||
"https://api.storyteller.ai/v1/session",
|
||||
"https://api.getartcraft.com/v1/session",
|
||||
"https://api.fakeyou.com/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "https://desktop.getartcraft.com" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_apis_get_development_origin() {
|
||||
for destination in [
|
||||
"http://localhost:12345/v1/session",
|
||||
"https://localhost:12345/v1/session",
|
||||
"http://127.0.0.1:12345/v1/session",
|
||||
"http://[::1]:12345/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "http://localhost" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_owned_hosts_are_not_apis() {
|
||||
for destination in [
|
||||
"https://cdn-2.fakeyou.com/media/a/b.png",
|
||||
"https://cdn.storyteller.ai/media/a/b.png",
|
||||
"https://style.storyteller.ai/",
|
||||
"https://app.getartcraft.com/",
|
||||
"https://getartcraft.com/",
|
||||
"http://api.storyteller.ai/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftHost, "{destination}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod third_party {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn providers_get_their_own_origins() {
|
||||
for (destination, name, origin) in [
|
||||
("https://grok.com/rest/app-chat/conversations/new", "Grok", "https://grok.com"),
|
||||
("https://assets.grok.com/users/a/b.mp4", "Grok", "https://grok.com"),
|
||||
("https://imagine-public.x.ai/a.png", "Grok", "https://grok.com"),
|
||||
("https://www.midjourney.com/api/submit-jobs", "Midjourney", "https://www.midjourney.com"),
|
||||
("https://cdn.midjourney.com/a/0_0.png", "Midjourney", "https://www.midjourney.com"),
|
||||
("https://sora.chatgpt.com/backend/nf/create", "Sora", "https://sora.chatgpt.com"),
|
||||
("https://videos.openai.com/a.mp4", "Sora", "https://sora.chatgpt.com"),
|
||||
("https://chatgpt.com/backend-api/sentinel", "ChatGPT", "https://chatgpt.com"),
|
||||
("https://api.worldlabs.ai/api/v1/worlds", "World Labs", "https://marble.worldlabs.ai"),
|
||||
("https://static.kinovi.ai/a.png", "Kinovi", "https://kinovi.ai"),
|
||||
("https://static.seedance2-pro.com/materials/a.png", "Seedance 2 Pro", "https://seedance2-pro.com"),
|
||||
("https://v3b.fal.media/files/a.png", "fal", "https://fal.ai"),
|
||||
("https://console.gmicloud.ai/api", "GMI Cloud", "https://console.gmicloud.ai"),
|
||||
] {
|
||||
let Destination::ThirdParty(provider) = classify(destination) else {
|
||||
panic!("{destination} is not a third party");
|
||||
};
|
||||
assert_eq!((provider.name, provider.origin), (name, origin));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookalikes_and_others_are_unknown() {
|
||||
for destination in [
|
||||
"https://api.storyteller.ai.example.com/v1/session",
|
||||
"https://notstoryteller.ai/",
|
||||
"https://evilgrok.com/",
|
||||
"https://grok.com.example.com/",
|
||||
"https://www.gravatar.com/avatar/a",
|
||||
"https://storage.googleapis.com/bucket/a.png",
|
||||
"http://192.168.1.2:12345/v1/session",
|
||||
"tauri://localhost",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::Unknown, "{destination}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(destination: &str) -> Destination {
|
||||
Destination::classify(&Url::parse(destination).unwrap())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! artcraft_client_identity
|
||||
//!
|
||||
//! How the ArtCraft desktop app identifies itself (`Origin`, `User-Agent`) to each destination.
|
||||
//!
|
||||
//! Our own APIs get the declared ArtCraft desktop identity. Known third-party providers get an
|
||||
//! identity close to their own website, and never ours. The native third-party Rust clients
|
||||
//! (Grok, Midjourney, Sora, etc.) set their own headers and do not use this crate.
|
||||
|
||||
pub mod destination;
|
||||
pub mod origins;
|
||||
pub mod third_party_provider;
|
||||
pub mod user_agents;
|
||||
@@ -0,0 +1,11 @@
|
||||
/// `Origin` sent to our deployed APIs.
|
||||
/// Keep in sync with `actix_cors_configs/src/configs/artcraft_desktop.rs` in artcraft-services.
|
||||
pub const ARTCRAFT_DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
|
||||
|
||||
/// `Origin` sent to a locally running API (eg. `http://localhost:12345`).
|
||||
/// Development CORS accepts any `localhost` origin.
|
||||
pub const ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN: &str = "http://localhost";
|
||||
|
||||
/// `Origin` packaged builds historically sent everywhere. Now only sent to our non-API hosts
|
||||
/// (CDNs, websites), where it is left unchanged.
|
||||
pub const LEGACY_DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai";
|
||||
@@ -0,0 +1,30 @@
|
||||
/// A third-party provider, and the `Origin` of its own website that we present to its hosts.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ThirdPartyProvider {
|
||||
pub name: &'static str,
|
||||
/// Registrable domains; subdomains match too.
|
||||
pub domains: &'static [&'static str],
|
||||
pub origin: &'static str,
|
||||
}
|
||||
|
||||
/// Every third party that gets an invented origin. Origins match what each provider's native
|
||||
/// Rust client already sends. First match wins, so more specific entries go first.
|
||||
pub const THIRD_PARTY_PROVIDERS: [ThirdPartyProvider; 9] = [
|
||||
ThirdPartyProvider { name: "Grok", domains: &["grok.com", "x.ai"], origin: "https://grok.com" },
|
||||
ThirdPartyProvider { name: "Midjourney", domains: &["midjourney.com"], origin: "https://www.midjourney.com" },
|
||||
ThirdPartyProvider {
|
||||
name: "Sora",
|
||||
domains: &["sora.chatgpt.com", "sora.com", "openai.com", "oaistatic.com"],
|
||||
origin: "https://sora.chatgpt.com",
|
||||
},
|
||||
ThirdPartyProvider { name: "ChatGPT", domains: &["chatgpt.com"], origin: "https://chatgpt.com" },
|
||||
ThirdPartyProvider {
|
||||
name: "World Labs",
|
||||
domains: &["worldlabs.ai", "wlt-ai.art"],
|
||||
origin: "https://marble.worldlabs.ai",
|
||||
},
|
||||
ThirdPartyProvider { name: "Kinovi", domains: &["kinovi.ai"], origin: "https://kinovi.ai" },
|
||||
ThirdPartyProvider { name: "Seedance 2 Pro", domains: &["seedance2-pro.com"], origin: "https://seedance2-pro.com" },
|
||||
ThirdPartyProvider { name: "fal", domains: &["fal.ai", "fal.run", "fal.media"], origin: "https://fal.ai" },
|
||||
ThirdPartyProvider { name: "GMI Cloud", domains: &["gmicloud.ai"], origin: "https://console.gmicloud.ai" },
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
/// `User-Agent` sent to our own APIs and hosts.
|
||||
///
|
||||
/// TODO: Change to "artcraft-desktop/1.0" once the backend services can parse it.
|
||||
pub const ARTCRAFT_DESKTOP_USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
/// `User-Agent` sent to third parties: the default of the OS webview Tauri runs on
|
||||
/// (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
|
||||
///
|
||||
/// NB: These are hardcoded snapshots and WILL go out of date as the OS webviews update
|
||||
/// (especially WebView2's Chrome/Edge version, which auto-updates on Windows). Rust can't read
|
||||
/// the webview's real `navigator.userAgent`, so refresh these periodically by checking
|
||||
/// `navigator.userAgent` in the desktop app's devtools on each OS.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)";
|
||||
+1
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use artcraft_client_identity::destination::Destination;
|
||||
use artcraft_client_identity::origins::LEGACY_DESKTOP_ORIGIN;
|
||||
use artcraft_client_identity::user_agents::{ARTCRAFT_DESKTOP_USER_AGENT, WEBVIEW_USER_AGENT};
|
||||
use http::{header, HeaderMap, HeaderValue};
|
||||
use url::{Host, Url};
|
||||
|
||||
/// The `User-Agent` to send when the frontend didn't set one.
|
||||
pub(crate) fn artcraft_default_user_agent(destination: &Url) -> &'static str {
|
||||
match Destination::classify(destination) {
|
||||
Destination::ArtcraftApi { .. } | Destination::ArtcraftHost => ARTCRAFT_DESKTOP_USER_AGENT,
|
||||
Destination::ThirdParty(_) | Destination::Unknown => WEBVIEW_USER_AGENT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the webview's own `Origin` (packaged `tauri://localhost` etc., or a Vite loopback
|
||||
/// origin at any port) with the identity chosen for the destination. The native HTTP bridge
|
||||
/// shares the desktop cookie jar, so its identity must not depend on the webview.
|
||||
pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) {
|
||||
let origin = headers.get(header::ORIGIN).and_then(|value| value.to_str().ok());
|
||||
// Packaged-app webview origins on macOS and Windows.
|
||||
let packaged_origin = headers.get(header::ORIGIN).is_none()
|
||||
|| matches!(origin, Some("null" | "http://tauri.localhost" | "tauri://localhost"));
|
||||
// Development webview origins (Vite on any loopback port).
|
||||
let webview_origin = packaged_origin || origin.is_some_and(is_loopback_origin);
|
||||
|
||||
let replacement = match Destination::classify(destination) {
|
||||
// Our APIs (deployed or local) see the declared desktop identity.
|
||||
Destination::ArtcraftApi { origin } if webview_origin => origin,
|
||||
// Known third parties see an origin close to their own website.
|
||||
Destination::ThirdParty(provider) if webview_origin => provider.origin,
|
||||
// Our CDNs and websites keep the historical packaged-app handling.
|
||||
Destination::ArtcraftHost if packaged_origin => LEGACY_DESKTOP_ORIGIN,
|
||||
// Unknown hosts and ordinary website origins are left untouched.
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// HeaderMap is a multimap; insert replaces all existing Origin values.
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(replacement));
|
||||
}
|
||||
|
||||
fn is_loopback_origin(origin: &str) -> bool {
|
||||
let Ok(url) = Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match url.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const API: &str = "https://api.storyteller.ai/v1/session";
|
||||
const LOCAL_API: &str = "http://localhost:12345/v1/session";
|
||||
const CDN: &str = "https://cdn-2.fakeyou.com/media/a/b.png";
|
||||
const GCS: &str = "https://storage.googleapis.com/bucket/a.png";
|
||||
const GROK: &str = "https://grok.com/rest/app-chat/conversations/new";
|
||||
const MIDJOURNEY: &str = "https://www.midjourney.com/api/submit-jobs";
|
||||
const UNKNOWN: &str = "https://www.gravatar.com/avatar/a";
|
||||
|
||||
const DEV_WEBVIEW_ORIGIN: &str = "http://127.0.0.1:5193";
|
||||
const PACKAGED_ORIGINS: [Option<&str>; 4] =
|
||||
[None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")];
|
||||
|
||||
mod development {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_dynamic_ports_and_loopback_hosts() {
|
||||
for origin in [
|
||||
"http://127.0.0.1:5193",
|
||||
"http://127.0.0.1:5194",
|
||||
"http://127.0.0.1:65535",
|
||||
"http://localhost:5173",
|
||||
"http://[::1]:5193",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), "https://desktop.getartcraft.com");
|
||||
assert_eq!(normalized(LOCAL_API, Some(origin)), "http://localhost");
|
||||
assert_eq!(normalized(GROK, Some(origin)), "https://grok.com");
|
||||
}
|
||||
assert_eq!(
|
||||
normalized("https://api.fakeyou.com/v1/session", Some(DEV_WEBVIEW_ORIGIN)),
|
||||
"https://desktop.getartcraft.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_cdns_and_unknown_destinations_unchanged() {
|
||||
for destination in [
|
||||
CDN,
|
||||
GCS,
|
||||
UNKNOWN,
|
||||
"https://api.storyteller.ai.example.com/v1/session",
|
||||
"http://api.storyteller.ai/v1/session",
|
||||
] {
|
||||
assert_eq!(normalized(destination, Some(DEV_WEBVIEW_ORIGIN)), DEV_WEBVIEW_ORIGIN, "{destination}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_webview_origins_unchanged() {
|
||||
for origin in [
|
||||
"https://app.getartcraft.com",
|
||||
"https://example.com",
|
||||
"http://localhost.example.com:5193",
|
||||
"http://192.168.1.2:5193",
|
||||
"not a URL",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), origin);
|
||||
assert_eq!(normalized(GROK, Some(origin)), origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod packaged {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn our_apis_get_desktop_identity() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(API, origin), "https://desktop.getartcraft.com");
|
||||
assert_eq!(normalized(LOCAL_API, origin), "http://localhost");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_parties_get_their_own_origin() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(GROK, origin), "https://grok.com");
|
||||
assert_eq!(normalized(MIDJOURNEY, origin), "https://www.midjourney.com");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdns_keep_legacy_origin() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(CDN, origin), "https://studio.storyteller.ai");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_destinations_never_get_our_origin() {
|
||||
for destination in [UNKNOWN, GCS] {
|
||||
assert_eq!(normalized(destination, Some("tauri://localhost")), "tauri://localhost");
|
||||
assert_eq!(normalized(destination, Some("null")), "null");
|
||||
assert_eq!(normalized_or_missing(destination, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_duplicate_origin_values_without_changing_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
|
||||
headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture"));
|
||||
normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers);
|
||||
assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1);
|
||||
assert_eq!(headers[header::ORIGIN], "https://desktop.getartcraft.com");
|
||||
assert_eq!(headers[header::COOKIE], "session=fixture");
|
||||
}
|
||||
}
|
||||
|
||||
mod user_agent {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ours_get_artcraft_user_agent() {
|
||||
for destination in [API, LOCAL_API, CDN] {
|
||||
assert_eq!(user_agent(destination), "storyteller-client/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_parties_get_webview_user_agent() {
|
||||
for destination in [GROK, MIDJOURNEY, GCS, UNKNOWN] {
|
||||
let user_agent = user_agent(destination);
|
||||
assert!(user_agent.starts_with("Mozilla/5.0 ("), "{user_agent}");
|
||||
assert!(!user_agent.contains("storyteller"), "{user_agent}");
|
||||
}
|
||||
}
|
||||
|
||||
fn user_agent(destination: &str) -> &'static str {
|
||||
artcraft_default_user_agent(&Url::parse(destination).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(destination: &str, origin: Option<&str>) -> String {
|
||||
normalized_or_missing(destination, origin).unwrap()
|
||||
}
|
||||
|
||||
fn normalized_or_missing(destination: &str, origin: Option<&str>) -> Option<String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers);
|
||||
headers.get(header::ORIGIN).map(|value| value.to_str().unwrap().to_owned())
|
||||
}
|
||||
}
|
||||
+4
-24
@@ -16,12 +16,11 @@ use tauri::{
|
||||
use tokio::sync::oneshot::{channel, Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
artcraft_identity::{artcraft_default_user_agent, normalize_artcraft_origin},
|
||||
scope::{Entry, Scope},
|
||||
Error, Http, Result,
|
||||
};
|
||||
|
||||
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
struct ReqwestResponse(reqwest::Response);
|
||||
impl tauri::Resource for ReqwestResponse {}
|
||||
|
||||
@@ -269,7 +268,7 @@ pub async fn fetch<R: Runtime>(
|
||||
builder = builder.cookie_provider(state.cookies_jar.clone());
|
||||
}
|
||||
|
||||
let mut request = builder.build()?.request(method.clone(), url);
|
||||
let mut request = builder.build()?.request(method.clone(), url.clone());
|
||||
|
||||
// POST and PUT requests should always have a 0 length content-length,
|
||||
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
|
||||
@@ -284,7 +283,7 @@ pub async fn fetch<R: Runtime>(
|
||||
}
|
||||
|
||||
if !headers.contains_key(header::USER_AGENT) {
|
||||
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
|
||||
headers.append(header::USER_AGENT, HeaderValue::from_static(artcraft_default_user_agent(&url)));
|
||||
}
|
||||
|
||||
// ensure we have an Origin header set
|
||||
@@ -305,26 +304,7 @@ pub async fn fetch<R: Runtime>(
|
||||
headers.remove(header::ORIGIN);
|
||||
};
|
||||
|
||||
// NB(bt): In production builds, the origin seems to be "null". This is what our server CORS filter sees:
|
||||
// [2025-05-01T23:42:35Z WARN actix_cors_configs::configs::fakeyou] Invalid origin: "null"
|
||||
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
|
||||
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
|
||||
// ...
|
||||
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
|
||||
// [2025-05-01T23:42:35Z INFO actix_web::middleware::logger] [storyteller-web-85f9b4974-ddvq7] IP=[redact]
|
||||
// 939322e1095ee5e4-IAD "POST /v1/login HTTP/1.1" 400 42 "-" "tauri-plugin-http/2.4.3" 0.000076
|
||||
let mut maybe_origin_header = headers.get(header::ORIGIN);
|
||||
|
||||
let replace_origin_header = maybe_origin_header.is_none()
|
||||
|| maybe_origin_header == Some(&HeaderValue::from_static("null")) // Above error (Mac)
|
||||
|| maybe_origin_header == Some(&HeaderValue::from_static("http://tauri.localhost")) // Windows
|
||||
|| maybe_origin_header == Some(&HeaderValue::from_static("tauri://localhost")); // Mac plausibly causes this
|
||||
|
||||
if replace_origin_header {
|
||||
// TODO(bt): Make this a sentinel origin, eg. sentinel.storyteller.ai.
|
||||
// NB: HeadersMap is a multimap, but this should ensure we just have one value.
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static("https://studio.storyteller.ai"));
|
||||
}
|
||||
normalize_artcraft_origin(&url, &mut headers);
|
||||
|
||||
if let Some(data) = data {
|
||||
request = request.body(data);
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -6,6 +6,11 @@ headless Chrome with mocked Tauri services. They do **not** measure native
|
||||
process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
|
||||
or generation latency.
|
||||
|
||||
These timings are not authentication acceptance tests: the benchmark mocks the
|
||||
native session and rejects frontend HTTP requests. Before accepting a desktop
|
||||
change, also run the [session and Library checks](desktop-session-regression.md),
|
||||
including real Tauri HTTP requests when changing the launcher or bridge.
|
||||
|
||||
## Changes and results
|
||||
|
||||
Seven samples per version; table values are medians. Each sample uses a fresh
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test:desktop-session": "node tools/testing/desktop-session.mjs",
|
||||
"perf:desktop": "node tools/performance/run.mjs"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Executable
+44
@@ -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"
|
||||
Reference in New Issue
Block a user