Compare commits

..
Author SHA1 Message Date
James Brunton 71b61b8ac6 bisect: testing commit e8e98128d 2025-12-01 15:29:57 +00:00
James BruntonandGitHub e8e98128d2 Allow login to SaaS for desktop instead of offline mode (#4941)
# Description of Changes
Makes the desktop options to sign in with your Stirling account, or sign
into self-hosted:

<img width="608" height="456" alt="image"
src="https://github.com/user-attachments/assets/a49988ab-db3f-4333-b242-790aee5c07c6"
/>

The first option still runs everything locally, just enforces that
you've signed in for now. Future work will enable sending operations
that can't be run locally to the server.
2025-11-22 00:38:59 +00:00
Anthony StirlingandGitHub e1a879a5f6 Differentiate unavailable tools by reason (#4916)
## Summary
- track endpoint disable reasons server-side and expose them through a
new `/api/v1/config/endpoints-availability` API that the frontend can
consume
- refresh the web UI tool management logic to cache endpoint details,
compute per-tool availability metadata, and show reason-specific
messaging (admin disabled vs missing dependency) when a tool cannot be
launched
- add the missing en-GB translations for the new unavailability labels
so the UI copy reflects the new distinction
<img width="1156" height="152" alt="image"
src="https://github.com/user-attachments/assets/b54eda37-fe5c-42f9-bd5f-9ee00398d1ae"
/>


<img width="930" height="168" alt="image"
src="https://github.com/user-attachments/assets/47c07ffa-adb7-4ce3-910c-b6ff73f6f993"
/>


## Testing
- `npm run typecheck:core` *(fails:
frontend/src/core/components/shared/LocalIcon.tsx expects
../../../assets/material-symbols-icons.json, which is not present in
this environment)*

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6919af7a493c8328bb5ac3d07e65452b)
2025-11-21 13:19:53 +00:00
Reece BrowneandGitHub 4fd336c26c Feature/v2/bookmarks (#4949)
bookmark panel on viewer
2025-11-21 11:33:49 +00:00
33 changed files with 1374 additions and 401 deletions
+3 -1
View File
@@ -21,7 +21,7 @@ on:
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
branches: [main, V2, V2-what-broke-desktop]
permissions:
contents: read
@@ -312,6 +312,8 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
+17
View File
@@ -14,6 +14,7 @@
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
@@ -633,6 +634,22 @@
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
+1
View File
@@ -10,6 +10,7 @@
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
+18 -13
View File
@@ -3827,6 +3827,7 @@
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All",
"toggleTheme": "Toggle Theme",
"toggleBookmarks": "Toggle Bookmarks",
"language": "Language",
"search": "Search PDF",
"panMode": "Pan Mode",
@@ -5622,15 +5623,23 @@
"description": "Enter credentials"
},
"mode": {
"offline": {
"title": "Use Offline",
"description": "Run locally without an internet connection"
"saas": {
"title": "Stirling Cloud",
"description": "Sign in with your Stirling account"
},
"server": {
"title": "Connect to Server",
"description": "Connect to a remote Stirling PDF server"
"selfhosted": {
"title": "Self-Hosted Server",
"description": "Connect to your own Stirling PDF server"
}
},
"saas": {
"title": "Sign in to Stirling",
"subtitle": "Sign in with your Stirling account"
},
"selfhosted": {
"title": "Sign in to Server",
"subtitle": "Enter your server credentials"
},
"server": {
"title": "Connect to Server",
"subtitle": "Enter your self-hosted server URL",
@@ -5672,16 +5681,12 @@
"connection": {
"title": "Connection Mode",
"mode": {
"offline": "Offline",
"server": "Server"
"saas": "Stirling Cloud",
"selfhosted": "Self-Hosted"
},
"server": "Server",
"user": "Logged in as",
"switchToServer": "Connect to Server",
"switchToOffline": "Switch to Offline",
"logout": "Logout",
"selectServer": "Select Server",
"login": "Login"
"logout": "Log Out"
},
"general": {
"title": "General",
+1
View File
@@ -0,0 +1 @@
bisect test
+133 -45
View File
@@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
// Response types for Spring Boot login
// Response types for Spring Boot login (self-hosted)
#[derive(Debug, Deserialize)]
struct SpringBootSession {
access_token: String,
@@ -145,6 +145,24 @@ struct SpringBootLoginResponse {
user: SpringBootUser,
}
// Response types for Supabase login (SaaS)
#[derive(Debug, Deserialize)]
struct SupabaseUserMetadata {
full_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SupabaseUser {
email: Option<String>,
user_metadata: Option<SupabaseUserMetadata>,
}
#[derive(Debug, Deserialize)]
struct SupabaseLoginResponse {
access_token: String,
user: SupabaseUser,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
@@ -153,7 +171,7 @@ pub struct LoginResponse {
}
/// Login command - makes HTTP request from Rust to bypass CORS
/// Supports Spring Boot authentication (self-hosted)
/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
#[tauri::command]
pub async fn login(
server_url: String,
@@ -162,54 +180,124 @@ pub async fn login(
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Build login URL
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Login URL: {}", login_url);
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
// Compare against the configured SaaS server URL from environment
let saas_server_url = env!("VITE_SAAS_SERVER_URL");
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
// Create HTTP client
let client = reqwest::Client::new();
// Make login request
let response = client
.post(&login_url)
.json(&serde_json::json!({
"username": username,
if is_supabase {
// Supabase authentication flow
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
// Supabase public API key from environment variable (required at compile time)
// Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building
let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY");
let request_body = serde_json::json!({
"email": 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)
});
let response = client
.post(&login_url)
.header("Content-Type", "application/json;charset=UTF-8")
.header("apikey", supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.header("X-Client-Info", "supabase-js-web/2.58.0")
.header("X-Supabase-Api-Version", "2024-01-01")
.json(&request_body)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Supabase login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Supabase response format
let login_response: SupabaseLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
let email = login_response.user.email.clone();
let username = login_response.user.user_metadata
.as_ref()
.and_then(|m| m.full_name.clone())
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,
email,
})
} else {
// Spring Boot authentication flow
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url);
let 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!("Spring Boot login response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Spring Boot response format
let login_response: SpringBootLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
Ok(LoginResponse {
token: login_response.session.access_token,
username: login_response.user.username,
email: login_response.user.email,
})
}
// 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,
})
}
+4 -4
View File
@@ -349,11 +349,11 @@ pub async fn start_backend(
};
match mode {
ConnectionMode::Offline => {
add_log("🔌 Running in Offline mode - starting local backend".to_string());
ConnectionMode::SaaS => {
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
}
ConnectionMode::Server => {
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
ConnectionMode::SelfHosted => {
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
}
}
+20 -1
View File
@@ -31,7 +31,7 @@ pub async fn get_connection_config(
let mode = store
.get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::Offline);
.unwrap_or(ConnectionMode::SaaS);
let server_config: Option<ServerConfig> = store
.get(SERVER_CONFIG_KEY)
@@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
Ok(!setup_completed)
}
#[tauri::command]
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
log::info!("Resetting setup completion flag");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
// Reset setup completion flag to force SetupWizard on next launch
store.set(FIRST_LAUNCH_KEY, serde_json::json!(false));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Setup completion flag reset successfully");
Ok(())
}
+1
View File
@@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
reset_setup_completion,
set_connection_mode,
};
pub use auth::{
+2
View File
@@ -19,6 +19,7 @@ use commands::{
get_user_info,
is_first_launch,
login,
reset_setup_completion,
save_auth_token,
save_user_info,
set_connection_mode,
@@ -85,6 +86,7 @@ pub fn run() {
is_default_pdf_handler,
set_as_default_pdf_handler,
is_first_launch,
reset_setup_completion,
check_backend_health,
login,
save_auth_token,
@@ -4,13 +4,6 @@ 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,
}
@@ -18,7 +11,6 @@ pub enum ServerType {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub url: String,
pub server_type: ServerType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -30,7 +22,7 @@ pub struct ConnectionState {
impl Default for ConnectionState {
fn default() -> Self {
Self {
mode: ConnectionMode::Offline,
mode: ConnectionMode::SaaS,
server_config: None,
}
}
@@ -0,0 +1,73 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react';
import { BookmarkCapability } from '@embedpdf/plugin-bookmark';
import { useViewer } from '@app/contexts/ViewerContext';
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
export function BookmarkAPIBridge() {
const { provides: bookmarkCapability } = useBookmarkCapability();
const { registerBridge } = useViewer();
const [state, setState] = useState<BookmarkState>({
bookmarks: null,
isLoading: false,
error: null,
});
const fetchBookmarks = useCallback(
async (capability: BookmarkCapability) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const task = capability.getBookmarks();
const result = await task.toPromise();
setState({
bookmarks: result.bookmarks ?? [],
isLoading: false,
error: null,
});
return result.bookmarks ?? [];
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
setState({
bookmarks: null,
isLoading: false,
error: message,
});
throw error;
}
},
[]
);
const api = useMemo<BookmarkAPIWrapper | null>(() => {
if (!bookmarkCapability) return null;
return {
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
clearBookmarks: () => {
setState({
bookmarks: null,
isLoading: false,
error: null,
});
},
setLocalBookmarks: (bookmarks, error = null) => {
setState({
bookmarks,
isLoading: false,
error,
});
},
};
}, [bookmarkCapability, fetchBookmarks]);
useEffect(() => {
if (!api) return;
registerBridge('bookmark', {
state,
api,
});
}, [api, state, registerBridge]);
return null;
}
@@ -0,0 +1,196 @@
/* Bookmark Sidebar - Modern styling to match AllTools section */
.bookmark-sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--bg-toolbar) 96%, transparent),
color-mix(in srgb, var(--bg-background) 90%, transparent)
);
border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent);
box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent);
backdrop-filter: blur(12px);
}
/* Header Section */
.bookmark-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.875rem;
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-subtle);
}
.bookmark-sidebar__header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bookmark-sidebar__header-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-blue-6);
font-size: 1.1rem;
}
/* Search Section */
.bookmark-sidebar__search {
background: var(--tool-panel-search-bg, var(--bg-toolbar));
border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle));
padding-top: 0.75rem !important;
}
/* Content Section */
.bookmark-sidebar__content {
padding-top: 0.5rem !important;
}
.bookmark-sidebar__empty-state,
.bookmark-sidebar__error {
padding: 2rem 1rem;
text-align: center;
}
.bookmark-sidebar__loading {
padding: 2rem 1rem !important;
}
/* Bookmark List */
.bookmark-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Bookmark Items */
.bookmark-item-wrapper {
display: flex;
flex-direction: column;
}
.bookmark-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-radius: 0.65rem;
cursor: default;
transition: all 0.2s ease;
background: transparent;
border: 1px solid transparent;
width: 100%;
box-sizing: border-box;
position: relative;
text-align: left;
outline: none;
}
.bookmark-item--clickable {
cursor: pointer;
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
}
.bookmark-item--clickable:hover {
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
transform: translateX(2px);
}
.bookmark-item--clickable:active {
transform: translateX(3px);
}
.bookmark-item--clickable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
outline-offset: 2px;
}
/* Expand Icon */
.bookmark-item__expand-icon {
flex-shrink: 0;
transition: transform 0.2s ease;
width: 2rem;
height: 2rem;
}
.bookmark-item:hover .bookmark-item__expand-icon {
transform: scale(1.05);
}
/* Dash for items without children */
.bookmark-item__dash {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
color: var(--text-muted);
font-size: 1.25rem;
opacity: 0.5;
font-weight: 300;
}
/* Content */
.bookmark-item__content {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.bookmark-item__title {
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--text-primary);
}
.bookmark-item--clickable:hover .bookmark-item__title {
color: var(--text-primary);
}
.bookmark-item__page {
line-height: 1.3;
font-size: 0.75rem;
}
/* Children Container */
.bookmark-item__children {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.25rem;
}
/* Smooth expand/collapse animation */
@keyframes bookmark-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bookmark-item__children {
animation: bookmark-fade-in 0.2s ease;
}
/* Accessibility & Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.bookmark-item,
.bookmark-item__expand-icon,
.bookmark-item__children {
transition: none;
animation: none;
}
}
@@ -0,0 +1,551 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useViewer } from '@app/contexts/ViewerContext';
import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models';
import BookmarksIcon from '@mui/icons-material/BookmarksRounded';
import '@app/components/viewer/BookmarkSidebar.css';
interface BookmarkSidebarProps {
visible: boolean;
thumbnailVisible: boolean;
documentCacheKey?: string;
preloadCacheKeys?: string[];
}
const SIDEBAR_WIDTH = '15rem';
type BookmarkNode = PdfBookmarkObject & { id: string };
type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error';
interface BookmarkCacheEntry {
status: BookmarkCacheStatus;
bookmarks: PdfBookmarkObject[] | null;
error: string | null;
lastFetched: number | null;
}
const createEntry = (overrides: Partial<BookmarkCacheEntry> = {}): BookmarkCacheEntry => ({
status: 'idle',
bookmarks: null,
error: null,
lastFetched: null,
...overrides,
});
const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => {
const target = bookmark.target;
if (!target) return null;
if (target.type === 'destination') {
return target.destination.pageIndex + 1;
}
if (target.type === 'action') {
const action = target.action;
if (
action.type === PdfActionType.Goto ||
action.type === PdfActionType.RemoteGoto
) {
return action.destination?.pageIndex !== undefined
? action.destination.pageIndex + 1
: null;
}
}
return null;
};
export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => {
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState('');
const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport());
const [activeEntry, setActiveEntry] = useState<BookmarkCacheEntry>(() => createEntry());
const cacheRef = useRef<Map<string, BookmarkCacheEntry>>(new Map());
const [fetchNonce, setFetchNonce] = useState(0);
const currentKeyRef = useRef<string | null>(documentCacheKey ?? null);
useEffect(() => {
currentKeyRef.current = documentCacheKey ?? null;
}, [documentCacheKey]);
// Poll once until the bookmark bridge registers
useEffect(() => {
if (bookmarkSupport) return;
let cancelled = false;
const id = setInterval(() => {
if (!cancelled && hasBookmarkSupport()) {
setBookmarkSupport(true);
clearInterval(id);
}
}, 250);
return () => {
cancelled = true;
clearInterval(id);
};
}, [bookmarkSupport, hasBookmarkSupport]);
// Reset UI and load cached entry (if any) when switching documents
useEffect(() => {
setExpanded({});
setSearchTerm('');
if (!documentCacheKey) {
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
return;
}
const cached = cacheRef.current.get(documentCacheKey);
if (cached) {
setActiveEntry(cached);
if (cached.status === 'success') {
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null);
} else if (cached.status === 'error') {
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error);
} else {
bookmarkActions.clearBookmarks();
}
} else {
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
}
}, [documentCacheKey, bookmarkActions]);
// Keep cache bounded to the currently relevant keys
useEffect(() => {
const allowed = new Set<string>();
if (documentCacheKey) {
allowed.add(documentCacheKey);
}
preloadCacheKeys.forEach(key => {
if (key) {
allowed.add(key);
}
});
cacheRef.current.forEach((_entry, key) => {
if (!allowed.has(key)) {
cacheRef.current.delete(key);
}
});
}, [documentCacheKey, preloadCacheKeys]);
// Fetch bookmarks for the active document when needed
useEffect(() => {
if (!bookmarkSupport || !documentCacheKey) return;
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (cached && (cached.status === 'loading' || cached.status === 'success')) {
return;
}
let cancelled = false;
const updateEntry = (entry: BookmarkCacheEntry) => {
cacheRef.current.set(key, entry);
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
};
updateEntry(createEntry({
status: 'loading',
bookmarks: cached?.bookmarks ?? null,
lastFetched: cached?.lastFetched ?? null,
}));
const fetchWithRetry = async () => {
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await bookmarkActions.fetchBookmarks();
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
const notReady =
message.includes('document') &&
message.includes('not') &&
message.includes('open');
if (!notReady || attempt === maxAttempts - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
}
return [];
};
fetchWithRetry()
.then(bookmarks => {
if (cancelled) return;
const entry = createEntry({
status: 'success',
bookmarks,
lastFetched: Date.now(),
});
updateEntry(entry);
if (currentKeyRef.current === key) {
bookmarkActions.setLocalBookmarks(bookmarks, null);
}
})
.catch(error => {
if (cancelled) return;
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
const fallback = cacheRef.current.get(key);
const entry = createEntry({
status: 'error',
bookmarks: fallback?.bookmarks ?? null,
error: message,
lastFetched: fallback?.lastFetched ?? null,
});
updateEntry(entry);
if (currentKeyRef.current === key) {
bookmarkActions.setLocalBookmarks(null, message);
}
});
return () => {
cancelled = true;
};
}, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]);
const requestReload = useCallback(() => {
if (!documentCacheKey) return;
cacheRef.current.delete(documentCacheKey);
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
setFetchNonce(value => value + 1);
}, [documentCacheKey, bookmarkActions]);
const bookmarksWithIds = useMemo(() => {
const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => {
if (!Array.isArray(nodes)) {
return [];
}
return nodes.map((node, index) => {
const id = `${prefix}-${index}`;
return {
...node,
id,
children: node.children ? assignIds(node.children, id) : undefined,
};
});
};
const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : [];
return assignIds(bookmarks);
}, [activeEntry.bookmarks]);
const currentStatus = activeEntry.status;
const isLocalLoading = bookmarkSupport && currentStatus === 'loading';
const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null;
const toggleNode = (nodeId: string) => {
setExpanded(prev => ({
...prev,
[nodeId]: !(prev[nodeId] ?? true),
}));
};
const expandAll = useCallback(() => {
const allExpanded: Record<string, boolean> = {};
const expandRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
allExpanded[node.id] = true;
expandRecursive(node.children as BookmarkNode[]);
}
});
};
expandRecursive(bookmarksWithIds);
setExpanded(allExpanded);
}, [bookmarksWithIds]);
const collapseAll = useCallback(() => {
const allCollapsed: Record<string, boolean> = {};
const collapseRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
allCollapsed[node.id] = false;
collapseRecursive(node.children as BookmarkNode[]);
}
});
};
collapseRecursive(bookmarksWithIds);
setExpanded(allCollapsed);
}, [bookmarksWithIds]);
const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => {
const target = bookmark.target;
if (target?.type === 'action') {
const action = target.action;
if (action.type === PdfActionType.URI && action.uri) {
event.preventDefault();
window.open(action.uri, '_blank', 'noopener');
return;
}
if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) {
event.preventDefault();
window.open(action.path, '_blank', 'noopener');
return;
}
}
const pageNumber = resolvePageNumber(bookmark);
if (pageNumber) {
scrollActions.scrollToPage(pageNumber);
}
};
const filteredBookmarks = useMemo(() => {
if (!searchTerm.trim()) return bookmarksWithIds;
const term = searchTerm.trim().toLowerCase();
const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => {
const results: BookmarkNode[] = [];
for (const node of nodeList) {
const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : [];
const matchesSelf = node.title?.toLowerCase().includes(term) ?? false;
if (matchesSelf || childMatches.length > 0) {
results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
}
}
return results;
};
return applyFilter(bookmarksWithIds);
}, [bookmarksWithIds, searchTerm]);
const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => {
if (!nodes || !Array.isArray(nodes)) {
return null;
}
return nodes.map((node, _index) => {
if (!node || !node.id) {
return null;
}
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isNodeExpanded = expanded[node.id] ?? true;
const pageNumber = resolvePageNumber(node);
return (
<div
key={node.id}
className="bookmark-item-wrapper"
style={{
marginLeft: depth > 0 ? `${depth * 0.75}rem` : '0',
}}
>
<div
className={`bookmark-item ${pageNumber ? 'bookmark-item--clickable' : ''}`}
onClick={(event) => handleBookmarkClick(node, event)}
role={pageNumber ? "button" : undefined}
tabIndex={pageNumber ? 0 : undefined}
onKeyDown={pageNumber ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleBookmarkClick(node, event as any);
}
} : undefined}
>
{hasChildren ? (
<ActionIcon
variant="subtle"
size="sm"
className="bookmark-item__expand-icon"
onClick={(event) => {
event.stopPropagation();
toggleNode(node.id);
}}
>
<LocalIcon
icon={isNodeExpanded ? 'keyboard-arrow-up' : 'keyboard-arrow-down'}
width="1rem"
height="1rem"
/>
</ActionIcon>
) : (
<span className="bookmark-item__dash">-</span>
)}
<div className="bookmark-item__content">
<Text
size="sm"
fw={500}
className="bookmark-item__title"
>
{node.title || 'Untitled'}
</Text>
{pageNumber && (
<Text size="xs" c="dimmed" className="bookmark-item__page">
Page {pageNumber}
</Text>
)}
</div>
</div>
{hasChildren && isNodeExpanded && (
<div className="bookmark-item__children">
{renderBookmarks(node.children as BookmarkNode[], depth + 1)}
</div>
)}
</div>
);
});
};
const isSearchActive = searchTerm.trim().length > 0;
const hasBookmarks = bookmarksWithIds.length > 0;
const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0;
const showEmptyState =
bookmarkSupport &&
documentCacheKey &&
!isLocalLoading &&
!currentError &&
currentStatus === 'success' &&
!hasBookmarks;
const showSearchEmpty =
bookmarkSupport &&
documentCacheKey &&
isSearchActive &&
hasBookmarks &&
filteredBookmarks.length === 0;
const showNoDocument = bookmarkSupport && !documentCacheKey;
if (!visible) {
return null;
}
return (
<Box
className="bookmark-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
zIndex: 998,
}}
>
<div className="bookmark-sidebar__header">
<div className="bookmark-sidebar__header-title">
<span className="bookmark-sidebar__header-icon">
<BookmarksIcon />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
Bookmarks
</Text>
</div>
{bookmarkSupport && bookmarksWithIds.length > 0 && (
<>
{Object.values(expanded).some(val => val === false) ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={expandAll}
aria-label="Expand all bookmarks"
title="Expand all"
>
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
</ActionIcon>
) : (
<ActionIcon
variant="subtle"
size="sm"
onClick={collapseAll}
aria-label="Collapse all bookmarks"
title="Collapse all"
>
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</>
)}
</div>
<Box px="sm" pb="sm" className="bookmark-sidebar__search">
<TextInput
value={searchTerm}
placeholder="Search bookmarks"
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={<LocalIcon icon="search" width="1.1rem" height="1.1rem" />}
size="xs"
/>
</Box>
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="bookmark-sidebar__content">
{!bookmarkSupport && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Bookmark support is unavailable for this viewer.
</Text>
</div>
)}
{bookmarkSupport && showNoDocument && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Open a PDF to view its bookmarks.
</Text>
</div>
)}
{bookmarkSupport && documentCacheKey && currentError && (
<Stack gap="xs" align="center" className="bookmark-sidebar__error">
<Text size="sm" c="red" ta="center">
{currentError}
</Text>
<Button size="xs" variant="light" onClick={requestReload}>
Retry
</Button>
</Stack>
)}
{bookmarkSupport && documentCacheKey && isLocalLoading && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="bookmark-sidebar__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
Loading bookmarks...
</Text>
</Stack>
)}
{showEmptyState && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks in this document
</Text>
</div>
)}
{showBookmarkList && (
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
)}
{showSearchEmpty && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks match your search
</Text>
</div>
)}
</Box>
</ScrollArea>
</Box>
);
};
@@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
@@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
const {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
zoomActions,
panActions: _panActions,
rotationActions: _rotationActions,
getScrollState,
getRotationState,
isAnnotationMode,
isAnnotationsVisible,
exportActions,
} = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
@@ -128,6 +141,42 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
const bookmarkCacheKey = React.useMemo(() => {
if (currentFile && isStirlingFile(currentFile)) {
return currentFile.fileId;
}
if (previewFile) {
const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`;
return `preview-${uniquePreviewId}`;
}
if (effectiveFile?.url) {
return effectiveFile.url;
}
if (effectiveFile?.file instanceof File) {
const fileObj = effectiveFile.file;
return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`;
}
return undefined;
}, [currentFile, effectiveFile, previewFile]);
// Generate cache keys for all active files to enable preloading
const allBookmarkCacheKeys = React.useMemo(() => {
if (previewFile) {
return [bookmarkCacheKey].filter(Boolean) as string[];
}
return activeFiles.map(file => {
if (isStirlingFile(file)) {
return file.fileId;
}
return undefined;
}).filter(Boolean) as string[];
}, [activeFiles, previewFile, bookmarkCacheKey]);
useWheelZoom({
ref: viewerRef,
onZoomIn: zoomActions.zoomIn,
@@ -219,6 +268,10 @@ const EmbedPdfViewerContent = ({
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
const sidebarWidthRem = 15;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
return (
<Box
ref={viewerRef}
@@ -260,7 +313,7 @@ const EmbedPdfViewerContent = ({
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
marginRight: `${totalRightMargin}rem`,
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
@@ -315,6 +368,12 @@ const EmbedPdfViewerContent = ({
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
/>
<BookmarkSidebar
visible={isBookmarkSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
{/* Navigation Warning Modal */}
{!previewFile && (
@@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
// Register thumbnail plugin for page thumbnails
createPluginRegistration(ThumbnailPluginPackage),
// Register bookmark plugin for PDF outline support
createPluginRegistration(BookmarkPluginPackage),
// Register rotate plugin
createPluginRegistration(RotatePluginPackage),
@@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
@@ -19,6 +19,7 @@ export function useViewerRightRailButtons() {
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@@ -111,6 +112,17 @@ export function useViewerRightRailButtons() {
viewer.toggleThumbnailSidebar();
}
},
{
id: 'viewer-toggle-bookmarks',
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
tooltip: bookmarkLabel,
ariaLabel: bookmarkLabel,
section: 'top' as const,
order: 55,
onClick: () => {
viewer.toggleBookmarkSidebar();
}
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -120,7 +132,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]);
useRightRailButtons(viewerButtons);
}
@@ -17,6 +17,7 @@ import {
RotationActions,
SearchActions,
ExportActions,
BookmarkActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
@@ -35,6 +36,7 @@ import {
SearchState,
ExportState,
ThumbnailAPIWrapper,
BookmarkState,
} from '@app/contexts/viewer/viewerBridges';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
@@ -74,6 +76,8 @@ interface ViewerContextType {
// UI state managed by this context
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
// Annotation visibility toggle
isAnnotationsVisible: boolean;
@@ -98,6 +102,8 @@ interface ViewerContextType {
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
getBookmarkState: () => BookmarkState;
hasBookmarkSupport: () => boolean;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
@@ -118,6 +124,7 @@ interface ViewerContextType {
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
@@ -135,6 +142,7 @@ interface ViewerProviderProps {
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
@@ -193,6 +201,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleBookmarkSidebar = () => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@@ -242,6 +254,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
return bridgeRefs.current.export?.state || { canExport: false };
};
const getBookmarkState = (): BookmarkState => {
return (
bridgeRefs.current.bookmark?.state || {
bookmarks: null,
isLoading: false,
error: null,
}
);
};
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
// Action handlers - call APIs directly
const {
scrollActions,
@@ -252,6 +276,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
@@ -263,6 +288,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Annotation controls
isAnnotationsVisible,
@@ -285,6 +312,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getSearchState,
getThumbnailAPI,
getExportState,
getBookmarkState,
hasBookmarkSupport,
// Immediate updates
registerImmediateZoomUpdate,
@@ -303,6 +332,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
// Bridge registration
registerBridge,
@@ -5,6 +5,7 @@ import {
ScrollState,
ZoomState,
} from '@app/contexts/viewer/viewerBridges';
import { PdfBookmarkObject } from '@embedpdf/models';
export interface ScrollActions {
scrollToPage: (page: number) => void;
@@ -58,6 +59,12 @@ export interface ExportActions {
saveAsCopy: () => Promise<ArrayBuffer | null>;
}
export interface BookmarkActions {
fetchBookmarks: () => Promise<PdfBookmarkObject[] | null>;
clearBookmarks: () => void;
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
}
export interface ViewerActionsBundle {
scrollActions: ScrollActions;
zoomActions: ZoomActions;
@@ -67,6 +74,7 @@ export interface ViewerActionsBundle {
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
}
interface ViewerActionDependencies {
@@ -307,5 +315,22 @@ export function createViewerActions({
rotationActions,
searchActions,
exportActions,
bookmarkActions: {
fetchBookmarks: async () => {
const api = registry.current.bookmark?.api;
if (!api?.fetchBookmarks) {
return null;
}
return api.fetchBookmarks();
},
clearBookmarks: () => {
const api = registry.current.bookmark?.api;
api?.clearBookmarks?.();
},
setLocalBookmarks: (bookmarks, error = null) => {
const api = registry.current.bookmark?.api;
api?.setLocalBookmarks?.(bookmarks ?? null, error);
},
},
};
}
@@ -1,4 +1,5 @@
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import { PdfBookmarkObject } from '@embedpdf/models';
export interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
@@ -59,6 +60,12 @@ export interface ExportAPIWrapper {
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
export interface BookmarkAPIWrapper {
fetchBookmarks: () => Promise<PdfBookmarkObject[]>;
clearBookmarks: () => void;
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
}
export interface ScrollState {
currentPage: number;
totalPages: number;
@@ -103,6 +110,12 @@ export interface ExportState {
canExport: boolean;
}
export interface BookmarkState {
bookmarks: PdfBookmarkObject[] | null;
isLoading: boolean;
error: string | null;
}
export interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
@@ -118,6 +131,7 @@ export interface BridgeStateMap {
search: SearchState;
thumbnail: unknown;
export: ExportState;
bookmark: BookmarkState;
}
export interface BridgeApiMap {
@@ -130,6 +144,7 @@ export interface BridgeApiMap {
search: SearchAPIWrapper;
thumbnail: ThumbnailAPIWrapper;
export: ExportAPIWrapper;
bookmark: BookmarkAPIWrapper;
}
export type BridgeKey = keyof BridgeStateMap;
@@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
search: null,
thumbnail: null,
export: null,
bookmark: null,
});
export function registerBridge<K extends BridgeKey>(
@@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
// Load connection mode on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
// Initialize backend health monitoring for server mode
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
console.log('[AppProviders] Initializing external backend monitoring for server mode');
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
void tauriBackendService.initializeExternalBackend();
}
}, [setupComplete, isFirstLaunch, connectionMode]);
// Only start bundled backend if in offline mode and setup is complete
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
// Self-hosted mode connects to remote server so doesn't need local backend
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldStartBackend);
// Show setup wizard on first launch
@@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<SetupWizard
onComplete={() => {
// Reload the page to reinitialize with new connection config
onComplete={async () => {
// Wait for backend to become healthy before reloading
// This prevents reloading mid-startup which would interrupt the backend
const maxWaitTime = 60000; // 60 seconds max
const checkInterval = 1000; // Check every second
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
if (tauriBackendService.isBackendHealthy()) {
window.location.reload();
return;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
// If we timeout, reload anyway
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
window.location.reload();
}}
/>
@@ -1,24 +1,15 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import {
connectionModeService,
ConnectionConfig,
ServerConfig,
} from '@app/services/connectionModeService';
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [showServerModal, setShowServerModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
// Load current config on mount
useEffect(() => {
@@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (currentConfig.mode === 'server') {
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
const user = await authService.getUserInfo();
setUserInfo(user);
}
@@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
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();
// Switch to SaaS mode
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
// Reset setup completion to force login screen on reload
await connectionModeService.resetSetupCompletion();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Reload the page to clear all state and reconnect to local backend
// Clear URL to home page before reload so we don't return to settings after re-login
window.history.replaceState({}, '', '/');
// Reload the page to clear all state and show login screen
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
@@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
{config.mode === 'offline'
? t('settings.connection.mode.offline', 'Offline')
: t('settings.connection.mode.server', 'Server')}
<Badge color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
{config.mode === 'saas'
? t('settings.connection.mode.saas', 'Stirling Cloud')
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
</Badge>
</Group>
{config.mode === 'server' && config.server_config && (
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')}
</Text>
<Text size="sm" c="dimmed">
{config.server_config.url}
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
</Text>
</div>
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
)}
<Group mt="md">
{config.mode === 'offline' ? (
<Button onClick={handleSwitchToServer} disabled={loading}>
{t('settings.connection.switchToServer', 'Connect to Server')}
</Button>
) : (
<>
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
{t('settings.connection.switchToOffline', 'Switch to Offline')}
</Button>
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Logout')}
</Button>
</>
)}
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Log Out')}
</Button>
</Group>
</Stack>
</Card>
{/* Server selection modal */}
<Modal
opened={showServerModal}
onClose={() => setShowServerModal(false)}
title={t('settings.connection.selectServer', 'Select Server')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
</Modal>
{/* Login modal */}
<Modal
opened={showLoginModal}
onClose={() => {
setShowLoginModal(false);
setNewServerConfig(null);
}}
title={t('settings.connection.login', 'Login')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{newServerConfig && (
<LoginForm
serverUrl={newServerConfig.url}
onLogin={handleLogin}
loading={loading}
/>
)}
</Modal>
</>
);
};
// Mini server selection component for settings
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
onSubmit,
}) => {
const { t } = useTranslation();
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
if (!url) {
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
setTesting(true);
setError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
onSubmit({
url,
server_type: serverType,
});
} catch (err) {
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
setTesting(false);
}
};
return (
<Stack gap="md">
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
<Stack gap="xs">
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
</Stack>
</Radio.Group>
{serverType === 'selfhosted' && (
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setError(null);
}}
disabled={testing}
error={error}
/>
)}
{error && !customUrl && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Button onClick={handleSubmit} loading={testing} fullWidth>
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
</Button>
</Stack>
);
};
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
interface LoginFormProps {
serverUrl: string;
isSaaS?: boolean;
onLogin: (username: string, password: string) => Promise<void>;
loading: boolean;
}
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
</Text>
<TextInput
@@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
import ComputerIcon from '@mui/icons-material/Computer';
interface ModeSelectionProps {
onSelect: (mode: 'offline' | 'server') => void;
onSelect: (mode: 'saas' | 'selfhosted') => void;
loading: boolean;
}
@@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
<Button
size="xl"
variant="default"
onClick={() => onSelect('offline')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('server')}
onClick={() => onSelect('saas')}
disabled={loading}
leftSection={<CloudIcon />}
styles={{
@@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
inner: {
justifyContent: 'flex-start',
},
section: {
marginRight: '1rem',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('selfhosted')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
section: {
marginRight: '1rem',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
</Text>
</div>
</Button>
@@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Connection successful
onSelect({
url,
server_type: 'selfhosted',
});
} catch (error) {
console.error('Connection test failed:', error);
@@ -7,13 +7,15 @@ 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 { useLogoPath } from '@app/hooks/useLogoPath';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/components/SetupWizard/SetupWizard.css';
enum SetupStep {
ModeSelection,
SaaSLogin,
ServerSelection,
Login,
SelfHostedLogin,
}
interface SetupWizardProps {
@@ -22,34 +24,42 @@ interface SetupWizardProps {
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation();
const logoPath = useLogoPath();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleModeSelection = (mode: 'offline' | 'server') => {
setSelectedMode(mode);
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
setError(null);
if (mode === 'offline') {
handleOfflineSetup();
if (mode === 'saas') {
// For SaaS, go directly to login screen with SaaS URL
setServerConfig({ url: STIRLING_SAAS_URL });
setActiveStep(SetupStep.SaaSLogin);
} else {
// For self-hosted, show server selection first
setActiveStep(SetupStep.ServerSelection);
}
};
const handleOfflineSetup = async () => {
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No SaaS server configured');
return;
}
try {
setLoading(true);
setError(null);
await connectionModeService.switchToOffline();
await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToSaaS(serverConfig.url);
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');
console.error('SaaS login failed:', err);
setError(err instanceof Error ? err.message : 'SaaS login failed');
setLoading(false);
}
};
@@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setActiveStep(SetupStep.Login);
setActiveStep(SetupStep.SelfHostedLogin);
};
const handleLogin = async (username: string, password: string) => {
const handleSelfHostedLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No server configured');
return;
@@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null);
await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToServer(serverConfig);
await connectionModeService.switchToSelfHosted(serverConfig);
await tauriBackendService.initializeExternalBackend();
onComplete();
} catch (err) {
console.error('Login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
console.error('Self-hosted login failed:', err);
setError(err instanceof Error ? err.message : 'Self-hosted login failed');
setLoading(false);
}
};
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.Login) {
if (activeStep === SetupStep.SaaSLogin) {
setActiveStep(SetupStep.ModeSelection);
setServerConfig(null);
} else if (activeStep === SetupStep.SelfHostedLogin) {
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.ModeSelection);
setSelectedMode(null);
setServerConfig(null);
}
};
@@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.welcome', 'Welcome to Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.title', 'Sign in to Stirling Cloud');
case SetupStep.ServerSelection:
return t('setup.server.title', 'Connect to Server');
case SetupStep.Login:
return t('setup.login.title', 'Sign In');
case SetupStep.SelfHostedLogin:
return t('setup.selfhosted.title', 'Sign in to Server');
default:
return '';
}
@@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
case SetupStep.SaaSLogin:
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
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');
case SetupStep.SelfHostedLogin:
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
default:
return '';
}
@@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
{/* Logo Header */}
<Stack gap="xs" align="center">
<Image
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
src={logoPath}
alt="Stirling PDF"
h={32}
h={64}
fit="contain"
/>
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
@@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
<ModeSelection onSelect={handleModeSelection} loading={loading} />
)}
{activeStep === SetupStep.SaaSLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
isSaaS={true}
onLogin={handleSaaSLogin}
loading={loading}
/>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelection onSelect={handleServerSelection} loading={loading} />
)}
{activeStep === SetupStep.Login && (
{activeStep === SetupStep.SelfHostedLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
onLogin={handleLogin}
isSaaS={false}
onLogin={handleSelfHostedLogin}
loading={loading}
/>
)}
+8 -1
View File
@@ -2,4 +2,11 @@
* Connection-related constants for desktop app
*/
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
// SaaS server URL from environment variable (required)
// The SaaS authentication server
// Will throw error if VITE_SAAS_SERVER_URL is not set
if (!import.meta.env.VITE_SAAS_SERVER_URL) {
throw new Error('VITE_SAAS_SERVER_URL environment variable is required');
}
export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL;
@@ -25,9 +25,7 @@ export function useBackendInitializer(enabled = true) {
const initializeBackend = async () => {
try {
console.log('[BackendInitializer] Starting backend...');
await tauriBackendService.startBackend(backendUrl);
console.log('[BackendInitializer] Backend started successfully');
// Begin health checks after a short delay
setTimeout(() => {
@@ -267,8 +267,8 @@ const DEFAULT_BACKEND_URL =
/**
* 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
* - SaaS mode: Uses local bundled backend (from env vars)
* - Self-hosted mode: Uses configured server URL from connection config
*/
export function useEndpointConfig(): EndpointConfig {
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
@@ -276,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig {
useEffect(() => {
connectionModeService.getCurrentConfig()
.then((config) => {
if (config.mode === 'server' && config.server_config?.url) {
if (config.mode === 'selfhosted' && config.server_config?.url) {
setBackendUrl(config.server_config.url);
} else {
// Offline mode - use default from env vars
// SaaS mode - use default from env vars (local backend)
setBackendUrl(DEFAULT_BACKEND_URL);
}
})
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests
const isRemote = await operationRouter.isRemoteMode();
const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) {
const token = await authService.getAuthToken();
if (token) {
@@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isOffline = await operationRouter.isOfflineMode();
const isSaaS = await operationRouter.isSaaSMode();
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
@@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const isRemote = await operationRouter.isRemoteMode();
const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) {
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
@@ -1,12 +1,10 @@
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 type ConnectionMode = 'saas' | 'selfhosted';
export interface ServerConfig {
url: string;
server_type: ServerType;
}
export interface ConnectionConfig {
@@ -31,7 +29,7 @@ export class ConnectionModeService {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'offline', server_config: null };
return this.currentConfig || { mode: 'saas', server_config: null };
}
async getCurrentMode(): Promise<ConnectionMode> {
@@ -64,38 +62,40 @@ export class ConnectionModeService {
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 };
// Default to SaaS mode on error
this.currentConfig = { mode: 'saas', server_config: null };
this.configLoadedOnce = true;
}
}
async switchToOffline(): Promise<void> {
console.log('Switching to offline mode');
async switchToSaaS(saasServerUrl: string): Promise<void> {
console.log('Switching to SaaS mode');
const serverConfig: ServerConfig = { url: saasServerUrl };
await invoke('set_connection_mode', {
mode: 'offline',
serverConfig: null,
});
this.currentConfig = { mode: 'offline', server_config: null };
this.notifyListeners();
console.log('Switched to offline mode successfully');
}
async switchToServer(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to server mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'server',
mode: 'saas',
serverConfig,
});
this.currentConfig = { mode: 'server', server_config: serverConfig };
this.currentConfig = { mode: 'saas', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to server mode successfully');
console.log('Switched to SaaS mode successfully');
}
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to self-hosted mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'selfhosted',
serverConfig,
});
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
}
async testConnection(url: string): Promise<boolean> {
@@ -126,6 +126,16 @@ export class ConnectionModeService {
return false;
}
}
async resetSetupCompletion(): Promise<void> {
try {
await invoke('reset_setup_completion');
console.log('Setup completion flag reset successfully');
} catch (error) {
console.error('Failed to reset setup completion:', error);
throw error;
}
}
}
export const connectionModeService = ConnectionModeService.getInstance();
@@ -22,14 +22,16 @@ export class OperationRouter {
const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing
if (mode === 'offline') {
if (mode === 'saas') {
// SaaS mode: For now, all operations run locally
// Future enhancement: complex operations will be sent to SaaS server
return 'local';
}
// In server mode, currently all operations go to remote
// In self-hosted 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)) {
// if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
// return 'local';
// }
@@ -66,19 +68,19 @@ export class OperationRouter {
}
/**
* Checks if we're currently in remote mode
* Checks if we're currently in self-hosted mode
*/
async isRemoteMode(): Promise<boolean> {
async isSelfHostedMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'server';
return mode === 'selfhosted';
}
/**
* Checks if we're currently in offline mode
* Checks if we're currently in SaaS mode
*/
async isOfflineMode(): Promise<boolean> {
async isSaaSMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'offline';
return mode === 'saas';
}
// Future enhancement: operation classification
@@ -64,7 +64,6 @@ export class TauriBackendService {
return;
}
console.log('[TauriBackendService] Initializing external backend monitoring');
this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting');
this.beginHealthMonitoring();
@@ -82,19 +81,17 @@ export class TauriBackendService {
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then(async (result) => {
console.log('Backend started:', result);
.then(async () => {
this.backendStarted = true;
this.setStatus('starting');
// Poll for the dynamically assigned port
await this.waitForPort();
this.beginHealthMonitoring();
})
.catch((error) => {
this.setStatus('unhealthy');
console.error('Failed to start backend:', error);
console.error('[TauriBackendService] Failed to start backend:', error);
throw error;
})
.finally(() => {
@@ -105,13 +102,11 @@ export class TauriBackendService {
}
private async waitForPort(maxAttempts = 30): Promise<void> {
console.log('[TauriBackendService] Waiting for backend port assignment...');
for (let i = 0; i < maxAttempts; i++) {
try {
const port = await invoke<number | null>('get_backend_port');
if (port) {
this.backendPort = port;
console.log(`[TauriBackendService] Backend port detected: ${port}`);
return;
}
} catch (error) {
@@ -138,11 +133,11 @@ export class TauriBackendService {
async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
// For remote server mode, check the configured server
if (mode !== 'offline') {
// For self-hosted mode, check the configured remote server
if (mode === 'selfhosted') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.error('[TauriBackendService] Server mode but no server URL configured');
console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
this.setStatus('unhealthy');
return false;
}
@@ -161,21 +156,20 @@ export class TauriBackendService {
} 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);
console.error('[TauriBackendService] Self-hosted server health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
}
// For offline mode, check the bundled backend via Rust
// For SaaS mode, check the bundled local backend via Rust
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Backend port not available yet');
return false;
}
@@ -197,7 +191,6 @@ export class TauriBackendService {
for (let i = 0; i < maxAttempts; i++) {
const isHealthy = await this.checkBackendHealth();
if (isHealthy) {
console.log('Backend is healthy');
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
@@ -210,7 +203,6 @@ export class TauriBackendService {
* 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');