Windows: single instance, launches started together share one window (#282, #317) (#715)

* Windows: open files in the running PdfCraft instead of a new window each time (#282, #317)

Outlook attachments, Explorer double-clicks and Open With start pdfcraft.exe with the file,
so every file got its own process and window. Now the first PdfCraft listens on a Unix domain
socket in %LOCALAPPDATA%\PdfCraft (or a portable copy's data folder), one per Windows session.
A later launch that only names files sends their absolute paths, waits for "ok" and exits; the
running app opens them as tabs through the existing OsEvent::Open path (the one macOS Apple
events use) and comes to the front.

Anything else keeps today's behaviour: --new-window, any other option, a launch without files,
or any failure to hand over within 5 seconds opens a window of its own. Other platforms are
unchanged.

The socket lives in the user's profile, so other accounts can't create, replace or reach it.
uds_windows (already in the lockfile through zbus) provides it: std has no Unix sockets on
Windows and the workspace forbids unsafe. A socket left by a crash is replaced on the next start.

* Windows: launches started together share one window (#282, #317)

Several files selected in Explorer and opened at once start one process
per file at the same moment. A launch could then find the socket bound
by another launch that was not listening yet: connecting failed, clearing
the socket failed with "Access is denied", and the launch opened a window
of its own.

Launches now take turns through a lock on a file beside the socket, held
only while a launch finds out whether it is the first. A socket that
cannot be cleared because another launch holds it is tried again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016es5tszobbb1yZ7JeozJXL

* Windows: a portable copy doesn't hand files to another instance

Its data folder may be writable by other accounts, so a socket there could leak or inject file
paths. Builds on art804's single-instance work in #602.

Co-authored-by: art804 <7828385+art804@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: art804 <7828385+art804@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Leo 2026-10-10 21:23:11 +11:00 committed by GitHub
parent e4cd9e2557
commit e742e29771
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 429 additions and 0 deletions

1
Cargo.lock generated
View File

@ -3553,6 +3553,7 @@ dependencies = [
"pollster 1.0.1",
"rustls-native-certs",
"serde_json",
"uds_windows",
"ureq",
"winresource",
"winsafe",

View File

@ -36,6 +36,10 @@ pollster = "1.0"
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# Single instance (#282): later launches hand their files to the running app over a Unix domain
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
uds_windows = "=1.2.1"
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.

View File

@ -9,6 +9,9 @@
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
//!
//! `--control <file>` enables the UI control channel (off by default): the app listens on a random
//! loopback port and writes `{"port", "token", "pid"}` to `<file>` (owner-only permissions). The
//! app doesn't start if it can't.
@ -28,6 +31,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
mod apple_events;
mod logging;
mod single_instance;
mod updates;
#[cfg(test)]
#[path = "windows_manifest.rs"]
@ -140,6 +144,7 @@ fn main() -> eframe::Result {
let mut options: Vec<(String, String)> = Vec::new();
let mut control_file: Option<String> = None;
let mut create_images = false;
let mut new_window = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
@ -149,6 +154,7 @@ fn main() -> eframe::Result {
}
"--control" => control_file = args.next(),
"--create-images" => create_images = true,
"--new-window" => new_window = true,
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
@ -168,6 +174,15 @@ fn main() -> eframe::Result {
Err(e) => log::warn!("no log file: {e}"),
}
}
// Windows: a launch that only names files (an Outlook attachment, an Explorer double-click)
// hands them to the PdfCraft already running and exits (#282, #317). Any option means the
// caller wants this launch's own window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
@ -183,6 +198,7 @@ fn main() -> eframe::Result {
app_creator(
launch.clone(),
Rc::clone(&started),
instance.as_ref(),
#[cfg(target_os = "macos")]
&apple_events,
),
@ -200,6 +216,7 @@ fn main() -> eframe::Result {
app_creator(
launch,
started,
instance.as_ref(),
#[cfg(target_os = "macos")]
&apple_events,
),
@ -273,6 +290,7 @@ struct Launch {
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated } = launch;
@ -289,6 +307,9 @@ fn app_creator<'a>(
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
if let Some(server) = instance {
app.os_events = Some(server.connect(&cc.egui_ctx));
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
open_control_channel(file, pdfcraft_ui_egui::control::serve(client))?;

View File

@ -0,0 +1,403 @@
//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft
//! that is already running, where they open as tabs, and exits (#282, #317).
//!
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, so
//! until now every file got its own process and window. The first PdfCraft listens on a Unix
//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of
//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so
//! two sign-ins of the same user never open files in each other's windows, and it lives in the
//! user's own profile, so other accounts can't create, replace or reach it: nobody else can
//! intercept a launch or open files in this user's window. The exchange is one line each way, a
//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as
//! File ▸ Open would.
//!
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS
//! already routes Finder opens to the running app through Apple events).
//!
//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix
//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace
//! forbids.
use std::sync::{Arc, Mutex, PoisonError};
#[cfg(windows)]
use std::time::Duration;
use pdfcraft_ui_egui::{OsEvent, OsEventsFn};
/// Longest request read: one line of JSON, far longer than any real list of paths.
#[cfg_attr(not(windows), allow(dead_code))]
const MAX_REQUEST: u64 = 1 << 20;
/// How long either side waits for the other before giving up.
#[cfg(windows)]
const TIMEOUT: Duration = Duration::from_secs(5);
/// What this launch should do.
#[cfg_attr(not(windows), allow(dead_code))]
pub enum Claim {
/// The running PdfCraft took the files: exit without a window.
HandedOff,
/// This is the first PdfCraft: open a window and accept later launches' files.
Primary(Server),
/// Open a window of its own and accept nothing (the behaviour before #282).
Alone,
}
/// Files that later launches handed over, and how to wake the UI for them.
#[derive(Default)]
struct Inbox {
paths: Vec<String>,
wake: Option<Box<dyn Fn() + Send>>,
}
/// The first PdfCraft's end of the socket. A background thread accepts launches until the
/// process exits; the UI picks their files up through [`Server::connect`].
pub struct Server {
inbox: Arc<Mutex<Inbox>>,
}
impl Server {
#[cfg_attr(not(windows), allow(dead_code))]
fn new() -> Self {
Self { inbox: Arc::default() }
}
/// Queue files from another launch and wake the UI.
#[cfg_attr(not(windows), allow(dead_code))]
fn deliver(&self, paths: Vec<String>) {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox.paths.extend(paths);
if let Some(wake) = &inbox.wake {
wake();
}
}
/// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before
/// the window existed are in the first batch. A batch also brings the window forward, as the
/// new window it replaces would have been.
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
let wake = ctx.clone();
self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint()));
let inbox = Arc::clone(&self.inbox);
let ctx = ctx.clone();
Box::new(move || {
let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
if paths.is_empty() {
return Vec::new();
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
vec![OsEvent::Open(paths)]
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn request(paths: &[String]) -> String {
let mut line = serde_json::json!({ "open": paths }).to_string();
line.push('\n');
line
}
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
/// whose `open` is a non-empty list of non-empty strings.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn parse_request(line: &str) -> Option<Vec<String>> {
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
let open = value.get("open")?.as_array()?;
let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
(!paths.is_empty()).then_some(paths)
}
/// File arguments made absolute against this launch's working folder, which the running app
/// doesn't share. A path that can't be made absolute is sent as it is.
pub fn absolute(paths: &[String]) -> Vec<String> {
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
/// when the launch asked for anything besides opening files, so it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
#[cfg(windows)]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
None => Claim::Alone,
}
}
#[cfg(not(windows))]
{
let _ = (files, may_hand_off);
Claim::Alone
}
}
/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none for a portable
/// copy.
#[cfg(windows)]
fn socket_path() -> Option<std::path::PathBuf> {
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
// A portable copy may sit in a folder other accounts can write to (`C:\Tools`): a socket
// there would let them receive this user's file paths or send it files to open. Portable
// launches each open their own window instead, and write nothing outside their folder.
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return None;
}
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
}
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
#[cfg_attr(not(windows), allow(dead_code))]
fn socket_name(session: Option<&str>) -> String {
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
/// files selected in Explorer and opened at once) take turns: the first one is listening before
/// the next one looks. Without it a launch could find the socket bound but not yet listening,
/// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file
/// beside the socket, which is never deleted; Windows releases it when the process ends, however
/// it ends. Best effort: without the lock the launch goes on as before.
#[cfg(windows)]
fn election(socket: &std::path::Path) -> Option<std::fs::File> {
if let Some(dir) = socket.parent() {
let _ = std::fs::create_dir_all(dir);
}
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(socket.with_extension("lock")).ok()?;
let deadline = std::time::Instant::now() + TIMEOUT;
loop {
match file.try_lock() {
Ok(()) => return Some(file),
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
Err(e) => {
log::info!("single instance: no election lock ({e})");
return None;
}
}
}
}
/// [`claim`] with the socket at `path`.
#[cfg(windows)]
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
use uds_windows::{UnixListener, UnixStream};
// Released when this function returns: by then this launch listens, or has handed over.
let _election = election(path);
// Two launches can start together: the one that loses the race to bind hands over to the
// one that won. A third round is for a socket that vanishes in between.
for _ in 0..3 {
match UnixStream::connect(path) {
Ok(stream) => {
if !may_hand_off {
return Claim::Alone;
}
return match hand_off(&stream, files) {
Ok(()) => Claim::HandedOff,
Err(e) => {
log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window");
Claim::Alone
}
};
}
// Nobody is listening: no socket yet, or one left by a PdfCraft that didn't exit
// cleanly (connecting to it is refused). Clear it and become the first. Removed
// without asking `path.exists()` first: on Windows that follows the socket's reparse
// point, which can fail, and a leftover socket would then block every later start.
Err(_) => match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
// Another launch has bound the socket and is about to listen: try it again.
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
std::thread::sleep(Duration::from_millis(50));
continue;
}
Err(e) => {
log::info!("single instance off: can't clear {} ({e})", path.display());
return Claim::Alone;
}
},
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match UnixListener::bind(path) {
Ok(listener) => return serve(listener),
// Another launch bound it first: hand over to that one on the next round.
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => continue,
// Older Windows, a path too long for a socket address, a file system without socket
// support (some network or removable drives): this launch works as before.
Err(e) => {
log::info!("single instance off: can't listen at {} ({e})", path.display());
return Claim::Alone;
}
}
}
Claim::Alone
}
/// Send `files` over `stream` and wait for the running app's `ok`.
#[cfg(windows)]
fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut writer = stream;
writer.write_all(request(files).as_bytes())?;
let mut reply = String::new();
std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?;
if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) }
}
/// Accept launches on a background thread for as long as the process runs.
#[cfg(windows)]
fn serve(listener: uds_windows::UnixListener) -> Claim {
let server = Server::new();
let inbox = Server { inbox: Arc::clone(&server.inbox) };
let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
for stream in listener.incoming() {
let result = stream.and_then(|s| receive(&s, &inbox));
if let Err(e) = result {
log::warn!("single instance: a launch's request failed ({e})");
// Don't spin if accepting keeps failing.
std::thread::sleep(Duration::from_millis(100));
}
}
});
match started {
Ok(_) => Claim::Primary(server),
Err(e) => {
log::warn!("single instance off: no listener thread ({e})");
Claim::Alone
}
}
}
/// Read one launch's request, queue its files and confirm.
#[cfg(windows)]
fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
std::io::BufReader::new(stream.take(MAX_REQUEST)).read_line(&mut line)?;
// A launch that wanted its own window only checked that this one is running.
if line.is_empty() {
return Ok(());
}
let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths);
let mut writer = stream;
writer.write_all(b"ok\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_names_the_paths_and_round_trips() {
let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
let line = request(&paths);
assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
assert_eq!(parse_request(&line), Some(paths));
}
#[test]
fn anything_but_a_list_of_paths_is_refused() {
for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] {
assert_eq!(parse_request(line), None, "{line:?}");
}
}
#[test]
fn relative_paths_are_made_absolute_against_this_launch() {
let out = absolute(&["Quote.pdf".to_string()]);
assert_eq!(out, vec![std::env::current_dir().unwrap().join("Quote.pdf").to_string_lossy().into_owned()]);
let already = std::env::current_dir().unwrap().join("x.pdf").to_string_lossy().into_owned();
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
}
#[test]
fn each_windows_session_gets_its_own_socket_name() {
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None), "instance.sock");
assert_eq!(socket_name(Some("")), "instance.sock");
}
#[test]
fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
let server = Server::new();
// Before the window exists: queued, nothing to wake yet.
server.deliver(vec!["a.pdf".into()]);
let ctx = egui::Context::default();
let mut poll = server.connect(&ctx);
server.deliver(vec!["b.pdf".into()]);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
assert!(poll().is_empty(), "each file is delivered once");
}
#[cfg(windows)]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir.join(socket_name(None))
}
#[cfg(windows)]
#[test]
fn a_second_launch_hands_its_files_to_the_first() {
let path = socket("hand-off");
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
let mut poll = first.connect(&egui::Context::default());
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
// A launch with options keeps its own window and doesn't take over the socket.
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
assert!(poll().is_empty());
}
#[cfg(windows)]
#[test]
fn launches_started_together_share_one_window() {
// Several files selected in Explorer and opened with Enter: one process per file, all at once.
let path = socket("together");
let barrier = Arc::new(std::sync::Barrier::new(8));
let launches: Vec<_> = (0..8)
.map(|i| {
let (path, barrier) = (path.clone(), Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
claim_at(&path, &[format!("C:\\{i}.pdf")], true)
})
})
.collect();
// The first launch's listener lives until every other launch has handed over.
let claims: Vec<Claim> = launches.into_iter().map(|t| t.join().unwrap()).collect();
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::Primary(_))).count(), 1, "one window");
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over");
}
#[cfg(windows)]
#[test]
fn a_socket_left_by_a_crashed_instance_is_replaced() {
let path = socket("stale");
// Bind and drop: the file stays, nobody listens.
drop(uds_windows::UnixListener::bind({
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
&path
}));
// `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail.
assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
}
}