From f4725b98b03f807b7c6a757508cc6467d77ce50d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 20 Nov 2025 10:03:34 +0000 Subject: [PATCH] Allow desktop app to connect to selfhosted servers (#4902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 ๐Ÿ™ --- .../software/SPDF/SPDFApplication.java | 10 + frontend/package-lock.json | 10 + frontend/package.json | 7 +- .../public/locales/en-GB/translation.json | 153 +++++++ frontend/src-tauri/Cargo.lock | 407 +++++++++++++++++- frontend/src-tauri/Cargo.toml | 3 + frontend/src-tauri/capabilities/default.json | 17 +- frontend/src-tauri/src/commands/auth.rs | 215 +++++++++ frontend/src-tauri/src/commands/backend.rs | 155 ++++--- frontend/src-tauri/src/commands/connection.rs | 111 +++++ frontend/src-tauri/src/commands/health.rs | 46 +- frontend/src-tauri/src/commands/mod.rs | 24 +- frontend/src-tauri/src/lib.rs | 41 +- .../src-tauri/src/state/connection_state.rs | 45 ++ frontend/src-tauri/src/state/mod.rs | 1 + .../hooks/tools/showJS/useShowJSOperation.ts | 4 +- frontend/src/core/services/apiClientSetup.ts | 2 +- .../src/desktop/components/AppProviders.tsx | 53 ++- .../desktop/components/ConnectionSettings.tsx | 287 ++++++++++++ .../components/SetupWizard/LoginForm.tsx | 85 ++++ .../components/SetupWizard/ModeSelection.tsx | 66 +++ .../SetupWizard/ServerSelection.tsx | 92 ++++ .../components/SetupWizard/SetupWizard.css | 20 + .../desktop/components/SetupWizard/index.tsx | 184 ++++++++ .../shared/config/configNavSections.tsx | 30 ++ .../desktop/components/shared/config/types.ts | 8 + frontend/src/desktop/constants/connection.ts | 5 + .../src/desktop/hooks/useAppInitialization.ts | 18 +- .../desktop/hooks/useBackendInitializer.ts | 10 +- .../src/desktop/hooks/useEndpointConfig.ts | 34 +- .../src/desktop/hooks/useFirstLaunchCheck.ts | 44 ++ frontend/src/desktop/services/apiClient.ts | 34 ++ .../src/desktop/services/apiClientConfig.ts | 15 +- .../src/desktop/services/apiClientSetup.ts | 128 +++++- frontend/src/desktop/services/authService.ts | 198 +++++++++ .../desktop/services/connectionModeService.ts | 131 ++++++ .../src/desktop/services/operationRouter.ts | 99 +++++ .../desktop/services/tauriBackendService.ts | 108 ++++- .../src/desktop/services/tauriHttpClient.ts | 361 ++++++++++++++++ frontend/tsconfig.desktop.json | 4 + frontend/tsconfig.proprietary.json | 2 + frontend/vite.config.ts | 37 +- frontend/vitest.config.ts | 123 ++++-- 43 files changed, 3209 insertions(+), 218 deletions(-) create mode 100644 frontend/src-tauri/src/commands/auth.rs create mode 100644 frontend/src-tauri/src/commands/connection.rs create mode 100644 frontend/src-tauri/src/state/connection_state.rs create mode 100644 frontend/src-tauri/src/state/mod.rs create mode 100644 frontend/src/desktop/components/ConnectionSettings.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/LoginForm.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/ModeSelection.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/ServerSelection.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/SetupWizard.css create mode 100644 frontend/src/desktop/components/SetupWizard/index.tsx create mode 100644 frontend/src/desktop/components/shared/config/configNavSections.tsx create mode 100644 frontend/src/desktop/components/shared/config/types.ts create mode 100644 frontend/src/desktop/constants/connection.ts create mode 100644 frontend/src/desktop/hooks/useFirstLaunchCheck.ts create mode 100644 frontend/src/desktop/services/apiClient.ts create mode 100644 frontend/src/desktop/services/authService.ts create mode 100644 frontend/src/desktop/services/connectionModeService.ts create mode 100644 frontend/src/desktop/services/operationRouter.ts create mode 100644 frontend/src/desktop/services/tauriHttpClient.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 587bc992e..d3a4ce776 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -12,6 +12,8 @@ import java.util.Properties; import org.springframework.boot.SpringApplication; 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.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() { log.info("Stirling-PDF Started."); String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 407f8ca06..6d4c6c529 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "@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", "axios": "^1.12.2", "globals": "^16.4.0", @@ -3887,6 +3888,15 @@ "@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": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fa614f35..a0d831d0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,8 +26,6 @@ "@embedpdf/plugin-viewport": "^1.4.1", "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", - "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-fs": "^2.4.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", @@ -39,6 +37,9 @@ "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@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", "axios": "^1.12.2", "globals": "^16.4.0", @@ -110,11 +111,11 @@ ] }, "devDependencies": { - "@tauri-apps/cli": "^2.5.0", "@eslint/js": "^9.36.0", "@iconify-json/material-symbols": "^1.2.37", "@iconify/utils": "^3.0.2", "@playwright/test": "^1.55.0", + "@tauri-apps/cli": "^2.5.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4af7afd8e..c2943e6cb 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5498,5 +5498,158 @@ "offline": "Backend Offline", "starting": "Backend starting up...", "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" } } diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index c40b35a8e..7ddc3dbfd 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -589,10 +589,29 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "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]] name = "core-foundation" version = "0.9.4" @@ -767,6 +786,12 @@ dependencies = [ "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]] name = "deranged" version = "0.5.5" @@ -871,6 +896,15 @@ dependencies = [ "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]] name = "dpi" version = "0.1.2" @@ -1365,8 +1399,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1376,9 +1412,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1548,6 +1586,25 @@ dependencies = [ "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]] name = "hashbrown" version = "0.12.3" @@ -1677,7 +1734,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -1701,6 +1758,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "httparse", @@ -1712,6 +1770,23 @@ dependencies = [ "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]] name = "hyper-tls" version = "0.5.0" @@ -1744,9 +1819,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2057,6 +2134,16 @@ dependencies = [ "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]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2137,6 +2224,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2155,6 +2248,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -3092,6 +3191,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3112,6 +3217,16 @@ dependencies = [ "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]] name = "quick-xml" version = "0.38.3" @@ -3121,6 +3236,61 @@ dependencies = [ "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]] name = "quote" version = "1.0.41" @@ -3167,6 +3337,16 @@ dependencies = [ "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]] name = "rand_chacha" version = "0.2.2" @@ -3187,6 +3367,16 @@ dependencies = [ "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]] name = "rand_core" version = "0.5.1" @@ -3205,6 +3395,15 @@ dependencies = [ "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]] name = "rand_hc" version = "0.2.0" @@ -3318,7 +3517,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -3336,7 +3535,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", @@ -3355,22 +3554,32 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", + "encoding_rs", "futures-core", "futures-util", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3380,6 +3589,21 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "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]] @@ -3427,6 +3651,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3449,6 +3679,20 @@ dependencies = [ "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]] name = "rustls-pemfile" version = "1.0.4" @@ -3458,6 +3702,27 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" @@ -3952,6 +4217,7 @@ version = "0.1.0" dependencies = [ "core-foundation 0.10.1", "core-services", + "keyring", "log", "reqwest 0.11.27", "serde", @@ -3959,9 +4225,11 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-fs", + "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-plugin-store", "tokio", ] @@ -3996,6 +4264,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4063,7 +4337,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "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]] @@ -4076,6 +4361,16 @@ dependencies = [ "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]] name = "system-deps" version = "6.2.2" @@ -4305,6 +4600,30 @@ dependencies = [ "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]] name = "tauri-plugin-log" version = "2.7.1" @@ -4363,6 +4682,22 @@ dependencies = [ "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]] name = "tauri-runtime" version = "2.9.1" @@ -4596,9 +4931,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2 0.6.1", + "tokio-macros", "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]] name = "tokio-native-tls" version = "0.3.1" @@ -4609,6 +4956,16 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.16" @@ -4898,6 +5255,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.7" @@ -5131,6 +5494,16 @@ dependencies = [ "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]] name = "webkit2gtk" version = "2.0.1" @@ -5175,6 +5548,15 @@ dependencies = [ "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]] name = "webview2-com" version = "0.38.0" @@ -5360,6 +5742,17 @@ dependencies = [ "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]] name = "windows-result" version = "0.3.4" @@ -5962,6 +6355,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index b75106195..968edc53d 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,7 +28,10 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" +tauri-plugin-http = "2.4.4" tauri-plugin-single-instance = "2.0.1" +tauri-plugin-store = "2.1.0" +keyring = "3.6.1" tokio = { version = "1.0", features = ["time"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 385973667..b6f98d92e 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -7,9 +7,18 @@ ], "permissions": [ "core:default", - { - "identifier": "fs:allow-read-file", - "allow": [{ "path": "**" }] - } + "http:default", + { + "identifier": "http:allow-fetch", + "allow": [ + { "url": "http://localhost:*" }, + { "url": "http://127.0.0.1:*" }, + { "url": "https://*" } + ] + }, + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "**" }] + } ] } diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs new file mode 100644 index 000000000..9ce4c3cda --- /dev/null +++ b/frontend/src-tauri/src/commands/auth.rs @@ -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, +} + +fn get_keyring_entry() -> Result { + 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, 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, +) -> 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, 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 = 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, +} + +#[derive(Debug, Deserialize)] +struct SpringBootLoginResponse { + session: SpringBootSession, + user: SpringBootUser, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub username: String, + pub email: Option, +} + +/// 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 { + 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, + }) +} diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c7bce50f7..27a4a7dfd 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -3,10 +3,12 @@ use tauri::Manager; use std::sync::Mutex; use std::path::PathBuf; 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> = Mutex::new(None); static BACKEND_STARTING: Mutex = Mutex::new(false); +static BACKEND_PORT: Mutex> = Mutex::new(None); // Helper function to reset starting flag fn reset_starting_flag() { @@ -14,6 +16,20 @@ fn reset_starting_flag() { *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 { + // 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::().ok(); + } + None +} + // Check if backend is already running or starting fn check_backend_status() -> Result<(), String> { // Check if backend is already running @@ -24,7 +40,7 @@ fn check_backend_status() -> Result<(), String> { return Err("Backend already running".to_string()); } } - + // Check and set starting flag to prevent multiple simultaneous starts { let mut starting_guard = BACKEND_STARTING.lock().unwrap(); @@ -34,7 +50,7 @@ fn check_backend_status() -> Result<(), String> { } *starting_guard = true; } - + Ok(()) } @@ -46,13 +62,13 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result { } else { jre_dir.join("bin").join("java") }; - + if !java_executable.exists() { let error_msg = format!("โŒ Bundled JRE not found at: {:?}", java_executable); add_log(error_msg.clone()); return Err(error_msg); } - + add_log(format!("โœ… Found bundled JRE: {:?}", java_executable)); Ok(java_executable) } @@ -77,20 +93,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result { .unwrap_or(false) }) .collect(); - + if jar_files.is_empty() { let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string(); add_log(error_msg.clone()); return Err(error_msg); } - + // Sort by filename to get the latest version (case-insensitive) jar_files.sort_by(|a, b| { let name_a = a.file_name().to_string_lossy().to_ascii_lowercase(); let name_b = b.file_name().to_string_lossy().to_ascii_lowercase(); name_b.cmp(&name_a) // Reverse order to get latest first }); - + let jar_path = jar_files[0].path(); add_log(format!("๐Ÿ“‹ Selected JAR: {:?}", jar_path.file_name().unwrap())); Ok(jar_path) @@ -123,23 +139,23 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".config").join("Stirling-PDF") }; - + // Create subdirectories for different purposes let config_dir = app_data_dir.join("configs"); let log_dir = app_data_dir.join("logs"); let work_dir = app_data_dir.join("workspace"); - + // Create all necessary directories std::fs::create_dir_all(&app_data_dir).ok(); std::fs::create_dir_all(&log_dir).ok(); std::fs::create_dir_all(&work_dir).ok(); std::fs::create_dir_all(&config_dir).ok(); - + add_log(format!("๐Ÿ“ App data directory: {}", app_data_dir.display())); add_log(format!("๐Ÿ“ Log directory: {}", log_dir.display())); add_log(format!("๐Ÿ“ Working directory: {}", work_dir.display())); add_log(format!("๐Ÿ“ Config directory: {}", config_dir.display())); - + // Define all Java options with Tauri-specific paths let log_path_option = format!("-Dlogging.file.path={}", log_dir.display()); @@ -150,10 +166,13 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & "-DSTIRLING_PDF_TAURI_MODE=true", &log_path_option, "-Dlogging.file.name=stirling-pdf.log", + "-Dserver.port=0", // Let OS assign an available port + "-Dsecurity.enableLogin=false", // Disable login for desktop mode + "-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode "-jar", - jar_path.to_str().unwrap() + jar_path.to_str().unwrap(), ]; - + // Log the equivalent command for external testing let java_command = format!( "TAURI_PARENT_PID={} \"{}\" {}", @@ -163,14 +182,14 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & ); add_log(format!("๐Ÿ”ง Equivalent command: {}", java_command)); add_log(format!("๐Ÿ“ Backend logs will be in: {}", log_dir.display())); - + // Additional macOS-specific checks if cfg!(target_os = "macos") { // Check if java executable has execute permissions if let Ok(metadata) = std::fs::metadata(java_path) { let permissions = metadata.permissions(); add_log(format!("๐Ÿ” Java executable permissions: {:?}", permissions)); - + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -181,7 +200,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & } } } - + // Check if we can read the JAR file if let Ok(metadata) = std::fs::metadata(jar_path) { add_log(format!("๐Ÿ“ฆ JAR file size: {} bytes", metadata.len())); @@ -189,7 +208,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & add_log("โš ๏ธ Cannot read JAR file metadata".to_string()); } } - + let sidecar_command = app .shell() .command(java_path.to_str().unwrap()) @@ -199,9 +218,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & .env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap()) .env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap()) .env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap()); - + add_log("โš™๏ธ Starting backend with bundled JRE...".to_string()); - + let (rx, child) = sidecar_command .spawn() .map_err(|e| { @@ -209,18 +228,18 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & add_log(error_msg.clone()); error_msg })?; - + // Store the process handle { let mut process_guard = BACKEND_PROCESS.lock().unwrap(); *process_guard = Some(child); } - + add_log("โœ… Backend started with bundled JRE, monitoring output...".to_string()); - + // Start monitoring output monitor_backend_output(rx); - + Ok(()) } @@ -229,7 +248,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { @@ -237,17 +256,22 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { @@ -255,13 +279,13 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver 0 { println!("โš ๏ธ Backend process ended with {} errors detected", error_count); } @@ -308,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver Result { +pub async fn start_backend( + app: tauri::AppHandle, + connection_state: tauri::State<'_, AppConnectionState>, +) -> Result { 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 if let Err(msg) = check_backend_status() { return Ok(msg); } - + // Use Tauri's resource API to find the bundled JRE and JAR let resource_dir = app.path().resource_dir().map_err(|e| { let error_msg = format!("โŒ Failed to get resource directory: {}", e); @@ -323,53 +369,60 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result { reset_starting_flag(); error_msg })?; - + add_log(format!("๐Ÿ” Resource directory: {:?}", resource_dir)); - + // Find the bundled JRE let java_executable = find_bundled_jre(&resource_dir).map_err(|e| { reset_starting_flag(); e })?; - + // Find the Stirling-PDF JAR let jar_path = find_stirling_jar(&resource_dir).map_err(|e| { reset_starting_flag(); e })?; - + // Normalize the paths to remove Windows UNC prefix let normalized_java_path = normalize_path(&java_executable); let normalized_jar_path = normalize_path(&jar_path); - + add_log(format!("๐Ÿ“ฆ Found JAR file: {:?}", jar_path)); add_log(format!("๐Ÿ“ฆ Normalized JAR path: {:?}", normalized_jar_path)); add_log(format!("๐Ÿ“ฆ Normalized Java path: {:?}", normalized_java_path)); - + // Create and start the Java command run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| { reset_starting_flag(); e })?; - + // Wait for the backend to start println!("โณ Waiting for backend startup..."); tokio::time::sleep(std::time::Duration::from_millis(10000)).await; - + // Reset the starting flag since startup is complete reset_starting_flag(); add_log("โœ… Backend startup sequence completed, starting flag cleared".to_string()); - + Ok("Backend startup initiated successfully with bundled JRE".to_string()) } +// Get the dynamically assigned backend port +#[tauri::command] +pub fn get_backend_port() -> Option { + let port_guard = BACKEND_PORT.lock().unwrap(); + *port_guard +} + // Cleanup function to stop backend on app exit pub fn cleanup_backend() { let mut process_guard = BACKEND_PROCESS.lock().unwrap(); if let Some(child) = process_guard.take() { let pid = child.pid(); add_log(format!("๐Ÿงน App shutting down, cleaning up backend process (PID: {})", pid)); - + match child.kill() { Ok(_) => { add_log(format!("โœ… Backend process (PID: {}) terminated during cleanup", pid)); @@ -380,4 +433,4 @@ pub fn cleanup_backend() { } } } -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs new file mode 100644 index 000000000..097d411b5 --- /dev/null +++ b/frontend/src-tauri/src/commands/connection.rs @@ -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, +} + +#[tauri::command] +pub async fn get_connection_config( + app_handle: AppHandle, + state: State<'_, AppConnectionState>, +) -> Result { + // 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 = 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, +) -> 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 { + 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) +} diff --git a/frontend/src-tauri/src/commands/health.rs b/frontend/src-tauri/src/commands/health.rs index 394c8462c..e807df7ef 100644 --- a/frontend/src-tauri/src/commands/health.rs +++ b/frontend/src-tauri/src/commands/health.rs @@ -1,36 +1,16 @@ -// Command to check if backend is healthy +use reqwest; + #[tauri::command] -pub async fn check_backend_health() -> Result { - let client = reqwest::Client::builder() +pub async fn check_backend_health(port: u16) -> Result { + let url = format!("http://localhost:{}/api/v1/info/status", port); + + match reqwest::Client::new() + .get(&url) .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 { - Ok(response) => { - let status = response.status(); - if status.is_success() { - match response.text().await { - Ok(_body) => { - println!("โœ… Backend health check successful"); - Ok(true) - } - Err(e) => { - println!("โš ๏ธ Failed to read health response: {}", e); - Ok(false) - } - } - } 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) - } + .send() + .await + { + Ok(response) => Ok(response.status().is_success()), + Err(_) => Ok(false), // Return false instead of error for connection failures } -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index ba9995ba6..ecf2f0a8d 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -1,9 +1,25 @@ pub mod backend; -pub mod health; pub mod files; +pub mod connection; +pub mod auth; pub mod default_app; +pub mod health; -pub use backend::{start_backend, cleanup_backend}; -pub use health::check_backend_health; -pub use files::{get_opened_files, clear_opened_files, add_opened_file}; +pub use backend::{cleanup_backend, get_backend_port, start_backend}; +pub use files::{add_opened_file, clear_opened_files, get_opened_files}; +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 health::check_backend_health; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 969479666..cc8e5b65f 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,18 +1,31 @@ -use tauri::{RunEvent, WindowEvent, Emitter, Manager}; +use tauri::{Manager, RunEvent, WindowEvent, Emitter}; mod utils; mod commands; +mod state; use commands::{ - start_backend, - check_backend_health, - get_opened_files, - clear_opened_files, - cleanup_backend, add_opened_file, + check_backend_health, + cleanup_backend, + clear_auth_token, + clear_opened_files, + clear_user_info, 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, + start_backend, }; +use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -20,6 +33,9 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::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| { // This callback runs when a second instance tries to start add_log(format!("๐Ÿ“‚ Second instance detected with args: {:?}", args)); @@ -60,12 +76,23 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ start_backend, - check_backend_health, + get_backend_port, get_opened_files, clear_opened_files, get_tauri_logs, + get_connection_config, + set_connection_mode, is_default_pdf_handler, set_as_default_pdf_handler, + is_first_launch, + 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!()) .expect("error while building tauri application") diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs new file mode 100644 index 000000000..e6a924956 --- /dev/null +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -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, +} + +impl Default for ConnectionState { + fn default() -> Self { + Self { + mode: ConnectionMode::Offline, + server_config: None, + } + } +} + +pub struct AppConnectionState(pub Mutex); + +impl Default for AppConnectionState { + fn default() -> Self { + Self(Mutex::new(ConnectionState::default())) + } +} diff --git a/frontend/src-tauri/src/state/mod.rs b/frontend/src-tauri/src/state/mod.rs new file mode 100644 index 000000000..4b8c86c60 --- /dev/null +++ b/frontend/src-tauri/src/state/mod.rs @@ -0,0 +1 @@ +pub mod connection_state; diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts index 081fc2723..834402d5f 100644 --- a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts +++ b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts @@ -5,7 +5,6 @@ import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation import type { StirlingFile } from '@app/types/fileContext'; import { extractErrorMessage } from '@app/utils/toolErrorHandler'; import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; -import type { ResponseType } from 'axios'; export interface ShowJSOperationHook extends ToolOperationHook { scriptText: string | null; @@ -71,8 +70,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => { const response = await apiClient.post('/api/v1/misc/show-javascript', formData, { headers: { 'Content-Type': 'multipart/form-data' }, - responseType: 'text' as ResponseType, - transformResponse: [(data) => data], + responseType: 'text', }); const text: string = typeof response.data === 'string' ? response.data : ''; diff --git a/frontend/src/core/services/apiClientSetup.ts b/frontend/src/core/services/apiClientSetup.ts index 07bc6782b..7bc356a7f 100644 --- a/frontend/src/core/services/apiClientSetup.ts +++ b/frontend/src/core/services/apiClientSetup.ts @@ -1,4 +1,4 @@ -import { AxiosInstance } from 'axios'; +import type { AxiosInstance } from 'axios'; import { getBrowserId } from '@app/utils/browserIdentifier'; export function setupApiInterceptors(client: AxiosInstance): void { diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index f5363b5e7..f6fc1ea1b 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -1,15 +1,66 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; import { DesktopConfigSync } from '@app/components/DesktopConfigSync'; 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 { connectionModeService } from '@desktop/services/connectionModeService'; +import { tauriBackendService } from '@app/services/tauriBackendService'; /** * Desktop application providers * Wraps proprietary providers and adds desktop-specific configuration * - 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 }) { + 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 ( + + { + // Reload the page to reinitialize with new connection config + window.location.reload(); + }} + /> + + ); + } + + // Normal app flow return ( { + const { t } = useTranslation(); + const [config, setConfig] = useState(null); + const [userInfo, setUserInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [showServerModal, setShowServerModal] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + const [newServerConfig, setNewServerConfig] = useState(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 {t('common.loading', 'Loading...')}; + } + + return ( + <> + + + + {t('settings.connection.title', 'Connection Mode')} + + {config.mode === 'offline' + ? t('settings.connection.mode.offline', 'Offline') + : t('settings.connection.mode.server', 'Server')} + + + + {config.mode === 'server' && config.server_config && ( + <> +
+ + {t('settings.connection.server', 'Server')} + + + {config.server_config.url} + +
+ + {userInfo && ( +
+ + {t('settings.connection.user', 'Logged in as')} + + + {userInfo.username} + {userInfo.email && ` (${userInfo.email})`} + +
+ )} + + )} + + + {config.mode === 'offline' ? ( + + ) : ( + <> + + + + )} + +
+
+ + {/* Server selection modal */} + setShowServerModal(false)} + title={t('settings.connection.selectServer', 'Select Server')} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + + + + {/* Login modal */} + { + setShowLoginModal(false); + setNewServerConfig(null); + }} + title={t('settings.connection.login', 'Login')} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {newServerConfig && ( + + )} + + + ); +}; + +// 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(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 ( + + setServerType(value as 'saas' | 'selfhosted')}> + + + + + + + {serverType === 'selfhosted' && ( + { + setCustomUrl(e.target.value); + setError(null); + }} + disabled={testing} + error={error} + /> + )} + + {error && !customUrl && ( + + {error} + + )} + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx new file mode 100644 index 000000000..be9951995 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -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; + loading: boolean; +} + +export const LoginForm: React.FC = ({ serverUrl, onLogin, loading }) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(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 ( +
+ + + {t('setup.login.connectingTo', 'Connecting to:')} {serverUrl} + + + { + setUsername(e.target.value); + setValidationError(null); + }} + disabled={loading} + required + /> + + { + setPassword(e.target.value); + setValidationError(null); + }} + disabled={loading} + required + /> + + {validationError && ( + + {validationError} + + )} + + + +
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx b/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx new file mode 100644 index 000000000..0512081d5 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx @@ -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 = ({ onSelect, loading }) => { + const { t } = useTranslation(); + + return ( + + + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx new file mode 100644 index 000000000..7d0f8de1e --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -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 = ({ onSelect, loading }) => { + const { t } = useTranslation(); + const [customUrl, setCustomUrl] = useState(''); + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(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 ( +
+ + { + 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' + )} + /> + + + +
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/SetupWizard.css b/frontend/src/desktop/components/SetupWizard/SetupWizard.css new file mode 100644 index 000000000..3f89cf572 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SetupWizard.css @@ -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); +} diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx new file mode 100644 index 000000000..3b47245e0 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -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 = ({ onComplete }) => { + const { t } = useTranslation(); + const [activeStep, setActiveStep] = useState(SetupStep.ModeSelection); + const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null); + const [serverConfig, setServerConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ + + + {/* Logo Header */} + + Stirling PDF + + {getStepTitle()} + + + {getStepSubtitle()} + + + + {/* Error Message */} + {error && ( + + + {error} + + + )} + + {/* Step Content */} + {activeStep === SetupStep.ModeSelection && ( + + )} + + {activeStep === SetupStep.ServerSelection && ( + + )} + + {activeStep === SetupStep.Login && ( + + )} + + {/* Back Button */} + {activeStep > SetupStep.ModeSelection && !loading && ( + + )} + + + +
+ ); +}; diff --git a/frontend/src/desktop/components/shared/config/configNavSections.tsx b/frontend/src/desktop/components/shared/config/configNavSections.tsx new file mode 100644 index 000000000..3582ccb99 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configNavSections.tsx @@ -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: , + }, + ], + }); + + return sections; +}; diff --git a/frontend/src/desktop/components/shared/config/types.ts b/frontend/src/desktop/components/shared/config/types.ts new file mode 100644 index 000000000..cdbf0558a --- /dev/null +++ b/frontend/src/desktop/components/shared/config/types.ts @@ -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]; diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts new file mode 100644 index 000000000..b9ba31f77 --- /dev/null +++ b/frontend/src/desktop/constants/connection.ts @@ -0,0 +1,5 @@ +/** + * Connection-related constants for desktop app + */ + +export const STIRLING_SAAS_URL = 'https://stirling.com/app'; diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index 1ef8ef694..68b5fc219 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -1,19 +1,15 @@ -import { useEffect } from 'react'; -import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; +import { useEffect, useState } from 'react'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; import { useFileManagement } from '@app/contexts/file/fileHooks'; /** * App initialization hook - * Desktop version: Handles Tauri-specific initialization - * - Starts the backend on app startup + * Desktop version: Handles Tauri-specific file initialization + * Requires FileContext - must be used inside FileContextProvider * - Handles files opened with the app (adds directly to FileContext) */ export function useAppInitialization(): void { - // Initialize backend on app startup - useBackendInitializer(); - // Get file management actions const { addFiles } = useFileManagement(); @@ -59,3 +55,11 @@ export function useAppInitialization(): void { loadOpenedFiles(); }, [openedFilePaths, openedFileLoading, addFiles]); } + +export function useSetupCompletion(): (completed: boolean) => void { + const [, setSetupComplete] = useState(false); + + return (completed: boolean) => { + setSetupComplete(completed); + }; +} diff --git a/frontend/src/desktop/hooks/useBackendInitializer.ts b/frontend/src/desktop/hooks/useBackendInitializer.ts index 259f4d875..e14c7ea53 100644 --- a/frontend/src/desktop/hooks/useBackendInitializer.ts +++ b/frontend/src/desktop/hooks/useBackendInitializer.ts @@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; /** * 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 { backendUrl } = useEndpointConfig(); useEffect(() => { + // Skip if disabled + if (!enabled) { + return; + } + // Skip if backend already running if (tauriBackendService.isBackendRunning()) { void checkHealth(); @@ -36,5 +42,5 @@ export function useBackendInitializer() { if (status !== 'healthy' && status !== 'starting') { void initializeBackend(); } - }, [status, backendUrl, checkHealth]); + }, [enabled, status, backendUrl, checkHealth]); } diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 2a8629ffd..8ccdc66e2 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -1,9 +1,10 @@ -import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { isAxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; import apiClient from '@app/services/apiClient'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { isBackendNotReadyError } from '@app/constants/backendErrors'; +import { connectionModeService } from '@desktop/services/connectionModeService'; interface EndpointConfig { 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 { - const backendUrl = useMemo(() => { - const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined; + const [backendUrl, setBackendUrl] = useState(DEFAULT_BACKEND_URL); - return runtimeEnv?.STIRLING_BACKEND_URL - || import.meta.env.VITE_DESKTOP_BACKEND_URL - || import.meta.env.VITE_API_BASE_URL - || 'http://localhost:8080'; + useEffect(() => { + connectionModeService.getCurrentConfig() + .then((config) => { + 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 }; diff --git a/frontend/src/desktop/hooks/useFirstLaunchCheck.ts b/frontend/src/desktop/hooks/useFirstLaunchCheck.ts new file mode 100644 index 000000000..641fe5bfc --- /dev/null +++ b/frontend/src/desktop/hooks/useFirstLaunchCheck.ts @@ -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 }; +} diff --git a/frontend/src/desktop/services/apiClient.ts b/frontend/src/desktop/services/apiClient.ts new file mode 100644 index 000000000..8773afc5e --- /dev/null +++ b/frontend/src/desktop/services/apiClient.ts @@ -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; diff --git a/frontend/src/desktop/services/apiClientConfig.ts b/frontend/src/desktop/services/apiClientConfig.ts index 3dfe2a4a7..c823f24e2 100644 --- a/frontend/src/desktop/services/apiClientConfig.ts +++ b/frontend/src/desktop/services/apiClientConfig.ts @@ -2,16 +2,19 @@ import { isTauri } from '@tauri-apps/api/core'; /** * 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 { if (!isTauri()) { return import.meta.env.VITE_API_BASE_URL || '/'; } - if (import.meta.env.DEV) { - // During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight - return '/'; - } - - return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080'; + // In Tauri mode, return empty string as placeholder + // The actual URL will be set dynamically by operationRouter based on: + // - Offline mode: dynamic port from tauriBackendService + // - Server mode: configured server URL from connectionModeService + return ''; } diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index eea7521a6..2ba6c5497 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -1,44 +1,134 @@ -import { AxiosInstance } from 'axios'; +import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; import { alert } from '@app/components/toast'; import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup'; import { tauriBackendService } from '@app/services/tauriBackendService'; 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'; const BACKEND_TOAST_COOLDOWN_MS = 4000; let lastBackendToast = 0; +// Extended config for custom properties +interface ExtendedRequestConfig extends InternalAxiosRequestConfig { + operationName?: string; + skipBackendReadyCheck?: boolean; + _retry?: boolean; +} + /** * Desktop-specific API interceptors * - Reuses the core interceptors - * - Blocks API calls while the bundled backend is still starting and shows - * a friendly toast for user-initiated requests (non-GET) + * - Dynamically sets base URL based on connection mode + * - 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 { coreSetup(client); + // Request interceptor: Set base URL and auth headers dynamically client.interceptors.request.use( - (config) => { - const skipCheck = config?.skipBackendReadyCheck === true; - if (skipCheck || tauriBackendService.isBackendHealthy()) { - return config; + async (config: InternalAxiosRequestConfig) => { + const extendedConfig = config as ExtendedRequestConfig; + + // 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(); - 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, - }); + // Debug logging + console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`); + + // Add auth token for remote requests + const isRemote = await operationRouter.isRemoteMode(); + if (isRemote) { + const token = await authService.getAuthToken(); + if (token) { + extendedConfig.headers.Authorization = `Bearer ${token}`; } } - 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); } ); } diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts new file mode 100644 index 000000000..65d0c4cce --- /dev/null +++ b/frontend/src/desktop/services/authService.ts @@ -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 { + try { + console.log('Logging in to:', serverUrl); + + // Call Rust login command (bypasses CORS) + const response = await invoke('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 { + 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 { + try { + const token = await invoke('get_auth_token'); + return token || null; + } catch (error) { + console.error('Failed to get auth token:', error); + return null; + } + } + + async isAuthenticated(): Promise { + const token = await this.getAuthToken(); + return token !== null; + } + + async getUserInfo(): Promise { + if (this.userInfo) { + return this.userInfo; + } + + try { + const userInfo = await invoke('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 { + 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 { + 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(); diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts new file mode 100644 index 000000000..f1a96c9c5 --- /dev/null +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -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 { + if (!this.configLoadedOnce) { + await this.loadConfig(); + } + return this.currentConfig || { mode: 'offline', server_config: null }; + } + + async getCurrentMode(): Promise { + const config = await this.getCurrentConfig(); + return config.mode; + } + + async getServerConfig(): Promise { + 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 { + try { + const config = await invoke('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 { + 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 { + 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 { + 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 { + try { + const result = await invoke('is_first_launch'); + return result; + } catch (error) { + console.error('Failed to check first launch:', error); + return false; + } + } +} + +export const connectionModeService = ConnectionModeService.getInstance(); diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts new file mode 100644 index 000000000..723878b97 --- /dev/null +++ b/frontend/src/desktop/services/operationRouter.ts @@ -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 { + 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 { + 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 { + const mode = await connectionModeService.getCurrentMode(); + return mode === 'server'; + } + + /** + * Checks if we're currently in offline mode + */ + async isOfflineMode(): Promise { + 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(); diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index 2a2ee6cef..ce8bd9336 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -1,4 +1,6 @@ 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'; @@ -6,6 +8,7 @@ export class TauriBackendService { private static instance: TauriBackendService; private backendStarted = false; private backendStatus: BackendStatus = 'stopped'; + private backendPort: number | null = null; private healthMonitor: Promise | null = null; private startPromise: Promise | null = null; private statusListeners = new Set<(status: BackendStatus) => void>(); @@ -29,6 +32,14 @@ export class TauriBackendService { 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 { this.statusListeners.add(listener); return () => { @@ -44,6 +55,21 @@ export class TauriBackendService { 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 { + 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 { if (this.backendStarted) { return; @@ -56,10 +82,14 @@ export class TauriBackendService { this.setStatus('starting'); this.startPromise = invoke('start_backend', { backendUrl }) - .then((result) => { + .then(async (result) => { console.log('Backend started:', result); this.backendStarted = true; this.setStatus('starting'); + + // Poll for the dynamically assigned port + await this.waitForPort(); + this.beginHealthMonitoring(); }) .catch((error) => { @@ -74,6 +104,24 @@ export class TauriBackendService { return this.startPromise; } + private async waitForPort(maxAttempts = 30): Promise { + console.log('[TauriBackendService] Waiting for backend port assignment...'); + for (let i = 0; i < maxAttempts; i++) { + try { + const port = await invoke('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() { if (this.healthMonitor) { return; @@ -88,16 +136,58 @@ export class TauriBackendService { } async checkBackendHealth(): Promise { + 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) { this.setStatus('stopped'); return false; } + + if (!this.backendPort) { + console.debug('[TauriBackendService] Backend port not available yet'); + return false; + } + try { - const isHealthy = await invoke('check_backend_health'); + const isHealthy = await invoke('check_backend_health', { port: this.backendPort }); this.setStatus(isHealthy ? 'healthy' : 'unhealthy'); return isHealthy; } 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'); return false; } @@ -115,6 +205,18 @@ export class TauriBackendService { this.setStatus('unhealthy'); 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(); diff --git a/frontend/src/desktop/services/tauriHttpClient.ts b/frontend/src/desktop/services/tauriHttpClient.ts new file mode 100644 index 000000000..89c87bfbb --- /dev/null +++ b/frontend/src/desktop/services/tauriHttpClient.ts @@ -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 { + data: T; + status: number; + statusText: string; + headers: Record; + config: TauriHttpRequestConfig; +} + +export interface TauriHttpRequestConfig { + url?: string; + method?: string; + baseURL?: string; + headers?: Record; + params?: Record | 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; +type ResponseInterceptor = (response: TauriHttpResponse) => Promise> | TauriHttpResponse; +type ErrorInterceptor = (error: any) => Promise; + +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).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(config: TauriHttpRequestConfig): Promise> { + // 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 = { ...(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 = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const httpResponse: TauriHttpResponse = { + 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; + } + + 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(config: TauriHttpRequestConfig): Promise> { + return this.executeRequest(config); + } + + async get(url: string, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'GET', url }); + } + + async delete(url: string, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'DELETE', url }); + } + + async head(url: string, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'HEAD', url }); + } + + async options(url: string, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'OPTIONS', url }); + } + + async post(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'POST', url, data }); + } + + async put(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...config, method: 'PUT', url, data }); + } + + async patch(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + return this.executeRequest({ ...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(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + 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(url, formData, config); + } + + async putForm(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + 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(url, formData, config); + } + + async patchForm(url: string, data?: any, config?: TauriHttpRequestConfig): Promise> { + 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(url, formData, config); + } +} + +// Factory function matching axios.create() +export function create(config?: TauriHttpRequestConfig): TauriHttpClient { + return new TauriHttpClient(config); +} + +// Default instance +export default new TauriHttpClient(); diff --git a/frontend/tsconfig.desktop.json b/frontend/tsconfig.desktop.json index fe0419a78..cb842c399 100644 --- a/frontend/tsconfig.desktop.json +++ b/frontend/tsconfig.desktop.json @@ -13,6 +13,10 @@ } }, "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", + "src/proprietary/**/*.test.ts*", + "src/proprietary/**/*.spec.ts*", "node_modules" ] } diff --git a/frontend/tsconfig.proprietary.json b/frontend/tsconfig.proprietary.json index 29f1b62df..4c726f09a 100644 --- a/frontend/tsconfig.proprietary.json +++ b/frontend/tsconfig.proprietary.json @@ -10,6 +10,8 @@ } }, "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", "src/desktop", "node_modules" ] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f7af0fdac..49aacd3ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -31,24 +31,25 @@ export default defineConfig(({ mode }) => { // tell vite to ignore watching `src-tauri` ignored: ['**/src-tauri/**'], }, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, - }, - '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, - }, - '/login/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, + // Only use proxy in web mode - Tauri handles backend connections directly + proxy: isDesktopMode ? undefined : { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, }, }, }, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index bf9880848..111099896 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,52 +2,87 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; -export default defineConfig((configEnv) => { - const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true'; - const isDesktopMode = - configEnv.mode === 'desktop' || - process.env.STIRLING_DESKTOP === 'true' || - process.env.VITE_DESKTOP === 'true'; - - const baseProject = isProprietary ? './tsconfig.proprietary.json' : './tsconfig.core.json'; - const desktopProject = isProprietary ? './tsconfig.desktop.json' : baseProject; - const tsconfigProject = isDesktopMode ? desktopProject : baseProject; - - return { - plugins: [ - react(), - tsconfigPaths({ - projects: [tsconfigProject], - }), +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/core/setupTests.ts'], + css: false, + exclude: [ + 'node_modules/', + 'src/**/*.spec.ts', // Exclude Playwright E2E tests + 'src/tests/test-fixtures/**' ], - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./src/core/setupTests.ts'], - css: false, // Disable CSS processing to speed up tests - include: [ - 'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' - ], + testTimeout: 10000, + hookTimeout: 10000, + coverage: { + reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', - 'src/**/*.spec.ts', // Exclude Playwright E2E tests - 'src/tests/test-fixtures/**' - ], - testTimeout: 10000, // 10 second timeout - 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 - ] - } + 'src/core/setupTests.ts', + '**/*.d.ts', + 'src/tests/test-fixtures/**', + 'src/**/*.spec.ts' + ] }, - esbuild: { - target: 'es2020' // Use older target to avoid warnings - } - }; + projects: [ + { + 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' + } });