Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

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

  ### Why

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

  ### What moves

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

  ### Wiring edits (40 files)

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

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

  ### Verification

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

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

  ### Test plan

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
-7
View File
@@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
/runtime/
**/stirling_thumbnail_handler.dll
-6836
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
[package]
name = "stirling-pdf"
version = "0.1.0"
description = "Stirling-PDF Desktop Application"
authors = ["Stirling-PDF Contributors"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
warnings = "deny"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [ "devtools"] }
tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.4"
tauri-plugin-fs = "2.5.0"
tauri-plugin-dialog = "2.7.0"
tauri-plugin-http = { version = "2.5.8", features = ["dangerous-settings"] }
tauri-plugin-single-instance = { version = "2.4.1", features = ["deep-link"] }
tauri-plugin-store = "2.4.2"
tauri-plugin-opener = "2.5.3"
tauri-plugin-deep-link = "2.4.6"
tauri-plugin-notification = "2.3.3"
tauri-plugin-window-state = "2.2.1"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.50", features = ["time", "sync"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] }
tiny_http = "0.12"
url = "2.5"
urlencoding = "2.1"
sha2 = "0.11"
base64 = "0.22"
rand = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSPrintInfo", "NSPrintOperation"] }
objc2-foundation = { version = "0.3.2", features = ["NSObject", "NSString", "NSURL"] }
objc2-pdf-kit = { version = "0.3.2", features = ["PDFDocument", "objc2-app-kit"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Shell",
"Win32_System_ApplicationInstallationAndServicing",
] }
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Stirling-PDF needs access to your local network to connect to self-hosted servers.</string>
</dict>
</plist>
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,63 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"http:default",
{
"identifier": "http:allow-fetch",
"allow": [
{ "url": "http://*" },
{ "url": "http://*:*" },
{ "url": "https://*" },
{ "url": "https://*:*" },
{ "url": "http://192.168.*.*" },
{ "url": "http://192.168.*.*:*" },
{ "url": "https://192.168.*.*" },
{ "url": "https://192.168.*.*:*" },
{ "url": "http://10.*.*.*" },
{ "url": "http://10.*.*.*:*" },
{ "url": "https://10.*.*.*" },
{ "url": "https://10.*.*.*:*" },
{ "url": "http://172.16.*.*" },
{ "url": "http://172.16.*.*:*" },
{ "url": "https://172.16.*.*" },
{ "url": "https://172.16.*.*:*" },
{ "url": "http://localhost:*" },
{ "url": "https://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://127.0.0.1:*" }
]
},
"http:allow-fetch-cancel",
"http:allow-fetch-read-body",
"http:allow-fetch-send",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "**" }]
},
"dialog:default",
"dialog:allow-message",
"dialog:allow-open",
"dialog:allow-save",
"opener:default",
"shell:allow-open",
"notification:allow-notify",
"notification:allow-request-permission",
"notification:allow-is-permission-granted",
"window-state:allow-filename",
"window-state:allow-restore-state",
"window-state:allow-save-window-state"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rainbow" viewBox="0 0 16 16"><path d="M8 4.5a7 7 0 0 0-7 7 .5.5 0 0 1-1 0 8 8 0 1 1 16 0 .5.5 0 0 1-1 0 7 7 0 0 0-7-7zm0 2a5 5 0 0 0-5 5 .5.5 0 0 1-1 0 6 6 0 1 1 12 0 .5.5 0 0 1-1 0 5 5 0 0 0-5-5zm0 2a3 3 0 0 0-3 3 .5.5 0 0 1-1 0 4 4 0 1 1 8 0 .5.5 0 0 1-1 0 3 3 0 0 0-3-3zm0 2a1 1 0 0 0-1 1 .5.5 0 0 1-1 0 2 2 0 1 1 4 0 .5.5 0 0 1-1 0 1 1 0 0 0-1-1z"/></svg>

Before

Width:  |  Height:  |  Size: 455 B

