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]>
@@ -1,7 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
/runtime/
|
||||
|
||||
**/stirling_thumbnail_handler.dll
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 829 B |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -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 |
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
pub mod connection_state;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "[AppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]""
|
||||
/>
|
||||
|
||||
<CustomAction
|
||||
Id="SetWriteProvisioningFileAllUsers"
|
||||
Property="WriteProvisioningFileAllUsers"
|
||||
Value="--output "[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]""
|
||||
/>
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
</InstallExecuteSequence>
|
||||
</Fragment>
|
||||
</Wix>
|
||||