mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Allow desktop app to connect to selfhosted servers (#4902)
# Description of Changes Changes the desktop app to allow connections to self-hosted servers on first startup. This was quite involved and hit loads of CORS issues all through the stack, but I think it's working now. This also changes the bundled backend to spawn on an OS-decided port rather than always spawning on `8080`, which means that the user can have other things running on port `8080` now and the app will still work fine. There were quite a few places that needed to be updated to decouple the app from explicitly using `8080` and I was originally going to split those changes out into another PR (#4939), but I couldn't get it working independently in the time I had, so the diff here is just going to be complex and contian two distinct changes - sorry 🙁
This commit is contained in:
@@ -12,6 +12,8 @@ import java.util.Properties;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.web.context.WebServerInitializedEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@@ -198,6 +200,14 @@ public class SPDFApplication {
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
public void onWebServerInitialized(WebServerInitializedEvent event) {
|
||||||
|
int actualPort = event.getWebServer().getPort();
|
||||||
|
serverPortStatic = String.valueOf(actualPort);
|
||||||
|
// Log the actual runtime port for Tauri to parse
|
||||||
|
log.info("Stirling-PDF running on port: {}", actualPort);
|
||||||
|
}
|
||||||
|
|
||||||
private static void printStartupLogs() {
|
private static void printStartupLogs() {
|
||||||
log.info("Stirling-PDF Started.");
|
log.info("Stirling-PDF Started.");
|
||||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||||
|
|||||||
Generated
+10
@@ -43,6 +43,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
@@ -3887,6 +3888,15 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-http": {
|
||||||
|
"version": "2.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz",
|
||||||
|
"integrity": "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -26,8 +26,6 @@
|
|||||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
|
||||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@mantine/core": "^8.3.1",
|
"@mantine/core": "^8.3.1",
|
||||||
@@ -39,6 +37,9 @@
|
|||||||
"@reactour/tour": "^3.8.0",
|
"@reactour/tour": "^3.8.0",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
|
"@tauri-apps/api": "^2.5.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
@@ -110,11 +111,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@iconify-json/material-symbols": "^1.2.37",
|
"@iconify-json/material-symbols": "^1.2.37",
|
||||||
"@iconify/utils": "^3.0.2",
|
"@iconify/utils": "^3.0.2",
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|||||||
@@ -5498,5 +5498,158 @@
|
|||||||
"offline": "Backend Offline",
|
"offline": "Backend Offline",
|
||||||
"starting": "Backend starting up...",
|
"starting": "Backend starting up...",
|
||||||
"wait": "Please wait for the backend to finish launching and try again."
|
"wait": "Please wait for the backend to finish launching and try again."
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"welcome": "Welcome to Stirling PDF",
|
||||||
|
"description": "Get started by choosing how you want to use Stirling PDF",
|
||||||
|
"step1": {
|
||||||
|
"label": "Choose Mode",
|
||||||
|
"description": "Offline or Server"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"label": "Select Server",
|
||||||
|
"description": "Self-hosted server"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"label": "Login",
|
||||||
|
"description": "Enter credentials"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"offline": {
|
||||||
|
"title": "Use Offline",
|
||||||
|
"description": "Run locally without an internet connection"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"title": "Connect to Server",
|
||||||
|
"description": "Connect to a remote Stirling PDF server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"title": "Connect to Server",
|
||||||
|
"subtitle": "Enter your self-hosted server URL",
|
||||||
|
"type": {
|
||||||
|
"saas": "Stirling PDF SaaS",
|
||||||
|
"selfhosted": "Self-hosted server"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"label": "Server URL",
|
||||||
|
"description": "Enter the full URL of your self-hosted Stirling PDF server"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"emptyUrl": "Please enter a server URL",
|
||||||
|
"unreachable": "Could not connect to server",
|
||||||
|
"testFailed": "Connection test failed"
|
||||||
|
},
|
||||||
|
"testing": "Testing connection..."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign In",
|
||||||
|
"subtitle": "Enter your credentials to continue",
|
||||||
|
"connectingTo": "Connecting to:",
|
||||||
|
"username": {
|
||||||
|
"label": "Username",
|
||||||
|
"placeholder": "Enter your username"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"label": "Password",
|
||||||
|
"placeholder": "Enter your password"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"emptyUsername": "Please enter your username",
|
||||||
|
"emptyPassword": "Please enter your password"
|
||||||
|
},
|
||||||
|
"submit": "Login"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"connection": {
|
||||||
|
"title": "Connection Mode",
|
||||||
|
"mode": {
|
||||||
|
"offline": "Offline",
|
||||||
|
"server": "Server"
|
||||||
|
},
|
||||||
|
"server": "Server",
|
||||||
|
"user": "Logged in as",
|
||||||
|
"switchToServer": "Connect to Server",
|
||||||
|
"switchToOffline": "Switch to Offline",
|
||||||
|
"logout": "Logout",
|
||||||
|
"selectServer": "Select Server",
|
||||||
|
"login": "Login"
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"title": "General",
|
||||||
|
"description": "Configure general application preferences.",
|
||||||
|
"user": "User",
|
||||||
|
"logout": "Log out",
|
||||||
|
"enableFeatures": {
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"title": "For System Administrators",
|
||||||
|
"intro": "Enable user authentication, team management, and workspace features for your organisation.",
|
||||||
|
"action": "Configure",
|
||||||
|
"and": "and",
|
||||||
|
"benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.",
|
||||||
|
"learnMore": "Learn more in documentation"
|
||||||
|
},
|
||||||
|
"defaultToolPickerMode": "Default tool picker mode",
|
||||||
|
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
|
||||||
|
"mode": {
|
||||||
|
"sidebar": "Sidebar",
|
||||||
|
"fullscreen": "Fullscreen"
|
||||||
|
},
|
||||||
|
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||||
|
"autoUnzip": "Auto-unzip API responses",
|
||||||
|
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||||
|
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
|
||||||
|
"autoUnzipFileLimit": "Auto-unzip file limit",
|
||||||
|
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
|
||||||
|
"defaultPdfEditor": "Default PDF editor",
|
||||||
|
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
|
||||||
|
"defaultPdfEditorInactive": "Another application is set as default",
|
||||||
|
"defaultPdfEditorChecking": "Checking...",
|
||||||
|
"defaultPdfEditorSet": "Already Default",
|
||||||
|
"setAsDefault": "Set as Default",
|
||||||
|
"updates": {
|
||||||
|
"title": "Software Updates",
|
||||||
|
"description": "Check for updates and view version information",
|
||||||
|
"currentVersion": "Current Version",
|
||||||
|
"latestVersion": "Latest Version",
|
||||||
|
"checkForUpdates": "Check for Updates",
|
||||||
|
"viewDetails": "View Details"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hotkeys": {
|
||||||
|
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||||
|
"searchPlaceholder": "Search tools...",
|
||||||
|
"none": "Not assigned",
|
||||||
|
"customBadge": "Custom",
|
||||||
|
"defaultLabel": "Default: {{shortcut}}",
|
||||||
|
"capturing": "Press keys… (Esc to cancel)",
|
||||||
|
"change": "Change shortcut",
|
||||||
|
"reset": "Reset",
|
||||||
|
"shortcut": "Shortcut",
|
||||||
|
"noShortcut": "No shortcut set"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sessionExpired": "Session Expired",
|
||||||
|
"pleaseLoginAgain": "Please login again.",
|
||||||
|
"accessDenied": "Access Denied",
|
||||||
|
"insufficientPermissions": "You do not have permission to perform this action."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"back": "Back",
|
||||||
|
"continue": "Continue",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"copy": "Copy",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"retry": "Retry",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"done": "Done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+403
-4
@@ -589,10 +589,29 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"log",
|
||||||
|
"publicsuffix",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -767,6 +786,12 @@ dependencies = [
|
|||||||
"syn 2.0.108",
|
"syn 2.0.108",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -871,6 +896,15 @@ dependencies = [
|
|||||||
"syn 2.0.108",
|
"syn 2.0.108",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1365,8 +1399,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1376,9 +1412,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1548,6 +1586,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http 1.3.1",
|
||||||
|
"indexmap 2.12.0",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1677,7 +1734,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1701,6 +1758,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2 0.4.12",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1712,6 +1770,23 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||||
|
dependencies = [
|
||||||
|
"http 1.3.1",
|
||||||
|
"hyper 1.7.0",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1744,9 +1819,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.1",
|
"socket2 0.6.1",
|
||||||
|
"system-configuration 0.6.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2057,6 +2134,16 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keyring"
|
||||||
|
version = "3.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
@@ -2137,6 +2224,12 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2155,6 +2248,12 @@ dependencies = [
|
|||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3092,6 +3191,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl-types"
|
||||||
|
version = "2.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3112,6 +3217,16 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffix"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||||
|
dependencies = [
|
||||||
|
"idna",
|
||||||
|
"psl-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.3"
|
version = "0.38.3"
|
||||||
@@ -3121,6 +3236,61 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2 0.6.1",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2 0.6.1",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.41"
|
version = "1.0.41"
|
||||||
@@ -3167,6 +3337,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3187,6 +3367,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3205,6 +3395,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3318,7 +3517,7 @@ dependencies = [
|
|||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
@@ -3336,7 +3535,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 0.1.2",
|
"sync_wrapper 0.1.2",
|
||||||
"system-configuration",
|
"system-configuration 0.5.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3355,22 +3554,32 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"cookie_store",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2 0.4.12",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.7.0",
|
"hyper 1.7.0",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3380,6 +3589,21 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3427,6 +3651,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3449,6 +3679,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3458,6 +3702,27 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3952,6 +4217,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-services",
|
"core-services",
|
||||||
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3959,9 +4225,11 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3996,6 +4264,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -4063,7 +4337,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys 0.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4076,6 +4361,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -4305,6 +4600,30 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-http"
|
||||||
|
version = "2.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cookie_store",
|
||||||
|
"data-url",
|
||||||
|
"http 1.3.1",
|
||||||
|
"regex",
|
||||||
|
"reqwest 0.12.24",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"urlpattern",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -4363,6 +4682,22 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-store"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.1"
|
version = "2.9.1"
|
||||||
@@ -4596,9 +4931,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.1",
|
"socket2 0.6.1",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.108",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4609,6 +4956,16 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.16"
|
version = "0.7.16"
|
||||||
@@ -4898,6 +5255,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
@@ -5131,6 +5494,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -5175,6 +5548,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -5360,6 +5742,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
"windows-strings 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5962,6 +6355,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
|||||||
tauri-plugin-log = "2.0.0-rc"
|
tauri-plugin-log = "2.0.0-rc"
|
||||||
tauri-plugin-shell = "2.1.0"
|
tauri-plugin-shell = "2.1.0"
|
||||||
tauri-plugin-fs = "2.4.4"
|
tauri-plugin-fs = "2.4.4"
|
||||||
|
tauri-plugin-http = "2.4.4"
|
||||||
tauri-plugin-single-instance = "2.0.1"
|
tauri-plugin-single-instance = "2.0.1"
|
||||||
|
tauri-plugin-store = "2.1.0"
|
||||||
|
keyring = "3.6.1"
|
||||||
tokio = { version = "1.0", features = ["time"] }
|
tokio = { version = "1.0", features = ["time"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"http:default",
|
||||||
{
|
{
|
||||||
"identifier": "fs:allow-read-file",
|
"identifier": "http:allow-fetch",
|
||||||
"allow": [{ "path": "**" }]
|
"allow": [
|
||||||
}
|
{ "url": "http://localhost:*" },
|
||||||
|
{ "url": "http://127.0.0.1:*" },
|
||||||
|
{ "url": "https://*" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
use keyring::Entry;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
const STORE_FILE: &str = "connection.json";
|
||||||
|
const USER_INFO_KEY: &str = "user_info";
|
||||||
|
const KEYRING_SERVICE: &str = "stirling-pdf";
|
||||||
|
const KEYRING_TOKEN_KEY: &str = "auth-token";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_keyring_entry() -> Result<Entry, String> {
|
||||||
|
Entry::new(KEYRING_SERVICE, KEYRING_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> {
|
||||||
|
log::info!("Saving auth token to keyring");
|
||||||
|
|
||||||
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
|
entry
|
||||||
|
.set_password(&token)
|
||||||
|
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("Auth token saved successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
log::debug!("Retrieving auth token from keyring");
|
||||||
|
|
||||||
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(token) => Ok(Some(token)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
log::info!("Clearing auth token from keyring");
|
||||||
|
|
||||||
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
|
// Delete the token - ignore error if it doesn't exist
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("Auth token cleared successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(keyring::Error::NoEntry) => {
|
||||||
|
log::info!("Auth token was already cleared");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_user_info(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
username: String,
|
||||||
|
email: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("Saving user info for: {}", username);
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
log::info!("User info saved successfully");
|
||||||
|
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> {
|
||||||
|
log::info!("Clearing user info");
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
log::info!("User info cleared successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response types for Spring Boot login
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 Spring Boot authentication (self-hosted)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<LoginResponse, String> {
|
||||||
|
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||||
|
|
||||||
|
// Build login URL
|
||||||
|
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||||
|
log::debug!("Login URL: {}", login_url);
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Make login request
|
||||||
|
let response = client
|
||||||
|
.post(&login_url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network error: {}", e))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
log::debug!("Login response status: {}", status);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
log::error!("Login failed with status {}: {}", status, error_text);
|
||||||
|
|
||||||
|
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 response: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ use tauri::Manager;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use crate::utils::add_log;
|
use crate::utils::add_log;
|
||||||
|
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
|
||||||
|
|
||||||
// Store backend process handle globally
|
// Store backend process handle and port globally
|
||||||
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
|
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
|
||||||
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
|
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
|
||||||
|
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
|
||||||
|
|
||||||
// Helper function to reset starting flag
|
// Helper function to reset starting flag
|
||||||
fn reset_starting_flag() {
|
fn reset_starting_flag() {
|
||||||
@@ -14,6 +16,20 @@ fn reset_starting_flag() {
|
|||||||
*starting_guard = false;
|
*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
|
// Check if backend is already running or starting
|
||||||
fn check_backend_status() -> Result<(), String> {
|
fn check_backend_status() -> Result<(), String> {
|
||||||
// Check if backend is already running
|
// Check if backend is already running
|
||||||
@@ -150,8 +166,11 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
|||||||
"-DSTIRLING_PDF_TAURI_MODE=true",
|
"-DSTIRLING_PDF_TAURI_MODE=true",
|
||||||
&log_path_option,
|
&log_path_option,
|
||||||
"-Dlogging.file.name=stirling-pdf.log",
|
"-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",
|
||||||
jar_path.to_str().unwrap()
|
jar_path.to_str().unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Log the equivalent command for external testing
|
// Log the equivalent command for external testing
|
||||||
@@ -238,16 +257,21 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
|||||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||||
add_log(format!("📤 Backend: {}", output_str));
|
add_log(format!("📤 Backend: {}", output_str));
|
||||||
|
|
||||||
// Look for startup indicators
|
// Look for actual runtime port from web server initialization
|
||||||
if output_str.contains("Started SPDFApplication") ||
|
// Format: "Stirling-PDF running on port: PORT"
|
||||||
output_str.contains("Navigate to "){
|
if output_str.contains("running on port:") {
|
||||||
_startup_detected = true;
|
_startup_detected = true;
|
||||||
add_log(format!("🎉 Backend startup detected: {}", output_str));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for port binding
|
if output_str.contains("Started SPDFApplication") {
|
||||||
if output_str.contains("8080") {
|
_startup_detected = true;
|
||||||
add_log(format!("🔌 Port 8080 related output: {}", output_str));
|
add_log(format!("🎉 Backend startup completed: {}", output_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
||||||
@@ -308,9 +332,31 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
|||||||
|
|
||||||
// Command to start the backend with bundled JRE
|
// Command to start the backend with bundled JRE
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
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());
|
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::Offline => {
|
||||||
|
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||||
|
}
|
||||||
|
ConnectionMode::Server => {
|
||||||
|
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if backend is already running or starting
|
// Check if backend is already running or starting
|
||||||
if let Err(msg) = check_backend_status() {
|
if let Err(msg) = check_backend_status() {
|
||||||
return Ok(msg);
|
return Ok(msg);
|
||||||
@@ -363,6 +409,13 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
|||||||
Ok("Backend startup initiated successfully with bundled JRE".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
|
// Cleanup function to stop backend on app exit
|
||||||
pub fn cleanup_backend() {
|
pub fn cleanup_backend() {
|
||||||
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use crate::state::connection_state::{
|
||||||
|
AppConnectionState,
|
||||||
|
ConnectionMode,
|
||||||
|
ServerConfig,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, 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";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionConfig {
|
||||||
|
pub mode: ConnectionMode,
|
||||||
|
pub server_config: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::Offline);
|
||||||
|
|
||||||
|
let server_config: Option<ServerConfig> = store
|
||||||
|
.get(SERVER_CONFIG_KEY)
|
||||||
|
.and_then(|v| serde_json::from_value(v.clone()).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();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ConnectionConfig {
|
||||||
|
mode,
|
||||||
|
server_config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_connection_mode(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppConnectionState>,
|
||||||
|
mode: ConnectionMode,
|
||||||
|
server_config: Option<ServerConfig>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("Setting connection mode: {:?}", mode);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to store
|
||||||
|
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(&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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
@@ -1,36 +1,16 @@
|
|||||||
// Command to check if backend is healthy
|
use reqwest;
|
||||||
#[tauri::command]
|
|
||||||
pub async fn check_backend_health() -> Result<bool, String> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
||||||
|
|
||||||
match client.get("http://localhost:8080/api/v1/info/status").send().await {
|
#[tauri::command]
|
||||||
Ok(response) => {
|
pub async fn check_backend_health(port: u16) -> Result<bool, String> {
|
||||||
let status = response.status();
|
let url = format!("http://localhost:{}/api/v1/info/status", port);
|
||||||
if status.is_success() {
|
|
||||||
match response.text().await {
|
match reqwest::Client::new()
|
||||||
Ok(_body) => {
|
.get(&url)
|
||||||
println!("✅ Backend health check successful");
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
Ok(true)
|
.send()
|
||||||
}
|
.await
|
||||||
Err(e) => {
|
{
|
||||||
println!("⚠️ Failed to read health response: {}", e);
|
Ok(response) => Ok(response.status().is_success()),
|
||||||
Ok(false)
|
Err(_) => Ok(false), // Return false instead of error for connection failures
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("⚠️ Health check failed with status: {}", status);
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Only log connection errors if they're not the common "connection refused" during startup
|
|
||||||
if !e.to_string().contains("connection refused") && !e.to_string().contains("No connection could be made") {
|
|
||||||
println!("❌ Health check error: {}", e);
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod health;
|
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod auth;
|
||||||
pub mod default_app;
|
pub mod default_app;
|
||||||
|
pub mod health;
|
||||||
|
|
||||||
pub use backend::{start_backend, cleanup_backend};
|
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||||
pub use health::check_backend_health;
|
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||||
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
|
pub use connection::{
|
||||||
|
get_connection_config,
|
||||||
|
is_first_launch,
|
||||||
|
set_connection_mode,
|
||||||
|
};
|
||||||
|
pub use auth::{
|
||||||
|
clear_auth_token,
|
||||||
|
clear_user_info,
|
||||||
|
get_auth_token,
|
||||||
|
get_user_info,
|
||||||
|
login,
|
||||||
|
save_auth_token,
|
||||||
|
save_user_info,
|
||||||
|
};
|
||||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||||
|
pub use health::check_backend_health;
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
|
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
|
||||||
|
|
||||||
mod utils;
|
mod utils;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod state;
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
start_backend,
|
|
||||||
check_backend_health,
|
|
||||||
get_opened_files,
|
|
||||||
clear_opened_files,
|
|
||||||
cleanup_backend,
|
|
||||||
add_opened_file,
|
add_opened_file,
|
||||||
|
check_backend_health,
|
||||||
|
cleanup_backend,
|
||||||
|
clear_auth_token,
|
||||||
|
clear_opened_files,
|
||||||
|
clear_user_info,
|
||||||
is_default_pdf_handler,
|
is_default_pdf_handler,
|
||||||
|
get_auth_token,
|
||||||
|
get_backend_port,
|
||||||
|
get_connection_config,
|
||||||
|
get_opened_files,
|
||||||
|
get_user_info,
|
||||||
|
is_first_launch,
|
||||||
|
login,
|
||||||
|
save_auth_token,
|
||||||
|
save_user_info,
|
||||||
|
set_connection_mode,
|
||||||
set_as_default_pdf_handler,
|
set_as_default_pdf_handler,
|
||||||
|
start_backend,
|
||||||
};
|
};
|
||||||
|
use state::connection_state::AppConnectionState;
|
||||||
use utils::{add_log, get_tauri_logs};
|
use utils::{add_log, get_tauri_logs};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -20,6 +33,9 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.manage(AppConnectionState::default())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
// This callback runs when a second instance tries to start
|
// This callback runs when a second instance tries to start
|
||||||
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||||
@@ -60,12 +76,23 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
start_backend,
|
start_backend,
|
||||||
check_backend_health,
|
get_backend_port,
|
||||||
get_opened_files,
|
get_opened_files,
|
||||||
clear_opened_files,
|
clear_opened_files,
|
||||||
get_tauri_logs,
|
get_tauri_logs,
|
||||||
|
get_connection_config,
|
||||||
|
set_connection_mode,
|
||||||
is_default_pdf_handler,
|
is_default_pdf_handler,
|
||||||
set_as_default_pdf_handler,
|
set_as_default_pdf_handler,
|
||||||
|
is_first_launch,
|
||||||
|
check_backend_health,
|
||||||
|
login,
|
||||||
|
save_auth_token,
|
||||||
|
get_auth_token,
|
||||||
|
clear_auth_token,
|
||||||
|
save_user_info,
|
||||||
|
get_user_info,
|
||||||
|
clear_user_info,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ConnectionMode {
|
||||||
|
Offline,
|
||||||
|
Server,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ServerType {
|
||||||
|
SaaS,
|
||||||
|
SelfHosted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub server_type: ServerType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionState {
|
||||||
|
pub mode: ConnectionMode,
|
||||||
|
pub server_config: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectionState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: ConnectionMode::Offline,
|
||||||
|
server_config: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppConnectionState(pub Mutex<ConnectionState>);
|
||||||
|
|
||||||
|
impl Default for AppConnectionState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(Mutex::new(ConnectionState::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod connection_state;
|
||||||
@@ -5,7 +5,6 @@ import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation
|
|||||||
import type { StirlingFile } from '@app/types/fileContext';
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
|
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
|
||||||
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
|
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
|
||||||
import type { ResponseType } from 'axios';
|
|
||||||
|
|
||||||
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
|
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
|
||||||
scriptText: string | null;
|
scriptText: string | null;
|
||||||
@@ -71,8 +70,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
|
|||||||
|
|
||||||
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
|
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
responseType: 'text' as ResponseType,
|
responseType: 'text',
|
||||||
transformResponse: [(data) => data],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const text: string = typeof response.data === 'string' ? response.data : '';
|
const text: string = typeof response.data === 'string' ? response.data : '';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||||
|
|
||||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
|
|||||||
@@ -1,15 +1,66 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||||
|
import { SetupWizard } from '@app/components/SetupWizard';
|
||||||
|
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||||
|
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||||
|
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop application providers
|
* Desktop application providers
|
||||||
* Wraps proprietary providers and adds desktop-specific configuration
|
* Wraps proprietary providers and adds desktop-specific configuration
|
||||||
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
|
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
|
||||||
|
* - Shows setup wizard on first launch
|
||||||
*/
|
*/
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
|
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||||
|
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
|
||||||
|
|
||||||
|
// Load connection mode on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize backend health monitoring for server mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
|
||||||
|
console.log('[AppProviders] Initializing external backend monitoring for server mode');
|
||||||
|
void tauriBackendService.initializeExternalBackend();
|
||||||
|
}
|
||||||
|
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||||
|
|
||||||
|
// Only start bundled backend if in offline mode and setup is complete
|
||||||
|
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
|
||||||
|
useBackendInitializer(shouldStartBackend);
|
||||||
|
|
||||||
|
// Show setup wizard on first launch
|
||||||
|
if (isFirstLaunch && !setupComplete) {
|
||||||
|
return (
|
||||||
|
<ProprietaryAppProviders
|
||||||
|
appConfigRetryOptions={{
|
||||||
|
maxRetries: 5,
|
||||||
|
initialDelay: 1000,
|
||||||
|
}}
|
||||||
|
appConfigProviderProps={{
|
||||||
|
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||||
|
bootstrapMode: 'non-blocking',
|
||||||
|
autoFetch: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SetupWizard
|
||||||
|
onComplete={() => {
|
||||||
|
// Reload the page to reinitialize with new connection config
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProprietaryAppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal app flow
|
||||||
return (
|
return (
|
||||||
<ProprietaryAppProviders
|
<ProprietaryAppProviders
|
||||||
appConfigRetryOptions={{
|
appConfigRetryOptions={{
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
connectionModeService,
|
||||||
|
ConnectionConfig,
|
||||||
|
ServerConfig,
|
||||||
|
} from '@app/services/connectionModeService';
|
||||||
|
import { authService, UserInfo } from '@app/services/authService';
|
||||||
|
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||||
|
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||||
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
|
|
||||||
|
export const ConnectionSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showServerModal, setShowServerModal] = useState(false);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
|
||||||
|
|
||||||
|
// Load current config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||||
|
setConfig(currentConfig);
|
||||||
|
|
||||||
|
if (currentConfig.mode === 'server') {
|
||||||
|
const user = await authService.getUserInfo();
|
||||||
|
setUserInfo(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSwitchToOffline = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await connectionModeService.switchToOffline();
|
||||||
|
|
||||||
|
// Reload config
|
||||||
|
const newConfig = await connectionModeService.getCurrentConfig();
|
||||||
|
setConfig(newConfig);
|
||||||
|
setUserInfo(null);
|
||||||
|
|
||||||
|
// Reload the page to start the local backend
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to switch to offline:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchToServer = () => {
|
||||||
|
setShowServerModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
|
||||||
|
setNewServerConfig(serverConfig);
|
||||||
|
setShowServerModal(false);
|
||||||
|
setShowLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (username: string, password: string) => {
|
||||||
|
if (!newServerConfig) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await authService.login(newServerConfig.url, username, password);
|
||||||
|
|
||||||
|
// Switch to server mode
|
||||||
|
await connectionModeService.switchToServer(newServerConfig);
|
||||||
|
|
||||||
|
// Reload config and user info
|
||||||
|
const newConfig = await connectionModeService.getCurrentConfig();
|
||||||
|
setConfig(newConfig);
|
||||||
|
const user = await authService.getUserInfo();
|
||||||
|
setUserInfo(user);
|
||||||
|
|
||||||
|
setShowLoginModal(false);
|
||||||
|
setNewServerConfig(null);
|
||||||
|
|
||||||
|
// Reload the page to stop local backend and initialize external backend monitoring
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
throw error; // Let LoginForm handle the error
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await authService.logout();
|
||||||
|
|
||||||
|
// Switch to offline mode
|
||||||
|
await connectionModeService.switchToOffline();
|
||||||
|
|
||||||
|
// Reload config
|
||||||
|
const newConfig = await connectionModeService.getCurrentConfig();
|
||||||
|
setConfig(newConfig);
|
||||||
|
setUserInfo(null);
|
||||||
|
|
||||||
|
// Reload the page to clear all state and reconnect to local backend
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <Text>{t('common.loading', 'Loading...')}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
|
||||||
|
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
|
||||||
|
{config.mode === 'offline'
|
||||||
|
? t('settings.connection.mode.offline', 'Offline')
|
||||||
|
: t('settings.connection.mode.server', 'Server')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{config.mode === 'server' && config.server_config && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('settings.connection.server', 'Server')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{config.server_config.url}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userInfo && (
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('settings.connection.user', 'Logged in as')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{userInfo.username}
|
||||||
|
{userInfo.email && ` (${userInfo.email})`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group mt="md">
|
||||||
|
{config.mode === 'offline' ? (
|
||||||
|
<Button onClick={handleSwitchToServer} disabled={loading}>
|
||||||
|
{t('settings.connection.switchToServer', 'Connect to Server')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
|
||||||
|
{t('settings.connection.switchToOffline', 'Switch to Offline')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||||
|
{t('settings.connection.logout', 'Logout')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Server selection modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showServerModal}
|
||||||
|
onClose={() => setShowServerModal(false)}
|
||||||
|
title={t('settings.connection.selectServer', 'Select Server')}
|
||||||
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||||
|
>
|
||||||
|
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Login modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showLoginModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
setNewServerConfig(null);
|
||||||
|
}}
|
||||||
|
title={t('settings.connection.login', 'Login')}
|
||||||
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||||
|
>
|
||||||
|
{newServerConfig && (
|
||||||
|
<LoginForm
|
||||||
|
serverUrl={newServerConfig.url}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mini server selection component for settings
|
||||||
|
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
|
||||||
|
const [customUrl, setCustomUrl] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTesting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isReachable = await connectionModeService.testConnection(url);
|
||||||
|
|
||||||
|
if (!isReachable) {
|
||||||
|
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
url,
|
||||||
|
server_type: serverType,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
|
||||||
|
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{serverType === 'selfhosted' && (
|
||||||
|
<TextInput
|
||||||
|
label={t('setup.server.url.label', 'Server URL')}
|
||||||
|
placeholder="https://your-server.com"
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomUrl(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={testing}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !customUrl && (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} loading={testing} fullWidth>
|
||||||
|
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
serverUrl: string;
|
||||||
|
onLogin: (username: string, password: string) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!username.trim()) {
|
||||||
|
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError(null);
|
||||||
|
await onLogin(username.trim(), password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t('setup.login.username.label', 'Username')}
|
||||||
|
placeholder={t('setup.login.username.placeholder', 'Enter your username')}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t('setup.login.password.label', 'Password')}
|
||||||
|
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{validationError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
mt="md"
|
||||||
|
fullWidth
|
||||||
|
color="#AF3434"
|
||||||
|
>
|
||||||
|
{t('setup.login.submit', 'Login')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Button, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import ComputerIcon from '@mui/icons-material/Computer';
|
||||||
|
|
||||||
|
interface ModeSelectionProps {
|
||||||
|
onSelect: (mode: 'offline' | 'server') => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => onSelect('offline')}
|
||||||
|
disabled={loading}
|
||||||
|
leftSection={<ComputerIcon />}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
height: 'auto',
|
||||||
|
padding: '1.25rem',
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||||
|
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
|
||||||
|
<Text size="sm" c="dimmed" fw={400}>
|
||||||
|
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => onSelect('server')}
|
||||||
|
disabled={loading}
|
||||||
|
leftSection={<CloudIcon />}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
height: 'auto',
|
||||||
|
padding: '1.25rem',
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||||
|
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
|
||||||
|
<Text size="sm" c="dimmed" fw={400}>
|
||||||
|
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack, Button, TextInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ServerConfig } from '@app/services/connectionModeService';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
|
||||||
|
interface ServerSelectionProps {
|
||||||
|
onSelect: (config: ServerConfig) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [customUrl, setCustomUrl] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const url = customUrl.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection before proceeding
|
||||||
|
setTesting(true);
|
||||||
|
setTestError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isReachable = await connectionModeService.testConnection(url);
|
||||||
|
|
||||||
|
if (!isReachable) {
|
||||||
|
setTestError(t('setup.server.error.unreachable', 'Could not connect to server'));
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection successful
|
||||||
|
onSelect({
|
||||||
|
url,
|
||||||
|
server_type: 'selfhosted',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection test failed:', error);
|
||||||
|
setTestError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t('setup.server.error.testFailed', 'Connection test failed')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label={t('setup.server.url.label', 'Server URL')}
|
||||||
|
placeholder="https://your-server.com"
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomUrl(e.target.value);
|
||||||
|
setTestError(null);
|
||||||
|
}}
|
||||||
|
disabled={loading || testing}
|
||||||
|
error={testError}
|
||||||
|
description={t(
|
||||||
|
'setup.server.url.description',
|
||||||
|
'Enter the full URL of your self-hosted Stirling PDF server'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={testing || loading}
|
||||||
|
disabled={loading}
|
||||||
|
mt="md"
|
||||||
|
fullWidth
|
||||||
|
color="#AF3434"
|
||||||
|
>
|
||||||
|
{testing
|
||||||
|
? t('setup.server.testing', 'Testing connection...')
|
||||||
|
: t('common.continue', 'Continue')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.setup-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
|
||||||
|
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||||
|
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||||
|
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
||||||
|
import { authService } from '@app/services/authService';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
import '@app/components/SetupWizard/SetupWizard.css';
|
||||||
|
|
||||||
|
enum SetupStep {
|
||||||
|
ModeSelection,
|
||||||
|
ServerSelection,
|
||||||
|
Login,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupWizardProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
||||||
|
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
|
||||||
|
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleModeSelection = (mode: 'offline' | 'server') => {
|
||||||
|
setSelectedMode(mode);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (mode === 'offline') {
|
||||||
|
handleOfflineSetup();
|
||||||
|
} else {
|
||||||
|
setActiveStep(SetupStep.ServerSelection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOfflineSetup = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await connectionModeService.switchToOffline();
|
||||||
|
await tauriBackendService.startBackend();
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set up offline mode:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelection = (config: ServerConfig) => {
|
||||||
|
setServerConfig(config);
|
||||||
|
setError(null);
|
||||||
|
setActiveStep(SetupStep.Login);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (username: string, password: string) => {
|
||||||
|
if (!serverConfig) {
|
||||||
|
setError('No server configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await authService.login(serverConfig.url, username, password);
|
||||||
|
await connectionModeService.switchToServer(serverConfig);
|
||||||
|
await tauriBackendService.initializeExternalBackend();
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setError(null);
|
||||||
|
if (activeStep === SetupStep.Login) {
|
||||||
|
setActiveStep(SetupStep.ServerSelection);
|
||||||
|
} else if (activeStep === SetupStep.ServerSelection) {
|
||||||
|
setActiveStep(SetupStep.ModeSelection);
|
||||||
|
setSelectedMode(null);
|
||||||
|
setServerConfig(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepTitle = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case SetupStep.ModeSelection:
|
||||||
|
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||||
|
case SetupStep.ServerSelection:
|
||||||
|
return t('setup.server.title', 'Connect to Server');
|
||||||
|
case SetupStep.Login:
|
||||||
|
return t('setup.login.title', 'Sign In');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepSubtitle = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case SetupStep.ModeSelection:
|
||||||
|
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||||
|
case SetupStep.ServerSelection:
|
||||||
|
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||||
|
case SetupStep.Login:
|
||||||
|
return t('setup.login.subtitle', 'Enter your credentials to continue');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setup-container">
|
||||||
|
<Container size="sm" className="setup-wrapper">
|
||||||
|
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Logo Header */}
|
||||||
|
<Stack gap="xs" align="center">
|
||||||
|
<Image
|
||||||
|
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
|
||||||
|
alt="Stirling PDF"
|
||||||
|
h={32}
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
|
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||||
|
{getStepTitle()}
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{getStepSubtitle()}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
|
||||||
|
<Text size="sm" c="red.7" ta="center">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
{activeStep === SetupStep.ModeSelection && (
|
||||||
|
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeStep === SetupStep.ServerSelection && (
|
||||||
|
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeStep === SetupStep.Login && (
|
||||||
|
<LoginForm
|
||||||
|
serverUrl={serverConfig?.url || ''}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back Button */}
|
||||||
|
{activeStep > SetupStep.ModeSelection && !loading && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleBack}
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
|
||||||
|
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
|
||||||
|
import { ConnectionSettings } from '@app/components/ConnectionSettings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop extension of createConfigNavSections that adds connection settings
|
||||||
|
*/
|
||||||
|
export const createConfigNavSections = (
|
||||||
|
isAdmin: boolean = false,
|
||||||
|
runningEE: boolean = false,
|
||||||
|
loginEnabled: boolean = false
|
||||||
|
): ConfigNavSection[] => {
|
||||||
|
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||||
|
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||||
|
|
||||||
|
// Add Connection section at the beginning (after Preferences)
|
||||||
|
sections.splice(1, 0, {
|
||||||
|
title: 'Connection',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'connectionMode',
|
||||||
|
label: 'Connection Mode',
|
||||||
|
icon: 'cloud-rounded',
|
||||||
|
component: <ConnectionSettings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
|
||||||
|
|
||||||
|
export const VALID_NAV_KEYS = [
|
||||||
|
...CORE_NAV_KEYS,
|
||||||
|
'connectionMode',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NavKey = typeof VALID_NAV_KEYS[number];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Connection-related constants for desktop app
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
|
||||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||||
import { fileOpenService } from '@app/services/fileOpenService';
|
import { fileOpenService } from '@app/services/fileOpenService';
|
||||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App initialization hook
|
* App initialization hook
|
||||||
* Desktop version: Handles Tauri-specific initialization
|
* Desktop version: Handles Tauri-specific file initialization
|
||||||
* - Starts the backend on app startup
|
* Requires FileContext - must be used inside FileContextProvider
|
||||||
* - Handles files opened with the app (adds directly to FileContext)
|
* - Handles files opened with the app (adds directly to FileContext)
|
||||||
*/
|
*/
|
||||||
export function useAppInitialization(): void {
|
export function useAppInitialization(): void {
|
||||||
// Initialize backend on app startup
|
|
||||||
useBackendInitializer();
|
|
||||||
|
|
||||||
// Get file management actions
|
// Get file management actions
|
||||||
const { addFiles } = useFileManagement();
|
const { addFiles } = useFileManagement();
|
||||||
|
|
||||||
@@ -59,3 +55,11 @@ export function useAppInitialization(): void {
|
|||||||
loadOpenedFiles();
|
loadOpenedFiles();
|
||||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
}, [openedFilePaths, openedFileLoading, addFiles]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetupCompletion(): (completed: boolean) => void {
|
||||||
|
const [, setSetupComplete] = useState(false);
|
||||||
|
|
||||||
|
return (completed: boolean) => {
|
||||||
|
setSetupComplete(completed);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to initialize backend and monitor health
|
* Hook to initialize backend and monitor health
|
||||||
|
* @param enabled - Whether to initialize the backend (default: true)
|
||||||
*/
|
*/
|
||||||
export function useBackendInitializer() {
|
export function useBackendInitializer(enabled = true) {
|
||||||
const { status, checkHealth } = useBackendHealth();
|
const { status, checkHealth } = useBackendHealth();
|
||||||
const { backendUrl } = useEndpointConfig();
|
const { backendUrl } = useEndpointConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if disabled
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if backend already running
|
// Skip if backend already running
|
||||||
if (tauriBackendService.isBackendRunning()) {
|
if (tauriBackendService.isBackendRunning()) {
|
||||||
void checkHealth();
|
void checkHealth();
|
||||||
@@ -36,5 +42,5 @@ export function useBackendInitializer() {
|
|||||||
if (status !== 'healthy' && status !== 'starting') {
|
if (status !== 'healthy' && status !== 'starting') {
|
||||||
void initializeBackend();
|
void initializeBackend();
|
||||||
}
|
}
|
||||||
}, [status, backendUrl, checkHealth]);
|
}, [enabled, status, backendUrl, checkHealth]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||||
|
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||||
|
|
||||||
interface EndpointConfig {
|
interface EndpointConfig {
|
||||||
backendUrl: string;
|
backendUrl: string;
|
||||||
@@ -235,17 +236,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default backend URL from environment variables
|
||||||
|
const DEFAULT_BACKEND_URL =
|
||||||
|
import.meta.env.VITE_DESKTOP_BACKEND_URL
|
||||||
|
|| import.meta.env.VITE_API_BASE_URL
|
||||||
|
|| '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop override exposing the backend URL used by the embedded server.
|
* Desktop override exposing the backend URL based on connection mode.
|
||||||
|
* - Offline mode: Uses local bundled backend (from env vars)
|
||||||
|
* - Server mode: Uses configured server URL from connection config
|
||||||
*/
|
*/
|
||||||
export function useEndpointConfig(): EndpointConfig {
|
export function useEndpointConfig(): EndpointConfig {
|
||||||
const backendUrl = useMemo(() => {
|
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||||
const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined;
|
|
||||||
|
|
||||||
return runtimeEnv?.STIRLING_BACKEND_URL
|
useEffect(() => {
|
||||||
|| import.meta.env.VITE_DESKTOP_BACKEND_URL
|
connectionModeService.getCurrentConfig()
|
||||||
|| import.meta.env.VITE_API_BASE_URL
|
.then((config) => {
|
||||||
|| 'http://localhost:8080';
|
if (config.mode === 'server' && config.server_config?.url) {
|
||||||
|
setBackendUrl(config.server_config.url);
|
||||||
|
} else {
|
||||||
|
// Offline mode - use default from env vars
|
||||||
|
setBackendUrl(DEFAULT_BACKEND_URL);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to get connection config:', err);
|
||||||
|
// Keep current URL on error
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { backendUrl };
|
return { backendUrl };
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { authService } from '@app/services/authService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First launch check hook
|
||||||
|
* Checks if this is the first time the app is being launched
|
||||||
|
* Does not require FileContext - can be used early in the provider hierarchy
|
||||||
|
*/
|
||||||
|
export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete: boolean } {
|
||||||
|
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
|
||||||
|
const [setupComplete, setSetupComplete] = useState(false);
|
||||||
|
const setupCheckCompleteRef = useRef(false);
|
||||||
|
|
||||||
|
// Check if this is first launch
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFirstLaunch = async () => {
|
||||||
|
try {
|
||||||
|
const firstLaunch = await connectionModeService.isFirstLaunch();
|
||||||
|
setIsFirstLaunch(firstLaunch);
|
||||||
|
|
||||||
|
if (!firstLaunch) {
|
||||||
|
// Not first launch - initialize auth state
|
||||||
|
await authService.initializeAuthState();
|
||||||
|
setSetupComplete(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCheckCompleteRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check first launch:', error);
|
||||||
|
// On error, assume not first launch and proceed
|
||||||
|
setIsFirstLaunch(false);
|
||||||
|
setSetupComplete(true);
|
||||||
|
setupCheckCompleteRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!setupCheckCompleteRef.current) {
|
||||||
|
checkFirstLaunch();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isFirstLaunch, setupComplete };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Desktop-specific API client using Tauri's native HTTP client
|
||||||
|
* This file overrides @core/services/apiClient.ts for desktop builds
|
||||||
|
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import { create } from '@app/services/tauriHttpClient';
|
||||||
|
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||||
|
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||||
|
import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||||
|
|
||||||
|
// Create Tauri HTTP client with default config
|
||||||
|
const apiClient = create({
|
||||||
|
baseURL: getApiBaseUrl(),
|
||||||
|
responseType: 'json',
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup interceptors (desktop-specific auth and backend ready checks)
|
||||||
|
// Cast to AxiosInstance - Tauri client has compatible API
|
||||||
|
setupApiInterceptors(apiClient as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
// ---------- Install error interceptor ----------
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Exports ----------
|
||||||
|
export default apiClient;
|
||||||
@@ -2,16 +2,19 @@ import { isTauri } from '@tauri-apps/api/core';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop override: Determine base URL depending on Tauri environment
|
* Desktop override: Determine base URL depending on Tauri environment
|
||||||
|
*
|
||||||
|
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
|
||||||
|
* based on connection mode and backend port. This initial baseURL is overridden
|
||||||
|
* by request interceptors in apiClientSetup.ts.
|
||||||
*/
|
*/
|
||||||
export function getApiBaseUrl(): string {
|
export function getApiBaseUrl(): string {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
return import.meta.env.VITE_API_BASE_URL || '/';
|
return import.meta.env.VITE_API_BASE_URL || '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
// In Tauri mode, return empty string as placeholder
|
||||||
// During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight
|
// The actual URL will be set dynamically by operationRouter based on:
|
||||||
return '/';
|
// - Offline mode: dynamic port from tauriBackendService
|
||||||
}
|
// - Server mode: configured server URL from connectionModeService
|
||||||
|
return '';
|
||||||
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,134 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
|
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
||||||
|
import { operationRouter } from '@app/services/operationRouter';
|
||||||
|
import { authService } from '@app/services/authService';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
import i18n from '@app/i18n';
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
let lastBackendToast = 0;
|
let lastBackendToast = 0;
|
||||||
|
|
||||||
|
// Extended config for custom properties
|
||||||
|
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||||
|
operationName?: string;
|
||||||
|
skipBackendReadyCheck?: boolean;
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop-specific API interceptors
|
* Desktop-specific API interceptors
|
||||||
* - Reuses the core interceptors
|
* - Reuses the core interceptors
|
||||||
* - Blocks API calls while the bundled backend is still starting and shows
|
* - Dynamically sets base URL based on connection mode
|
||||||
* a friendly toast for user-initiated requests (non-GET)
|
* - Adds auth token for remote server requests
|
||||||
|
* - Blocks API calls while the bundled backend is still starting
|
||||||
|
* - Handles auth token refresh on 401 errors
|
||||||
*/
|
*/
|
||||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
coreSetup(client);
|
coreSetup(client);
|
||||||
|
|
||||||
|
// Request interceptor: Set base URL and auth headers dynamically
|
||||||
client.interceptors.request.use(
|
client.interceptors.request.use(
|
||||||
(config) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
const skipCheck = config?.skipBackendReadyCheck === true;
|
const extendedConfig = config as ExtendedRequestConfig;
|
||||||
if (skipCheck || tauriBackendService.isBackendHealthy()) {
|
|
||||||
return config;
|
// Get the operation name from config if provided
|
||||||
|
const operation = extendedConfig.operationName;
|
||||||
|
|
||||||
|
// Get the appropriate base URL for this operation
|
||||||
|
const baseUrl = await operationRouter.getBaseUrl(operation);
|
||||||
|
|
||||||
|
// Build the full URL
|
||||||
|
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
|
||||||
|
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = (config.method || 'get').toLowerCase();
|
// Debug logging
|
||||||
if (method !== 'get') {
|
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
// Add auth token for remote requests
|
||||||
lastBackendToast = now;
|
const isRemote = await operationRouter.isRemoteMode();
|
||||||
alert({
|
if (isRemote) {
|
||||||
alertType: 'error',
|
const token = await authService.getAuthToken();
|
||||||
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
if (token) {
|
||||||
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||||
isPersistentPopup: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(createBackendNotReadyError());
|
// Backend readiness check (for local backend)
|
||||||
|
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||||
|
const isOffline = await operationRouter.isOfflineMode();
|
||||||
|
|
||||||
|
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||||
|
const method = (extendedConfig.method || 'get').toLowerCase();
|
||||||
|
if (method !== 'get') {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
|
||||||
|
lastBackendToast = now;
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||||
|
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(createBackendNotReadyError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return extendedConfig;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor: Handle auth errors
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config as ExtendedRequestConfig;
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - try to refresh token
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const isRemote = await operationRouter.isRemoteMode();
|
||||||
|
if (isRemote) {
|
||||||
|
const serverConfig = await connectionModeService.getServerConfig();
|
||||||
|
if (serverConfig) {
|
||||||
|
const refreshed = await authService.refreshToken(serverConfig.url);
|
||||||
|
if (refreshed) {
|
||||||
|
// Retry the original request with new token
|
||||||
|
const token = await authService.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return client(originalRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh failed or not in remote mode - user needs to login again
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
||||||
|
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 403 Forbidden - unauthorized access
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: i18n.t('auth.accessDenied', 'Access Denied'),
|
||||||
|
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
|
||||||
|
isPersistentPopup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
private static instance: AuthService;
|
||||||
|
private authStatus: AuthStatus = 'unauthenticated';
|
||||||
|
private userInfo: UserInfo | null = null;
|
||||||
|
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||||
|
|
||||||
|
static getInstance(): AuthService {
|
||||||
|
if (!AuthService.instance) {
|
||||||
|
AuthService.instance = new AuthService();
|
||||||
|
}
|
||||||
|
return AuthService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
|
||||||
|
this.authListeners.add(listener);
|
||||||
|
// Immediately notify new listener of current state
|
||||||
|
listener(this.authStatus, this.userInfo);
|
||||||
|
return () => {
|
||||||
|
this.authListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners() {
|
||||||
|
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
|
||||||
|
this.authStatus = status;
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
|
||||||
|
try {
|
||||||
|
console.log('Logging in to:', serverUrl);
|
||||||
|
|
||||||
|
// Call Rust login command (bypasses CORS)
|
||||||
|
const response = await invoke<LoginResponse>('login', {
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, username: returnedUsername, email } = response;
|
||||||
|
|
||||||
|
// Save the token to keyring
|
||||||
|
await invoke('save_auth_token', { token });
|
||||||
|
|
||||||
|
// Save user info to store
|
||||||
|
await invoke('save_user_info', {
|
||||||
|
username: returnedUsername || username,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInfo: UserInfo = {
|
||||||
|
username: returnedUsername || username,
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
|
||||||
|
console.log('Login successful');
|
||||||
|
return userInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
|
||||||
|
// Rust commands return string errors
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Login failed. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Logging out');
|
||||||
|
|
||||||
|
// Clear token from keyring
|
||||||
|
await invoke('clear_auth_token');
|
||||||
|
|
||||||
|
// Clear user info from store
|
||||||
|
await invoke('clear_user_info');
|
||||||
|
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
|
||||||
|
console.log('Logged out successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during logout:', error);
|
||||||
|
// Still set status to unauthenticated even if clear fails
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const token = await invoke<string | null>('get_auth_token');
|
||||||
|
return token || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAuthenticated(): Promise<boolean> {
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
return token !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserInfo(): Promise<UserInfo | null> {
|
||||||
|
if (this.userInfo) {
|
||||||
|
return this.userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = await invoke<UserInfo | null>('get_user_info');
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
return userInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get user info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('Refreshing auth token');
|
||||||
|
this.setAuthStatus('refreshing', this.userInfo);
|
||||||
|
|
||||||
|
const currentToken = await this.getAuthToken();
|
||||||
|
if (!currentToken) {
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the server's refresh endpoint
|
||||||
|
const response = await axios.post(
|
||||||
|
`${serverUrl}/api/v1/auth/refresh`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${currentToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { token } = response.data;
|
||||||
|
|
||||||
|
// Save the new token
|
||||||
|
await invoke('save_auth_token', { token });
|
||||||
|
|
||||||
|
const userInfo = await this.getUserInfo();
|
||||||
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
|
||||||
|
console.log('Token refreshed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error);
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
|
||||||
|
// Clear stored credentials on refresh failure
|
||||||
|
await this.logout();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeAuthState(): Promise<void> {
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
const userInfo = await this.getUserInfo();
|
||||||
|
|
||||||
|
if (token && userInfo) {
|
||||||
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
} else {
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = AuthService.getInstance();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
|
export type ConnectionMode = 'offline' | 'server';
|
||||||
|
export type ServerType = 'saas' | 'selfhosted';
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
url: string;
|
||||||
|
server_type: ServerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionConfig {
|
||||||
|
mode: ConnectionMode;
|
||||||
|
server_config: ServerConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionModeService {
|
||||||
|
private static instance: ConnectionModeService;
|
||||||
|
private currentConfig: ConnectionConfig | null = null;
|
||||||
|
private configLoadedOnce = false;
|
||||||
|
private modeListeners = new Set<(config: ConnectionConfig) => void>();
|
||||||
|
|
||||||
|
static getInstance(): ConnectionModeService {
|
||||||
|
if (!ConnectionModeService.instance) {
|
||||||
|
ConnectionModeService.instance = new ConnectionModeService();
|
||||||
|
}
|
||||||
|
return ConnectionModeService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentConfig(): Promise<ConnectionConfig> {
|
||||||
|
if (!this.configLoadedOnce) {
|
||||||
|
await this.loadConfig();
|
||||||
|
}
|
||||||
|
return this.currentConfig || { mode: 'offline', server_config: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentMode(): Promise<ConnectionMode> {
|
||||||
|
const config = await this.getCurrentConfig();
|
||||||
|
return config.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerConfig(): Promise<ServerConfig | null> {
|
||||||
|
const config = await this.getCurrentConfig();
|
||||||
|
return config.server_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
|
||||||
|
this.modeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.modeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners() {
|
||||||
|
if (this.currentConfig) {
|
||||||
|
this.modeListeners.forEach(listener => listener(this.currentConfig!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await invoke<ConnectionConfig>('get_connection_config');
|
||||||
|
this.currentConfig = config;
|
||||||
|
this.configLoadedOnce = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load connection config:', error);
|
||||||
|
// Default to offline mode on error
|
||||||
|
this.currentConfig = { mode: 'offline', server_config: null };
|
||||||
|
this.configLoadedOnce = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToOffline(): Promise<void> {
|
||||||
|
console.log('Switching to offline mode');
|
||||||
|
|
||||||
|
await invoke('set_connection_mode', {
|
||||||
|
mode: 'offline',
|
||||||
|
serverConfig: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentConfig = { mode: 'offline', server_config: null };
|
||||||
|
this.notifyListeners();
|
||||||
|
|
||||||
|
console.log('Switched to offline mode successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToServer(serverConfig: ServerConfig): Promise<void> {
|
||||||
|
console.log('Switching to server mode:', serverConfig);
|
||||||
|
|
||||||
|
await invoke('set_connection_mode', {
|
||||||
|
mode: 'server',
|
||||||
|
serverConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentConfig = { mode: 'server', server_config: serverConfig };
|
||||||
|
this.notifyListeners();
|
||||||
|
|
||||||
|
console.log('Switched to server mode successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(url: string): Promise<boolean> {
|
||||||
|
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
|
||||||
|
try {
|
||||||
|
// Test connection by hitting the health/status endpoint
|
||||||
|
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
connectTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOk = response.ok;
|
||||||
|
console.log(`[ConnectionModeService] Server connection test result: ${isOk}`);
|
||||||
|
return isOk;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ConnectionModeService] Server connection test failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isFirstLaunch(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<boolean>('is_first_launch');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check first launch:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionModeService = ConnectionModeService.getInstance();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
|
|
||||||
|
export type ExecutionTarget = 'local' | 'remote';
|
||||||
|
|
||||||
|
export class OperationRouter {
|
||||||
|
private static instance: OperationRouter;
|
||||||
|
|
||||||
|
static getInstance(): OperationRouter {
|
||||||
|
if (!OperationRouter.instance) {
|
||||||
|
OperationRouter.instance = new OperationRouter();
|
||||||
|
}
|
||||||
|
return OperationRouter.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines where an operation should execute
|
||||||
|
* @param _operation - The operation name (for future operation classification)
|
||||||
|
* @returns 'local' or 'remote'
|
||||||
|
*/
|
||||||
|
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|
||||||
|
// Current implementation: simple mode-based routing
|
||||||
|
if (mode === 'offline') {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
// In server mode, currently all operations go to remote
|
||||||
|
// Future enhancement: check if operation is "simple" and route to local if so
|
||||||
|
// Example future logic:
|
||||||
|
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
|
||||||
|
// return 'local';
|
||||||
|
// }
|
||||||
|
|
||||||
|
return 'remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base URL for an operation based on execution target
|
||||||
|
* @param _operation - The operation name (for future operation classification)
|
||||||
|
* @returns Base URL for API calls
|
||||||
|
*/
|
||||||
|
async getBaseUrl(_operation?: string): Promise<string> {
|
||||||
|
const target = await this.getExecutionTarget(_operation);
|
||||||
|
|
||||||
|
if (target === 'local') {
|
||||||
|
// Use dynamically assigned port from backend service
|
||||||
|
const backendUrl = tauriBackendService.getBackendUrl();
|
||||||
|
if (!backendUrl) {
|
||||||
|
throw new Error('Backend URL not available - backend may still be starting');
|
||||||
|
}
|
||||||
|
// Strip trailing slash to avoid double slashes in URLs
|
||||||
|
return backendUrl.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote: get from server config
|
||||||
|
const serverConfig = await connectionModeService.getServerConfig();
|
||||||
|
if (!serverConfig) {
|
||||||
|
console.warn('No server config found');
|
||||||
|
throw new Error('Server configuration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip trailing slash to avoid double slashes in URLs
|
||||||
|
return serverConfig.url.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we're currently in remote mode
|
||||||
|
*/
|
||||||
|
async isRemoteMode(): Promise<boolean> {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
return mode === 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we're currently in offline mode
|
||||||
|
*/
|
||||||
|
async isOfflineMode(): Promise<boolean> {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
return mode === 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future enhancement: operation classification
|
||||||
|
// private isSimpleOperation(operation: string): boolean {
|
||||||
|
// const simpleOperations = [
|
||||||
|
// 'rotate',
|
||||||
|
// 'merge',
|
||||||
|
// 'split',
|
||||||
|
// 'extract-pages',
|
||||||
|
// 'remove-pages',
|
||||||
|
// 'reorder-pages',
|
||||||
|
// 'metadata',
|
||||||
|
// ];
|
||||||
|
// return simpleOperations.includes(operation);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operationRouter = OperationRouter.getInstance();
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
|
||||||
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||||
|
|
||||||
@@ -6,6 +8,7 @@ export class TauriBackendService {
|
|||||||
private static instance: TauriBackendService;
|
private static instance: TauriBackendService;
|
||||||
private backendStarted = false;
|
private backendStarted = false;
|
||||||
private backendStatus: BackendStatus = 'stopped';
|
private backendStatus: BackendStatus = 'stopped';
|
||||||
|
private backendPort: number | null = null;
|
||||||
private healthMonitor: Promise<void> | null = null;
|
private healthMonitor: Promise<void> | null = null;
|
||||||
private startPromise: Promise<void> | null = null;
|
private startPromise: Promise<void> | null = null;
|
||||||
private statusListeners = new Set<(status: BackendStatus) => void>();
|
private statusListeners = new Set<(status: BackendStatus) => void>();
|
||||||
@@ -29,6 +32,14 @@ export class TauriBackendService {
|
|||||||
return this.backendStatus === 'healthy';
|
return this.backendStatus === 'healthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBackendPort(): number | null {
|
||||||
|
return this.backendPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackendUrl(): string | null {
|
||||||
|
return this.backendPort ? `http://localhost:${this.backendPort}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
|
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
|
||||||
this.statusListeners.add(listener);
|
this.statusListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -44,6 +55,21 @@ export class TauriBackendService {
|
|||||||
this.statusListeners.forEach(listener => listener(status));
|
this.statusListeners.forEach(listener => listener(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize health monitoring for an external server (server mode)
|
||||||
|
* Does not start bundled backend, but enables health checks
|
||||||
|
*/
|
||||||
|
async initializeExternalBackend(): Promise<void> {
|
||||||
|
if (this.backendStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TauriBackendService] Initializing external backend monitoring');
|
||||||
|
this.backendStarted = true; // Mark as active for health checks
|
||||||
|
this.setStatus('starting');
|
||||||
|
this.beginHealthMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
async startBackend(backendUrl?: string): Promise<void> {
|
async startBackend(backendUrl?: string): Promise<void> {
|
||||||
if (this.backendStarted) {
|
if (this.backendStarted) {
|
||||||
return;
|
return;
|
||||||
@@ -56,10 +82,14 @@ export class TauriBackendService {
|
|||||||
this.setStatus('starting');
|
this.setStatus('starting');
|
||||||
|
|
||||||
this.startPromise = invoke('start_backend', { backendUrl })
|
this.startPromise = invoke('start_backend', { backendUrl })
|
||||||
.then((result) => {
|
.then(async (result) => {
|
||||||
console.log('Backend started:', result);
|
console.log('Backend started:', result);
|
||||||
this.backendStarted = true;
|
this.backendStarted = true;
|
||||||
this.setStatus('starting');
|
this.setStatus('starting');
|
||||||
|
|
||||||
|
// Poll for the dynamically assigned port
|
||||||
|
await this.waitForPort();
|
||||||
|
|
||||||
this.beginHealthMonitoring();
|
this.beginHealthMonitoring();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -74,6 +104,24 @@ export class TauriBackendService {
|
|||||||
return this.startPromise;
|
return this.startPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForPort(maxAttempts = 30): Promise<void> {
|
||||||
|
console.log('[TauriBackendService] Waiting for backend port assignment...');
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const port = await invoke<number | null>('get_backend_port');
|
||||||
|
if (port) {
|
||||||
|
this.backendPort = port;
|
||||||
|
console.log(`[TauriBackendService] Backend port detected: ${port}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get backend port:', error);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error('Failed to detect backend port after 15 seconds');
|
||||||
|
}
|
||||||
|
|
||||||
private beginHealthMonitoring() {
|
private beginHealthMonitoring() {
|
||||||
if (this.healthMonitor) {
|
if (this.healthMonitor) {
|
||||||
return;
|
return;
|
||||||
@@ -88,16 +136,58 @@ export class TauriBackendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkBackendHealth(): Promise<boolean> {
|
async checkBackendHealth(): Promise<boolean> {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|
||||||
|
// For remote server mode, check the configured server
|
||||||
|
if (mode !== 'offline') {
|
||||||
|
const serverConfig = await connectionModeService.getServerConfig();
|
||||||
|
if (!serverConfig) {
|
||||||
|
console.error('[TauriBackendService] Server mode but no server URL configured');
|
||||||
|
this.setStatus('unhealthy');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = serverConfig.url.replace(/\/$/, '');
|
||||||
|
const healthUrl = `${baseUrl}/api/v1/info/status`;
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
connectTimeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHealthy = response.ok;
|
||||||
|
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||||
|
return isHealthy;
|
||||||
|
} catch (error) {
|
||||||
|
const errorStr = String(error);
|
||||||
|
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||||
|
console.error('[TauriBackendService] Server health check failed:', error);
|
||||||
|
}
|
||||||
|
this.setStatus('unhealthy');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For offline mode, check the bundled backend via Rust
|
||||||
if (!this.backendStarted) {
|
if (!this.backendStarted) {
|
||||||
this.setStatus('stopped');
|
this.setStatus('stopped');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.backendPort) {
|
||||||
|
console.debug('[TauriBackendService] Backend port not available yet');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isHealthy = await invoke<boolean>('check_backend_health');
|
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
|
||||||
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||||
return isHealthy;
|
return isHealthy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Health check failed:', error);
|
const errorStr = String(error);
|
||||||
|
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||||
|
console.error('[TauriBackendService] Bundled backend health check failed:', error);
|
||||||
|
}
|
||||||
this.setStatus('unhealthy');
|
this.setStatus('unhealthy');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -115,6 +205,18 @@ export class TauriBackendService {
|
|||||||
this.setStatus('unhealthy');
|
this.setStatus('unhealthy');
|
||||||
throw new Error('Backend failed to become healthy after 60 seconds');
|
throw new Error('Backend failed to become healthy after 60 seconds');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset backend state (used when switching from external to local backend)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
console.log('[TauriBackendService] Resetting backend state');
|
||||||
|
this.backendStarted = false;
|
||||||
|
this.backendPort = null;
|
||||||
|
this.setStatus('stopped');
|
||||||
|
this.healthMonitor = null;
|
||||||
|
this.startPromise = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tauriBackendService = TauriBackendService.getInstance();
|
export const tauriBackendService = TauriBackendService.getInstance();
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tauri HTTP Client - wrapper around Tauri's native HTTP client
|
||||||
|
* Provides axios-compatible API while bypassing CORS restrictions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TauriHttpResponse<T = any> {
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
config: TauriHttpRequestConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriHttpRequestConfig {
|
||||||
|
url?: string;
|
||||||
|
method?: string;
|
||||||
|
baseURL?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
params?: Record<string, string | number | boolean> | any;
|
||||||
|
data?: any;
|
||||||
|
timeout?: number;
|
||||||
|
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
|
||||||
|
withCredentials?: boolean;
|
||||||
|
// Custom properties for desktop
|
||||||
|
operationName?: string;
|
||||||
|
skipBackendReadyCheck?: boolean;
|
||||||
|
// Axios compatibility properties (ignored by Tauri HTTP)
|
||||||
|
suppressErrorToast?: boolean;
|
||||||
|
cancelToken?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriHttpError extends Error {
|
||||||
|
config?: TauriHttpRequestConfig;
|
||||||
|
code?: string;
|
||||||
|
request?: unknown;
|
||||||
|
response?: TauriHttpResponse;
|
||||||
|
isAxiosError: boolean;
|
||||||
|
toJSON: () => object;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestInterceptor = (config: TauriHttpRequestConfig) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
|
||||||
|
type ResponseInterceptor<T = any> = (response: TauriHttpResponse<T>) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
|
||||||
|
type ErrorInterceptor = (error: any) => Promise<any>;
|
||||||
|
|
||||||
|
interface Interceptors {
|
||||||
|
request: {
|
||||||
|
handlers: RequestInterceptor[];
|
||||||
|
use: (onFulfilled: RequestInterceptor, onRejected?: ErrorInterceptor) => number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
handlers: { fulfilled: ResponseInterceptor; rejected?: ErrorInterceptor }[];
|
||||||
|
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class TauriHttpClient {
|
||||||
|
public defaults: TauriHttpRequestConfig = {
|
||||||
|
baseURL: '',
|
||||||
|
headers: {},
|
||||||
|
timeout: 120000,
|
||||||
|
responseType: 'json',
|
||||||
|
withCredentials: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public interceptors: Interceptors = {
|
||||||
|
request: {
|
||||||
|
handlers: [],
|
||||||
|
use: (onFulfilled: RequestInterceptor, _onRejected?: ErrorInterceptor) => {
|
||||||
|
this.interceptors.request.handlers.push(onFulfilled);
|
||||||
|
return this.interceptors.request.handlers.length - 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
handlers: [],
|
||||||
|
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => {
|
||||||
|
this.interceptors.response.handlers.push({ fulfilled: onFulfilled, rejected: onRejected });
|
||||||
|
return this.interceptors.response.handlers.length - 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(config?: TauriHttpRequestConfig) {
|
||||||
|
if (config) {
|
||||||
|
this.defaults = { ...this.defaults, ...config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createError(message: string, config?: TauriHttpRequestConfig, code?: string, response?: TauriHttpResponse): TauriHttpError {
|
||||||
|
const error = new Error(message) as TauriHttpError;
|
||||||
|
error.config = config;
|
||||||
|
error.code = code;
|
||||||
|
error.response = response;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
error.toJSON = () => ({
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
config: error.config,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUrl(config: TauriHttpRequestConfig): string {
|
||||||
|
let url = config.url || '';
|
||||||
|
|
||||||
|
// If URL is already absolute, use it as-is
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend baseURL if present
|
||||||
|
const baseURL = config.baseURL || this.defaults.baseURL || '';
|
||||||
|
if (baseURL) {
|
||||||
|
url = baseURL + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
if (config.params && typeof config.params === 'object') {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.entries(config.params as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
if (queryString) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + queryString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeRequest<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
// Merge with defaults
|
||||||
|
const mergedConfig: TauriHttpRequestConfig = {
|
||||||
|
...this.defaults,
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...this.defaults.headers,
|
||||||
|
...config.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run request interceptors
|
||||||
|
let finalConfig = mergedConfig;
|
||||||
|
for (const interceptor of this.interceptors.request.handlers) {
|
||||||
|
finalConfig = await Promise.resolve(interceptor(finalConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.buildUrl(finalConfig);
|
||||||
|
const method = (finalConfig.method || 'GET').toUpperCase();
|
||||||
|
|
||||||
|
// Prepare request body and headers
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
const headers: Record<string, string> = { ...(finalConfig.headers || {}) };
|
||||||
|
|
||||||
|
if (finalConfig.data) {
|
||||||
|
if (finalConfig.data instanceof FormData) {
|
||||||
|
// FormData can be passed directly
|
||||||
|
body = finalConfig.data;
|
||||||
|
} else if (typeof finalConfig.data === 'object') {
|
||||||
|
// Serialize as JSON
|
||||||
|
body = JSON.stringify(finalConfig.data);
|
||||||
|
if (!headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = String(finalConfig.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Debug logging
|
||||||
|
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
|
||||||
|
|
||||||
|
// Make the request using Tauri's native HTTP client (standard Fetch API)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse response based on responseType
|
||||||
|
let data: T;
|
||||||
|
const responseType = finalConfig.responseType || 'json';
|
||||||
|
|
||||||
|
if (responseType === 'json') {
|
||||||
|
data = await response.json() as T;
|
||||||
|
} else if (responseType === 'text') {
|
||||||
|
data = (await response.text()) as T;
|
||||||
|
} else if (responseType === 'blob') {
|
||||||
|
// Standard fetch doesn't set blob.type from Content-Type header (unlike axios)
|
||||||
|
// Set it manually to match axios behavior
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (!blob.type) {
|
||||||
|
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
data = new Blob([blob], { type: contentType }) as T;
|
||||||
|
} else {
|
||||||
|
data = blob as T;
|
||||||
|
}
|
||||||
|
} else if (responseType === 'arraybuffer') {
|
||||||
|
data = (await response.arrayBuffer()) as T;
|
||||||
|
} else {
|
||||||
|
data = await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Headers to plain object
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
responseHeaders[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpResponse: TauriHttpResponse<T> = {
|
||||||
|
data,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText || '',
|
||||||
|
headers: responseHeaders,
|
||||||
|
config: finalConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for HTTP errors
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = this.createError(
|
||||||
|
`Request failed with status code ${response.status}`,
|
||||||
|
finalConfig,
|
||||||
|
'ERR_BAD_REQUEST',
|
||||||
|
httpResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run error interceptors
|
||||||
|
let finalError: unknown = error;
|
||||||
|
for (const handler of this.interceptors.response.handlers) {
|
||||||
|
if (handler.rejected) {
|
||||||
|
try {
|
||||||
|
finalError = await Promise.resolve(handler.rejected(finalError));
|
||||||
|
} catch (e) {
|
||||||
|
finalError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run response interceptors
|
||||||
|
let finalResponse = httpResponse;
|
||||||
|
for (const handler of this.interceptors.response.handlers) {
|
||||||
|
finalResponse = await Promise.resolve(handler.fulfilled(finalResponse)) as TauriHttpResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalResponse;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// If it's already a TauriHttpError with interceptors run, re-throw
|
||||||
|
if (error && typeof error === 'object' && 'isAxiosError' in error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new error for network/other failures
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Network Error';
|
||||||
|
const httpError = this.createError(
|
||||||
|
errorMessage,
|
||||||
|
finalConfig,
|
||||||
|
'ERR_NETWORK'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run error interceptors
|
||||||
|
let finalError: unknown = httpError;
|
||||||
|
for (const handler of this.interceptors.response.handlers) {
|
||||||
|
if (handler.rejected) {
|
||||||
|
try {
|
||||||
|
finalError = await Promise.resolve(handler.rejected(finalError));
|
||||||
|
} catch (e) {
|
||||||
|
finalError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'GET', url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'DELETE', url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async head<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'HEAD', url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async options<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'OPTIONS', url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'POST', url, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'PUT', url, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
return this.executeRequest<T>({ ...config, method: 'PATCH', url, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axios compatibility methods
|
||||||
|
create(config?: TauriHttpRequestConfig): TauriHttpClient {
|
||||||
|
return new TauriHttpClient({ ...this.defaults, ...config });
|
||||||
|
}
|
||||||
|
|
||||||
|
getUri(config?: TauriHttpRequestConfig): string {
|
||||||
|
return this.buildUrl({ ...this.defaults, ...config });
|
||||||
|
}
|
||||||
|
|
||||||
|
async postForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
const formData = data instanceof FormData ? data : new FormData();
|
||||||
|
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.post<T>(url, formData, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async putForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
const formData = data instanceof FormData ? data : new FormData();
|
||||||
|
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.put<T>(url, formData, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
|
||||||
|
const formData = data instanceof FormData ? data : new FormData();
|
||||||
|
if (!(data instanceof FormData) && data && typeof data === 'object') {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.patch<T>(url, formData, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory function matching axios.create()
|
||||||
|
export function create(config?: TauriHttpRequestConfig): TauriHttpClient {
|
||||||
|
return new TauriHttpClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default instance
|
||||||
|
export default new TauriHttpClient();
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"src/core/**/*.test.ts*",
|
||||||
|
"src/core/**/*.spec.ts*",
|
||||||
|
"src/proprietary/**/*.test.ts*",
|
||||||
|
"src/proprietary/**/*.spec.ts*",
|
||||||
"node_modules"
|
"node_modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"src/core/**/*.test.ts*",
|
||||||
|
"src/core/**/*.spec.ts*",
|
||||||
"src/desktop",
|
"src/desktop",
|
||||||
"node_modules"
|
"node_modules"
|
||||||
]
|
]
|
||||||
|
|||||||
+19
-18
@@ -31,24 +31,25 @@ export default defineConfig(({ mode }) => {
|
|||||||
// tell vite to ignore watching `src-tauri`
|
// tell vite to ignore watching `src-tauri`
|
||||||
ignored: ['**/src-tauri/**'],
|
ignored: ['**/src-tauri/**'],
|
||||||
},
|
},
|
||||||
proxy: {
|
// Only use proxy in web mode - Tauri handles backend connections directly
|
||||||
'/api': {
|
proxy: isDesktopMode ? undefined : {
|
||||||
target: 'http://localhost:8080',
|
'/api': {
|
||||||
changeOrigin: true,
|
target: 'http://localhost:8080',
|
||||||
secure: false,
|
changeOrigin: true,
|
||||||
xfwd: true,
|
secure: false,
|
||||||
},
|
xfwd: true,
|
||||||
'/oauth2': {
|
},
|
||||||
target: 'http://localhost:8080',
|
'/oauth2': {
|
||||||
changeOrigin: true,
|
target: 'http://localhost:8080',
|
||||||
secure: false,
|
changeOrigin: true,
|
||||||
xfwd: true,
|
secure: false,
|
||||||
},
|
xfwd: true,
|
||||||
'/login/oauth2': {
|
},
|
||||||
target: 'http://localhost:8080',
|
'/login/oauth2': {
|
||||||
changeOrigin: true,
|
target: 'http://localhost:8080',
|
||||||
secure: false,
|
changeOrigin: true,
|
||||||
xfwd: true,
|
secure: false,
|
||||||
|
xfwd: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+79
-44
@@ -2,52 +2,87 @@ import { defineConfig } from 'vitest/config';
|
|||||||
import react from '@vitejs/plugin-react-swc';
|
import react from '@vitejs/plugin-react-swc';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig((configEnv) => {
|
export default defineConfig({
|
||||||
const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true';
|
test: {
|
||||||
const isDesktopMode =
|
globals: true,
|
||||||
configEnv.mode === 'desktop' ||
|
environment: 'jsdom',
|
||||||
process.env.STIRLING_DESKTOP === 'true' ||
|
setupFiles: ['./src/core/setupTests.ts'],
|
||||||
process.env.VITE_DESKTOP === 'true';
|
css: false,
|
||||||
|
exclude: [
|
||||||
const baseProject = isProprietary ? './tsconfig.proprietary.json' : './tsconfig.core.json';
|
'node_modules/',
|
||||||
const desktopProject = isProprietary ? './tsconfig.desktop.json' : baseProject;
|
'src/**/*.spec.ts', // Exclude Playwright E2E tests
|
||||||
const tsconfigProject = isDesktopMode ? desktopProject : baseProject;
|
'src/tests/test-fixtures/**'
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tsconfigPaths({
|
|
||||||
projects: [tsconfigProject],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
test: {
|
testTimeout: 10000,
|
||||||
globals: true,
|
hookTimeout: 10000,
|
||||||
environment: 'jsdom',
|
coverage: {
|
||||||
setupFiles: ['./src/core/setupTests.ts'],
|
reporter: ['text', 'json', 'html'],
|
||||||
css: false, // Disable CSS processing to speed up tests
|
|
||||||
include: [
|
|
||||||
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
|
|
||||||
],
|
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
'src/**/*.spec.ts', // Exclude Playwright E2E tests
|
'src/core/setupTests.ts',
|
||||||
'src/tests/test-fixtures/**'
|
'**/*.d.ts',
|
||||||
],
|
'src/tests/test-fixtures/**',
|
||||||
testTimeout: 10000, // 10 second timeout
|
'src/**/*.spec.ts'
|
||||||
hookTimeout: 10000, // 10 second timeout for setup/teardown
|
]
|
||||||
coverage: {
|
|
||||||
reporter: ['text', 'json', 'html'],
|
|
||||||
exclude: [
|
|
||||||
'node_modules/',
|
|
||||||
'src/core/setupTests.ts',
|
|
||||||
'**/*.d.ts',
|
|
||||||
'src/tests/test-fixtures/**',
|
|
||||||
'src/**/*.spec.ts' // Exclude Playwright files from coverage
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
esbuild: {
|
projects: [
|
||||||
target: 'es2020' // Use older target to avoid warnings
|
{
|
||||||
}
|
test: {
|
||||||
};
|
name: 'core',
|
||||||
|
include: ['src/core/**/*.test.{ts,tsx}'],
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/core/setupTests.ts'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: ['./tsconfig.core.json'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2020'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: {
|
||||||
|
name: 'proprietary',
|
||||||
|
include: ['src/proprietary/**/*.test.{ts,tsx}'],
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/core/setupTests.ts'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: ['./tsconfig.proprietary.json'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2020'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: {
|
||||||
|
name: 'desktop',
|
||||||
|
include: ['src/desktop/**/*.test.{ts,tsx}'],
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/core/setupTests.ts'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: ['./tsconfig.desktop.json'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2020'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2020'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user