diff --git a/backend/internal/domain/model/version.go b/backend/internal/domain/model/version.go index 56c1f05..c8bfd77 100644 --- a/backend/internal/domain/model/version.go +++ b/backend/internal/domain/model/version.go @@ -2,4 +2,4 @@ package model // AppVersion stores the compiled version of the application. // Automatically maintained by scripts/sync-version.js and injected into the go build via -ldflags. -var AppVersion = "v0.1.19-alpha" +var AppVersion = "v0.1.20-beta" diff --git a/frontend/package.json b/frontend/package.json index d9cdc5d..c8e3027 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.1.19-alpha", + "version": "0.1.20-beta", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 36bbac2..48b8aad 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -73,6 +73,137 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -168,6 +299,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -380,6 +524,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -609,10 +762,11 @@ dependencies = [ [[package]] name = "devaulty" -version = "0.1.16-alpha" +version = "0.1.20-beta" dependencies = [ "dirs", "futures-util", + "libc", "log", "reqwest 0.12.28", "serde", @@ -621,6 +775,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", "uuid", @@ -789,6 +944,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -826,6 +1008,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -966,6 +1168,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -1325,6 +1540,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -2263,6 +2484,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2302,6 +2533,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2390,6 +2627,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2435,6 +2683,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3233,6 +3495,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3678,6 +3950,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4139,9 +4427,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4191,6 +4491,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5148,6 +5459,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5225,3 +5606,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index dde8985..60b3847 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devaulty" -version = "0.1.19-alpha" +version = "0.1.20-beta" description = "Devaulty Desktop Client" authors = ["Matheus Cunha"] license = "" @@ -25,8 +25,12 @@ tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] } tauri-plugin-log = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-single-instance = "2" dirs = "6.0" tokio = { version = "1", features = ["time", "fs", "io-util"] } uuid = { version = "1", features = ["v4"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } futures-util = "0.3" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index b9cca5d..c7b18fc 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -76,6 +76,31 @@ pub fn ensure_executable(_path: &std::path::Path) -> std::io::Result<()> { Ok(()) } +// Asks the kernel to send SIGTERM to the backend child if this (parent) +// process dies for any reason without going through tray::quit_app - crash, +// `kill -9`, session logout, etc. Without this, the Go backend is left as an +// orphan still holding its SQLite lock and HTTP port. +// PR_SET_PDEATHSIG is a Linux-only prctl; libc doesn't expose it for macOS, +// so this must stay behind target_os = "linux", not the broader cfg(unix). +#[cfg(target_os = "linux")] +fn die_with_parent() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static { + let parent_pid = std::process::id() as libc::pid_t; + move || { + unsafe { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + } + // Guards the fork/prctl race: if the parent already died between fork() + // and this prctl() call, getppid() no longer matches, so terminate + // immediately instead of running on as an orphan anyway. + if unsafe { libc::getppid() } != parent_pid { + unsafe { + libc::raise(libc::SIGTERM); + } + } + Ok(()) + } +} + // Locates, prepares, and spawns the bundled Go backend as a child process, // wiring its stdout session handshake into the shared SessionState. // Straight extraction of the logic that used to live inline in run()'s @@ -123,6 +148,18 @@ pub fn spawn_backend(app: &tauri::AppHandle, devaulty_data_dir: &std::path::Path command.creation_flags(CREATE_NO_WINDOW); } + // Ties the backend's lifetime to this process on Linux so it can never + // outlive Devaulty as an orphan holding the DB lock / port open. + // macOS has no PR_SET_PDEATHSIG equivalent in libc, so this is skipped + // there for now. + #[cfg(target_os = "linux")] + { + use std::os::unix::process::CommandExt; + unsafe { + command.pre_exec(die_with_parent()); + } + } + match command.spawn() { Ok(mut child) => { // Mark as bundled production mode only after successful process spawn diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index e47e3a5..628ac67 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,10 +3,20 @@ mod commands; mod session; mod tray; +use std::sync::atomic::Ordering; use std::sync::Arc; +use std::time::Duration; + +use tauri::Manager; use session::SessionState; +// How long the main window is kept alive (hidden, not destroyed) after being +// sent to the tray before its WebView is actually torn down to reclaim RAM. +// Reopening within this window is instant (just an unhide); reopening after +// it has elapsed goes through the full splash + rebuild flow, same as boot. +const TRAY_DESTROY_GRACE_PERIOD: Duration = Duration::from_secs(180); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Ensure the user config/data directory exists for database storage @@ -27,6 +37,13 @@ pub fn run() { let state_clone = Arc::clone(&session_state); tauri::Builder::default() + // Must be registered before other plugins: if Devaulty is launched again + // while an instance is already running (double-click, launcher, CLI), + // this intercepts the second launch and just brings the existing window + // forward instead of spawning a whole new process + Go backend. + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + tray::show_main_window(app); + })) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(session_state) @@ -58,11 +75,52 @@ pub fn run() { .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() == "main" { + // Real close, not a permanent one: hide immediately so reopening + // right away (the common case) is instant, then schedule the + // actual WebView teardown after a grace period so idle time in + // the tray still gets its RAM back. See TRAY_DESTROY_GRACE_PERIOD. api.prevent_close(); let _ = window.hide(); + + let app_handle = window.app_handle().clone(); + let state = app_handle.state::>().inner().clone(); + + // Stamp this hide with the current epoch under the shared lock. + let my_epoch = { + let mut epoch = state.hide_epoch.lock().unwrap(); + *epoch += 1; + *epoch + }; + + tauri::async_runtime::spawn(async move { + tokio::time::sleep(TRAY_DESTROY_GRACE_PERIOD).await; + + // Hold the lock across the whole check-and-destroy sequence so a + // concurrent reopen (tray::show_main_window) can't slip in + // between the epoch check and the destroy() call. + let epoch = state.hide_epoch.lock().unwrap(); + if *epoch == my_epoch { + if let Some(w) = app_handle.get_webview_window("main") { + let _ = w.destroy(); + } + } + }); } } }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + // Destroying the "main" window above would otherwise make Tauri quit + // the whole process once no windows are left. We only want to exit + // when the user explicitly picks "Quit" from the tray (tray::quit_app + // sets state.quitting before calling app.exit(0)), so only swallow + // the exit request when it wasn't an intentional quit. + if let tauri::RunEvent::ExitRequested { api, .. } = event { + let state = app_handle.state::>(); + if !state.quitting.load(Ordering::SeqCst) { + api.prevent_exit(); + } + } + }); } \ No newline at end of file diff --git a/frontend/src-tauri/src/session.rs b/frontend/src-tauri/src/session.rs index 759481f..8e8ec82 100644 --- a/frontend/src-tauri/src/session.rs +++ b/frontend/src-tauri/src/session.rs @@ -1,4 +1,5 @@ use std::process::Child; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; // In-memory state shared between Tauri commands and the backend child process. @@ -9,6 +10,18 @@ pub struct SessionState { pub child_process: Mutex>, pub is_bundled_mode: Mutex, pub active_download_cancel: Mutex)>>, + // Guards the hide -> grace-period -> destroy sequence against reopen races. + // Both the destroy timer (lib.rs) and any path that reopens/recreates the + // window (tray::show_main_window) must hold this lock for their entire + // check-then-act sequence, not just the epoch read - otherwise a reopen + // landing between the epoch check and the actual destroy() call would let + // the timer tear down a window the user just reopened. + pub hide_epoch: Mutex, + // Set right before tray::quit_app calls app.exit(0), so the ExitRequested + // handler in lib.rs's run() closure knows this exit is intentional and + // should NOT be swallowed by the prevent_exit() used to survive the main + // window being destroyed with no windows left open. + pub quitting: AtomicBool, } // Response sent to React when it calls `invoke("get_backend_info")` diff --git a/frontend/src-tauri/src/tray.rs b/frontend/src-tauri/src/tray.rs index 801d808..a5b1c01 100644 --- a/frontend/src-tauri/src/tray.rs +++ b/frontend/src-tauri/src/tray.rs @@ -1,18 +1,20 @@ +use std::sync::atomic::Ordering; use std::sync::Arc; use tauri::{ image::Image, menu::{Menu, MenuItem}, tray::TrayIconBuilder, - AppHandle, Manager, + AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, }; use crate::session::SessionState; // Builds and registers the system tray icon with a minimal "Open" / "Quit" menu. -// Closing the main window hides it instead of exiting the process (wired in -// lib.rs's on_window_event), so the backend and its resolved resource paths -// (including the AppImage FUSE mount, when applicable) stay alive until the -// user explicitly quits from here. +// Closing the main window hides it (and, after a grace period, destroys its +// WebView) instead of exiting the process (wired in lib.rs's on_window_event +// + run() exit-request handler), so the backend and its resolved resource +// paths (including the AppImage FUSE mount, when applicable) stay alive +// until the user explicitly quits from here. pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let open_item = MenuItem::with_id(app, "open", "Open Devaulty", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; @@ -34,15 +36,73 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -fn show_main_window(app: &AppHandle) { +// Shows the main window, recreating it from scratch if it was previously +// destroyed (grace period elapsed after a tray minimize) or doesn't exist +// yet (second-instance relaunch). Mirrors the "main" window entry in +// tauri.conf.json so the rebuilt window is indistinguishable from the one +// created at boot. The Go backend is already running at this point +// (port/token resolved on first launch), so get_backend_info on the +// frontend resolves almost immediately here. +pub fn show_main_window(app: &AppHandle) { + let state = app.state::>(); + + // Hold the same lock the destroy timer uses. Bumping the epoch here is + // what invalidates a pending timer, but only holding the lock for the + // *entire* reopen/recreate sequence (not just the bump) guarantees no + // destroy() can run concurrently and undo this reopen. + let mut epoch = state.hide_epoch.lock().unwrap(); + *epoch += 1; + if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); + return; + } + + // Cold path: the window was actually destroyed. Recreate the splash + // window too (it was closed/destroyed by commands::close_splash right + // after the last boot) and rebuild "main" hidden, exactly like app + // startup. The frontend's normal boot sequence - get_backend_info, then + // invoking close_splash once it's ready - takes care of showing "main" + // and tearing the splash back down, so reopening after a long idle period + // looks the same as first launch instead of a blank window mid-render. + if app.get_webview_window("splash").is_none() { + if let Err(e) = recreate_splash_window(app) { + log::error!("Failed to recreate splash window: {}", e); + } } + + if let Err(e) = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("Devaulty") + .min_inner_size(1000.0, 650.0) + .resizable(true) + .fullscreen(false) + .visible(false) + .build() + { + log::error!("Failed to recreate main window: {}", e); + } +} + +// Rebuilds the "splash" window with the same attributes it has in +// tauri.conf.json (used only at first boot otherwise). +fn recreate_splash_window(app: &AppHandle) -> tauri::Result<()> { + WebviewWindowBuilder::new(app, "splash", WebviewUrl::App("/splash.html".into())) + .title("Devaulty") + .inner_size(550.0, 400.0) + .center() + .decorations(false) + .transparent(true) + .always_on_top(true) + .build()?; + Ok(()) } fn quit_app(app: &AppHandle) { let state = app.state::>(); + // Mark this exit as intentional before requesting it, so lib.rs's + // ExitRequested handler lets it through instead of preventing it. + state.quitting.store(true, Ordering::SeqCst); if let Some(mut child) = state.child_process.lock().unwrap().take() { let _ = child.kill(); } diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 78dff1b..2924984 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Devaulty", - "version": "0.1.19-alpha", + "version": "0.1.20-beta", "identifier": "com.devaulty.app", "build": { "frontendDist": "../dist",