Skip to content

Commit 2b09058

Browse files
authored
Add desktop multi-window support (Stirling-Tools#6463)
1 parent 28b8182 commit 2b09058

15 files changed

Lines changed: 573 additions & 72 deletions

File tree

frontend/editor/public/locales/en-GB/translation.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo"
9090
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
9191
noValidFiles = "No valid files to process"
9292
oops = "Oops!"
93+
openInNewWindow = "Open in new window"
9394
openInViewer = "Open in Viewer"
9495
operationCancelled = "Operation cancelled"
9596
page = "Page"

frontend/editor/src-tauri/capabilities/default.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "default",
44
"description": "enables the default permissions",
5-
"windows": ["main"],
5+
"windows": ["main", "main-*"],
66
"permissions": [
77
"core:default",
88
"core:window:allow-destroy",

frontend/editor/src-tauri/src/commands/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ pub mod auth;
55
pub mod default_app;
66
pub mod platform;
77
pub mod print;
8+
pub mod window;
89

910
pub use backend::{cleanup_backend, get_backend_port, start_backend};
1011
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
12+
pub use window::{
13+
forward_files_to_window,
14+
open_files_in_new_window,
15+
open_in_new_window,
16+
pop_window_file_ids,
17+
target_window_label,
18+
MAIN_WINDOW_LABEL,
19+
};
1120
pub use connection::{
1221
get_connection_config,
1322
is_first_launch,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
use crate::commands::files::add_opened_file;
2+
use crate::utils::add_log;
3+
use std::collections::HashMap;
4+
use std::sync::atomic::{AtomicU32, Ordering};
5+
use std::sync::Mutex;
6+
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
7+
8+
// The primary window created from tauri.conf.json.
9+
pub const MAIN_WINDOW_LABEL: &str = "main";
10+
11+
static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2);
12+
13+
// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths
14+
// (which use the global OPENED_FILES queue), these reference files already in
15+
// the shared IndexedDB store, so a "new window" opened from the My Files page
16+
// loads the same file by reference. Keyed by the new window's label.
17+
static PENDING_FILE_IDS: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
18+
19+
fn next_window_label() -> String {
20+
let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst);
21+
format!("main-{}", id)
22+
}
23+
24+
fn queue_file_ids(label: &str, ids: Vec<String>) {
25+
let mut guard = PENDING_FILE_IDS.lock().unwrap();
26+
let map = guard.get_or_insert_with(HashMap::new);
27+
map.entry(label.to_string()).or_default().extend(ids);
28+
}
29+
30+
// Shared window builder: every Stirling window must use identical WebView2
31+
// browser args so they can share one user-data folder (see the note below),
32+
// so all spawn paths funnel through here.
33+
fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow, String> {
34+
let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
35+
.title("Stirling-PDF")
36+
.inner_size(1280.0, 800.0)
37+
// Below this width the file manager collapses to its mobile layout,
38+
// so keep new windows above the breakpoint.
39+
.min_inner_size(1030.0, 600.0)
40+
.resizable(true);
41+
42+
// WebView2 (Windows only) requires every webview sharing a user-data folder
43+
// to use identical additional_browser_args. wry's behaviour
44+
// (webview2/mod.rs:294): when the user provides args it uses them as-is and
45+
// does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the
46+
// main window's actual args are EXACTLY what tauri.conf.json declares -
47+
// nothing more. We mirror that string byte-for-byte so windows share one data
48+
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
49+
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
50+
#[cfg(target_os = "windows")]
51+
let builder =
52+
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
53+
54+
builder.build().map_err(|e| e.to_string())
55+
}
56+
57+
// Run `work` on the main thread and await its result. WebView2 on Windows
58+
// refuses to create a webview off the main thread (HRESULT 0x8007139F), but
59+
// Tauri command handlers run on a worker thread - so any window creation has to
60+
// hop over first. Centralised here so every command does it the same way.
61+
async fn run_on_main_thread_result<F, R>(app: &AppHandle, work: F) -> Result<R, String>
62+
where
63+
F: FnOnce() -> R + Send + 'static,
64+
R: Send + 'static,
65+
{
66+
let (tx, rx) = tokio::sync::oneshot::channel();
67+
app.run_on_main_thread(move || {
68+
let _ = tx.send(work());
69+
})
70+
.map_err(|e| e.to_string())?;
71+
rx.await.map_err(|e| e.to_string())
72+
}
73+
74+
// Spawn a new webview window in the same Tauri process.
75+
// The backend stays single; only the frontend is duplicated.
76+
// If `paths` is non-empty, they're enqueued under the new window's label,
77+
// so the React app pops them on mount just like a fresh launch with a file.
78+
fn spawn_new_window(app: &AppHandle, paths: Vec<String>) -> Result<String, String> {
79+
let label = next_window_label();
80+
81+
for path in &paths {
82+
add_opened_file(path.clone());
83+
}
84+
85+
match build_window(app, &label, "/") {
86+
Ok(window) => {
87+
add_log(format!(
88+
"🪟 Spawned new window '{}' with {} initial file(s)",
89+
label,
90+
paths.len()
91+
));
92+
// The new window pops the shared queue on mount, so the files are
93+
// already waiting for it. We target the emit at this window only
94+
// (not a broadcast) so already-open windows don't race to pop them.
95+
if !paths.is_empty() {
96+
let _ = window.emit_to(label.as_str(), "files-changed", ());
97+
}
98+
Ok(label)
99+
}
100+
Err(err) => {
101+
add_log(format!(
102+
"❌ Failed to spawn new window '{}': {}",
103+
label, err
104+
));
105+
Err(err)
106+
}
107+
}
108+
}
109+
110+
#[tauri::command]
111+
pub async fn open_in_new_window(app: AppHandle, paths: Vec<String>) -> Result<String, String> {
112+
let valid_paths: Vec<String> = paths
113+
.into_iter()
114+
.filter(|p| {
115+
let exists = std::path::Path::new(p).exists();
116+
if !exists {
117+
add_log(format!(
118+
"⚠️ Ignoring non-existent path for new window: {}",
119+
p
120+
));
121+
}
122+
exists
123+
})
124+
.collect();
125+
126+
let app_clone = app.clone();
127+
run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await?
128+
}
129+
130+
// Open already-stored files (by IndexedDB id) in a fresh window. Used by the
131+
// "Open in new window" action on the My Files page. The ids are queued under
132+
// the new window's label; the new window pops them on mount and loads them from
133+
// the shared store into its workspace.
134+
#[tauri::command]
135+
pub async fn open_files_in_new_window(
136+
app: AppHandle,
137+
file_ids: Vec<String>,
138+
) -> Result<String, String> {
139+
let label = next_window_label();
140+
let app_clone = app.clone();
141+
run_on_main_thread_result(&app, move || {
142+
build_window(&app_clone, &label, "/").map(|window| {
143+
let count = file_ids.len();
144+
// Queue the ids only after the window is created, so a failed build
145+
// doesn't leave orphaned ids under a label no window will consume.
146+
queue_file_ids(&label, file_ids);
147+
add_log(format!(
148+
"🪟 Spawned new window '{}' for {} stored file(s)",
149+
label, count
150+
));
151+
// The new window also pops on mount; this emit is a nudge in case it
152+
// mounted before the ids were queued.
153+
let _ = window.emit_to(label.as_str(), "window-files-ready", ());
154+
label.clone()
155+
})
156+
})
157+
.await?
158+
}
159+
160+
// Pop (return and clear) the stored-file ids queued for the calling window.
161+
#[tauri::command]
162+
pub async fn pop_window_file_ids(window: WebviewWindow) -> Result<Vec<String>, String> {
163+
let label = window.label().to_string();
164+
let ids = {
165+
let mut guard = PENDING_FILE_IDS.lock().unwrap();
166+
guard
167+
.as_mut()
168+
.and_then(|map| map.remove(&label))
169+
.unwrap_or_default()
170+
};
171+
if !ids.is_empty() {
172+
add_log(format!(
173+
"📂 Returning {} stored file id(s) for window '{}'",
174+
ids.len(),
175+
label
176+
));
177+
}
178+
Ok(ids)
179+
}
180+
181+
// Pick the best existing window to receive an opened file: the focused one,
182+
// else the main window, else any open window. Returns None only if there are
183+
// no windows at all. Used so file-opens (file association, "open with") land in
184+
// the window the user is actually looking at, and still work if the original
185+
// "main" window has been closed.
186+
pub fn target_window_label(app: &AppHandle) -> Option<String> {
187+
let windows = app.webview_windows();
188+
if let Some((label, _)) = windows
189+
.iter()
190+
.find(|(_, w)| w.is_focused().unwrap_or(false))
191+
{
192+
return Some(label.clone());
193+
}
194+
if windows.contains_key(MAIN_WINDOW_LABEL) {
195+
return Some(MAIN_WINDOW_LABEL.to_string());
196+
}
197+
windows.keys().next().cloned()
198+
}
199+
200+
// Add files to the shared queue and notify a specific window to consume them.
201+
// Used by drag-drop, the macOS open event, and the second-instance callback
202+
// (when --new-window is NOT set). The emit is targeted at `label` so only that
203+
// window pops the queue - other windows ignore it and keep their own files.
204+
pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec<String>) {
205+
for path in &paths {
206+
add_opened_file(path.clone());
207+
}
208+
if let Some(window) = app.get_webview_window(label) {
209+
let _ = app.emit_to(label, "files-changed", ());
210+
let _ = window.set_focus();
211+
let _ = window.unminimize();
212+
} else {
213+
// Target window is gone; let any window pick the files up.
214+
let _ = app.emit("files-changed", ());
215+
}
216+
}

0 commit comments

Comments
 (0)