33//! Owns the boot path: plugin wiring in the SPEC s14 order
44//! (single-instance FIRST, then deep-link, then autostart + notification),
55//! a `.setup()` that runs migrations, assembles + spawns the per-account
6- //! orchestrators, manages the [`AppState`], builds the tray, and installs the
7- //! panic hook, plus the SPEC s11.3 sync IPC command registration.
6+ //! orchestrators, manages the [`AppState`], builds the tray, wires the
7+ //! deep-link `on_open_url` callback, and shows-or-hides the main window based
8+ //! on the `--minimized` flag, plus the SPEC s11.3 sync IPC command
9+ //! registration and the clean-shutdown path on quit (ROADMAP M5: "Quit
10+ //! cleanly shuts down the runtime, no orphaned tokio tasks").
11+
12+ // The `rust_i18n::i18n!` macro MUST be invoked at the crate root: it generates
13+ // `crate::_rust_i18n_t`, which every `rust_i18n::t!` call site (tray, OS
14+ // notifications) resolves against. Invoking it inside a submodule would place
15+ // the helper at `crate::<module>::_rust_i18n_t` and break those call sites.
16+ // The `locales` path is relative to `CARGO_MANIFEST_DIR` (src-tauri/).
17+ rust_i18n:: i18n!( "locales" , fallback = "en-US" ) ;
818
919mod app_state;
1020mod assembly;
1121mod commands;
1222mod crypto_provider_impl;
23+ // The elevation module is the complete M5-shipped "run elevated on login" /
24+ // "restart elevated" public API (ROADMAP M3.5 deferred to M5). Its callers are
25+ // the Settings IPC commands, which land in M6 (ROADMAP M6 "IPC commands per
26+ // SPEC s11.1/s11.2/s11.6 fully wired"); M5's IPC surface is sync-only. The
27+ // module is therefore reachable-but-uncalled until M6, so allow dead_code here
28+ // rather than registering an M6-scope settings command early.
29+ #[ allow( dead_code) ]
1330mod elevation;
1431mod events;
1532mod i18n;
@@ -19,56 +36,178 @@ mod tray;
1936
2037use std:: path:: PathBuf ;
2138
22- use tauri:: Manager ;
39+ use tauri:: { Manager , RunEvent } ;
40+ use tauri_plugin_deep_link:: DeepLinkExt ;
2341
2442pub use app_state:: { AccountHandle , AppState , RemoteMode } ;
2543
26- /// Resolve the SQLite state-DB path under the OS config dir
27- /// (`<config_dir>/driven/state.db`, SPEC s2).
44+ /// CLI flag (SPEC s13): boot straight to the tray with no visible window.
45+ /// Passed by the autostart launcher so login start does not pop a window.
46+ const ARG_MINIMIZED : & str = "--minimized" ;
47+
48+ /// CLI flag (DESIGN s4.1): quit a running instance. Reachable only via the
49+ /// tray menu or this flag; a second launch carrying it asks the primary
50+ /// instance (via the single-instance callback) to exit.
51+ const ARG_QUIT : & str = "--quit" ;
52+
53+ /// The main window label, matching `tauri.conf.json` `app.windows[].label`
54+ /// (SPEC s20). The window is declared there with `visible: false`, so it
55+ /// exists hidden at boot and we show it for a normal (non-`--minimized`)
56+ /// launch.
57+ const MAIN_WINDOW : & str = "main" ;
58+
59+ /// `<config_dir>/driven/state.db` (SPEC s2), resolved from Tauri's
60+ /// `app_config_dir()` (`config_dir() + identifier`). `app.driven` is the
61+ /// `tauri.conf.json` identifier, so this is `<config_dir>/app.driven/...`;
62+ /// the `driven/` segment keeps the state DB grouped with the logs the panic
63+ /// hook + diagnostic bundle use.
64+ fn state_db_path ( app : & tauri:: AppHandle ) -> anyhow:: Result < PathBuf > {
65+ let config_dir = app
66+ . path ( )
67+ . app_config_dir ( )
68+ . map_err ( |e| anyhow:: anyhow!( "resolve app_config_dir: {e}" ) ) ?;
69+ Ok ( config_dir. join ( "driven" ) . join ( "state.db" ) )
70+ }
71+
72+ /// Show + focus the main window (a normal launch, a tray/dock click, or a
73+ /// second-launch surface). No-op if the window is not present.
74+ fn show_main_window ( app : & tauri:: AppHandle ) {
75+ if let Some ( window) = app. get_webview_window ( MAIN_WINDOW ) {
76+ let _ = window. unminimize ( ) ;
77+ let _ = window. show ( ) ;
78+ let _ = window. set_focus ( ) ;
79+ }
80+ }
81+
82+ /// `true` if `argv` carries the `--minimized` boot flag (SPEC s13).
83+ fn argv_has_minimized ( argv : & [ String ] ) -> bool {
84+ argv. iter ( ) . any ( |a| a == ARG_MINIMIZED )
85+ }
86+
87+ /// `true` if `argv` carries the `--quit` flag (DESIGN s4.1).
88+ fn argv_has_quit ( argv : & [ String ] ) -> bool {
89+ argv. iter ( ) . any ( |a| a == ARG_QUIT )
90+ }
91+
92+ /// Handle a deep-link URL forwarded to the primary instance. The window is
93+ /// surfaced so the user sees the result; route-specific handling
94+ /// (`driven://restore/...` etc.) is an M6+ concern - the M5 contract is that
95+ /// a deep link wakes and shows the running app rather than spawning a
96+ /// duplicate (SPEC s14).
97+ fn handle_deep_link ( app : & tauri:: AppHandle , url : & str ) {
98+ tracing:: info!( target: "driven::app" , url, "deep link opened" ) ;
99+ show_main_window ( app) ;
100+ }
101+
102+ /// Apply a second-launch invocation forwarded by the single-instance plugin
103+ /// (SPEC s14): `--quit` exits the primary; otherwise we surface the existing
104+ /// window unless the relaunch itself was `--minimized`. The deep-link plugin
105+ /// hooks this same callback to forward URLs as argv on Windows/Linux.
106+ fn handle_second_launch ( app : & tauri:: AppHandle , argv : & [ String ] ) {
107+ if argv_has_quit ( argv) {
108+ tracing:: info!( target: "driven::app" , "second launch requested quit" ) ;
109+ app. exit ( 0 ) ;
110+ return ;
111+ }
112+ if argv_has_minimized ( argv) {
113+ // A login-start relaunch while already running: nothing to surface.
114+ tracing:: debug!( target: "driven::app" , "second launch was --minimized; staying in tray" ) ;
115+ return ;
116+ }
117+ show_main_window ( app) ;
118+ }
119+
120+ /// Abort every per-account orchestrator run loop so quit leaves no orphaned
121+ /// tokio tasks (ROADMAP M5 acceptance; the committed [`AccountHandle`]
122+ /// contract is abort-on-shutdown - app_state.rs documents the run loop as
123+ /// "aborted on shutdown").
28124///
29- /// TODO(M5): derive from `app.path().app_config_dir()` inside `.setup()`
30- /// instead of this placeholder (which exists so the boot path compiles).
31- fn state_db_path ( ) -> PathBuf {
32- todo ! ( "M5: <app_config_dir>/driven/state.db via app.path().app_config_dir()" )
125+ /// The committed [`Orchestrator`](driven_core::orchestrator::Orchestrator)
126+ /// trait object does not expose the concrete `SyncOrchestrator::shutdown()`
127+ /// watch-signal (the between-cycles graceful drain of DESIGN s5.10.2 lives on
128+ /// the concrete type, not the trait), so the shell tears the loops down via
129+ /// the public `run_loop` `JoinHandle::abort()`. Abort schedules cancellation
130+ /// of each task; combined with process exit this guarantees no run loop
131+ /// outlives the app.
132+ fn shutdown_orchestrators ( app : & tauri:: AppHandle ) {
133+ let Some ( state) = app. try_state :: < AppState > ( ) else {
134+ return ;
135+ } ;
136+ for ( account_id, handle) in state. accounts ( ) {
137+ tracing:: info!( target: "driven::app" , account_id = %account_id, "aborting orchestrator run loop on quit" ) ;
138+ handle. run_loop . abort ( ) ;
139+ }
33140}
34141
35142#[ cfg_attr( mobile, tauri:: mobile_entry_point) ]
36143pub fn run ( ) {
37144 tracing_subscriber:: fmt:: init ( ) ;
145+ // i18n must initialise before any tray/notification string is built;
146+ // keep this ahead of the builder (and ahead of the panic hook, which only
147+ // emits ASCII).
38148 i18n:: init ( ) ;
39- // SPEC s17: install the crash-dump panic hook before anything can panic.
149+ // SPEC s17: install the crash-dump panic hook before anything can panic,
150+ // so a panic during plugin init / `.setup()` / assembly is captured too.
40151 panic_hook:: install ( ) ;
41152
42- tauri:: Builder :: default ( )
153+ let build_result = tauri:: Builder :: default ( )
43154 // SPEC s14: single-instance MUST be registered FIRST so deep-link can
44155 // hook its second-launch callback (forwarding URLs + argv to the
45- // primary instance). The callback focuses/surfaces the existing window.
46- . plugin ( tauri_plugin_single_instance:: init ( |_app, _argv, _cwd| {
47- // TODO(M5): show_window(app, "main", Route::default()) +
48- // handle_argv(app, argv) (parse --minimized / --restore <path>).
156+ // primary instance). The callback surfaces the existing window and
157+ // applies the forwarded argv (`--quit` / `--minimized` / a deep-link
158+ // URL passed as an arg on Windows + Linux).
159+ . plugin ( tauri_plugin_single_instance:: init ( |app, argv, _cwd| {
160+ handle_second_launch ( app, & argv) ;
49161 } ) )
50162 // SPEC s14: deep-link SECOND so it hooks the single-instance callback.
51163 . plugin ( tauri_plugin_deep_link:: init ( ) )
52164 // SPEC s13: autostart (LaunchAgent on macOS; registry/.desktop
53165 // elsewhere) with the --minimized arg so login start boots to tray.
54166 . plugin ( tauri_plugin_autostart:: init (
55167 tauri_plugin_autostart:: MacosLauncher :: LaunchAgent ,
56- Some ( vec ! [ "--minimized" ] ) ,
168+ Some ( vec ! [ ARG_MINIMIZED ] ) ,
57169 ) )
170+ // SPEC s11.7 / M5: OS notifications (first-sync-done, error states).
58171 . plugin ( tauri_plugin_notification:: init ( ) )
59172 . setup ( |app| {
60173 let handle = app. handle ( ) . clone ( ) ;
61174 // Boot path (SPEC s11): migrations -> assemble + spawn
62175 // orchestrators -> manage AppState -> build tray. Async work runs
63- // on the Tauri async runtime; failures abort startup.
176+ // on the Tauri async runtime; failures abort startup (and are
177+ // captured by the panic hook only if they panic - here they
178+ // propagate as a `setup` error and Tauri reports them).
64179 tauri:: async_runtime:: block_on ( async move {
65- let db_path = state_db_path ( ) ;
180+ let db_path = state_db_path ( & handle ) ? ;
66181 let state = migrations:: run ( & db_path) . await ?;
67182 let app_state = assembly:: build_and_spawn ( & handle, state) . await ?;
68183 handle. manage ( app_state) ;
69184 tray:: build ( & handle) ?;
70185 Ok :: < ( ) , anyhow:: Error > ( ( ) )
71186 } ) ?;
187+
188+ // SPEC s14: deep-link URLs arrive via this callback (NOT argv
189+ // parsing) - on macOS via the Apple event, on Windows/Linux via
190+ // the single-instance argv forwarding, transparently.
191+ let dl_handle = app. handle ( ) . clone ( ) ;
192+ app. deep_link ( ) . on_open_url ( move |event| {
193+ for url in event. urls ( ) {
194+ handle_deep_link ( & dl_handle, url. as_str ( ) ) ;
195+ }
196+ } ) ;
197+
198+ // SPEC s13 / s20: the main window is declared hidden in
199+ // tauri.conf.json. Show it for a normal launch; keep it hidden
200+ // (tray-only) when started with --minimized (e.g. from autostart
201+ // at login). `std::env::args` is the primary-process argv; the
202+ // second-launch argv is handled by the single-instance callback.
203+ let argv: Vec < String > = std:: env:: args ( ) . collect ( ) ;
204+ if argv_has_quit ( & argv) {
205+ // A first launch carrying --quit (no primary to forward to):
206+ // there is nothing running to quit, so honour it by exiting.
207+ app. handle ( ) . exit ( 0 ) ;
208+ } else if !argv_has_minimized ( & argv) {
209+ show_main_window ( app. handle ( ) ) ;
210+ }
72211 Ok ( ( ) )
73212 } )
74213 . invoke_handler ( tauri:: generate_handler![
@@ -77,6 +216,27 @@ pub fn run() {
77216 commands:: sync:: resume_sync,
78217 commands:: sync:: get_sync_status,
79218 ] )
80- . run ( tauri:: generate_context!( ) )
81- . expect ( "error while running Driven Tauri application" ) ;
219+ . build ( tauri:: generate_context!( ) ) ;
220+
221+ // No `expect()` at the boundary (the workspace bans `unwrap`/`expect` in
222+ // non-test code): on a build failure, log it and exit non-zero rather than
223+ // panic. The panic hook would catch a panic here too, but a clean exit is
224+ // the right shape for an unrecoverable startup error.
225+ let app = match build_result {
226+ Ok ( app) => app,
227+ Err ( err) => {
228+ tracing:: error!( target: "driven::app" , %err, "failed to build Driven Tauri application" ) ;
229+ std:: process:: exit ( 1 ) ;
230+ }
231+ } ;
232+
233+ // Drive the event loop ourselves so quit tears the orchestrator run loops
234+ // down cleanly (ROADMAP M5 "Quit cleanly shuts down the runtime, no
235+ // orphaned tokio tasks"). On the exit request we abort every run loop
236+ // before letting the process exit.
237+ app. run ( |app_handle, event| {
238+ if let RunEvent :: ExitRequested { .. } = & event {
239+ shutdown_orchestrators ( app_handle) ;
240+ }
241+ } ) ;
82242}
0 commit comments