mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
|
||||
|
||||
mod utils;
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use commands::{
|
||||
add_opened_file,
|
||||
cleanup_backend,
|
||||
clear_auth_token,
|
||||
clear_opened_files,
|
||||
clear_refresh_token,
|
||||
clear_user_info,
|
||||
is_default_pdf_handler,
|
||||
get_auth_token,
|
||||
get_backend_port,
|
||||
get_connection_config,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
get_refresh_token,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
login,
|
||||
reset_setup_completion,
|
||||
save_auth_token,
|
||||
save_refresh_token,
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
set_as_default_pdf_handler,
|
||||
get_desktop_os,
|
||||
print_pdf_file_native,
|
||||
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;
|
||||
|
||||
fn dispatch_deep_link(app: &AppHandle, url: &str) {
|
||||
add_log(format!("🔗 Dispatching deep link: {}", url));
|
||||
let _ = app.emit("deep-link", url.to_string());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build()
|
||||
)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||
|
||||
// Scan args for PDF files (skip first arg which is the executable)
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
|
||||
|
||||
// Store file for later retrieval (in case frontend isn't ready yet)
|
||||
add_opened_file(arg.clone());
|
||||
|
||||
// Bring the existing window to front
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
let _ = app.emit("files-changed", ());
|
||||
}))
|
||||
.setup(|app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
|
||||
// Process command line arguments on first launch
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Initial file from command line: {}", arg));
|
||||
add_opened_file(arg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let app_handle = app.handle();
|
||||
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if let Err(err) = app_handle.deep_link().register_all() {
|
||||
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
|
||||
}
|
||||
|
||||
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
|
||||
let initial_handle = app_handle.clone();
|
||||
for url in urls {
|
||||
dispatch_deep_link(&initial_handle, url.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let event_app_handle = app_handle.clone();
|
||||
app_handle.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
dispatch_deep_link(&event_app_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
add_log("🚀 Starting bundled backend in background".to_string());
|
||||
let connection_state = app_handle.state::<AppConnectionState>();
|
||||
if let Err(e) = commands::backend::start_backend(app_handle.clone(), connection_state).await {
|
||||
add_log(format!("⚠️ Backend start failed: {}", e));
|
||||
}
|
||||
});
|
||||
|
||||
add_log("🔍 DEBUG: Setup completed".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_backend,
|
||||
get_backend_port,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
get_connection_config,
|
||||
set_connection_mode,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
login,
|
||||
save_auth_token,
|
||||
get_auth_token,
|
||||
clear_auth_token,
|
||||
save_refresh_token,
|
||||
get_refresh_token,
|
||||
clear_refresh_token,
|
||||
save_user_info,
|
||||
get_user_info,
|
||||
clear_user_info,
|
||||
start_oauth_login,
|
||||
get_desktop_os,
|
||||
print_pdf_file_native,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
match event {
|
||||
RunEvent::ExitRequested { .. } => {
|
||||
add_log("🔄 App exit requested, cleaning up...".to_string());
|
||||
cleanup_backend();
|
||||
// Use Tauri's built-in cleanup
|
||||
app_handle.cleanup_before_exit();
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
|
||||
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
|
||||
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||
// Backend cleanup happens in ExitRequested when window actually closes
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
||||
use tauri::DragDropEvent;
|
||||
match drag_drop_event {
|
||||
DragDropEvent::Drop { paths, .. } => {
|
||||
add_log(format!("📂 Files dropped: {:?}", paths));
|
||||
let mut added_files = false;
|
||||
|
||||
for path in paths {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
add_log(format!("📂 Processing dropped file: {}", path_str));
|
||||
add_opened_file(path_str.to_string());
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Opened { urls } => {
|
||||
use urlencoding::decode;
|
||||
|
||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||
let mut added_files = false;
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
if url_str.starts_with("file://") {
|
||||
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
|
||||
|
||||
// Decode URL-encoded characters (%20 -> space, etc.)
|
||||
let file_path = match decode(encoded_path) {
|
||||
Ok(decoded) => decoded.into_owned(),
|
||||
Err(e) => {
|
||||
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
|
||||
encoded_path.to_string() // Fallback to encoded path
|
||||
}
|
||||
};
|
||||
|
||||
add_log(format!("📂 Processing opened file: {}", file_path));
|
||||
add_opened_file(file_path);
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Only log unhandled events in debug mode to reduce noise
|
||||
// #[cfg(debug_assertions)]
|
||||
// add_log(format!("🔍 DEBUG: Unhandled event: {:?}", event));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionMode {
|
||||
SaaS,
|
||||
SelfHosted,
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionState {
|
||||
pub mode: ConnectionMode,
|
||||
pub server_config: Option<ServerConfig>,
|
||||
pub lock_connection_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ConnectionMode::SaaS,
|
||||
server_config: None,
|
||||
lock_connection_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppConnectionState(pub Mutex<ConnectionState>);
|
||||
|
||||
impl Default for AppConnectionState {
|
||||
fn default() -> Self {
|
||||
Self(Mutex::new(ConnectionState::default()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod connection_state;
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::sync::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Store backend logs globally
|
||||
static BACKEND_LOGS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
|
||||
|
||||
// Get platform-specific log directory
|
||||
fn get_log_directory() -> PathBuf {
|
||||
if cfg!(target_os = "macos") {
|
||||
// macOS: ~/Library/Logs/Stirling-PDF
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join("Library").join("Logs").join("Stirling-PDF")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// Windows: %APPDATA%\Stirling-PDF\logs
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string());
|
||||
PathBuf::from(appdata).join("Stirling-PDF").join("logs")
|
||||
} else {
|
||||
// Linux: ~/.config/Stirling-PDF/logs
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".config").join("Stirling-PDF").join("logs")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to add log entry
|
||||
pub fn add_log(message: String) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let log_entry = format!("{}: {}", timestamp, message);
|
||||
|
||||
// Add to memory logs
|
||||
{
|
||||
let mut logs = BACKEND_LOGS.lock().unwrap();
|
||||
logs.push_back(log_entry.clone());
|
||||
// Keep only last 100 log entries
|
||||
if logs.len() > 100 {
|
||||
logs.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
write_to_log_file(&log_entry);
|
||||
|
||||
// Remove trailing newline if present
|
||||
let clean_message = message.trim_end_matches('\n').to_string();
|
||||
println!("{}", clean_message); // Also print to console
|
||||
}
|
||||
|
||||
// Write log entry to file
|
||||
fn write_to_log_file(log_entry: &str) {
|
||||
let log_dir = get_log_directory();
|
||||
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!("Failed to create log directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let log_file = log_dir.join("tauri-backend.log");
|
||||
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
if let Err(e) = writeln!(file, "{}", log_entry) {
|
||||
eprintln!("Failed to write to log file: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to open log file {:?}: {}", log_file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current logs for debugging
|
||||
pub fn get_logs() -> Vec<String> {
|
||||
let logs = BACKEND_LOGS.lock().unwrap();
|
||||
logs.iter().cloned().collect()
|
||||
}
|
||||
|
||||
// Command to get logs from frontend
|
||||
#[tauri::command]
|
||||
pub async fn get_tauri_logs() -> Result<Vec<String>, String> {
|
||||
Ok(get_logs())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod logging;
|
||||
pub mod paths;
|
||||
|
||||
pub use logging::{add_log, get_tauri_logs};
|
||||
pub use paths::{app_data_dir, system_provisioning_dir};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user