fix(linux): open files from later launches in the running PdfCraft (#1726) (#2037)

The single-instance handoff (#282, #715) ran only on Windows; every other platform returned
Claim::Alone, so on Linux each xdg-open or file-manager open started a second process and window.
Linux and the BSDs now use the same Unix-socket exchange through std's UnixListener/UnixStream,
with the socket in $XDG_RUNTIME_DIR/pdfcraft (never /tmp) and named after WAYLAND_DISPLAY or
DISPLAY, so a launch on another display opens its own window. No XDG_RUNTIME_DIR: as before.
macOS is unchanged (Apple events).

winit ignores Focus on Wayland, so on a native Wayland window the handoff also requests user
attention (xdg-activation), which desktops show in the taskbar.

The hand-off, together and stale-socket tests now run on Linux/BSD too; a new test covers the
display socket names.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tydude001
2026-10-15 02:39:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d8f32e767c
commit 32a397f27a
4 changed files with 119 additions and 51 deletions
Generated
+1
View File
@@ -3884,6 +3884,7 @@ dependencies = [
"pdfcraft-engine",
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
"rustls-native-certs",
"serde_json",
"uds_windows",
+5
View File
@@ -36,6 +36,11 @@ fmv-macos-events = "=0.1.0"
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs).
# Already in the tree through winit and eframe.
raw-window-handle = "0.6"
[dev-dependencies]
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
+17 -4
View File
@@ -213,9 +213,10 @@ 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.
// Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
// running and exits (#282, #317, #1726). 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(()),
@@ -332,6 +333,18 @@ struct Launch {
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
}
/// Whether the window is a native Wayland one (not X11 or XWayland).
#[cfg(all(unix, not(target_os = "macos")))]
fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool {
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_)))
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
false
}
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
@@ -368,7 +381,7 @@ 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));
app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc)));
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
+96 -47
View File
@@ -1,41 +1,50 @@
//! 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).
//! One PdfCraft per Windows sign-in or Linux/BSD display: a launch that only names files hands them
//! to the PdfCraft that is already running, where they open as tabs, and exits (#282, #317, #1726).
//!
//! 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.
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
//! and 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. On Linux and the BSDs it is in
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
//! launch on another display or seat opens its own window there. Either way it lives in a folder
//! only the user can reach, so other accounts can't create, replace or reach it: nobody else can
//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
//! the app's own and is shared by every run of it, so the handoff works there too. 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).
//! starts a window of its own, as before. macOS keeps its current behaviour (it 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.
//! On Windows 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)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
use std::time::Duration;
#[cfg(all(unix, not(target_os = "macos")))]
use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(windows)]
use uds_windows::{UnixListener, UnixStream};
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))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
const MAX_REQUEST: u64 = 1 << 20;
/// How long either side waits for the other before giving up.
#[cfg(windows)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
const TIMEOUT: Duration = Duration::from_secs(5);
/// What this launch should do.
#[cfg_attr(not(windows), allow(dead_code))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub enum Claim {
/// The running PdfCraft took the files: exit without a window.
HandedOff,
@@ -59,13 +68,13 @@ pub struct Server {
}
impl Server {
#[cfg_attr(not(windows), allow(dead_code))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), 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))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), 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);
@@ -76,8 +85,10 @@ impl Server {
/// 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 {
/// new window it replaces would have been. A Wayland window can't bring itself forward (winit
/// ignores `Focus` there), so on `native_wayland` it asks for the user's attention instead,
/// which desktops show by highlighting it in the taskbar.
pub fn connect(&self, ctx: &egui::Context, native_wayland: bool) -> 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);
@@ -89,13 +100,16 @@ impl Server {
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
if native_wayland {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
}
vec![OsEvent::Open(paths)]
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
#[cfg_attr(not(windows), allow(dead_code))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn request(paths: &[String]) -> String {
let mut line = serde_json::json!({ "open": paths }).to_string();
line.push('\n');
@@ -104,7 +118,7 @@ pub fn request(paths: &[String]) -> String {
/// 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))]
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), 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()?;
@@ -121,14 +135,14 @@ pub fn absolute(paths: &[String]) -> Vec<String> {
/// 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)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
None => Claim::Alone,
}
}
#[cfg(not(windows))]
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
let _ = (files, may_hand_off);
Claim::Alone
@@ -148,6 +162,28 @@ fn socket_path() -> Option<std::path::PathBuf> {
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
}
/// The socket in `$XDG_RUNTIME_DIR/pdfcraft`; none when `$XDG_RUNTIME_DIR` is unset. Never `/tmp`
/// instead: other accounts can create files there, and so could listen in this user's place.
#[cfg(all(unix, not(target_os = "macos")))]
fn socket_path() -> Option<std::path::PathBuf> {
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let name = display_socket_name(var("WAYLAND_DISPLAY").or_else(|| var("DISPLAY")).as_deref());
// The XDG base directory spec makes it the user's own (mode 0700), so the folder inside it is
// out of other accounts' reach whatever the umask.
var("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("pdfcraft").join(name))
}
/// The socket's file name for a display (`WAYLAND_DISPLAY`, else `DISPLAY`): a launch opens its
/// files on the display it was started on, never in a window on another seat, a nested
/// compositor or a remote X display. Letters, digits, `-`, `_` and `.` are kept, and of those the
/// last 32, where an absolute `WAYLAND_DISPLAY` (`/run/user/1000/wayland-1`) differs.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
fn display_socket_name(display: Option<&str>) -> String {
let kept: Vec<char> = display.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
let display: String = kept.get(kept.len().saturating_sub(32)..).unwrap_or_default().iter().collect();
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
/// 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.
@@ -161,9 +197,9 @@ fn socket_name(session: Option<&str>) -> String {
/// 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)]
/// beside the socket, which is never deleted; the system releases it when the process ends,
/// however it ends. Best effort: without the lock the launch goes on as before.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn election(socket: &std::path::Path) -> Option<std::fs::File> {
if let Some(dir) = socket.parent() {
let _ = std::fs::create_dir_all(dir);
@@ -183,9 +219,8 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
}
/// [`claim`] with the socket at `path`.
#[cfg(windows)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
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
@@ -241,8 +276,8 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
}
/// 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<()> {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
@@ -254,8 +289,8 @@ fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Resu
}
/// Accept launches on a background thread for as long as the process runs.
#[cfg(windows)]
fn serve(listener: uds_windows::UnixListener) -> Claim {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn serve(listener: 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 || {
@@ -278,8 +313,8 @@ fn serve(listener: uds_windows::UnixListener) -> Claim {
}
/// Read one launch's request, queue its files and confirm.
#[cfg(windows)]
fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
@@ -338,26 +373,40 @@ mod tests {
// 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);
let mut poll = server.connect(&ctx, false);
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)]
#[test]
fn each_display_gets_its_own_socket_name() {
assert_eq!(display_socket_name(Some("wayland-0")), "instance-wayland-0.sock");
assert_eq!(display_socket_name(Some(":0")), "instance-0.sock");
assert_ne!(display_socket_name(Some(":1.0")), display_socket_name(Some(":10")));
assert_ne!(display_socket_name(Some("wayland-0")), display_socket_name(Some("wayland-1")));
// An absolute WAYLAND_DISPLAY keeps the end of its path, where nested compositors differ.
let nested = display_socket_name(Some("/home/someone/.cache/nested-compositor/run/wayland-1"));
assert!(nested.ends_with("wayland-1.sock") && !nested.contains('/'), "{nested}");
assert_eq!(display_socket_name(Some("../../x/y")), "instance-....xy.sock", "never a path");
assert_eq!(display_socket_name(None), "instance.sock");
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
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)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
#[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());
let mut poll = first.connect(&egui::Context::default(), false);
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.
@@ -365,10 +414,10 @@ mod tests {
assert!(poll().is_empty());
}
#[cfg(windows)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
#[test]
fn launches_started_together_share_one_window() {
// Several files selected in Explorer and opened with Enter: one process per file, all at once.
// Several files selected in Explorer or a file manager and opened at once: one process per file.
let path = socket("together");
let barrier = Arc::new(std::sync::Barrier::new(8));
let launches: Vec<_> = (0..8)
@@ -386,12 +435,12 @@ mod tests {
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over");
}
#[cfg(windows)]
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
#[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({
drop(UnixListener::bind({
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
&path
}));