Add desktop multi-window support (#6463)

This commit is contained in:
Anthony Stirling
2026-05-29 19:35:54 +01:00
committed by GitHub
parent 28b81828b5
commit 2b0905887b
15 changed files with 571 additions and 70 deletions
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": ["main", "main-*"],
"permissions": [
"core:default",
"core:window:allow-destroy",
@@ -5,9 +5,18 @@ pub mod auth;
pub mod default_app;
pub mod platform;
pub mod print;
pub mod window;
pub use backend::{cleanup_backend, get_backend_port, start_backend};
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
pub use window::{
forward_files_to_window,
open_files_in_new_window,
open_in_new_window,
pop_window_file_ids,
target_window_label,
MAIN_WINDOW_LABEL,
};
pub use connection::{
get_connection_config,
is_first_launch,
@@ -0,0 +1,216 @@
use crate::commands::files::add_opened_file;
use crate::utils::add_log;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
// The primary window created from tauri.conf.json.
pub const MAIN_WINDOW_LABEL: &str = "main";
static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2);
// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths
// (which use the global OPENED_FILES queue), these reference files already in
// the shared IndexedDB store, so a "new window" opened from the My Files page
// loads the same file by reference. Keyed by the new window's label.
static PENDING_FILE_IDS: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
fn next_window_label() -> String {
let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst);
format!("main-{}", id)
}
fn queue_file_ids(label: &str, ids: Vec<String>) {
let mut guard = PENDING_FILE_IDS.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
map.entry(label.to_string()).or_default().extend(ids);
}
// Shared window builder: every Stirling window must use identical WebView2
// browser args so they can share one user-data folder (see the note below),
// so all spawn paths funnel through here.
fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow, String> {
let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
.title("Stirling-PDF")
.inner_size(1280.0, 800.0)
// Below this width the file manager collapses to its mobile layout,
// so keep new windows above the breakpoint.
.min_inner_size(1030.0, 600.0)
.resizable(true);
// WebView2 (Windows only) requires every webview sharing a user-data folder
// to use identical additional_browser_args. wry's behaviour
// (webview2/mod.rs:294): when the user provides args it uses them as-is and
// does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the
// main window's actual args are EXACTLY what tauri.conf.json declares -
// nothing more. We mirror that string byte-for-byte so windows share one data
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
#[cfg(target_os = "windows")]
let builder =
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
builder.build().map_err(|e| e.to_string())
}
// Run `work` on the main thread and await its result. WebView2 on Windows
// refuses to create a webview off the main thread (HRESULT 0x8007139F), but
// Tauri command handlers run on a worker thread - so any window creation has to
// hop over first. Centralised here so every command does it the same way.
async fn run_on_main_thread_result<F, R>(app: &AppHandle, work: F) -> Result<R, String>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let _ = tx.send(work());
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())
}
// Spawn a new webview window in the same Tauri process.
// The backend stays single; only the frontend is duplicated.
// If `paths` is non-empty, they're enqueued under the new window's label,
// so the React app pops them on mount just like a fresh launch with a file.
fn spawn_new_window(app: &AppHandle, paths: Vec<String>) -> Result<String, String> {
let label = next_window_label();
for path in &paths {
add_opened_file(path.clone());
}
match build_window(app, &label, "/") {
Ok(window) => {
add_log(format!(
"🪟 Spawned new window '{}' with {} initial file(s)",
label,
paths.len()
));
// The new window pops the shared queue on mount, so the files are
// already waiting for it. We target the emit at this window only
// (not a broadcast) so already-open windows don't race to pop them.
if !paths.is_empty() {
let _ = window.emit_to(label.as_str(), "files-changed", ());
}
Ok(label)
}
Err(err) => {
add_log(format!(
"❌ Failed to spawn new window '{}': {}",
label, err
));
Err(err)
}
}
}
#[tauri::command]
pub async fn open_in_new_window(app: AppHandle, paths: Vec<String>) -> Result<String, String> {
let valid_paths: Vec<String> = paths
.into_iter()
.filter(|p| {
let exists = std::path::Path::new(p).exists();
if !exists {
add_log(format!(
"⚠️ Ignoring non-existent path for new window: {}",
p
));
}
exists
})
.collect();
let app_clone = app.clone();
run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await?
}
// Open already-stored files (by IndexedDB id) in a fresh window. Used by the
// "Open in new window" action on the My Files page. The ids are queued under
// the new window's label; the new window pops them on mount and loads them from
// the shared store into its workspace.
#[tauri::command]
pub async fn open_files_in_new_window(
app: AppHandle,
file_ids: Vec<String>,
) -> Result<String, String> {
let label = next_window_label();
let app_clone = app.clone();
run_on_main_thread_result(&app, move || {
build_window(&app_clone, &label, "/").map(|window| {
let count = file_ids.len();
// Queue the ids only after the window is created, so a failed build
// doesn't leave orphaned ids under a label no window will consume.
queue_file_ids(&label, file_ids);
add_log(format!(
"🪟 Spawned new window '{}' for {} stored file(s)",
label, count
));
// The new window also pops on mount; this emit is a nudge in case it
// mounted before the ids were queued.
let _ = window.emit_to(label.as_str(), "window-files-ready", ());
label.clone()
})
})
.await?
}
// Pop (return and clear) the stored-file ids queued for the calling window.
#[tauri::command]
pub async fn pop_window_file_ids(window: WebviewWindow) -> Result<Vec<String>, String> {
let label = window.label().to_string();
let ids = {
let mut guard = PENDING_FILE_IDS.lock().unwrap();
guard
.as_mut()
.and_then(|map| map.remove(&label))
.unwrap_or_default()
};
if !ids.is_empty() {
add_log(format!(
"📂 Returning {} stored file id(s) for window '{}'",
ids.len(),
label
));
}
Ok(ids)
}
// Pick the best existing window to receive an opened file: the focused one,
// else the main window, else any open window. Returns None only if there are
// no windows at all. Used so file-opens (file association, "open with") land in
// the window the user is actually looking at, and still work if the original
// "main" window has been closed.
pub fn target_window_label(app: &AppHandle) -> Option<String> {
let windows = app.webview_windows();
if let Some((label, _)) = windows
.iter()
.find(|(_, w)| w.is_focused().unwrap_or(false))
{
return Some(label.clone());
}
if windows.contains_key(MAIN_WINDOW_LABEL) {
return Some(MAIN_WINDOW_LABEL.to_string());
}
windows.keys().next().cloned()
}
// Add files to the shared queue and notify a specific window to consume them.
// Used by drag-drop, the macOS open event, and the second-instance callback
// (when --new-window is NOT set). The emit is targeted at `label` so only that
// window pops the queue - other windows ignore it and keep their own files.
pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec<String>) {
for path in &paths {
add_opened_file(path.clone());
}
if let Some(window) = app.get_webview_window(label) {
let _ = app.emit_to(label, "files-changed", ());
let _ = window.set_focus();
let _ = window.unminimize();
} else {
// Target window is gone; let any window pick the files up.
let _ = app.emit("files-changed", ());
}
}
+66 -60
View File
@@ -11,12 +11,16 @@ use commands::{
clear_opened_files,
clear_refresh_token,
clear_user_info,
forward_files_to_window,
is_default_pdf_handler,
get_auth_token,
get_backend_port,
get_connection_config,
get_opened_files,
open_files_in_new_window,
open_in_new_window,
pop_opened_files,
pop_window_file_ids,
get_refresh_token,
get_user_info,
is_first_launch,
@@ -31,6 +35,8 @@ use commands::{
print_pdf_file_native,
start_backend,
start_oauth_login,
target_window_label,
MAIN_WINDOW_LABEL,
};
use commands::connection::apply_provisioning_if_present;
use state::connection_state::AppConnectionState;
@@ -47,6 +53,16 @@ fn dispatch_deep_link(app: &AppHandle, url: &str) {
}
}
// Extract existing file paths from CLI args (skips the executable name).
fn parse_launch_files(args: &[String]) -> Vec<String> {
args
.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -66,38 +82,33 @@ pub fn run() {
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
// Runs in the existing instance when a second launch is attempted
// (e.g. "open with" / double-click while the app is running).
add_log(format!("📂 Second instance detected with args: {:?}", args));
// Scan args for PDF files (skip first arg which is the executable)
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
let files = parse_launch_files(&args);
// Route to the window the user is in (focused -> main -> any) so opens
// consolidate into one window instead of spawning a new one.
let label = target_window_label(app).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
if !files.is_empty() {
add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label));
forward_files_to_window(app, &label, files);
} else if let Some(window) = app.get_webview_window(&label) {
// No files: just bring the app to the front.
let _ = window.set_focus();
let _ = window.unminimize();
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
// Files passed on the command line at first launch load into the main
// window once the frontend mounts.
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
for path in parse_launch_files(&args) {
add_log(format!("📂 Initial file from command line: {}", path));
add_opened_file(path);
}
{
@@ -147,6 +158,9 @@ pub fn run() {
get_opened_files,
pop_opened_files,
clear_opened_files,
open_in_new_window,
open_files_in_new_window,
pop_window_file_ids,
get_tauri_logs,
get_connection_config,
set_connection_mode,
@@ -183,26 +197,19 @@ pub fn run() {
// Don't cleanup here - let JavaScript handler prevent close if needed
// Backend cleanup happens in ExitRequested when window actually closes
}
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => {
use tauri::DragDropEvent;
match drag_drop_event {
DragDropEvent::Drop { paths, .. } => {
add_log(format!("📂 Files dropped: {:?}", paths));
let mut added_files = false;
if let DragDropEvent::Drop { paths, .. } = drag_drop_event {
add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths));
let file_paths: Vec<String> = paths
.iter()
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect();
for path in paths {
if let Some(path_str) = path.to_str() {
add_log(format!("📂 Processing dropped file: {}", path_str));
add_opened_file(path_str.to_string());
added_files = true;
}
}
if added_files {
let _ = app_handle.emit("files-changed", ());
}
// Route to the window the file was actually dropped on.
if !file_paths.is_empty() {
forward_files_to_window(app_handle, &label, file_paths);
}
_ => {}
}
}
#[cfg(target_os = "macos")]
@@ -210,30 +217,29 @@ pub fn run() {
use urlencoding::decode;
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
let file_paths: Vec<String> = urls
.iter()
.filter_map(|url| {
let url_str = url.as_str();
if !url_str.starts_with("file://") {
return None;
}
let encoded = url_str.strip_prefix("file://").unwrap_or(url_str);
// Decode URL-encoded characters (%20 -> space, etc.)
let file_path = match decode(encoded_path) {
Ok(decoded) => decoded.into_owned(),
match decode(encoded) {
Ok(decoded) => Some(decoded.into_owned()),
Err(e) => {
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
encoded_path.to_string() // Fallback to encoded path
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e));
Some(encoded.to_string())
}
};
}
})
.collect();
add_log(format!("📂 Processing opened file: {}", file_path));
add_opened_file(file_path);
added_files = true;
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
if !file_paths.is_empty() {
// Route to the window the user is in (focused -> main -> any).
let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
forward_files_to_window(app_handle, &label, file_paths);
}
}
_ => {