-107
View File
@@ -1,107 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "stirling-provisioner"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
@@ -1,8 +0,0 @@
[package]
name = "stirling-provisioner"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -1,76 +0,0 @@
use serde::Serialize;
use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProvisioningConfig<'a> {
server_url: &'a str,
lock_connection_mode: bool,
}
fn parse_bool(value: &str) -> bool {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "y" => true,
_ => false,
}
}
fn main() -> Result<(), String> {
let mut output: Option<PathBuf> = None;
let mut url: Option<String> = None;
let mut lock_value: Option<String> = None;
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--output" => {
let value = args
.next()
.ok_or_else(|| "--output requires a value".to_string())?;
output = Some(PathBuf::from(value));
}
"--url" => {
let value = args
.next()
.ok_or_else(|| "--url requires a value".to_string())?;
url = Some(value);
}
"--lock" => {
let value = args
.next()
.ok_or_else(|| "--lock requires a value".to_string())?;
lock_value = Some(value);
}
_ => {
return Err(format!("Unknown argument: {}", arg));
}
}
}
let output = output.ok_or_else(|| "Missing --output".to_string())?;
let url = url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "Missing --url".to_string())?;
let lock = lock_value.as_deref().map(parse_bool).unwrap_or(false);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
let config = ProvisioningConfig {
server_url: url.as_str(),
lock_connection_mode: lock,
};
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize provisioning data: {}", e))?;
fs::write(&output, json)
.map_err(|e| format!("Failed to write provisioning file {}: {}", output.display(), e))?;
Ok(())
}
-898
View File
@@ -1,898 +0,0 @@
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())
}
-478
View File
@@ -1,478 +0,0 @@
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);
}
}
}
}
@@ -1,275 +0,0 @@
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(())
}
@@ -1,248 +0,0 @@
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))
}
}
-44
View File
@@ -1,44 +0,0 @@
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)
}
-32
View File
@@ -1,32 +0,0 @@
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;
@@ -1,20 +0,0 @@
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,
}
}
-68
View File
@@ -1,68 +0,0 @@
#[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())
}
-246
View File
@@ -1,246 +0,0 @@
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));
}
}
});
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
@@ -1,40 +0,0 @@
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()))
}
}
-1
View File
@@ -1 +0,0 @@
pub mod connection_state;
-90
View File
@@ -1,90 +0,0 @@
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())
}
-5
View File
@@ -1,5 +0,0 @@
pub mod logging;
pub mod paths;
pub use logging::{add_log, get_tauri_logs};
pub use paths::{app_data_dir, system_provisioning_dir};
-31
View File
@@ -1,31 +0,0 @@
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
}
}
-16
View File
@@ -1,16 +0,0 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Stirling-PDF
Comment=Locally hosted web application that allows you to perform various operations on PDF files
TryExec={{exec}}
Exec={{exec}} %F
Icon={{icon}}
Terminal=false
MimeType=application/pdf;
Categories=Office;Graphics;Utility;
Actions=open-file;
[Desktop Action open-file]
Name=Open PDF File
Exec={{exec}} %F
-88
View File
@@ -1,88 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.11.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npx vite --mode desktop",
"beforeBuildCommand": "npx vite build --mode desktop"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": ["deb", "rpm", "appimage", "dmg", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": ["libs/*.jar", "runtime/jre/**/*"],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
},
"rpm": {
"desktopTemplate": "stirling-pdf.desktop"
},
"appimage": {
"bundleMediaFramework": false
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"fragmentPaths": ["windows/wix/provisioning.wxs"],
"componentGroupRefs": ["ProvisioningComponentGroup"]
}
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": ["stirlingpdf"]
}
}
}
}
@@ -1 +0,0 @@
/target
-174
View File
@@ -1,174 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "stirling-thumbnail-handler"
version = "0.1.0"
dependencies = [
"windows",
"windows-core",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
@@ -1,26 +0,0 @@
[package]
name = "stirling-thumbnail-handler"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies.windows]
version = "0.58"
features = [
"implement",
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_Graphics_Gdi",
"Win32_Graphics_Imaging",
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem",
"Storage_Streams",
"Data_Pdf",
"Foundation",
]
[dependencies]
windows-core = "0.58"
@@ -1,61 +0,0 @@
# Windows PDF Thumbnail Handler
A lightweight COM DLL that provides PDF page-preview thumbnails in Windows Explorer when Stirling-PDF is the default PDF application.
## Why this exists
When Stirling-PDF registers as the default PDF handler, Windows associates `.pdf` files with Stirling's ProgID. Without a thumbnail handler on that ProgID, Explorer falls back to showing the application icon (the big S logo) instead of a page preview. This DLL restores thumbnail previews by implementing the Windows Shell `IThumbnailProvider` COM interface.
## How it works
1. **Explorer requests a thumbnail** — when a folder with PDFs is opened in Medium/Large icon view, Explorer loads the DLL via the registered COM CLSID.
2. **Shell calls `IInitializeWithStream`** — passes the PDF file content as an `IStream`.
3. **Shell calls `IThumbnailProvider::GetThumbnail(cx)`** — requests a bitmap of size `cx × cx`.
4. **The DLL renders page 1** using the built-in `Windows.Data.Pdf` WinRT API (the same engine Edge uses), preserving aspect ratio.
5. **WIC decodes the rendered PNG** into BGRA pixels, which are copied into an `HBITMAP` via `CreateDIBSection`.
6. **Explorer displays the bitmap** as the file's thumbnail.
All COM methods are wrapped in `catch_unwind` so a malformed PDF cannot crash Explorer.
## Technical details
| | |
|---|---|
| **Language** | Rust (cdylib) |
| **DLL size** | ~156 KB |
| **External deps** | None — uses only Windows built-in APIs |
| **PDF renderer** | `Windows.Data.Pdf` (WinRT, Windows 10+) |
| **Image decode** | WIC (`IWICImagingFactory`) with BGRA32 format conversion |
| **COM CLSID** | `{2D2FBE3A-9A88-4308-A52E-7EF63CA7CF48}` |
| **Threading model** | Apartment (STA — standard for shell extensions) |
| **Min Windows** | Windows 10 |
## Registry entries (managed by MSI)
The WiX installer (`provisioning.wxs`) registers:
- **CLSID** at `HKLM\SOFTWARE\Classes\CLSID\{2D2FBE3A-...}\InprocServer32` pointing to the DLL
- **Shellex** at `HKLM\SOFTWARE\Classes\.pdf\shellex\{E357FCCD-...}` linking `.pdf` thumbnails to our CLSID
Both are automatically removed on uninstall.
## Building
The DLL is built automatically as part of the Tauri build pipeline via `build-provisioner.mjs`:
```bash
cd frontend
npm run tauri-build
```
To build the DLL standalone:
```bash
cd frontend/src-tauri/thumbnail-handler
cargo build --release
# Output: target/release/stirling_thumbnail_handler.dll
```
## Linux / macOS
This DLL is Windows-only. Linux and macOS don't need it — their thumbnail systems (thumbnailers on Linux, Quick Look on macOS) are decoupled from the default app association and continue working regardless of which app is set as default.
@@ -1,383 +0,0 @@
//! Stirling-PDF Windows Thumbnail Handler
//!
//! A lightweight COM DLL that implements IThumbnailProvider for PDF files.
//! Uses the built-in Windows.Data.Pdf WinRT API to render page 1 as a thumbnail.
use std::cell::RefCell;
use std::ffi::c_void;
use std::panic::catch_unwind;
use std::sync::atomic::{AtomicU32, Ordering};
use windows::core::{implement, IUnknown, Interface, GUID, HRESULT};
use windows::Win32::Foundation::{
BOOL, CLASS_E_CLASSNOTAVAILABLE, CLASS_E_NOAGGREGATION, E_FAIL, E_UNEXPECTED, S_FALSE, S_OK,
};
use windows::Win32::Graphics::Gdi::{
CreateDIBSection, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP,
};
use windows::Win32::Graphics::Imaging::{
CLSID_WICImagingFactory, GUID_WICPixelFormat32bppBGRA, IWICImagingFactory,
WICBitmapDitherTypeNone, WICBitmapPaletteTypeCustom, WICDecodeMetadataCacheOnDemand,
};
use windows::Win32::System::Com::{
CoCreateInstance, IClassFactory, IClassFactory_Impl, IStream, CLSCTX_INPROC_SERVER,
STATFLAG_DEFAULT, STREAM_SEEK_SET,
};
use windows::Win32::UI::Shell::{
IThumbnailProvider, IThumbnailProvider_Impl, SHCreateMemStream, WTS_ALPHATYPE,
};
use windows::Win32::UI::Shell::PropertiesSystem::{
IInitializeWithStream, IInitializeWithStream_Impl,
};
// WinRT imports for PDF rendering
use windows::Data::Pdf::PdfDocument;
use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream, IRandomAccessStream};
// CLSID for this thumbnail handler -- must match WiX registry entries
const CLSID_STIRLING_THUMBNAIL: GUID = GUID::from_u128(0x2d2fbe3a_9a88_4308_a52e_7ef63ca7cf48);
static DLL_REF_COUNT: AtomicU32 = AtomicU32::new(0);
// Maximum PDF size we'll attempt to thumbnail (256 MB)
const MAX_PDF_SIZE: usize = 256 * 1024 * 1024;
// ---------------------------------------------------------------------------
// ThumbnailProvider -- the COM object
// ---------------------------------------------------------------------------
#[implement(IThumbnailProvider, IInitializeWithStream)]
struct ThumbnailProvider {
stream: RefCell<Option<IStream>>,
}
impl ThumbnailProvider {
fn new() -> Self {
DLL_REF_COUNT.fetch_add(1, Ordering::SeqCst);
Self {
stream: RefCell::new(None),
}
}
}
impl Drop for ThumbnailProvider {
fn drop(&mut self) {
DLL_REF_COUNT.fetch_sub(1, Ordering::SeqCst);
}
}
impl IInitializeWithStream_Impl for ThumbnailProvider_Impl {
fn Initialize(
&self,
pstream: Option<&IStream>,
_grfmode: u32,
) -> windows::core::Result<()> {
let result = catch_unwind(std::panic::AssertUnwindSafe(|| {
*self.stream.borrow_mut() = pstream.cloned();
}));
match result {
Ok(()) => Ok(()),
Err(_) => Err(E_UNEXPECTED.into()),
}
}
}
impl IThumbnailProvider_Impl for ThumbnailProvider_Impl {
fn GetThumbnail(
&self,
cx: u32,
phbmp: *mut HBITMAP,
pdwalpha: *mut WTS_ALPHATYPE,
) -> windows::core::Result<()> {
let result = catch_unwind(std::panic::AssertUnwindSafe(|| {
self.get_thumbnail_inner(cx, phbmp, pdwalpha)
}));
match result {
Ok(inner) => inner,
Err(_) => Err(E_UNEXPECTED.into()),
}
}
}
impl ThumbnailProvider_Impl {
fn get_thumbnail_inner(
&self,
cx: u32,
phbmp: *mut HBITMAP,
pdwalpha: *mut WTS_ALPHATYPE,
) -> windows::core::Result<()> {
let stream = self.stream.borrow();
let stream = stream.as_ref().ok_or(E_FAIL)?;
// Step 1: Read the IStream into a byte buffer
let bytes = read_istream_to_vec(stream)?;
if bytes.is_empty() {
return Err(E_FAIL.into());
}
// Step 2: Load the PDF via WinRT
let winrt_stream = bytes_to_random_access_stream(&bytes)?;
let pdf_doc = PdfDocument::LoadFromStreamAsync(&winrt_stream)?.get()?;
if pdf_doc.PageCount()? == 0 {
return Err(E_FAIL.into());
}
let page = pdf_doc.GetPage(0)?;
// Step 3: Render page 1 to a PNG stream
let output_stream = InMemoryRandomAccessStream::new()?;
let render_options = windows::Data::Pdf::PdfPageRenderOptions::new()?;
// Calculate dimensions preserving aspect ratio
let page_size = page.Size()?;
let scale = cx as f64 / f64::max(page_size.Width as f64, page_size.Height as f64);
let render_w = (page_size.Width as f64 * scale).max(1.0) as u32;
let render_h = (page_size.Height as f64 * scale).max(1.0) as u32;
render_options.SetDestinationWidth(render_w)?;
render_options.SetDestinationHeight(render_h)?;
page.RenderWithOptionsToStreamAsync(&output_stream, &render_options)?
.get()?;
// Step 4: Decode the PNG using WIC -> raw BGRA pixels -> HBITMAP
let hbitmap = png_stream_to_hbitmap(&output_stream, render_w, render_h)?;
// Step 5: Return the HBITMAP
unsafe {
*phbmp = hbitmap;
// WTSAT_ARGB = 2
*pdwalpha = WTS_ALPHATYPE(2);
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helper: read IStream to Vec<u8> (with loop for short reads)
// ---------------------------------------------------------------------------
fn read_istream_to_vec(stream: &IStream) -> windows::core::Result<Vec<u8>> {
unsafe {
// Get stream size
let mut stat = std::mem::zeroed();
stream.Stat(&mut stat, STATFLAG_DEFAULT)?;
let size = stat.cbSize as usize;
if size == 0 {
return Ok(Vec::new());
}
if size > MAX_PDF_SIZE {
return Err(E_FAIL.into());
}
// Seek to beginning
stream.Seek(0, STREAM_SEEK_SET, None)?;
// Read all bytes, looping for short reads
let mut buffer = vec![0u8; size];
let mut total_read = 0usize;
while total_read < size {
let mut bytes_read = 0u32;
stream
.Read(
buffer[total_read..].as_mut_ptr() as *mut c_void,
(size - total_read) as u32,
Some(&mut bytes_read),
)
.ok()?;
if bytes_read == 0 {
break;
}
total_read += bytes_read as usize;
}
buffer.truncate(total_read);
Ok(buffer)
}
}
// ---------------------------------------------------------------------------
// Helper: bytes -> WinRT IRandomAccessStream
// ---------------------------------------------------------------------------
fn bytes_to_random_access_stream(bytes: &[u8]) -> windows::core::Result<IRandomAccessStream> {
let mem_stream = InMemoryRandomAccessStream::new()?;
let writer = DataWriter::CreateDataWriter(&mem_stream)?;
writer.WriteBytes(bytes)?;
writer.StoreAsync()?.get()?;
// Detach the writer so it doesn't close the stream
writer.DetachStream()?;
// Seek back to beginning
mem_stream.Seek(0)?;
Ok(mem_stream.cast()?)
}
// ---------------------------------------------------------------------------
// Helper: PNG stream -> HBITMAP via WIC (with format conversion to BGRA32)
// ---------------------------------------------------------------------------
fn png_stream_to_hbitmap(
winrt_stream: &InMemoryRandomAccessStream,
width: u32,
height: u32,
) -> windows::core::Result<HBITMAP> {
unsafe {
// Seek to beginning and read PNG data
winrt_stream.Seek(0)?;
let size = winrt_stream.Size()? as usize;
if size == 0 {
return Err(E_FAIL.into());
}
let reader = windows::Storage::Streams::DataReader::CreateDataReader(
&winrt_stream.GetInputStreamAt(0)?,
)?;
reader.LoadAsync(size as u32)?.get()?;
let mut png_bytes = vec![0u8; size];
reader.ReadBytes(&mut png_bytes)?;
// Create a COM IStream from the PNG bytes
let png_stream = SHCreateMemStream(Some(&png_bytes)).ok_or(E_FAIL)?;
// Create WIC factory and decode the PNG
let wic_factory: IWICImagingFactory =
CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_INPROC_SERVER)?;
let decoder = wic_factory.CreateDecoderFromStream(
&png_stream,
std::ptr::null(),
WICDecodeMetadataCacheOnDemand,
)?;
let frame = decoder.GetFrame(0)?;
// Convert to BGRA32 to ensure consistent pixel format
let converter = wic_factory.CreateFormatConverter()?;
converter.Initialize(
&frame,
&GUID_WICPixelFormat32bppBGRA,
WICBitmapDitherTypeNone,
None,
0.0,
WICBitmapPaletteTypeCustom,
)?;
// Read pixels as BGRA
let stride = width * 4;
let buf_size = (stride * height) as usize;
let mut pixels = vec![0u8; buf_size];
converter.CopyPixels(std::ptr::null(), stride, &mut pixels)?;
// Create a DIB section HBITMAP
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width as i32,
biHeight: -(height as i32), // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [std::mem::zeroed()],
};
let mut bits: *mut c_void = std::ptr::null_mut();
let hbitmap = CreateDIBSection(None, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)?;
if bits.is_null() {
return Err(E_FAIL.into());
}
// Copy pixel data into the DIB section
std::ptr::copy_nonoverlapping(pixels.as_ptr(), bits as *mut u8, buf_size);
Ok(hbitmap)
}
}
// ---------------------------------------------------------------------------
// ClassFactory
// ---------------------------------------------------------------------------
#[implement(IClassFactory)]
struct ThumbnailProviderFactory;
impl IClassFactory_Impl for ThumbnailProviderFactory_Impl {
fn CreateInstance(
&self,
punkouter: Option<&IUnknown>,
riid: *const GUID,
ppvobject: *mut *mut c_void,
) -> windows::core::Result<()> {
unsafe {
*ppvobject = std::ptr::null_mut();
}
if punkouter.is_some() {
return Err(CLASS_E_NOAGGREGATION.into());
}
let provider = ThumbnailProvider::new();
let unknown: IUnknown = provider.into();
unsafe { unknown.query(&*riid, ppvobject).ok() }
}
fn LockServer(&self, flock: BOOL) -> windows::core::Result<()> {
if flock.as_bool() {
DLL_REF_COUNT.fetch_add(1, Ordering::SeqCst);
} else {
DLL_REF_COUNT.fetch_sub(1, Ordering::SeqCst);
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// DLL exports
// ---------------------------------------------------------------------------
#[no_mangle]
unsafe extern "system" fn DllGetClassObject(
rclsid: *const GUID,
riid: *const GUID,
ppv: *mut *mut c_void,
) -> HRESULT {
if ppv.is_null() {
return E_FAIL;
}
*ppv = std::ptr::null_mut();
if *rclsid != CLSID_STIRLING_THUMBNAIL {
return CLASS_E_CLASSNOTAVAILABLE;
}
let factory = ThumbnailProviderFactory;
let unknown: IUnknown = factory.into();
match unknown.query(&*riid, ppv).ok() {
Ok(()) => S_OK,
Err(e) => e.into(),
}
}
#[no_mangle]
extern "system" fn DllCanUnloadNow() -> HRESULT {
if DLL_REF_COUNT.load(Ordering::SeqCst) == 0 {
S_OK
} else {
S_FALSE
}
}
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
<DirectoryRef Id="TARGETDIR">
<Directory Id="AppDataFolder" />
<Directory Id="CommonAppDataFolder" />
</DirectoryRef>
<DirectoryRef Id="INSTALLDIR">
<Component Id="ProvisionerBinaryComponent" Guid="*">
<File Id="ProvisionerExe" Source="$(sys.SOURCEFILEDIR)stirling-provision.exe" KeyPath="yes" />
</Component>
<Component Id="ThumbnailHandlerDllComponent" Guid="*">
<File Id="ThumbnailHandlerDll" Source="$(sys.SOURCEFILEDIR)stirling_thumbnail_handler.dll" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- PDF Thumbnail Handler COM registration -->
<DirectoryRef Id="TARGETDIR">
<Component Id="ThumbnailHandlerClsidComponent" Guid="*">
<RegistryKey Root="HKLM" Key="SOFTWARE\Classes\CLSID\{2D2FBE3A-9A88-4308-A52E-7EF63CA7CF48}">
<RegistryValue Type="string" Value="Stirling-PDF Thumbnail Handler" KeyPath="yes" />
</RegistryKey>
<RegistryKey Root="HKLM" Key="SOFTWARE\Classes\CLSID\{2D2FBE3A-9A88-4308-A52E-7EF63CA7CF48}\InprocServer32">
<RegistryValue Type="string" Value="[INSTALLDIR]stirling_thumbnail_handler.dll" />
<RegistryValue Type="string" Name="ThreadingModel" Value="Apartment" />
</RegistryKey>
</Component>
<Component Id="ThumbnailHandlerShellexComponent" Guid="*">
<RegistryKey Root="HKLM" Key="SOFTWARE\Classes\.pdf\shellex\{E357FCCD-A995-4576-B01F-234630154E96}">
<RegistryValue Type="string" Value="{2D2FBE3A-9A88-4308-A52E-7EF63CA7CF48}" KeyPath="yes" />
</RegistryKey>
</Component>
</DirectoryRef>
<ComponentGroup Id="ProvisioningComponentGroup">
<ComponentRef Id="ProvisionerBinaryComponent" />
<ComponentRef Id="ThumbnailHandlerDllComponent" />
<ComponentRef Id="ThumbnailHandlerClsidComponent" />
<ComponentRef Id="ThumbnailHandlerShellexComponent" />
</ComponentGroup>
<CustomAction
Id="WriteProvisioningFilePerUser"
FileKey="ProvisionerExe"
Execute="deferred"
Impersonate="yes"
Return="check"
ExeCommand="[WriteProvisioningFilePerUser]"
/>
<CustomAction
Id="WriteProvisioningFileAllUsers"
FileKey="ProvisionerExe"
Execute="deferred"
Impersonate="no"
Return="check"
ExeCommand="[WriteProvisioningFileAllUsers]"
/>
<CustomAction
Id="SetWriteProvisioningFilePerUser"
Property="WriteProvisioningFilePerUser"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
/>
<CustomAction
Id="SetWriteProvisioningFileAllUsers"
Property="WriteProvisioningFileAllUsers"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot;"
/>
<InstallExecuteSequence>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">STIRLING_SERVER_URL &lt;&gt; &quot;&quot; AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
</InstallExecuteSequence>
</Fragment>
</Wix>