mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d8f32e767c
commit
32a397f27a
Generated
+1
@@ -3884,6 +3884,7 @@ dependencies = [
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-ui-egui",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rustls-native-certs",
|
||||
"serde_json",
|
||||
"uds_windows",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user