diff --git a/_docs/dev_setup.md b/_docs/dev_setup.md index c3f0c536f5..6a61c856db 100644 --- a/_docs/dev_setup.md +++ b/_docs/dev_setup.md @@ -33,6 +33,11 @@ Only after the real frontend socket is bound does it start Tauri with that exact 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 diff --git a/crates/vendor/tauri-plugin-http/src/artcraft_origin.rs b/crates/vendor/tauri-plugin-http/src/artcraft_origin.rs new file mode 100644 index 0000000000..8bbab9f22c --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/artcraft_origin.rs @@ -0,0 +1,124 @@ +use http::{header, HeaderMap, HeaderValue}; +use url::{Host, Url}; + +const DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai"; + +/// The native HTTP bridge shares the desktop cookie jar. Its API identity must +/// not depend on which loopback port Vite happened to bind for this launch. +pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) { + let origin = headers.get(header::ORIGIN); + // Preserve the existing packaged-app handling on macOS and Windows. + let packaged_origin = origin.is_none() + || matches!( + origin.and_then(|value| value.to_str().ok()), + Some("null" | "http://tauri.localhost" | "tauri://localhost") + ); + // Only first-party API requests get the development-origin replacement. + // Leave third-party requests and ordinary website origins untouched. + let development_origin = destination.scheme() == "https" + && matches!(destination.host_str(), Some("api.storyteller.ai" | "api.fakeyou.com")) + && origin.and_then(|value| value.to_str().ok()).is_some_and(is_loopback_origin); + + if packaged_origin || development_origin { + // HeaderMap is a multimap; insert replaces all existing Origin values. + headers.insert(header::ORIGIN, HeaderValue::from_static(DESKTOP_ORIGIN)); + } +} + +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"; + + 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)), DESKTOP_ORIGIN); + } + assert_eq!( + normalized("https://api.fakeyou.com/v1/session", Some("http://127.0.0.1:5193")), + DESKTOP_ORIGIN + ); + } + + #[test] + fn leaves_other_destinations_and_non_loopback_origins_unchanged() { + for destination in [ + "https://example.com/v1/session", + "https://api.storyteller.ai.example.com/v1/session", + "http://api.storyteller.ai/v1/session", + "http://localhost:12345/v1/session", + ] { + assert_eq!(normalized(destination, Some("http://127.0.0.1:5193")), "http://127.0.0.1:5193"); + } + 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); + } + } + } + + mod packaged { + use super::*; + + #[test] + fn preserves_existing_desktop_origins() { + for origin in [None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")] + { + assert_eq!(normalized(API, origin), DESKTOP_ORIGIN); + } + } + + #[test] + fn replaces_duplicate_origin_values_without_changing_session_headers() { + let mut headers = HeaderMap::new(); + headers.append(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:5193")); + headers.append(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:5193")); + 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], DESKTOP_ORIGIN); + assert_eq!(headers[header::COOKIE], "session=fixture"); + } + } + + fn normalized(destination: &str, origin: Option<&str>) -> 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[header::ORIGIN].to_str().unwrap().to_owned() + } +} diff --git a/crates/vendor/tauri-plugin-http/src/commands.rs b/crates/vendor/tauri-plugin-http/src/commands.rs index 7093a7270b..737ee9ba2d 100644 --- a/crates/vendor/tauri-plugin-http/src/commands.rs +++ b/crates/vendor/tauri-plugin-http/src/commands.rs @@ -16,6 +16,7 @@ use tauri::{ use tokio::sync::oneshot::{channel, Receiver, Sender}; use crate::{ + artcraft_origin::normalize_artcraft_origin, scope::{Entry, Scope}, Error, Http, Result, }; @@ -269,7 +270,7 @@ pub async fn fetch( 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 @@ -305,26 +306,7 @@ pub async fn fetch( 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); diff --git a/crates/vendor/tauri-plugin-http/src/lib.rs b/crates/vendor/tauri-plugin-http/src/lib.rs index 1da8993fba..8a7b2bc511 100644 --- a/crates/vendor/tauri-plugin-http/src/lib.rs +++ b/crates/vendor/tauri-plugin-http/src/lib.rs @@ -12,6 +12,8 @@ use tauri::{ pub use error::{Error, Result}; +mod artcraft_origin; + // NB(bt): We're making everything public so we can access it. pub mod commands; pub mod error; diff --git a/docs/desktop-session-regression.md b/docs/desktop-session-regression.md new file mode 100644 index 0000000000..01324c0aed --- /dev/null +++ b/docs/desktop-session-regression.md @@ -0,0 +1,132 @@ +# 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. + +## Observed native results + +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. diff --git a/docs/performance.md b/docs/performance.md index 04162b2039..dda6b787cd 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -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 diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx index b81a06f9bc..88ee789b65 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx @@ -596,6 +596,7 @@ export const TopBar = ({ pageName }: Props) => {