diff --git a/apps/staged/justfile b/apps/staged/justfile index 842387d93..65c81a025 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -58,6 +58,119 @@ dev repo="": {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} pnpm exec tauri dev --config "$TAURI_CONFIG" +# Run with the HTTPS web server enabled for phone/browser access. +# Requires PEM cert/key files and a hostname covered by the certificate. +dev-web repo="": + #!/usr/bin/env bash + set -euo pipefail + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" || -z "${STAGED_WEB_HOST:-}" ]]; then + printf '%s\n' \ + 'Error: `just dev-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files and a hostname covered by the certificate:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' STAGED_WEB_HOST=hostname.example.com \' \ + ' just dev-web' >&2 + exit 1 + fi + + VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)") + export VITE_PORT + export STAGED_WEB_SERVER=1 + TAURI_CONFIG="{\"build\":{\"devUrl\":\"https://${STAGED_WEB_HOST}:${VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort --host 0.0.0.0\"}}" + + echo "Starting on https://${STAGED_WEB_HOST}:${VITE_PORT} (HTTPS web server on :5175)" + {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} + pnpm exec tauri dev --config "$TAURI_CONFIG" + +# Build and run the release app with the HTTPS web server. Requires PEM cert/key files. +run-web: + #!/usr/bin/env bash + set -euo pipefail + + # Unlike `dev-web` there is no Vite/HMR: the desktop window and the phone + # get the same built bundle, so this is the path that exercises the service + # worker and the production frontend. One Staged process at a time — a + # `just dev`/`dev-web` instance elsewhere shares ~/.staged and the port. + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" ]]; then + printf '%s\n' \ + 'Error: `just run-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files covering the hostname you browse to:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' just run-web' >&2 + exit 1 + fi + + # `tauri build` runs `pnpm run build` first, so dist/ is always regenerated + # here. The window's assets are compiled into the binary while the server + # reads dist/ from disk — rebuilding both together keeps them in sync. + # `--bundles app` skips the DMG that `just build` would also produce. + pnpm exec tauri build --bundles app + + # Cargo resolves a relative CARGO_TARGET_DIR against its own cwd (src-tauri) + TARGET_DIR="$(pwd)/src-tauri/target" + if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then + case "$CARGO_TARGET_DIR" in + /*) TARGET_DIR="$CARGO_TARGET_DIR" ;; + *) TARGET_DIR="$(pwd)/src-tauri/$CARGO_TARGET_DIR" ;; + esac + fi + + APP_BIN="$TARGET_DIR/release/bundle/macos/Staged.app/Contents/MacOS/Staged" + if [[ ! -x "$APP_BIN" ]]; then + echo "Error: no app bundle at $APP_BIN" >&2 + exit 1 + fi + + export STAGED_WEB_SERVER=1 + + # The server locates the frontend by trying /../dist, then + # /../../../../dist, then ../dist relative to the cwd. Only the last + # one resolves for a bundled release binary, so launch from src-tauri to + # make ../dist mean apps/staged/dist. Anywhere else serves 404s. + cd src-tauri + + "$APP_BIN" & + APP_PID=$! + trap 'kill "$APP_PID" 2>/dev/null || true' EXIT + + # Release builds install no logger, so web server failures (bad cert path, + # :5175 already bound by another Staged instance) are completely silent. + # Probe the server rather than trusting that the app launched. + STATUS=000 + for _ in $(seq 1 30); do + kill -0 "$APP_PID" 2>/dev/null || break + STATUS=$(curl -sk --max-time 2 -o /dev/null -w '%{http_code}' https://localhost:5175/sw.js) || true + [[ "$STATUS" == "000" ]] || break + sleep 1 + done + + case "$STATUS" in + 2*|3*) + echo "Web server ready on https://${STAGED_WEB_HOST:-$(hostname)}:5175" + ;; + 404) + echo "Web server is up but dist/ did not resolve — it is serving 404s." >&2 + ;; + *) + printf '%s\n' \ + 'Web server never came up on :5175. Release builds log nothing, so check:' \ + ' - STAGED_WEB_CERT_PATH / STAGED_WEB_KEY_PATH point at readable PEM files' \ + ' - no other Staged instance already holds the port' >&2 + ;; + esac + + wait "$APP_PID" || true + # Build the app for production build: pnpm run tauri:build diff --git a/apps/staged/package.json b/apps/staged/package.json index 3730d36a5..e24b12b6e 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings && tsc -p tsconfig.node.json", diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 0deb57fba..c1ec8dc17 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -11,7 +11,6 @@ dependencies = [ "anyhow", "async-trait", "axum", - "axum-extra", "base64 0.23.1", "blox-cli", "builderbot-actions", @@ -29,7 +28,6 @@ dependencies = [ "objc2-core-graphics", "pikchr", "png 0.18.1", - "rand 0.10.3", "regex", "reqwest", "resvg", @@ -41,7 +39,6 @@ dependencies = [ "serde_json", "sha2 0.11.0", "strip-ansi-escapes", - "subtle", "tar", "tauri", "tauri-build", @@ -493,28 +490,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.21.7" @@ -929,7 +904,6 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ - "percent-encoding", "time", "version_check", ] diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 350bc35dd..90983b89b 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -65,13 +65,9 @@ tauri-plugin-store = "2.5.0" # MCP server for project sessions rmcp = { version = "3.5", features = ["server", "transport-streamable-http-server"] } axum = { version = "0.8", features = ["ws", "http2"] } -axum-extra = { version = "0.12", features = ["cookie"] } tower-http = { version = "0.7", features = ["fs", "cors", "compression-br", "compression-gzip"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } -rand = "0.10" -hex = "0.4" -subtle = "2.6" time = "0.3" pikchr = "0.1.4" @@ -81,9 +77,11 @@ resvg = "0.48" usvg = "0.48" tiny-skia = "0.12" -# Managed Node.js runtime: tarball extraction for runtime installs +# Managed Node.js runtime: tarball extraction and checksum verification for +# runtime installs tar = "0.4" flate2 = "1" +hex = "0.4" # Note image dimensions (note_media::dimensions): header-only pixel size for # every stored raster format, and the PNG pHYs chunk. Both are already in the diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 5a14b7788..cf3fa4a87 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2081,21 +2081,17 @@ pub fn run() { preference_events::forward_to_web(app.handle(), path, event_tx.clone()); } - // Web server startup is stubbed out in this build. - // TODO(web): restore web server startup from the `mobile-web` branch. + // Start the Axum web server only when opted-in via environment variable. + // This avoids exposing an HTTP server on all interfaces for users who + // don't need browser-based access. let web_server_enabled = std::env::var("STAGED_WEB_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if web_server_enabled { - let auth_token = web_server::generate_token(); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, - auth_token, - sessions: std::sync::Arc::new(std::sync::Mutex::new( - std::collections::HashSet::new(), - )), }); } diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index cc0e419a5..5493dab08 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -5,12 +5,8 @@ //! //! - `POST /api/invoke/{command}` — dispatches to the same logic as Tauri commands //! - `GET /api/events` — WebSocket that broadcasts Tauri events as JSON -//! - `POST /api/auth` — accepts bearer token and sets session cookie //! - `GET /*` — static files from `../dist` (the built Svelte frontend) //! -//! All `/api/*` routes (except `/api/auth`) require authentication via either -//! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -//! //! # Handler futures must not own queue state //! //! `invoke_command` awaits [`dispatch`] inline, and hyper's HTTP/1 default @@ -23,11 +19,6 @@ //! spawns it and only awaits the handle; the source-text test //! `web_arms_that_write_running_rows_are_detached` keeps new arms honest. -// The full implementation is preserved here but start() is currently stubbed out, -// so most items appear unused to the compiler. -#![allow(dead_code, unused_imports)] - -use std::collections::HashSet; use std::io; use std::net::SocketAddr; use std::path::PathBuf; @@ -37,17 +28,13 @@ use std::time::Duration; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{DefaultBodyLimit, Path, Query, Request, State, WebSocketUpgrade}; use axum::http::StatusCode; -use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::serve::Listener; use axum::Router; -use axum_extra::extract::cookie::{Cookie, CookieJar}; -use rand::RngExt; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use serde_json::Value; -use subtle::ConstantTimeEq; use tauri::Manager; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::broadcast; @@ -77,10 +64,6 @@ use crate::store::{self, Store}; pub struct WebAppState { pub app_handle: tauri::AppHandle, pub event_tx: broadcast::Sender, - /// Hex-encoded 256-bit token required to authenticate web clients. - pub auth_token: String, - /// Set of valid session IDs, one per authenticated client. - pub sessions: Arc>>, } /// A serialized event for WebSocket broadcast. @@ -144,12 +127,6 @@ pub fn emit_to_all( // Server startup // ============================================================================= -/// Generate a cryptographically random hex-encoded token (256-bit). -pub fn generate_token() -> String { - let bytes: [u8; 32] = rand::rng().random(); - hex::encode(bytes) -} - const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH"; const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH"; @@ -228,17 +205,53 @@ impl Listener for TlsListener { /// Start the Axum web server in a background tokio task. /// -/// Stubbed — logs a warning and returns. The full implementation (TLS listener, -/// Axum router with static file serving) is intentionally disabled in this build. -/// All route handlers, auth middleware, and the `dispatch()` match block are kept -/// compiling so they stay in sync with the rest of the codebase. -/// -/// TODO(web): restore full web server startup from the `mobile-web` branch. -/// That restore should serve [`app_router`], which already composes the API -/// routes, [`media_router`] (the frontend's `resolveStagedMediaUrl` emits -/// `/api/media/...` in non-Tauri builds) and the static bundle. -pub fn start(_state: WebAppState) { - log::warn!("Web server requested but this build has the web server stubbed out"); +/// This should be called from the Tauri `setup` hook after all managed state +/// has been registered. +pub fn start(state: WebAppState) { + tauri::async_runtime::spawn(async move { + let dist_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + // In dev, the exe is in src-tauri/target/debug; dist is at ../../dist relative to src-tauri + .map(|p| { + // Try multiple candidate paths for the built frontend + let candidates = vec![ + p.join("../dist"), // production bundle + p.join("../../../../dist"), // dev (target/debug -> src-tauri -> apps/staged -> dist) + PathBuf::from("../dist"), // relative to cwd + ]; + candidates + .into_iter() + .find(|c| c.exists()) + .unwrap_or_else(|| PathBuf::from("../dist")) + }) + .unwrap_or_else(|| PathBuf::from("../dist")); + + let app = app_router(state, &dist_dir); + + let addr = "0.0.0.0:5175"; + let tls_acceptor = match load_tls_acceptor() { + Ok(acceptor) => acceptor, + Err(e) => { + log::error!("[web_server] {e}"); + return; + } + }; + log::info!( + "[web_server] starting HTTPS on {addr}, serving static files from {}", + dist_dir.display() + ); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("[web_server] failed to bind {addr}: {e}"); + return; + } + }; + if let Err(e) = axum::serve(TlsListener::new(listener, tls_acceptor), app).await { + log::error!("[web_server] server error: {e}"); + } + }); } /// The composed app: API routes, note media, and the built frontend from @@ -248,7 +261,9 @@ pub fn start(_state: WebAppState) { /// and a base64-image-heavy one ~3×. The default predicate skips images and /// bodies under 32 bytes, so WebSocket upgrades and media are untouched. pub(crate) fn app_router(state: WebAppState, dist_dir: &std::path::Path) -> Router { - let media_routes = match media_router(state.clone()) { + // Note media is served from `/api/media/...`, which the frontend's + // `resolveStagedMediaUrl` emits in non-Tauri builds. + let media_routes = match media_router(&state) { Ok(router) => router, Err(e) => { log::warn!("[web_server] note media unavailable: {e}"); @@ -306,25 +321,21 @@ where ) } -/// Kept ready for the mobile-web startup restore. The route and auth layer are -/// exercised together by oneshot tests, without starting a listener. -pub(crate) fn media_router(state: WebAppState) -> Result { +/// Note and tool media routes, mounted by [`app_router`]. The routes are +/// exercised by oneshot tests, without starting a listener. +pub(crate) fn media_router(state: &WebAppState) -> Result { let store = get_store( state .app_handle .state::>>>() .inner(), )?; - Ok(media_router_for_store( - store, - state.auth_token, - state.sessions, - )) + Ok(media_router_for_store(store)) } /// Tool and note media are user/agent-supplied bytes served from the app /// origin. Viewed top-level (e.g. "Open image in new tab") an SVG would run -/// script with the session cookie in scope; `sandbox` puts it in an opaque +/// script with this origin's `/api` routes in reach; `sandbox` puts it in an opaque /// origin with script disabled and `default-src 'none'` blocks its subresource /// loads, while `style-src 'unsafe-inline'` keeps its own `