Skip to content

Commit 72d4ee6

Browse files
pmaxhoganclaude
andcommitted
feat(app): plugin wiring + panic hook + autostart
Boot path in the SPEC s14 plugin order: single-instance FIRST (second-launch argv: --quit exits, --minimized stays in tray, else surfaces the window), deep-link SECOND, autostart (LaunchAgent, --minimized), notification. .setup() resolves the DB path -> migrations::run -> assembly::build_and_spawn -> manage AppState -> tray::build -> deep-link on_open_url. --minimized boots hidden; RunEvent::ExitRequested aborts every run loop for a clean quit. panic_hook::install writes a crash dump (redaction-policy banner + UTC time + panic info + backtrace) under the config logs dir, then chains the previous hook; every step is best-effort so the hook never re-panics. The rust_i18n::i18n! macro is moved to the crate root (lib.rs) so the generated crate::_rust_i18n_t resolves for every tray/notification t! call site; i18n::init keeps the OS-detected locale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent 9476484 commit 72d4ee6

3 files changed

Lines changed: 338 additions & 35 deletions

File tree

src-tauri/src/i18n.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
//! tooltip). Locale is OS-detected on first run and overridable via
66
//! `Settings -> UI -> locale` (DESIGN s8.7 / SPEC s22).
77
8-
rust_i18n::i18n!("locales", fallback = "en-US");
9-
108
pub fn init() {
119
let locale = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
1210
rust_i18n::set_locale(&locale);

src-tauri/src/lib.rs

Lines changed: 180 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,30 @@
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

919
mod app_state;
1020
mod assembly;
1121
mod commands;
1222
mod 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)]
1330
mod elevation;
1431
mod events;
1532
mod i18n;
@@ -19,56 +36,178 @@ mod tray;
1936

2037
use std::path::PathBuf;
2138

22-
use tauri::Manager;
39+
use tauri::{Manager, RunEvent};
40+
use tauri_plugin_deep_link::DeepLinkExt;
2341

2442
pub 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)]
36143
pub 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

Comments
 (0)