Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,898 @@
use keyring::{Entry};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
use tiny_http::{Response, Server};
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::Rng;
use rand::distr::Alphanumeric;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const TOKENS_STORE_FILE: &str = "tokens.json";
const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token";
const AUTH_TOKEN_STORE_KEY: &str = "auth_token";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub username: String,
pub email: Option<String>,
}
fn get_keyring_entry() -> Result<Entry, String> {
log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY);
let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| {
log::error!("Failed to create keyring entry: {}", e);
format!("Failed to access keyring: {}", e)
})?;
log::debug!("Keyring entry created successfully");
Ok(entry)
}
fn get_refresh_token_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_REFRESH_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
}
#[tauri::command]
pub async fn save_auth_token(app_handle: AppHandle, token: String) -> Result<(), String> {
let trimmed = token.trim();
if trimmed.is_empty() {
log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string());
}
if trimmed.len() != token.len() {
log::debug!("Auth token had surrounding whitespace; storing trimmed token");
}
// Try keyring first (works in environments where Credential Manager persists writes).
// Any failure - including entry creation - falls through to the Tauri Store fallback
// below, never an early return, so restricted environments still persist the token.
match get_keyring_entry() {
Ok(entry) => match entry.set_password(trimmed) {
Ok(_) => {
// Verify it persists. On managed Win10 Pro environments (GPO restricting
// Credential Manager, AV/EDR rules, roaming-profile DPAPI quirks) the
// read-back can return the wrong value or NoEntry even when set succeeded.
match entry.get_password() {
Ok(saved) if saved == trimmed => {
// Clear any stale fallback copy so the keyring stays authoritative.
if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) {
if store.get(AUTH_TOKEN_STORE_KEY).is_some() {
store.delete(AUTH_TOKEN_STORE_KEY);
let _ = store.save();
}
}
log::info!("Auth token saved to keyring");
return Ok(());
}
_ => {
log::info!("Keyring did not persist auth token - using Tauri Store fallback");
}
}
}
Err(e) => {
log::info!("Keyring set failed for auth token: {} - using Tauri Store fallback", e);
}
},
Err(e) => {
log::info!("Keyring entry unavailable for auth token: {} - using Tauri Store fallback", e);
}
}
// Fallback to Tauri Store (same pattern as save_refresh_token)
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.set(
AUTH_TOKEN_STORE_KEY,
serde_json::to_value(trimmed)
.map_err(|e| format!("Failed to serialize token: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
log::info!("Auth token saved to Tauri Store (fallback)");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(app_handle: AppHandle) -> Result<Option<String>, String> {
// Try keyring first (production / unrestricted environments). Any failure -
// including entry creation - falls through to the Tauri Store fallback below.
match get_keyring_entry() {
Ok(entry) => match entry.get_password() {
Ok(token) => return Ok(Some(token)),
Err(keyring::Error::NoEntry) => {
log::debug!("No auth token in keyring, trying Tauri Store");
}
Err(e) => {
log::warn!("Keyring error reading auth token: {} - trying Tauri Store", e);
}
},
Err(e) => {
log::warn!("Keyring entry unavailable for auth token: {} - trying Tauri Store", e);
}
}
// Fallback to Tauri Store
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
let token: Option<String> = store
.get(AUTH_TOKEN_STORE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(token)
}
#[tauri::command]
pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> {
// Clear from keyring (best-effort). Entry creation failure must not block clearing
// the disk fallback, otherwise a stale fallback token could survive sign-out.
match get_keyring_entry() {
Ok(entry) => match entry.delete_credential() {
Ok(_) | Err(keyring::Error::NoEntry) => {}
Err(e) => {
log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e);
// Overwrite with an empty token so a stale value cannot be reused
if let Err(e2) = entry.set_password("") {
log::warn!("Failed to overwrite keyring auth token: {}", e2);
}
}
},
Err(e) => {
log::warn!("Keyring entry unavailable while clearing auth token: {} - clearing Tauri Store fallback only", e);
}
}
// Clear from Tauri Store fallback
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.delete(AUTH_TOKEN_STORE_KEY);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving refresh token - trying keyring first");
let entry = get_refresh_token_keyring_entry()?;
// Try keyring (works in production with code signing)
match entry.set_password(&token) {
Ok(_) => {
// Verify it persists (fails in unsigned dev builds)
match entry.get_password() {
Ok(saved) if saved == token => {
log::info!("✅ Refresh token saved to keyring (production mode)");
return Ok(());
}
_ => {
log::info!("Keyring doesn't persist - using Tauri Store fallback (dev mode)");
}
}
}
Err(e) => {
log::info!("Keyring failed: {} - using Tauri Store fallback", e);
}
}
// Fallback to Tauri Store (dev mode without code signing)
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.set(
REFRESH_TOKEN_STORE_KEY,
serde_json::to_value(&token)
.map_err(|e| format!("Failed to serialize token: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
log::info!("✅ Refresh token saved to Tauri Store (fallback)");
Ok(())
}
#[tauri::command]
pub async fn get_refresh_token(app_handle: AppHandle) -> Result<Option<String>, String> {
// Try keyring first (production)
let entry = get_refresh_token_keyring_entry()?;
match entry.get_password() {
Ok(token) => {
log::info!("✅ Refresh token retrieved from keyring");
return Ok(Some(token));
}
Err(keyring::Error::NoEntry) => {
log::debug!("No token in keyring, trying Tauri Store");
}
Err(e) => {
log::warn!("Keyring error: {} - trying Tauri Store", e);
}
}
// Fallback to Tauri Store (dev)
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
let token: Option<String> = store
.get(REFRESH_TOKEN_STORE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
if token.is_some() {
log::info!("✅ Refresh token retrieved from Tauri Store");
} else {
log::info!("No refresh token found");
}
Ok(token)
}
#[tauri::command]
pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing refresh token from all storage");
// Clear from keyring
let entry = get_refresh_token_keyring_entry()?;
match entry.delete_credential() {
Ok(_) => log::info!("Cleared from keyring"),
Err(keyring::Error::NoEntry) => log::debug!("Not in keyring"),
Err(e) => log::warn!("Keyring clear error: {}", e),
}
// Clear from Tauri Store
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.delete(REFRESH_TOKEN_STORE_KEY);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
log::info!("✅ Refresh token cleared");
Ok(())
}
#[tauri::command]
pub async fn save_user_info(
app_handle: AppHandle,
username: String,
email: Option<String>,
) -> Result<(), String> {
let user_info = UserInfo { username, email };
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
USER_INFO_KEY,
serde_json::to_value(&user_info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
log::debug!("Retrieving user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let user_info: Option<UserInfo> = store
.get(USER_INFO_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(user_info)
}
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(USER_INFO_KEY);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
Ok(())
}
// Response types for Spring Boot login (self-hosted)
#[derive(Debug, Deserialize)]
struct SpringBootSession {
access_token: String,
}
#[derive(Debug, Deserialize)]
struct SpringBootUser {
username: String,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SpringBootLoginResponse {
session: SpringBootSession,
user: SpringBootUser,
}
// Response types for Supabase login (SaaS)
#[derive(Debug, Deserialize)]
struct SupabaseUserMetadata {
full_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SupabaseUser {
email: Option<String>,
user_metadata: Option<SupabaseUserMetadata>,
}
#[derive(Debug, Deserialize)]
struct SupabaseLoginResponse {
access_token: String,
user: SupabaseUser,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
pub email: Option<String>,
}
/// Login command - makes HTTP request from Rust to bypass CORS
/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
#[tauri::command]
pub async fn login(
server_url: String,
username: String,
password: String,
mfa_code: Option<String>,
supabase_key: String,
saas_server_url: String,
) -> Result<LoginResponse, String> {
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
// Create HTTP client with certificate bypass
// This handles:
// - Self-signed certificates
// - Missing intermediate certificates
// - Certificate hostname mismatches
// Note: Rustls only supports TLS 1.2 and TLS 1.3
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(30))
.user_agent("StirlingPDF-Desktop/1.0 Tauri")
.build()
.map_err(|e| {
log::error!("Failed to create HTTP client: {}", e);
format!("Failed to create HTTP client: {}", e)
})?;
if is_supabase {
// Supabase authentication flow
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
let request_body = serde_json::json!({
"email": username,
"password": password,
});
let response = client
.post(&login_url)
.header("Content-Type", "application/json;charset=UTF-8")
.header("apikey", &supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.header("X-Client-Info", "supabase-js-web/2.58.0")
.header("X-Supabase-Api-Version", "2024-01-01")
.json(&request_body)
.send()
.await
.map_err(|e| {
let error_msg = e.to_string();
let error_lower = error_msg.to_lowercase();
log::error!("Supabase login network error: {}", e);
// Detect TLS version mismatch
if error_lower.contains("peer is incompatible") ||
error_lower.contains("protocol version") ||
error_lower.contains("peerincompatible") ||
(error_lower.contains("handshake") && (error_lower.contains("tls") || error_lower.contains("ssl"))) {
format!(
"TLS version not supported: The Supabase server appears to require an unsupported TLS version. \
Please contact support. Technical details: {}", e
)
} else {
format!("Network error connecting to Supabase: {}", e)
}
})?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Supabase login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Supabase response format
let login_response: SupabaseLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
let email = login_response.user.email.clone();
let username = login_response.user.user_metadata
.as_ref()
.and_then(|m| m.full_name.clone())
.or_else(|| email.clone())
.unwrap_or_else(|| username);
Ok(LoginResponse {
token: login_response.access_token,
username,
email,
})
} else {
// Spring Boot authentication flow
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url);
let mut payload = serde_json::json!({
"username": username,
"password": password,
});
if let Some(code) = mfa_code
.as_ref()
.map(|c| c.trim())
.filter(|c| !c.is_empty())
{
payload["mfaCode"] = serde_json::Value::String(code.to_string());
}
let response = client
.post(&login_url)
.json(&payload)
.send()
.await
.map_err(|e| {
let error_msg = e.to_string();
let error_lower = error_msg.to_lowercase();
log::error!("Spring Boot login network error: {}", e);
// Detect TLS version mismatch (server using TLS 1.0/1.1)
if error_lower.contains("peer is incompatible") ||
error_lower.contains("protocol version") ||
error_lower.contains("peerincompatible") ||
(error_lower.contains("handshake") && (error_lower.contains("tls") || error_lower.contains("ssl"))) {
format!(
"TLS version not supported: The server appears to be using TLS 1.0 or TLS 1.1, which are not supported by this desktop app. \
Please upgrade your server to use TLS 1.2 or higher, or use the web version of Stirling-PDF instead. \
Technical details: {}", e
)
// Other TLS/SSL errors (certificate issues)
} else if error_lower.contains("tls") || error_lower.contains("ssl") ||
error_lower.contains("certificate") || error_lower.contains("decrypt") {
format!(
"TLS/SSL connection error: This usually means the server has certificate issues. \
The desktop app accepts self-signed certificates, so this might be a TLS version issue. \
Technical details: {}", e
)
} else if error_lower.contains("connection refused") {
format!("Connection refused: Server is not reachable at {}. Check if the server is running and the URL is correct.", login_url)
} else if error_lower.contains("timeout") {
format!("Connection timeout: Server at {} is not responding. Check your network connection.", login_url)
} else if error_lower.contains("dns") || error_lower.contains("resolve") {
format!("DNS resolution failed: Cannot resolve hostname. Check if the server URL is correct.")
} else {
format!("Network error: {}", e)
}
})?;
let status = response.status();
log::debug!("Spring Boot login response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
let error_code = error_json
.get("error")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
if let Some(code) = error_code {
if code == "mfa_required" || code == "invalid_mfa_code" {
return Err(code);
}
}
}
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Spring Boot response format
let login_response: SpringBootLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
Ok(LoginResponse {
token: login_response.session.access_token,
username: login_response.user.username,
email: login_response.user.email,
})
}
}
/// Generate PKCE code_verifier (random 43-128 character string)
fn generate_code_verifier() -> String {
rand::rng()
.sample_iter(Alphanumeric)
.take(128)
.map(char::from)
.collect()
}
/// Generate PKCE code_challenge from code_verifier (SHA256 hash, base64url encoded)
fn generate_code_challenge(code_verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let hash = hasher.finalize();
URL_SAFE_NO_PAD.encode(hash)
}
/// Opens the system browser for OAuth authentication with localhost callback server
/// Uses 127.0.0.1 (loopback) which is supported by Google OAuth with any port
/// Implements PKCE (Proof Key for Code Exchange) for secure OAuth flow
#[tauri::command]
pub async fn start_oauth_login(
_app_handle: AppHandle,
provider: String,
auth_server_url: String,
supabase_key: String,
success_html: String,
error_html: String,
) -> Result<OAuthCallbackResult, String> {
log::info!("Starting OAuth login for provider: {} with auth server: {}", provider, auth_server_url);
// Generate PKCE code_verifier and code_challenge
let code_verifier = generate_code_verifier();
let code_challenge = generate_code_challenge(&code_verifier);
log::debug!("PKCE code_verifier generated: {} chars", code_verifier.len());
log::debug!("PKCE code_challenge: {}", code_challenge);
// Use port 0 to let OS assign an available port (avoids port reuse issues)
// Supabase allows any localhost port via redirect_to parameter
let server = Server::http("127.0.0.1:0")
.map_err(|e| format!("Failed to create OAuth callback server: {}", e))?;
let port = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr.port(),
#[cfg(unix)]
tiny_http::ListenAddr::Unix(_) => {
return Err("OAuth callback server bound to Unix socket instead of TCP port".to_string())
}
};
let callback_url = format!("http://127.0.0.1:{}/callback", port);
log::info!("OAuth callback URL: {}", callback_url);
// Build OAuth URL with authorization code flow + PKCE
// Note: Use redirect_to (not redirect_uri) to tell Supabase where to redirect after processing
// Supabase handles its own /auth/v1/callback internally
// prompt=select_account forces Google to show account picker every time
let oauth_url = format!(
"{}/auth/v1/authorize?provider={}&redirect_to={}&code_challenge={}&code_challenge_method=S256&prompt=select_account",
auth_server_url.trim_end_matches('/'),
provider,
urlencoding::encode(&callback_url),
urlencoding::encode(&code_challenge)
);
log::info!("Full OAuth URL: {}", oauth_url);
log::info!("========================================");
// Open system browser
if let Err(e) = tauri_plugin_opener::open_url(&oauth_url, None::<&str>) {
log::error!("Failed to open browser: {}", e);
return Err(format!("Failed to open browser: {}", e));
}
// Wait for OAuth callback with timeout
let result = Arc::new(Mutex::new(None));
let result_clone = Arc::clone(&result);
// Spawn server handling in blocking thread
let server_handle = std::thread::spawn(move || {
log::info!("Waiting for OAuth callback...");
// Wait for callback (with timeout)
for _ in 0..120 { // 2 minute timeout
if let Ok(Some(request)) = server.recv_timeout(std::time::Duration::from_secs(1)) {
let url_str = format!("http://127.0.0.1{}", request.url());
log::debug!("Received OAuth callback: {}", url_str);
// Parse the authorization code from URL
let callback_data = parse_oauth_callback(&url_str);
// Respond with appropriate HTML based on result
let html_response = match &callback_data {
Ok(_) => {
log::info!("Successfully extracted authorization code");
success_html.clone()
}
Err(error_msg) => {
log::warn!("OAuth callback error: {}", error_msg);
// Replace {error} placeholder with actual error message
error_html.replace("{error}", error_msg)
}
};
let response = Response::from_string(html_response)
.with_header(tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap())
.with_header(tiny_http::Header::from_bytes(&b"Connection"[..], &b"close"[..]).unwrap());
let _ = request.respond(response);
// Store result and exit loop
let mut result_lock = result_clone.lock().unwrap();
*result_lock = Some(callback_data);
break;
}
}
});
// Wait for server thread to complete
server_handle.join()
.map_err(|_| "OAuth callback server thread panicked".to_string())?;
// Get result
let callback_data = result.lock().unwrap().take()
.ok_or_else(|| "OAuth callback timeout - no response received".to_string())?;
// Handle the callback data - exchange authorization code for tokens
match callback_data? {
OAuthCallbackData::Code { code, redirect_uri } => {
log::info!("OAuth completed with authorization code flow, exchanging code...");
exchange_code_for_token(&auth_server_url, &code, &redirect_uri, &code_verifier, &supabase_key).await
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OAuthCallbackResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<i64>,
}
// Internal enum for handling authorization code flow
#[derive(Debug, Clone)]
enum OAuthCallbackData {
Code { code: String, redirect_uri: String },
}
/// Exchange authorization code for access token using PKCE
async fn exchange_code_for_token(
auth_server_url: &str,
code: &str,
_redirect_uri: &str,
code_verifier: &str,
supabase_key: &str,
) -> Result<OAuthCallbackResult, String> {
log::info!("Exchanging authorization code for access token with PKCE");
// Create HTTP client with certificate bypass
// This handles:
// - Self-signed certificates
// - Missing intermediate certificates
// - Certificate hostname mismatches
// Note: Rustls only supports TLS 1.2 and TLS 1.3
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(30))
.user_agent("StirlingPDF-Desktop/1.0 Tauri")
.build()
.map_err(|e| {
log::error!("Failed to create HTTP client: {}", e);
format!("Failed to create HTTP client: {}", e)
})?;
// grant_type goes in query string, not body!
let token_url = format!("{}/auth/v1/token?grant_type=pkce", auth_server_url.trim_end_matches('/'));
// Body should be JSON with auth_code and code_verifier
let body = serde_json::json!({
"auth_code": code,
"code_verifier": code_verifier,
});
log::debug!("Token exchange URL: {}", token_url);
log::debug!("Code verifier length: {} chars", code_verifier.len());
let response = client
.post(&token_url)
.header("Content-Type", "application/json")
.header("apikey", supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.json(&body)
.send()
.await
.map_err(|e| {
let error_msg = e.to_string();
let error_lower = error_msg.to_lowercase();
log::error!("OAuth token exchange network error: {}", e);
// Detect TLS version mismatch
if error_lower.contains("peer is incompatible") ||
error_lower.contains("protocol version") ||
error_lower.contains("peerincompatible") ||
(error_lower.contains("handshake") && (error_lower.contains("tls") || error_lower.contains("ssl"))) {
format!(
"TLS version not supported: The authentication server appears to require an unsupported TLS version. \
Please contact support. Technical details: {}", e
)
} else {
format!("Failed to exchange code for token: {}", e)
}
})?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Token exchange failed with status {}: {}", status, error_text);
return Err(format!("Token exchange failed: {}", error_text));
}
// Parse token response
let token_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse token response: {}", e))?;
log::info!("Token exchange successful");
let access_token = token_response
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "No access_token in token response".to_string())?
.to_string();
let refresh_token = token_response
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expires_in = token_response
.get("expires_in")
.and_then(|v| v.as_i64());
Ok(OAuthCallbackResult {
access_token,
refresh_token,
expires_in,
})
}
fn parse_oauth_callback(url_str: &str) -> Result<OAuthCallbackData, String> {
// Parse URL to extract authorization code or error
let parsed_url = url::Url::parse(url_str)
.map_err(|e| format!("Failed to parse callback URL: {}", e))?;
// Check for OAuth error first (error responses take precedence)
let mut error = None;
let mut error_description = None;
let mut code = None;
for (key, value) in parsed_url.query_pairs() {
match key.as_ref() {
"error" => error = Some(value.to_string()),
"error_description" => error_description = Some(value.to_string()),
"code" => code = Some(value.to_string()),
_ => {}
}
}
// If OAuth provider returned an error, fail immediately
if let Some(error_code) = error {
let error_msg = if let Some(description) = error_description {
format!("OAuth authentication failed: {} - {}", error_code, description)
} else {
format!("OAuth authentication failed: {}", error_code)
};
log::error!("{}", error_msg);
return Err(error_msg);
}
// If we have a code, return it
if let Some(auth_code) = code {
log::info!("Found authorization code in callback");
// Reconstruct the redirect_uri (without query params) for token exchange
let redirect_uri = if let Some(port) = parsed_url.port() {
format!("{}://{}:{}{}",
parsed_url.scheme(),
parsed_url.host_str().unwrap_or("127.0.0.1"),
port,
parsed_url.path()
)
} else {
format!("{}://{}{}",
parsed_url.scheme(),
parsed_url.host_str().unwrap_or("127.0.0.1"),
parsed_url.path()
)
};
return Ok(OAuthCallbackData::Code {
code: auth_code,
redirect_uri,
});
}
// No authorization code or error found
Err("No authorization code or error found in OAuth callback".to_string())
}
@@ -0,0 +1,478 @@
use tauri_plugin_shell::ShellExt;
use tauri::Manager;
use std::sync::Mutex;
use std::path::{Path, PathBuf};
use crate::utils::{add_log, app_data_dir};
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
// Store backend process handle and port globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
// Helper function to reset starting flag
fn reset_starting_flag() {
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
*starting_guard = false;
}
// Extract port number from "Stirling-PDF running on port: PORT" log line
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
// Look for pattern: "running on port: PORT"
if let Some(start) = log_line.find("running on port: ") {
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
// Take digits until whitespace or end of line
let port_str: String = after_prefix.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
return port_str.parse::<u16>().ok();
}
None
}
// Check if backend is already running or starting
fn check_backend_status() -> Result<(), String> {
// Check if backend is already running
{
let process_guard = BACKEND_PROCESS.lock().unwrap();
if process_guard.is_some() {
add_log("⚠️ Backend process already running, skipping start".to_string());
return Err("Backend already running".to_string());
}
}
// Check and set starting flag to prevent multiple simultaneous starts
{
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
if *starting_guard {
add_log("⚠️ Backend already starting, skipping duplicate start".to_string());
return Err("Backend startup already in progress".to_string());
}
*starting_guard = true;
}
Ok(())
}
// Find the bundled JRE and return the java executable path
fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let jre_dir = resource_dir.join("runtime").join("jre");
let java_executable = if cfg!(windows) {
jre_dir.join("bin").join("java.exe")
} else {
jre_dir.join("bin").join("java")
};
if !java_executable.exists() {
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
add_log(error_msg.clone());
return Err(error_msg);
}
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
Ok(java_executable)
}
// Find the Stirling-PDF JAR file
fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let libs_dir = resource_dir.join("libs");
let mut jar_files: Vec<_> = std::fs::read_dir(&libs_dir)
.map_err(|e| {
let error_msg = format!("Failed to read libs directory: {}. Make sure the JAR is copied to libs/", e);
add_log(error_msg.clone());
error_msg
})?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let path = entry.path();
// Match any .jar file containing "stirling-pdf" (case-insensitive)
path.extension().and_then(|s| s.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")).unwrap_or(false)
&& path.file_name()
.and_then(|f| f.to_str())
.map(|name| name.to_ascii_lowercase().contains("stirling-pdf"))
.unwrap_or(false)
})
.collect();
if jar_files.is_empty() {
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
add_log(error_msg.clone());
return Err(error_msg);
}
// Sort by filename to get the latest version (case-insensitive)
jar_files.sort_by(|a, b| {
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
name_b.cmp(&name_a) // Reverse order to get latest first
});
let jar_path = jar_files[0].path();
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
Ok(jar_path)
}
// Normalize path to remove Windows UNC prefix
fn normalize_path(path: &PathBuf) -> PathBuf {
if cfg!(windows) {
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
PathBuf::from(&path_str[4..]) // Remove \\?\ prefix
} else {
path.clone()
}
} else {
path.clone()
}
}
fn migrate_legacy_workspace(legacy_dir: &PathBuf, target_root: &PathBuf) -> std::io::Result<()> {
for entry in std::fs::read_dir(legacy_dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dest_path = target_root.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else if file_type.is_file() {
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else if file_type.is_file() {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
// 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 = app_data_dir();
// Create subdirectories for different purposes
let config_dir = app_data_dir.join("configs");
let log_dir = app_data_dir.join("logs");
let work_dir = app_data_dir.clone();
let legacy_work_dir = app_data_dir.join("workspace");
// Create all necessary directories
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(&log_dir).ok();
std::fs::create_dir_all(&work_dir).ok();
std::fs::create_dir_all(&config_dir).ok();
// Migrate legacy workspace content into the app data root before launch.
if legacy_work_dir.exists() {
add_log(format!("📦 Migrating legacy workspace from {}", legacy_work_dir.display()));
if let Err(err) = migrate_legacy_workspace(&legacy_work_dir, &app_data_dir) {
add_log(format!("⚠️ Failed to migrate legacy workspace: {}", err));
} else {
match std::fs::remove_dir_all(&legacy_work_dir) {
Ok(_) => add_log("✅ Removed legacy workspace directory after migration".to_string()),
Err(err) => add_log(format!("⚠️ Failed to remove legacy workspace: {}", err)),
}
}
}
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
add_log(format!("📁 Log directory: {}", log_dir.display()));
add_log(format!("📁 Working directory: {}", work_dir.display()));
add_log(format!("📁 Config directory: {}", config_dir.display()));
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
let java_options = vec![
"-Xmx2g",
"-DBROWSER_OPEN=false",
"-DSTIRLING_PDF_TAURI_MODE=true",
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-Dserver.port=0", // Let OS assign an available port
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
"-jar",
jar_path.to_str().unwrap(),
];
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
std::process::id(),
java_path.display(),
java_options.join(" ")
);
add_log(format!("🔧 Equivalent command: {}", java_command));
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
// Additional macOS-specific checks
if cfg!(target_os = "macos") {
// Check if java executable has execute permissions
if let Ok(metadata) = std::fs::metadata(java_path) {
let permissions = metadata.permissions();
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = permissions.mode();
add_log(format!("🔍 Java executable mode: 0o{:o}", mode));
if mode & 0o111 == 0 {
add_log("⚠️ Java executable may not have execute permissions".to_string());
}
}
}
// Check if we can read the JAR file
if let Ok(metadata) = std::fs::metadata(jar_path) {
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
} else {
add_log("⚠️ Cannot read JAR file metadata".to_string());
}
}
let sidecar_command = app
.shell()
.command(java_path.to_str().unwrap())
.args(java_options)
.current_dir(&work_dir) // Set working directory to writable location
.env("TAURI_PARENT_PID", std::process::id().to_string())
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
add_log("⚙️ Starting backend with bundled JRE...".to_string());
let (rx, child) = sidecar_command
.spawn()
.map_err(|e| {
let error_msg = format!("❌ Failed to spawn sidecar: {}", e);
add_log(error_msg.clone());
error_msg
})?;
// Store the process handle
{
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
// Start monitoring output
monitor_backend_output(rx);
Ok(())
}
// Monitor backend output in a separate task
fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_shell::process::CommandEvent>) {
tokio::spawn(async move {
let mut _startup_detected = false;
let mut error_count = 0;
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for actual runtime port from web server initialization
// Format: "Stirling-PDF running on port: PORT"
if output_str.contains("running on port:") {
_startup_detected = true;
if let Some(port) = extract_port_from_running_log(&output_str) {
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
}
}
if output_str.contains("Started SPDFApplication") {
_startup_detected = true;
add_log(format!("🎉 Backend startup completed: {}", output_str));
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
error_count += 1;
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
}
// Look for specific common issues
if output_str.contains("Address already in use") {
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
}
if output_str.contains("java.lang.ClassNotFoundException") {
add_log("🚨 CRITICAL: Missing Java dependencies!".to_string());
}
if output_str.contains("java.io.FileNotFoundException") {
add_log("🚨 CRITICAL: Required file not found!".to_string());
}
}
tauri_plugin_shell::process::CommandEvent::Error(error) => {
add_log(format!("❌ Backend process error: {}", error));
}
tauri_plugin_shell::process::CommandEvent::Terminated(payload) => {
add_log(format!("💀 Backend terminated with code: {:?}", payload.code));
if let Some(code) = payload.code {
match code {
0 => println!("✅ Process terminated normally"),
1 => println!("❌ Process terminated with generic error"),
2 => println!("❌ Process terminated due to misuse"),
126 => println!("❌ Command invoked cannot execute"),
127 => println!("❌ Command not found"),
128 => println!("❌ Invalid exit argument"),
130 => println!("❌ Process terminated by Ctrl+C"),
_ => println!("❌ Process terminated with code: {}", code),
}
}
// Clear the stored process handle
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = None;
}
_ => {
println!("🔍 Unknown command event: {:?}", event);
}
}
}
if error_count > 0 {
println!("⚠️ Backend process ended with {} errors detected", error_count);
}
});
}
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(
app: tauri::AppHandle,
connection_state: tauri::State<'_, AppConnectionState>,
) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check connection mode
let mode = {
let state = connection_state.0.lock().map_err(|e| {
let error_msg = format!("❌ Failed to access connection state: {}", e);
add_log(error_msg.clone());
error_msg
})?;
state.mode.clone()
};
match mode {
ConnectionMode::SaaS => {
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
}
ConnectionMode::SelfHosted => {
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
}
ConnectionMode::Local => {
add_log("💻 Running in Local-only mode - starting local backend".to_string());
}
}
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);
}
// Use Tauri's resource API to find the bundled JRE and JAR
let resource_dir = app.path().resource_dir().map_err(|e| {
let error_msg = format!("❌ Failed to get resource directory: {}", e);
add_log(error_msg.clone());
reset_starting_flag();
error_msg
})?;
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
let normalized_java_path = normalize_path(&java_executable);
let normalized_jar_path = normalize_path(&jar_path);
add_log(format!("📦 Found JAR file: {:?}", jar_path));
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
reset_starting_flag();
e
})?;
// Reset the starting flag since startup is complete
reset_starting_flag();
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
Ok("Backend startup initiated successfully with bundled JRE".to_string())
}
// Get the dynamically assigned backend port
#[tauri::command]
pub fn get_backend_port() -> Option<u16> {
let port_guard = BACKEND_PORT.lock().unwrap();
*port_guard
}
// Cleanup function to stop backend on app exit
pub fn cleanup_backend() {
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
if let Some(child) = process_guard.take() {
let pid = child.pid();
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
match child.kill() {
Ok(_) => {
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
}
Err(e) => {
add_log(format!("❌ Failed to terminate backend process during cleanup: {}", e));
println!("❌ Failed to terminate backend process during cleanup: {}", e);
}
}
}
}
@@ -0,0 +1,275 @@
use crate::state::connection_state::{
AppConnectionState,
ConnectionMode,
ServerConfig,
};
use crate::utils::{add_log, app_data_dir, system_provisioning_dir};
use serde::{Deserialize, Serialize};
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]
pub async fn get_connection_config(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
) -> Result<ConnectionConfig, String> {
// Try to load from store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let mode = store
.get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::SaaS);
let server_config: Option<ServerConfig> = store
.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,
})
}
#[tauri::command]
pub async fn set_connection_mode(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
mode: ConnectionMode,
server_config: Option<ServerConfig>,
lock_connection_mode: Option<bool>,
) -> Result<(), String> {
log::info!("Setting connection mode: {:?}", mode);
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
// If the store is already locked, protect connection_mode, server_config, and the lock
// flag from being overwritten by any JS-side call.
// Only allow marking setup_completed and updating auth-related fields.
let already_locked = store
.get(LOCK_CONNECTION_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already_locked {
log::warn!("set_connection_mode called while lock_connection_mode=true — preserving connection settings, but marking setup as completed");
// Still allow setup_completed to be written so the onboarding doesn't repeat.
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
return Ok(());
}
// 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();
if let Some(lock) = lock_connection_mode {
conn_state.lock_connection_mode = lock;
}
}
store.set(
CONNECTION_MODE_KEY,
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
);
if let Some(config) = &server_config {
store.set(
SERVER_CONFIG_KEY,
serde_json::to_value(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?,
);
} else {
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));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Connection mode saved successfully");
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> {
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let setup_completed = store
.get(FIRST_LAUNCH_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(!setup_completed)
}
#[tauri::command]
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
log::info!("Resetting setup completion flag");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
// Reset setup completion flag to force SetupWizard on next launch
store.set(FIRST_LAUNCH_KEY, serde_json::json!(false));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Setup completion flag reset successfully");
Ok(())
}
@@ -0,0 +1,248 @@
use crate::utils::add_log;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use windows::core::HSTRING;
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
IApplicationAssociationRegistration, ApplicationAssociationRegistration,
ASSOCIATIONTYPE, ASSOCIATIONLEVEL,
};
unsafe {
// Initialize COM for this thread
let hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
// RPC_E_CHANGED_MODE means COM is already initialized, which is fine
if hr.is_err() && hr != RPC_E_CHANGED_MODE {
return Err(format!("Failed to initialize COM: {:?}", hr));
}
let result = (|| -> Result<bool, String> {
// Create the IApplicationAssociationRegistration instance
let reg: IApplicationAssociationRegistration =
CoCreateInstance(&ApplicationAssociationRegistration, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("Failed to create COM instance: {}", e))?;
// Query the current default handler for .pdf extension
let extension = HSTRING::from(".pdf");
let default_app = reg.QueryCurrentDefault(
&extension,
ASSOCIATIONTYPE(0), // AT_FILEEXTENSION
ASSOCIATIONLEVEL(1), // AL_EFFECTIVE - gets the effective default (user or machine level)
)
.map_err(|e| format!("Failed to query current default: {}", e))?;
// Convert PWSTR to String
let default_str = default_app.to_string()
.map_err(|e| format!("Failed to convert default app string: {}", e))?;
add_log(format!("Windows PDF handler ProgID: {}", default_str));
// Check if it contains "Stirling" (case-insensitive)
// Note: This checks the ProgID registered by the installer
let is_default = default_str.to_lowercase().contains("stirling");
Ok(is_default)
})();
// Clean up COM
CoUninitialize();
result
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
use std::process::Command;
// Windows 10+ approach: Open Settings app directly to default apps
// This is more reliable than COM APIs which require pre-registration
// ms-settings:defaultapps opens the default apps settings page
let result = Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.output()
.map_err(|e| format!("Failed to open Windows Settings: {}", e))?;
if result.status.success() {
add_log("Opened Windows default apps settings".to_string());
Ok("opened_dialog".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to open settings: {}", error));
Err(format!("Failed to open default apps settings: {}", error))
}
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
use std::process::Command;
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
use std::process::Command;
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}
@@ -0,0 +1,44 @@
use crate::utils::add_log;
use std::sync::Mutex;
// Store the opened file paths globally (supports multiple files)
static OPENED_FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Add an opened file path
pub fn add_opened_file(file_path: String) {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.push(file_path.clone());
add_log(format!("📂 File stored for later retrieval: {}", file_path));
}
// Command to get opened file paths (if app was launched with files)
#[tauri::command]
pub async fn get_opened_files() -> Result<Vec<String>, String> {
// Get all files from the OPENED_FILES store
// Command line args are processed in setup() callback and added to this store
// Additional files from second instances or events are also added here
let opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
}
// Command to clear the opened files (after processing)
#[tauri::command]
pub async fn clear_opened_files() -> Result<(), String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.clear();
add_log("📂 Cleared opened files".to_string());
Ok(())
}
// Command to atomically get and clear opened file paths
#[tauri::command]
pub async fn pop_opened_files() -> Result<Vec<String>, String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
opened_files.clear();
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
Ok(all_files)
}
@@ -0,0 +1,32 @@
pub mod backend;
pub mod files;
pub mod connection;
pub mod auth;
pub mod default_app;
pub mod platform;
pub mod print;
pub use backend::{cleanup_backend, get_backend_port, start_backend};
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
reset_setup_completion,
set_connection_mode,
};
pub use auth::{
clear_auth_token,
clear_refresh_token,
clear_user_info,
get_auth_token,
get_refresh_token,
get_user_info,
login,
save_auth_token,
save_refresh_token,
save_user_info,
start_oauth_login,
};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use platform::get_desktop_os;
pub use print::print_pdf_file_native;
@@ -0,0 +1,20 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DesktopOS {
MacOS,
Windows,
Linux,
Unknown,
}
#[tauri::command]
pub fn get_desktop_os() -> DesktopOS {
match std::env::consts::OS {
"macos" => DesktopOS::MacOS,
"windows" => DesktopOS::Windows,
"linux" => DesktopOS::Linux,
_ => DesktopOS::Unknown,
}
}
@@ -0,0 +1,68 @@
#[cfg(target_os = "macos")]
mod macos {
use std::path::Path;
use std::sync::mpsc;
use objc2::rc::autoreleasepool;
use objc2::AnyThread;
use objc2_app_kit::NSPrintInfo;
use objc2_foundation::{MainThreadMarker, NSString, NSURL};
use objc2_pdf_kit::{PDFDocument, PDFPrintScalingMode};
use tauri::AppHandle;
#[tauri::command]
pub fn print_pdf_file_native(app: AppHandle, file_path: String, title: Option<String>) -> Result<(), String> {
if !Path::new(&file_path).exists() {
return Err(format!("Print file does not exist: {}", file_path));
}
let (sender, receiver) = mpsc::channel();
app.run_on_main_thread(move || {
let result = autoreleasepool(|_| {
let mtm = MainThreadMarker::new()
.ok_or_else(|| "macOS print must run on the main thread".to_string())?;
let path_string = NSString::from_str(&file_path);
let file_url = NSURL::fileURLWithPath(&path_string);
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &file_url) }
.ok_or_else(|| format!("Failed to load PDF for printing: {}", file_path))?;
let print_info = NSPrintInfo::sharedPrintInfo();
let print_operation = unsafe {
document
.printOperationForPrintInfo_scalingMode_autoRotate(
Some(&print_info),
PDFPrintScalingMode::PageScaleDownToFit,
true,
mtm,
)
}
.ok_or_else(|| "PDFKit did not create a print operation".to_string())?;
if let Some(job_title) = title.as_deref() {
print_operation.setJobTitle(Some(&NSString::from_str(job_title)));
}
print_operation.setShowsPrintPanel(true);
print_operation.setShowsProgressPanel(true);
let _ = print_operation.runOperation();
Ok(())
});
let _ = sender.send(result);
}).map_err(|error| error.to_string())?;
receiver
.recv()
.map_err(|error| error.to_string())?
}
}
#[cfg(target_os = "macos")]
pub use macos::print_pdf_file_native;
#[cfg(not(target_os = "macos"))]
#[tauri::command]
pub fn print_pdf_file_native(_file_path: String, _title: Option<String>) -> Result<(), String> {
Err("Native PDF printing is only implemented on macOS".to_string())
}