Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/staged/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
Expand All @@ -14,5 +18,10 @@
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
</body>
</html>
29 changes: 29 additions & 0 deletions apps/staged/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ 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 the app for production
build:
pnpm run tauri:build
Expand Down
4 changes: 3 additions & 1 deletion apps/staged/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +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",
Expand All @@ -23,6 +23,7 @@
"@tauri-apps/cli": "^2.10.0",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
"fake-indexeddb": "^6.2.5",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.46.4",
Expand All @@ -41,6 +42,7 @@
"@tauri-apps/plugin-store": "^2.4.2",
"@tauri-apps/plugin-updater": "^2.10.0",
"ansi-to-html": "^0.7.2",
"idb-keyval": "^6.2.2",
"lucide-svelte": "^0.577.0",
"marked": "^17.0.1",
"sanitize-html": "^2.17.0",
Expand Down
15 changes: 15 additions & 0 deletions apps/staged/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Staged",
"short_name": "Staged",
"start_url": "/",
"display": "standalone",
"background_color": "#1e1e2e",
"theme_color": "#1e1e2e",
"icons": [
{
"src": "/vite.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
74 changes: 74 additions & 0 deletions apps/staged/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/// <reference lib="webworker" />

// Cache name includes a version so we can bust on deploy.
// Bump this (or automate via build) when shipping new assets.
const CACHE_NAME = 'staged-v1';

// Install: pre-cache the app shell entry point.
// Vite-hashed assets will be cached on first fetch via the fetch handler.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(['/']))
);
// Activate immediately instead of waiting for old tabs to close.
self.skipWaiting();
});

// Activate: clean up old caches from previous versions.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
// Start controlling all open clients immediately.
self.clients.claim();
});

// Fetch: network-first for navigation and API, cache-first for hashed assets.
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);

// Never cache API calls or WebSocket upgrades.
if (url.pathname.startsWith('/api/')) return;

// Navigation requests (HTML pages): network-first with cache fallback.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
return;
}

// Static assets (JS, CSS, images): Vite hashes these filenames, so they are
// immutable and safe to serve cache-first.
if (
url.pathname.startsWith('/assets/') ||
url.pathname.endsWith('.svg') ||
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.ico')
) {
event.respondWith(
caches.match(event.request).then(
(cached) =>
cached ||
fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
)
);
return;
}
});
4 changes: 2 additions & 2 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ struct DbState {
needs_reset: Mutex<Option<StoreIncompatibility>>,
}

/// Holds the bearer token for web server authentication so it can be
/// retrieved by the frontend (Tauri command) and shown to the user.
struct WebAccessToken(String);

#[derive(Default)]
struct ShutdownState {
quit_in_progress: AtomicBool,
Expand Down Expand Up @@ -256,6 +260,12 @@ fn stop_actions_for_app_shutdown(app_handle: &tauri::AppHandle) {
// Store status commands
// =============================================================================

/// Returns the bearer token used to authenticate web browser clients.
#[tauri::command]
fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String {
token.0.clone()
}

/// Returns null if the store is ready, or version info if a reset is needed.
#[tauri::command]
fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option<StoreIncompatibility> {
Expand Down Expand Up @@ -1746,14 +1756,16 @@ pub fn run() {
let (event_tx, _) = tokio::sync::broadcast::channel::<web_server::WebEvent>(256);
app.manage(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();
app.manage(WebAccessToken(auth_token.clone()));
web_server::start(web_server::WebAppState {
app_handle: app.handle().clone(),
event_tx,
Expand All @@ -1762,6 +1774,8 @@ pub fn run() {
std::collections::HashSet::new(),
)),
});
} else {
app.manage(WebAccessToken(String::new()));
}

if cfg!(debug_assertions) {
Expand Down Expand Up @@ -1792,6 +1806,7 @@ pub fn run() {
}
})
.invoke_handler(tauri::generate_handler![
get_web_access_token,
get_store_status,
confirm_reset_store,
list_projects,
Expand Down
74 changes: 62 additions & 12 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@
//! All `/api/*` routes (except `/api/auth`) require authentication via either
//! an `Authorization: Bearer <token>` header or a valid `staged_session` cookie.

// 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;
Expand Down Expand Up @@ -188,14 +184,68 @@ 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.
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) {
let token = state.auth_token.clone();
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"));

// Protected API routes require auth (Bearer token or session cookie)
let api_routes = Router::new()
.route("/api/invoke/{command}", post(invoke_command))
.route("/api/events", get(ws_events))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth));

// Auth endpoint is public (it's where you submit the token)
let auth_route = Router::new().route("/api/auth", post(authenticate));

let app = api_routes
.merge(auth_route)
.fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true))
.layer(CorsLayer::permissive())
.with_state(state);

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()
);
log::info!("[web_server] web access token: {token}");
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}");
}
});
}

// =============================================================================
Expand Down
Loading