Headless windows installer (#5664)

This commit is contained in:
Anthony Stirling
2026-02-06 18:06:01 +00:00
committed by GitHub
parent 94e517df3c
commit ba72a2a623
21 changed files with 557 additions and 34 deletions
+1 -1
View File
@@ -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/
+1
View File
@@ -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",
+28
View File
@@ -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);
+107
View File
@@ -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"
@@ -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"
@@ -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<PathBuf> = None;
let mut url: Option<String> = None;
let mut lock_value: Option<String> = 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(())
}
+2 -11
View File
@@ -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");
+129 -1
View File
@@ -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<ServerConfig>,
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<ServerConfig>,
lock_connection_mode: Option<bool>,
) -> 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<String>,
lock_connection_mode: Option<bool>,
}
fn provisioning_file_paths() -> Vec<PathBuf> {
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::<AppConnectionState>().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<bool, String> {
+5
View File
@@ -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();
@@ -17,6 +17,7 @@ pub struct ServerConfig {
pub struct ConnectionState {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
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,
}
}
}
+3 -1
View File
@@ -1,3 +1,5 @@
pub mod logging;
pub mod paths;
pub use logging::{add_log, get_tauri_logs};
pub use logging::{add_log, get_tauri_logs};
pub use paths::{app_data_dir, system_provisioning_dir};
+31
View File
@@ -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<PathBuf> {
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
}
}
+10 -2
View File
@@ -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",
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
<DirectoryRef Id="TARGETDIR">
<Directory Id="AppDataFolder" />
<Directory Id="CommonAppDataFolder" />
</DirectoryRef>
<DirectoryRef Id="INSTALLDIR">
<Component Id="ProvisionerBinaryComponent" Guid="*">
<File Id="ProvisionerExe" Source="$(sys.SOURCEFILEDIR)stirling-provision.exe" KeyPath="yes" />
</Component>
</DirectoryRef>
<ComponentGroup Id="ProvisioningComponentGroup">
<ComponentRef Id="ProvisionerBinaryComponent" />
</ComponentGroup>
<CustomAction
Id="WriteProvisioningFilePerUser"
FileKey="ProvisionerExe"
Execute="deferred"
Impersonate="yes"
Return="check"
ExeCommand="[WriteProvisioningFilePerUser]"
/>
<CustomAction
Id="WriteProvisioningFileAllUsers"
FileKey="ProvisionerExe"
Execute="deferred"
Impersonate="no"
Return="check"
ExeCommand="[WriteProvisioningFileAllUsers]"
/>
<CustomAction
Id="SetWriteProvisioningFilePerUser"
Property="WriteProvisioningFilePerUser"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
/>
<CustomAction
Id="SetWriteProvisioningFileAllUsers"
Property="WriteProvisioningFileAllUsers"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
/>
<InstallExecuteSequence>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
</InstallExecuteSequence>
</Fragment>
</Wix>
@@ -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 (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<div style={{ minHeight: '100vh' }} />
</ProprietaryAppProviders>
);
}
// Show setup wizard on first launch
if (isFirstLaunch && !setupComplete) {
return (
@@ -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();
@@ -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<SetupWizardProps> = ({ onComplete }) => {
const [error, setError] = useState<string | null>(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<SetupWizardProps> = ({ onComplete }) => {
};
const handleSelfHostedClick = () => {
if (lockConnectionMode) {
return;
}
setError(null);
setActiveStep(SetupStep.ServerSelection);
};
@@ -259,6 +262,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ 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<SetupWizardProps> = ({ 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 (
<DesktopAuthLayout>
{/* Step Content */}
{activeStep === SetupStep.SaaSLogin && (
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
<SaaSLoginScreen
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
onLogin={handleSaaSLogin}
@@ -287,7 +306,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
/>
)}
{activeStep === SetupStep.SaaSSignup && (
{!lockConnectionMode && activeStep === SetupStep.SaaSSignup && (
<SaaSSignupScreen
loading={loading}
error={error}
@@ -296,7 +315,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
/>
)}
{activeStep === SetupStep.ServerSelection && (
{!lockConnectionMode && activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
loading={loading}
@@ -319,7 +338,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
)}
{/* Back Button */}
{activeStep > SetupStep.SaaSLogin && !loading && (
{!lockConnectionMode && activeStep > SetupStep.SaaSLogin && !loading && (
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
<button
type="button"
@@ -16,8 +16,11 @@ export function useAccountLogout() {
try {
await signOut();
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
await connectionModeService.resetSetupCompletion().catch(() => {});
const currentConfig = await connectionModeService.getCurrentConfig();
if (!currentConfig.lock_connection_mode) {
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
await connectionModeService.resetSetupCompletion().catch(() => {});
}
window.history.replaceState({}, '', '/');
window.location.reload();
@@ -107,6 +107,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
if (typeof window !== 'undefined') {
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
}
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
@@ -17,6 +17,7 @@ export interface ServerConfig {
export interface ConnectionConfig {
mode: ConnectionMode;
server_config: ServerConfig | null;
lock_connection_mode: boolean;
}
export interface DiagnosticResult {
@@ -50,7 +51,7 @@ export class ConnectionModeService {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'saas', server_config: null };
return this.currentConfig || { mode: 'saas', server_config: null, lock_connection_mode: false };
}
async getCurrentMode(): Promise<ConnectionMode> {
@@ -84,12 +85,16 @@ export class ConnectionModeService {
} catch (error) {
console.error('Failed to load connection config:', error);
// Default to SaaS mode on error
this.currentConfig = { mode: 'saas', server_config: null };
this.currentConfig = { mode: 'saas', server_config: null, lock_connection_mode: false };
this.configLoadedOnce = true;
}
}
async switchToSaaS(saasServerUrl: string): Promise<void> {
if (this.currentConfig?.lock_connection_mode) {
throw new Error('Connection mode is locked by provisioning');
}
console.log('Switching to SaaS mode');
const serverConfig: ServerConfig = { url: saasServerUrl };
@@ -99,7 +104,7 @@ export class ConnectionModeService {
serverConfig,
});
this.currentConfig = { mode: 'saas', server_config: serverConfig };
this.currentConfig = { mode: 'saas', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
this.notifyListeners();
console.log('Switched to SaaS mode successfully');
@@ -113,7 +118,7 @@ export class ConnectionModeService {
serverConfig,
});
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig };
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
@@ -901,6 +906,9 @@ export class ConnectionModeService {
}
async resetSetupCompletion(): Promise<void> {
if (this.currentConfig?.lock_connection_mode) {
return;
}
try {
await invoke('reset_setup_completion');
console.log('Setup completion flag reset successfully');
@@ -171,6 +171,12 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
if (!config) {
return;
}
if (!config.appVersion) {
return;
}
if (loginEnabled && !isAuthenticated) {
return;
}
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
// Use WAU estimate for no-login scenarios OR for login non-admin users
@@ -353,4 +359,3 @@ export function useServerExperienceContext() {
}
return context;
}