From ba72a2a6230c76befc92caf41db2e41ed9c5e31e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:06:01 +0000 Subject: [PATCH] Headless windows installer (#5664) --- .gitignore | 2 +- frontend/package.json | 1 + frontend/scripts/build-provisioner.mjs | 28 ++++ frontend/src-tauri/provisioner/Cargo.lock | 107 ++++++++++++++ frontend/src-tauri/provisioner/Cargo.toml | 8 ++ frontend/src-tauri/provisioner/src/main.rs | 76 ++++++++++ frontend/src-tauri/src/commands/backend.rs | 13 +- frontend/src-tauri/src/commands/connection.rs | 130 +++++++++++++++++- frontend/src-tauri/src/lib.rs | 5 + .../src-tauri/src/state/connection_state.rs | 2 + frontend/src-tauri/src/utils/mod.rs | 4 +- frontend/src-tauri/src/utils/paths.rs | 31 +++++ frontend/src-tauri/tauri.conf.json | 12 +- .../src-tauri/windows/wix/provisioning.wxs | 59 ++++++++ .../src/desktop/components/AppProviders.tsx | 39 +++++- .../desktop/components/ConnectionSettings.tsx | 10 +- .../desktop/components/SetupWizard/index.tsx | 31 ++++- .../src/desktop/extensions/accountLogout.ts | 7 +- .../src/desktop/services/apiClientSetup.ts | 3 + .../desktop/services/connectionModeService.ts | 16 ++- .../contexts/ServerExperienceContext.tsx | 7 +- 21 files changed, 557 insertions(+), 34 deletions(-) create mode 100644 frontend/scripts/build-provisioner.mjs create mode 100644 frontend/src-tauri/provisioner/Cargo.lock create mode 100644 frontend/src-tauri/provisioner/Cargo.toml create mode 100644 frontend/src-tauri/provisioner/src/main.rs create mode 100644 frontend/src-tauri/src/utils/paths.rs create mode 100644 frontend/src-tauri/windows/wix/provisioning.wxs diff --git a/.gitignore b/.gitignore index cbd894ff0..0c20958f2 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,7 @@ app/proprietary/build common/build proprietary/build stirling-pdf/build +frontend/src-tauri/provisioner/target # Byte-compiled / optimized / DLL files __pycache__/ @@ -243,4 +244,3 @@ docs/type3/signatures/ # Type3 sample PDFs (development only) **/type3/samples/ - diff --git a/frontend/package.json b/frontend/package.json index d866a6383..aa9c12696 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -71,6 +71,7 @@ "web-vitals": "^5.1.0" }, "scripts": { + "pretauri-build": "node scripts/build-provisioner.mjs", "predev": "npm run generate-icons", "dev": "vite", "prebuild": "npm run generate-icons", diff --git a/frontend/scripts/build-provisioner.mjs b/frontend/scripts/build-provisioner.mjs new file mode 100644 index 000000000..2f974a195 --- /dev/null +++ b/frontend/scripts/build-provisioner.mjs @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +if (process.platform !== 'win32') { + process.exit(0); +} + +const frontendDir = process.cwd(); +const tauriDir = resolve(frontendDir, 'src-tauri'); +const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml'); + +execFileSync( + 'cargo', + ['build', '--release', '--manifest-path', provisionerManifest], + { stdio: 'inherit' } +); + +const provisionerExe = join(tauriDir, 'provisioner', 'target', 'release', 'stirling-provisioner.exe'); +if (!existsSync(provisionerExe)) { + throw new Error(`Provisioner binary not found at ${provisionerExe}`); +} + +const wixDir = join(tauriDir, 'windows', 'wix'); +mkdirSync(wixDir, { recursive: true }); + +const destExe = join(wixDir, 'stirling-provision.exe'); +copyFileSync(provisionerExe, destExe); diff --git a/frontend/src-tauri/provisioner/Cargo.lock b/frontend/src-tauri/provisioner/Cargo.lock new file mode 100644 index 000000000..0bc318ab6 --- /dev/null +++ b/frontend/src-tauri/provisioner/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "stirling-provisioner" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/frontend/src-tauri/provisioner/Cargo.toml b/frontend/src-tauri/provisioner/Cargo.toml new file mode 100644 index 000000000..5565c9854 --- /dev/null +++ b/frontend/src-tauri/provisioner/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "stirling-provisioner" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/frontend/src-tauri/provisioner/src/main.rs b/frontend/src-tauri/provisioner/src/main.rs new file mode 100644 index 000000000..1ab8ecc73 --- /dev/null +++ b/frontend/src-tauri/provisioner/src/main.rs @@ -0,0 +1,76 @@ +use serde::Serialize; +use std::env; +use std::fs; +use std::path::PathBuf; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProvisioningConfig<'a> { + server_url: &'a str, + lock_connection_mode: bool, +} + +fn parse_bool(value: &str) -> bool { + match value.trim().to_lowercase().as_str() { + "1" | "true" | "yes" | "y" => true, + _ => false, + } +} + +fn main() -> Result<(), String> { + let mut output: Option = None; + let mut url: Option = None; + let mut lock_value: Option = None; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--output" => { + let value = args + .next() + .ok_or_else(|| "--output requires a value".to_string())?; + output = Some(PathBuf::from(value)); + } + "--url" => { + let value = args + .next() + .ok_or_else(|| "--url requires a value".to_string())?; + url = Some(value); + } + "--lock" => { + let value = args + .next() + .ok_or_else(|| "--lock requires a value".to_string())?; + lock_value = Some(value); + } + _ => { + return Err(format!("Unknown argument: {}", arg)); + } + } + } + + let output = output.ok_or_else(|| "Missing --output".to_string())?; + let url = url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing --url".to_string())?; + let lock = lock_value.as_deref().map(parse_bool).unwrap_or(false); + + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; + } + + let config = ProvisioningConfig { + server_url: url.as_str(), + lock_connection_mode: lock, + }; + + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize provisioning data: {}", e))?; + + fs::write(&output, json) + .map_err(|e| format!("Failed to write provisioning file {}: {}", output.display(), e))?; + + Ok(()) +} diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index b79715a6b..19c117174 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -2,7 +2,7 @@ use tauri_plugin_shell::ShellExt; use tauri::Manager; use std::sync::Mutex; use std::path::{Path, PathBuf}; -use crate::utils::add_log; +use crate::utils::{add_log, app_data_dir}; use crate::state::connection_state::{AppConnectionState, ConnectionMode}; // Store backend process handle and port globally @@ -168,16 +168,7 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> { // Create, configure and run the Java command to run Stirling-PDF JAR fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &PathBuf) -> Result<(), String> { // Get platform-specific application data directory for Tauri mode - let app_data_dir = if cfg!(target_os = "macos") { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join("Library").join("Application Support").join("Stirling-PDF") - } else if cfg!(target_os = "windows") { - let appdata = std::env::var("APPDATA").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string()); - PathBuf::from(appdata).join("Stirling-PDF") - } else { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join(".config").join("Stirling-PDF") - }; + let app_data_dir = app_data_dir(); // Create subdirectories for different purposes let config_dir = app_data_dir.join("configs"); diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs index f2accec1f..8b8c68a81 100644 --- a/frontend/src-tauri/src/commands/connection.rs +++ b/frontend/src-tauri/src/commands/connection.rs @@ -3,19 +3,25 @@ use crate::state::connection_state::{ ConnectionMode, ServerConfig, }; +use crate::utils::{add_log, app_data_dir, system_provisioning_dir}; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, State}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_store::StoreExt; const STORE_FILE: &str = "connection.json"; const FIRST_LAUNCH_KEY: &str = "setup_completed"; const CONNECTION_MODE_KEY: &str = "connection_mode"; const SERVER_CONFIG_KEY: &str = "server_config"; +const LOCK_CONNECTION_KEY: &str = "lock_connection_mode"; +const PROVISIONING_FILE_NAME: &str = "stirling-provisioning.json"; #[derive(Debug, Serialize, Deserialize)] pub struct ConnectionConfig { pub mode: ConnectionMode, pub server_config: Option, + pub lock_connection_mode: bool, } #[tauri::command] @@ -37,15 +43,22 @@ pub async fn get_connection_config( .get(SERVER_CONFIG_KEY) .and_then(|v| serde_json::from_value(v.clone()).ok()); + let lock_connection_mode = store + .get(LOCK_CONNECTION_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // Update in-memory state if let Ok(mut conn_state) = state.0.lock() { conn_state.mode = mode.clone(); conn_state.server_config = server_config.clone(); + conn_state.lock_connection_mode = lock_connection_mode; } Ok(ConnectionConfig { mode, server_config, + lock_connection_mode, }) } @@ -55,6 +68,7 @@ pub async fn set_connection_mode( state: State<'_, AppConnectionState>, mode: ConnectionMode, server_config: Option, + lock_connection_mode: Option, ) -> Result<(), String> { log::info!("Setting connection mode: {:?}", mode); @@ -62,6 +76,9 @@ pub async fn set_connection_mode( if let Ok(mut conn_state) = state.0.lock() { conn_state.mode = mode.clone(); conn_state.server_config = server_config.clone(); + if let Some(lock) = lock_connection_mode { + conn_state.lock_connection_mode = lock; + } } // Save to store @@ -84,6 +101,14 @@ pub async fn set_connection_mode( store.delete(SERVER_CONFIG_KEY); } + if let Some(lock) = lock_connection_mode { + store.set( + LOCK_CONNECTION_KEY, + serde_json::to_value(lock) + .map_err(|e| format!("Failed to serialize lock flag: {}", e))?, + ); + } + // Mark setup as completed store.set(FIRST_LAUNCH_KEY, serde_json::json!(true)); @@ -95,6 +120,109 @@ pub async fn set_connection_mode( Ok(()) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProvisioningConfig { + server_url: Option, + lock_connection_mode: Option, +} + +fn provisioning_file_paths() -> Vec { + let mut paths = Vec::new(); + paths.push(app_data_dir().join(PROVISIONING_FILE_NAME)); + + if let Some(system_dir) = system_provisioning_dir() { + paths.push(system_dir.join(PROVISIONING_FILE_NAME)); + } + + paths +} + +pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), String> { + let provisioning_paths = provisioning_file_paths(); + let provisioning_path = provisioning_paths + .into_iter() + .find(|path| path.exists()); + + let provisioning_path = match provisioning_path { + Some(path) => path, + None => return Ok(()), + }; + + add_log(format!( + "🧩 Provisioning file detected: {}", + provisioning_path.display() + )); + + let raw = fs::read_to_string(&provisioning_path) + .map_err(|e| format!("Failed to read provisioning file: {}", e))?; + let parsed: ProvisioningConfig = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse provisioning file: {}", e))?; + + let server_url = parsed + .server_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if server_url.is_none() { + add_log("⚠️ Provisioning file missing serverUrl; skipping apply".to_string()); + return Ok(()); + } + + let lock_flag = parsed.lock_connection_mode.unwrap_or(false); + + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + store.set( + CONNECTION_MODE_KEY, + serde_json::to_value(&ConnectionMode::SelfHosted) + .map_err(|e| format!("Failed to serialize mode: {}", e))?, + ); + + let server_config = ServerConfig { + url: server_url.clone().unwrap(), + }; + store.set( + SERVER_CONFIG_KEY, + serde_json::to_value(&server_config) + .map_err(|e| format!("Failed to serialize config: {}", e))?, + ); + + store.set( + LOCK_CONNECTION_KEY, + serde_json::to_value(lock_flag) + .map_err(|e| format!("Failed to serialize lock flag: {}", e))?, + ); + + store.set(FIRST_LAUNCH_KEY, serde_json::json!(true)); + + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + + if let Ok(mut conn_state) = app_handle.state::().0.lock() { + conn_state.mode = ConnectionMode::SelfHosted; + conn_state.server_config = Some(server_config); + conn_state.lock_connection_mode = lock_flag; + } + + let user_app_data = app_data_dir(); + if provisioning_path.starts_with(&user_app_data) { + match fs::remove_file(&provisioning_path) { + Ok(_) => add_log("✅ Provisioning file applied and removed".to_string()), + Err(err) => add_log(format!( + "⚠️ Provisioning applied but failed to remove file: {}", + err + )), + } + } else { + add_log("ℹ️ Provisioning applied from system location; leaving file in place".to_string()); + } + + Ok(()) +} #[tauri::command] pub async fn is_first_launch(app_handle: AppHandle) -> Result { diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index ad49ea16e..673442dcf 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ use commands::{ start_backend, start_oauth_login, }; +use commands::connection::apply_provisioning_if_present; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; use tauri_plugin_deep_link::DeepLinkExt; @@ -116,6 +117,10 @@ pub fn run() { }); } + if let Err(err) = apply_provisioning_if_present(&app.handle()) { + add_log(format!("⚠️ Failed to apply provisioning file: {}", err)); + } + // Start backend immediately, non-blocking let app_handle = app.handle().clone(); diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs index 61db28cbe..1e4ca9b17 100644 --- a/frontend/src-tauri/src/state/connection_state.rs +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -17,6 +17,7 @@ pub struct ServerConfig { pub struct ConnectionState { pub mode: ConnectionMode, pub server_config: Option, + pub lock_connection_mode: bool, } impl Default for ConnectionState { @@ -24,6 +25,7 @@ impl Default for ConnectionState { Self { mode: ConnectionMode::SaaS, server_config: None, + lock_connection_mode: false, } } } diff --git a/frontend/src-tauri/src/utils/mod.rs b/frontend/src-tauri/src/utils/mod.rs index 258efca6c..9f2e56952 100644 --- a/frontend/src-tauri/src/utils/mod.rs +++ b/frontend/src-tauri/src/utils/mod.rs @@ -1,3 +1,5 @@ pub mod logging; +pub mod paths; -pub use logging::{add_log, get_tauri_logs}; \ No newline at end of file +pub use logging::{add_log, get_tauri_logs}; +pub use paths::{app_data_dir, system_provisioning_dir}; diff --git a/frontend/src-tauri/src/utils/paths.rs b/frontend/src-tauri/src/utils/paths.rs new file mode 100644 index 000000000..e0e5b1547 --- /dev/null +++ b/frontend/src-tauri/src/utils/paths.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +pub fn app_data_dir() -> PathBuf { + if cfg!(target_os = "macos") { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("Stirling-PDF") + } else if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA") + .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string()); + PathBuf::from(appdata).join("Stirling-PDF") + } else { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".config").join("Stirling-PDF") + } +} + +pub fn system_provisioning_dir() -> Option { + if cfg!(target_os = "windows") { + let program_data = std::env::var("PROGRAMDATA").ok()?; + Some(PathBuf::from(program_data).join("Stirling-PDF")) + } else if cfg!(target_os = "macos") { + Some(PathBuf::from("/Library").join("Application Support").join("Stirling-PDF")) + } else if cfg!(target_os = "linux") { + Some(PathBuf::from("/etc").join("stirling-pdf")) + } else { + None + } +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index ebcba59e4..73543cef3 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "frontendDist": "../dist", "devUrl": "http://localhost:5173", "beforeDevCommand": "npm run dev -- --mode desktop", - "beforeBuildCommand": "npm run build -- --mode desktop" + "beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop" }, "app": { "windows": [ @@ -62,7 +62,15 @@ "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com" + "timestampUrl": "http://timestamp.digicert.com", + "wix": { + "fragmentPaths": [ + "windows/wix/provisioning.wxs" + ], + "componentGroupRefs": [ + "ProvisioningComponentGroup" + ] + } }, "macOS": { "minimumSystemVersion": "10.15", diff --git a/frontend/src-tauri/windows/wix/provisioning.wxs b/frontend/src-tauri/windows/wix/provisioning.wxs new file mode 100644 index 000000000..2d908815b --- /dev/null +++ b/frontend/src-tauri/windows/wix/provisioning.wxs @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0) + STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0) + STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2) + STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2) + + + diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 02617cd0d..90c9161b8 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -9,6 +9,8 @@ import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; import { connectionModeService } from '@app/services/connectionModeService'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { authService } from '@app/services/authService'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { isTauri } from '@tauri-apps/api/core'; /** * Desktop application providers @@ -21,7 +23,6 @@ export function AppProviders({ children }: { children: ReactNode }) { const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null); const [authChecked, setAuthChecked] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); - // Load connection mode on mount useEffect(() => { void connectionModeService.getCurrentMode().then(setConnectionMode); @@ -51,6 +52,42 @@ export function AppProviders({ children }: { children: ReactNode }) { const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas'; useBackendInitializer(shouldMonitorBackend); + useEffect(() => { + if (!authChecked) { + return; + } + + if (!isTauri()) { + return; + } + + const currentWindow = getCurrentWindow(); + currentWindow + .show() + .then(() => currentWindow.unminimize().catch(() => {})) + .then(() => currentWindow.setFocus().catch(() => {})) + .then(() => currentWindow.requestUserAttention(1).catch(() => {})) + .catch(() => {}); + }, [authChecked]); + + if (!authChecked) { + return ( + +
+ + ); + } + // Show setup wizard on first launch if (isFirstLaunch && !setupComplete) { return ( diff --git a/frontend/src/desktop/components/ConnectionSettings.tsx b/frontend/src/desktop/components/ConnectionSettings.tsx index b0ba688c9..3fb972aa4 100644 --- a/frontend/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/src/desktop/components/ConnectionSettings.tsx @@ -31,11 +31,13 @@ export const ConnectionSettings: React.FC = () => { setLoading(true); await authService.logout(); - // Switch to SaaS mode - await connectionModeService.switchToSaaS(STIRLING_SAAS_URL); + if (!config?.lock_connection_mode) { + // Switch to SaaS mode + await connectionModeService.switchToSaaS(STIRLING_SAAS_URL); - // Reset setup completion to force login screen on reload - await connectionModeService.resetSetupCompletion(); + // Reset setup completion to force login screen on reload + await connectionModeService.resetSetupCompletion(); + } // Reload config const newConfig = await connectionModeService.getCurrentConfig(); diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index a8e1f17ea..cbe79a4aa 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout'; import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen'; @@ -10,7 +10,6 @@ import { AuthServiceError, authService, UserInfo } from '@app/services/authServi import { tauriBackendService } from '@app/services/tauriBackendService'; import { STIRLING_SAAS_URL } from '@app/constants/connection'; import { listen } from '@tauri-apps/api/event'; -import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; enum SetupStep { @@ -32,6 +31,7 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const [error, setError] = useState(null); const [selfHostedMfaCode, setSelfHostedMfaCode] = useState(''); const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false); + const [lockConnectionMode, setLockConnectionMode] = useState(false); const handleSaaSLogin = async (username: string, password: string) => { if (!serverConfig) { @@ -82,6 +82,9 @@ export const SetupWizard: React.FC = ({ onComplete }) => { }; const handleSelfHostedClick = () => { + if (lockConnectionMode) { + return; + } setError(null); setActiveStep(SetupStep.ServerSelection); }; @@ -259,6 +262,9 @@ export const SetupWizard: React.FC = ({ onComplete }) => { }, [onComplete, serverConfig?.url]); const handleBack = () => { + if (lockConnectionMode) { + return; + } setError(null); if (activeStep === SetupStep.SelfHostedLogin) { setSelfHostedMfaCode(''); @@ -272,10 +278,23 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } }; + useEffect(() => { + const loadConfig = async () => { + const currentConfig = await connectionModeService.getCurrentConfig(); + if (currentConfig.lock_connection_mode && currentConfig.server_config?.url) { + setLockConnectionMode(true); + setServerConfig(currentConfig.server_config); + setActiveStep(SetupStep.SelfHostedLogin); + } + }; + + void loadConfig(); + }, []); + return ( {/* Step Content */} - {activeStep === SetupStep.SaaSLogin && ( + {!lockConnectionMode && activeStep === SetupStep.SaaSLogin && ( = ({ onComplete }) => { /> )} - {activeStep === SetupStep.SaaSSignup && ( + {!lockConnectionMode && activeStep === SetupStep.SaaSSignup && ( = ({ onComplete }) => { /> )} - {activeStep === SetupStep.ServerSelection && ( + {!lockConnectionMode && activeStep === SetupStep.ServerSelection && ( = ({ onComplete }) => { )} {/* Back Button */} - {activeStep > SetupStep.SaaSLogin && !loading && ( + {!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (