Repository: zng-ui/zng (language: rust)

Issue:
Implement single instance app
When a second instance of the app opens it connects with the first, sends the args over IPC and exits.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97e87ec79..cd5ba0c08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@
 * **Breaking:** Remove `"bytemuck"` feature from `zng-unique-id`.
     - Now must use `impl_unique_id_bytemuck!` to generate the impls.
     - Note that this is only a breaking change for direct dependents of `zng-unique-id`.
+* Add single app-process instance mode.
+    - Adds `zng-ext-single-instance` crate re-exported in `zng::app` when non-default 
+      Cargo feature `"single_instance"` is enabled.
+* Implement `AsRef<std::path::Path>` for `Txt`.
 
 # 0.3.4
 
diff --git a/crates/README.md b/crates/README.md
index e203455d7..30611cc6a 100644
--- a/crates/README.md
+++ b/crates/README.md
@@ -67,6 +67,7 @@ with prefix `zng-ext-`.
 - `zng-ext-clipboard`
 - `zng-ext-window`
 - `zng-ext-undo`
+- `zng-ext-single-instance`
 
 ### Widget
 
diff --git a/crates/zng-app/Cargo.toml b/crates/zng-app/Cargo.toml
index 3502d9a1e..108c6e097 100644
--- a/crates/zng-app/Cargo.toml
+++ b/crates/zng-app/Cargo.toml
@@ -14,8 +14,9 @@ keywords = ["gui", "ui", "user-interface", "zng"]
 [features]
 default = ["ipc", "debug_default"]
 
-# Enables pre-build views and connecting to views running in another process.
-ipc = ["zng-view-api/ipc"]
+# Enables IPC tasks and pre-build views and connecting to views running in another process.
+ipc = ["zng-view-api/ipc", "zng-task/ipc"]
+
 # Enable the `"dyn_*"` and `"inspector"` features in debug builds.
 debug_default = ["zng-var/debug_default", "zng-app-context/dyn_closure"]
 
@@ -74,7 +75,9 @@ zng-time = { path = "../zng-time", version = "0.2.5" }
 zng-var = { path = "../zng-var", version = "0.2.6" }
 zng-txt = { path = "../zng-txt", version = "0.2.5" }
 zng-task = { path = "../zng-task", version = "0.2.6" }
-zng-view-api = { path = "../zng-view-api", version = "0.3.0", features = ["var"] }
+zng-view-api = { path = "../zng-view-api", version = "0.3.0", features = [
+    "var",
+] }
 zng-state-map = { path = "../zng-state-map", version = "0.2.5" }
 zng-layout = { path = "../zng-layout", version = "0.2.6" }
 zng-color = { path = "../zng-color", version = "0.2.8" }
@@ -95,4 +98,4 @@ unicase = "2.7"
 
 [package.metadata.docs.rs]
 all-features = true
-rustdoc-args = [ "--html-in-header", "doc/html-in-header.html" ]
+rustdoc-args = ["--html-in-header", "doc/html-in-header.html"]
diff --git a/crates/zng-app/README.md b/crates/zng-app/README.md
index 2b36607da..93b99f4aa 100644
--- a/crates/zng-app/README.md
+++ b/crates/zng-app/README.md
@@ -7,7 +7,7 @@ This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-fi
 This crate provides 11 feature flags, 2 enabled by default.
 
 #### `"ipc"`
-Enables pre-build views and connecting to views running in another process.
+Enables IPC tasks and pre-build views and connecting to views running in another process.
 
 *Enabled by default.*
 
diff --git a/crates/zng-ext-single-instance/Cargo.toml b/crates/zng-ext-single-instance/Cargo.toml
new file mode 100644
index 000000000..1cdddded1
--- /dev/null
+++ b/crates/zng-ext-single-instance/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "zng-ext-single-instance"
+version = "0.2.0"
+authors = ["The Zng Project Developers"]
+edition = "2021"
+license = "Apache-2.0 OR MIT"
+readme = "README.md"
+description = "Part of the zng project."
+documentation = "https://zng-ui.github.io/doc/zng_ext_l10n"
+repository = "https://github.com/zng-ui/zng"
+categories = ["gui"]
+keywords = ["gui", "ui", "user-interface", "zng"]
+
+[dependencies]
+zng-app = { path = "../zng-app", version = "0.3.4" }
+zng-txt = { path = "../zng-txt", version = "0.2.5" }
+zng-task = { path = "../zng-task", version = "0.2.6" }
+zng-ext-fs-watcher = { path = "../zng-ext-fs-watcher", version = "0.2.8" }
+
+parking_lot = "0.12"
+single-instance = "0.3"
+tracing = "0.1"
+serde_json = "1.0"
\ No newline at end of file
diff --git a/crates/zng-ext-single-instance/README.md b/crates/zng-ext-single-instance/README.md
new file mode 100644
index 000000000..bf784cf50
--- /dev/null
+++ b/crates/zng-ext-single-instance/README.md
@@ -0,0 +1,7 @@
+<!--do doc --readme header-->
+This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-file#crates) project.
+
+
+<!--do doc --readme features-->
+
+
diff --git a/crates/zng-ext-single-instance/src/lib.rs b/crates/zng-ext-single-instance/src/lib.rs
new file mode 100644
index 000000000..149f5cf97
--- /dev/null
+++ b/crates/zng-ext-single-instance/src/lib.rs
@@ -0,0 +1,272 @@
+#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/master/examples/res/image/zng-logo-icon.png")]
+#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/master/examples/res/image/zng-logo.png")]
+//!
+//! Single app-process instance mode.
+//!
+//! # Crate
+//!
+#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
+
+use std::{
+    io::{Read, Write},
+    time::Duration,
+};
+
+use zng_app::{
+    event::{event, event_args},
+    handler::{async_app_hn, clmv},
+    AppExtension,
+};
+use zng_ext_fs_watcher::WATCHER;
+use zng_txt::{ToTxt, Txt};
+
+/// Single instance event manager.
+///
+/// # Events
+///
+/// Events this extension provides.
+///
+/// * [`APP_INSTANCE_EVENT`]
+///
+/// Note that the event will notify even if [`single_instance()`] is not called, but it must be
+/// called to make the app actually single instance.
+#[derive(Default)]
+pub struct SingleInstanceManager {}
+impl AppExtension for SingleInstanceManager {
+    fn init(&mut self) {
+        let args: Box<[_]> = std::env::args().map(Txt::from).collect();
+        APP_INSTANCE_EVENT.notify(AppInstanceArgs::now(args, 0usize));
+
+        if let Some(name) = SINGLE_INSTANCE.lock().as_ref().map(|l| l.name.clone()) {
+            let args_file = std::env::temp_dir().join(name);
+            let mut count = 1usize;
+            WATCHER
+                .on_file_changed(
+                    &args_file,
+                    async_app_hn!(args_file, |_, _| {
+                        let args = zng_task::wait(clmv!(args_file, || {
+                            for i in 0..5 {
+                                if i > 0 {
+                                    std::thread::sleep(Duration::from_millis(200));
+                                }
+
+                                // take args
+                                // read all text and truncates the file
+                                match std::fs::File::options().read(true).write(true).open(&args_file) {
+                                    Ok(mut file) => {
+                                        let mut s = String::new();
+                                        if let Err(e) = file.read_to_string(&mut s) {
+                                            tracing::error!("error reading args (retry {i}), {e}");
+                                            continue;
+                                        }
+                                        file.set_len(0).unwrap();
+                                        return s;
+                                    }
+                                    Err(e) => {
+                                        if e.kind() == std::io::ErrorKind::NotFound {
+                                            return String::new();
+                                        }
+                                        tracing::error!("error reading args (retry {i}), {e}")
+                                    }
+                                }
+                            }
+                            String::new()
+                        }))
+                        .await;
+
+                        // parse args
+                        for line in args.lines() {
+                            let line = line.trim();
+                            if line.is_empty() {
+                                continue;
+                            }
+
+                            let args = match serde_json::from_str::<Box<[Txt]>>(line) {
+                                Ok(args) => args,
+                                Err(e) => {
+                                    tracing::error!("invalid args, {e}");
+                                    Box::new([])
+                                }
+                            };
+
+                            APP_INSTANCE_EVENT.notify(AppInstanceArgs::now(args, count));
+
+                            count += 1;
+                        }
+                    }),
+                )
+                .perm();
+        } else {
+            tracing::warn!("using `SingleInstanceManager` without calling `single_instance()`");
+        }
+    }
+}
+
+event_args! {
+    /// Arguments for [`APP_INSTANCE_EVENT`].
+    pub struct AppInstanceArgs {
+        /// Arguments the app instance was started with.
+        ///
+        /// See [`std::env::args`] for more details.
+        pub args: Box<[Txt]>,
+
+        /// Instance count. Is zero for the current process, in single instance mode
+        /// increments for each subsequent attempt to instantiate the app.
+        pub count: usize,
+
+        ..
+
+        fn delivery_list(&self, _list: &mut UpdateDeliveryList) { }
+    }
+}
+impl AppInstanceArgs {
+    /// If the arguments are for the currently executing process (main).
+    ///
+    /// This is only `true` once, on the first event on startup.
+    pub fn is_current(&self) -> bool {
+        self.count == 0
+    }
+}
+
+event! {
+    /// App instance init event, with the arguments.
+    ///
+    /// This event notifies once on start. If the app is "single instance" this event will also notify for each
+    /// new attempt to instantiate while the current process is already running.
+    pub static APP_INSTANCE_EVENT: AppInstanceArgs;
+}
+
+/// Enable single instance mode for the app-process.
+///
+/// The current executable path is used as the unique name. See [`single_instance_named`] for more details about name.
+///
+/// # Examples
+///
+/// The example below demonstrates a single instance process setup. The [`single_instance()`] function is called before
+/// the app starts building. After the app starts building, before run, a subscription for [`APP_INSTANCE_EVENT`] is setup,
+/// this event will receive args for the current instance on run and for other instances latter.
+///
+/// ```
+/// # mod zng { pub(crate) use zng_ext_single_instance as app; }
+/// # mod view_process { pub fn init() { } }
+/// # trait FakeDefaults { fn defaults(self) -> zng_app::AppExtended<impl zng_app::AppExtension>; }
+/// # impl FakeDefaults for zng_app::APP { fn defaults(self) -> zng_app::AppExtended<impl zng_app::AppExtension> { self.minimal() } }
+/// # use zng_app::{APP, handler::app_hn};
+/// fn main() {
+///     view_process::init();
+///     // zng::task::ipc::run_worker(worker);
+///
+///     // must be called after `view_process`, `run_worker` and before the APP build.
+///     zng::app::single_instance();
+///
+///     app_main();
+/// }
+///
+/// fn app_main() {
+///     let app = APP.defaults();
+///
+///     zng::app::APP_INSTANCE_EVENT
+///         .on_event(app_hn!(|args: &zng::app::AppInstanceArgs, _| {
+///             println!("app instance #{}, args: {:?}", args.count, args.args);
+///         }))
+///         .perm();
+///
+/// # macro_rules! demo { () => {
+///     app.run_window(async {
+///         Window! {
+///             child_align = Align::CENTER;
+///             child = Button! {
+///                 child = Text!("Spawn Instance");
+///                 on_click = hn!(|_| {
+///                     let exe = std::env::current_exe().unwrap();
+///                     std::process::Command::new(exe).arg("app arg 1").arg("--arg2").spawn().unwrap();
+///                 });
+///             }
+///         }
+///     });
+/// # }}
+/// }
+/// ```
+pub fn single_instance() {
+    single_instance_named(
+        std::env::current_exe()
+            .expect("current exe is required")
+            .canonicalize()
+            .expect("canonical current exe is required")
+            .display()
+            .to_txt(),
+    )
+}
+
+/// Enable single instance mode for the app-process, using a custom unique identifier for the app.
+///
+/// The name should have only ASCII alphanumerics and be at maximum 128 bytes in length only, otherwise it will be mangled.
+/// The name is used to create a global lock, see the [single-instance] crate for more details.
+///
+/// [single-instance]: https://docs.rs/single-instance/
+pub fn single_instance_named(unique_name: impl Into<Txt>) {
+    single_instance_impl(unique_name.into())
+}
+
+/// If single instance mode is enabled and the current process is the instance.
+///
+/// This is only valid after calling [`single_instance`] or [`single_instance_named`].
+///
+/// [`single_instance`]: fn@single_instance
+pub fn is_single_instance() -> bool {
+    SINGLE_INSTANCE.lock().is_some()
+}
+
+fn single_instance_impl(name: Txt) {
+    let mut lock = SINGLE_INSTANCE.lock();
+    assert!(lock.is_none(), "single_instance already called in this process");
+
+    let name: String = name
+        .chars()
+        .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' })
+        .collect();
+    let mut name = name.as_str();
+    if name.len() > 128 {
+        name = &name[name.len() - 128..];
+    }
+    let name = zng_txt::formatx!("zng-si-{name}");
+
+    let l = single_instance::SingleInstance::new(&name).expect("failed to create single instance lock");
+
+    if l.is_single() {
+        *lock = Some(SingleInstanceData { _lock: l, name });
+    } else {
+        tracing::info!("another instance running, will send args an exit");
+
+        let args: Box<[_]> = std::env::args().collect();
+        let args = format!("\n{}\n", serde_json::to_string(&args).unwrap());
+
+        let try_write = move || -> std::io::Result<()> {
+            let mut file = std::fs::File::options()
+                .create(true)
+                .append(true)
+                .open(std::env::temp_dir().join(name.as_str()))?;
+            file.write_all(args.as_bytes())
+        };
+
+        for i in 0..5 {
+            if i > 0 {
+                std::thread::sleep(std::time::Duration::from_millis(300));
+            }
+            match try_write() {
+                Ok(_) => std::process::exit(0),
+                Err(e) => {
+                    eprintln!("error writing args (retries: {i}), {e}");
+                }
+            }
+        }
+        std::process::exit(1);
+    }
+}
+
+struct SingleInstanceData {
+    _lock: single_instance::SingleInstance,
+    name: Txt,
+}
+
+static SINGLE_INSTANCE: parking_lot::Mutex<Option<SingleInstanceData>> = parking_lot::Mutex::new(None);
diff --git a/crates/zng-task/README.md b/crates/zng-task/README.md
index 2e3b7abd2..1abb8d6b1 100644
--- a/crates/zng-task/README.md
+++ b/crates/zng-task/README.md
@@ -5,11 +5,14 @@ This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-fi
 <!--do doc --readme features-->
 ## Cargo Features
 
-This crate provides 3 feature flags, 0 enabled by default.
+This crate provides 4 feature flags, 0 enabled by default.
 
 #### `"deadlock_detection"`
 Enables parking_lot deadlock detection.
 
+#### `"ipc"`
+Enables ipc tasks.
+
 #### `"http"`
 Enables http tasks.
 
diff --git a/crates/zng-txt/src/lib.rs b/crates/zng-txt/src/lib.rs
index 9fd63e06a..fa2aa5791 100644
--- a/crates/zng-txt/src/lib.rs
+++ b/crates/zng-txt/src/lib.rs
@@ -532,6 +532,11 @@ impl AsRef<str> for Txt {
         self.0.as_ref()
     }
 }
+impl AsRef<std::path::Path> for Txt {
+    fn as_ref(&self) -> &std::path::Path {
+        self.0.as_ref()
+    }
+}
 impl std::borrow::Borrow<str> for Txt {
     fn borrow(&self) -> &str {
         self.as_str()
diff --git a/crates/zng-unique-id/README.md b/crates/zng-unique-id/README.md
index d7cead618..00ad990da 100644
--- a/crates/zng-unique-id/README.md
+++ b/crates/zng-unique-id/README.md
@@ -5,18 +5,12 @@ This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-fi
 <!--do doc --readme features-->
 ## Cargo Features
 
-This crate provides 2 feature flags, 2 enabled by default.
-
+ This crate provides 1 feature flag, enabled by default.
 #### `"named"`
 Enable associated names for ID types.
 
 *Enabled by default.*
 
-#### `"bytemuck"`
-Derive `bytemuck` for ID types.
-
-*Enabled by default.*
-
 <!--do doc --readme #SECTION-END-->
 
 
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index c5b8723cf..78bdb6486 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -69,6 +69,11 @@ test_util = [
 # Not enabled by default, but enabled by `feature="test_util"`.
 multi_app = ["zng-app/multi_app"]
 
+# Allow single app-process instance mode.
+#
+# Note that `zng::app::single_instance` must used to enable single instance mode.
+single_instance = ["dep:zng-ext-single-instance"]
+
 # Instrument every widget outer-most node to trace UI methods.
 trace_widget = ["zng-app/trace_widget"]
 
@@ -207,6 +212,7 @@ zng-wgt-markdown = { path = "../zng-wgt-markdown", version = "0.2.9" }
 zng-wgt-inspector = { path = "../zng-wgt-inspector", version = "0.2.9" }
 
 zng-wgt-material-icons = { path = "../zng-wgt-material-icons", version = "0.2.9", default-features = false, optional = true }
+zng-ext-single-instance = { path = "../zng-ext-single-instance", version = "0.2.0", optional = true }
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/crates/zng/README.md b/crates/zng/README.md
index efebbb302..ad1a7dc86 100644
--- a/crates/zng/README.md
+++ b/crates/zng/README.md
@@ -45,7 +45,7 @@ See the [`documentation`] for more details.
 <!--do doc --readme features-->
 ## Cargo Features
 
-This crate provides 25 feature flags, 3 enabled by default.
+This crate provides 26 feature flags, 3 enabled by default.
 
 #### `"view"`
 Include the default view-process implementation.
@@ -92,6 +92,11 @@ depending on the current thread.
 
 Not enabled by default, but enabled by `feature="test_util"`.
 
+#### `"single_instance"`
+Allow single app-process instance mode.
+
+Note that `zng::app::single_instance` must used to enable single instance mode.
+
 #### `"trace_widget"`
 Instrument every widget outer-most node to trace UI methods.
 
@@ -150,7 +155,7 @@ Needs `cargo-about` and Internet connection during build.
 Not enabled by default. Note that `"view_prebuilt"` always bundles licenses.
 
 #### `"ipc"`
-Enables pre-build views and connecting to views running in another process.
+Enables IPC tasks, pre-build views and connecting to views running in another process.
 
 *Enabled by default.*
 
diff --git a/crates/zng/src/app.rs b/crates/zng/src/app.rs
index 9be1d580e..5e11decaf 100644
--- a/crates/zng/src/app.rs
+++ b/crates/zng/src/app.rs
@@ -423,3 +423,6 @@ pub mod raw_device_events {
         DeviceArgs, DeviceId, MotionArgs, StaticDeviceId, DEVICE_ADDED_EVENT, DEVICE_REMOVED_EVENT, MOTION_EVENT,
     };
 }
+
+#[cfg(feature = "single_instance")]
+pub use zng_ext_single_instance::{is_single_instance, single_instance, single_instance_named, AppInstanceArgs, APP_INSTANCE_EVENT};
diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs
index 7eccecda2..5e0d91b1c 100644
--- a/crates/zng/src/lib.rs
+++ b/crates/zng/src/lib.rs
@@ -837,9 +837,11 @@ mod defaults {
         /// * [`ImageManager`]
         /// * [`ClipboardManager`]
         /// * [`UndoManager`]
+        /// * [`SingleInstanceManager`] if the `"single_instance"` feature is enabled.
         /// * [`MaterialFonts`] if any `"material_icons*"` feature is enabled.
         ///
         /// [`MaterialFonts`]: zng_wgt_material_icons::MaterialFonts
+        /// [`SingleInstanceManager`]: zng_ext_single_instance::SingleInstanceManager
         /// [`ConfigManager`]: zng_ext_config::ConfigManager
         /// [`L10nManager`]: zng_ext_l10n::L10nManager
         /// [`FontManager`]: zng_ext_font::FontManager
@@ -861,6 +863,9 @@ mod defaults {
                 .extend(ClipboardManager::default())
                 .extend(UndoManager::default());
 
+            #[cfg(feature = "single_instance")]
+            let r = r.extend(zng_ext_single_instance::SingleInstanceManager::default());
+
             #[cfg(any(
                 feature = "material_icons_outlined",
                 feature = "material_icons_filled",
diff --git a/tools/do-tasks/src/util.rs b/tools/do-tasks/src/util.rs
index 7a3d5b96f..6ea7718e2 100644
--- a/tools/do-tasks/src/util.rs
+++ b/tools/do-tasks/src/util.rs
@@ -561,12 +561,12 @@ fn parse_publish_version(version: &str) -> (u32, u32, u32) {
             match n.parse() {
                 Ok(n) => n,
                 Err(e) => {
-                    error(f!("{e}, expected version #.#.#"));
+                    error(f!("{e}, expected version #.#.# in local dependency"));
                     0
                 }
             }
         } else {
-            error("expected version #.#.#");
+            error("expected version #.#.# in local dependency");
             0
         }
     }


Test patch: