* 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:
parent
e4cd9e2557
commit
e742e29771
|
|
@ -3553,6 +3553,7 @@ dependencies = [
|
|||
"pollster 1.0.1",
|
||||
"rustls-native-certs",
|
||||
"serde_json",
|
||||
"uds_windows",
|
||||
"ureq",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))?;
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue