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
4 changes: 4 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 Down
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"
}
]
}
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 @@ -260,6 +264,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 @@ -1752,14 +1762,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 @@ -1768,6 +1780,8 @@ pub fn run() {
std::collections::HashSet::new(),
)),
});
} else {
app.manage(WebAccessToken(String::new()));
}

if cfg!(debug_assertions) {
Expand Down Expand Up @@ -1798,6 +1812,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
39 changes: 38 additions & 1 deletion apps/staged/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { isTauri, listenToEvent, getWindowSync, type UnlistenFn } from './lib/transport';
import WebLogin from './lib/features/layout/WebLogin.svelte';
import * as commands from './lib/api/commands';
import TopBar from './lib/features/layout/TopBar.svelte';
import ProjectHome from './lib/features/projects/ProjectHome.svelte';
Expand Down Expand Up @@ -40,6 +41,8 @@
import { projectStateStore } from './lib/stores/projectState.svelte';
import { initBloxEnv } from './lib/stores/bloxEnv.svelte';
import { listenForSessionStatus } from './lib/listeners/sessionStatusListener';
import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener';
import { listenForPageLifecycle } from './lib/listeners/pageLifecycleListener';
import { darkMode } from './lib/stores/isDark.svelte';
import * as prPollingService from './lib/services/prPollingService';
import { projectsList } from './lib/features/projects/projectsSidebarState.svelte';
Expand All @@ -49,6 +52,8 @@
const updaterCheckIntervalMs = 15 * 60 * 1000;

let showSessionLab = $state(false);
let currentHash = $state(window.location.hash);
const showLogin = $derived(!isTauri && currentHash === '#/login');
let unlistenSettings: UnlistenFn | undefined;
let unlistenFind: UnlistenFn | undefined;
let unlistenFindNext: UnlistenFn | undefined;
Expand All @@ -57,6 +62,8 @@
let unlistenZoomOut: UnlistenFn | undefined;
let unlistenZoomReset: UnlistenFn | undefined;
let unlistenSessionStatus: UnlistenFn | undefined;
let unlistenCacheInvalidation: UnlistenFn | undefined;
let unlistenPageLifecycle: (() => void) | undefined;
let unregisterShortcuts: (() => void) | null = null;
let stopUpdaterLoop: (() => void) | null = null;
let storeIncompat = $state<StoreIncompatibility | null>(null);
Expand Down Expand Up @@ -202,9 +209,32 @@
};
}

function onHashChange() {
currentHash = window.location.hash;
}

onMount(async () => {
darkMode.init();
document.addEventListener('keydown', handleKonamiKey);
window.addEventListener('hashchange', onHashChange);

// In web mode, verify we have a valid session before loading the app.
// This shows the login page immediately rather than after the first
// failed API call triggers a 401 redirect.
if (!isTauri) {
try {
const resp = await fetch('/api/invoke/get_store_status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (resp.status === 401) {
window.location.hash = '#/login';
}
} catch {
// Server unreachable — login page won't help, continue loading
}
}

// Listen for the app menu Preferences item.
unlistenSettings = await listenToEvent('menu:settings', () => {
Expand Down Expand Up @@ -232,6 +262,8 @@
// Global session-status listener — must live at App level so it works
// regardless of which view the user is on. See sessionStatusListener.ts.
unlistenSessionStatus = await listenForSessionStatus();
unlistenCacheInvalidation = await listenForCacheInvalidation();
unlistenPageLifecycle = listenForPageLifecycle();

try {
await initPreferences();
Expand Down Expand Up @@ -391,6 +423,7 @@

onDestroy(() => {
document.removeEventListener('keydown', handleKonamiKey);
window.removeEventListener('hashchange', onHashChange);
unregisterShortcuts?.();
unlistenSettings?.();
unlistenFind?.();
Expand All @@ -400,6 +433,8 @@
unlistenZoomOut?.();
unlistenZoomReset?.();
unlistenSessionStatus?.();
unlistenCacheInvalidation?.();
unlistenPageLifecycle?.();
stopUpdaterLoop?.();
});

Expand All @@ -421,7 +456,9 @@
}
</script>

{#if preferences.loaded}
{#if showLogin}
<WebLogin />
{:else if preferences.loaded}
{#if storeIncompat && storeIncompat.kind === 'needs_reset'}
<main class="reset-shell">
<div class="update-state">
Expand Down
Loading