//! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`). //! The GUI never talks to a store itself; every store call is a storebound command. pub mod accounts; pub mod ai_fix; pub mod cache; pub mod card; pub mod commands; pub mod confirm; pub mod downloads; pub mod logs; pub mod menu_panel; pub mod policy; pub mod profiles; pub mod registry; pub mod selftest; pub mod updates; pub mod sidecar; pub mod store; pub mod tray; pub mod workspace; include!("commands_list.rs"); use std::sync::Mutex; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_deep_link::DeepLinkExt; /// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless /// the user set the variable (WEBKIT_DISABLE_DMABUF_RENDERER=0 keeps DMABUF). Must run before any webview exists. pub fn linux_webkit_env() { #[cfg(target_os = "linux")] if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { // SAFETY: called first thing in main, before any thread is started. unsafe { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") }; } } /// Started by the logon entry (`--hidden`): the app runs in the tray and shows no window. fn started_hidden() -> bool { std::env::args().any(|a| a == "--hidden") } /// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with /// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit, /// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window /// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu, /// rendered from the same tree, stays on the icon there (and anywhere the panels fail). fn tray(app: &tauri::App) -> tauri::Result<()> { let handle = app.handle(); let panels = handle.state::(); if let Ok(mut n) = panels.nodes.lock() { *n = tray::initial_nodes(); } let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false); if cfg!(target_os = "linux") { panels.native.store(true, std::sync::atomic::Ordering::Relaxed); b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?); } if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref())) .on_tray_icon_event(|t, e| { let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return }; let app = t.app_handle(); // both open from the icon's rectangle (not the click point): their edge sits next to the icon, above a // bottom taskbar, so nothing starts inside the taskbar let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); let p = rect.position.to_physical::(scale); let s = rect.size.to_physical::(scale); let icon = if s.width > 0.0 && s.height > 0.0 { card::Rect { x: p.x, y: p.y, w: s.width, h: s.height } } else { card::Rect { x: position.x, y: position.y, w: 1.0, h: 1.0 } }; match button { MouseButton::Right => menu_panel::open_root(app, menu_panel::Anchor::Tray { x: icon.x, y: icon.y, w: icon.w, h: icon.h }), MouseButton::Left => { if app.get_webview_window(card::LABEL).is_none() { show_main(app); return; } card::toggle(app, icon); } _ => {} } }) .build(app)?; Ok(()) } /// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does. pub fn dispatch_menu(app: &AppHandle, id: &str) { match tray::action_of(id) { Some(tray::Action::Open) => show_main(app), Some(tray::Action::Quit) => app.exit(0), Some(tray::Action::Event(word)) => { let _ = app.emit_to(policy::MAIN, "tray-action", word); } Some(tray::Action::Route(route)) => { show_main(app); let _ = app.emit_to(policy::MAIN, "tray-route", route); } None => {} } } /// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is /// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged. /// Background updater (docs/desktop-release.md, "Dev channel"): asks the chosen channel's latest.json at start, every /// 15 minutes, right after a resume from sleep and when Settings switched the channel; installs a strictly newer, signed /// build silently (the plugin checks the minisign signature first), deferred while the main window is in use (at most /// updates::MAX_DEFERS checks in a row). Every step goes to host.log as `auto-update ...`. fn auto_update(app: AppHandle) { tauri::async_runtime::spawn(async move { use std::time::{Duration, Instant, SystemTime}; tokio::time::sleep(Duration::from_secs(10)).await; let mut first = true; let mut last_check = Instant::now(); let mut last_wall = SystemTime::now(); let mut defers = 0u32; loop { let now_wall = SystemTime::now(); let gap = now_wall.duration_since(last_wall).map(|d| d.as_secs()).unwrap_or(0); last_wall = now_wall; let kicked = updates::take_kick(); if updates::check_due(first, kicked, gap, last_check.elapsed().as_secs()) { let why = if first { "start" } else if kicked { "channel switched" } else if gap > updates::TICK_SECS + 60 { "resume" } else { "every 15 min" }; first = false; last_check = Instant::now(); defers = auto_update_once(&app, why, defers).await; } tokio::time::sleep(Duration::from_secs(updates::TICK_SECS)).await; } }); } /// One check of the background updater; returns the new count of deferred installs. async fn auto_update_once(app: &AppHandle, why: &str, defers: u32) -> u32 { let state = app.state::(); let channel = match state.saved.lock() { Ok(s) => s.settings.channel.clone(), Err(_) => return defers, }; let current = env!("CARGO_PKG_VERSION"); let update = match commands::find_update(app, &channel).await { Ok(Some(u)) => u, Ok(None) => { logs::host("info", &format!("auto-update ({why}): {current} is current on {channel}")); return 0; } Err(e) => { logs::host("warn", &format!("auto-update ({why}): check of {channel} failed: {}", e.message)); return defers; } }; if !updates::should_install(current, &update.version) { logs::host("info", &format!("auto-update ({why}): {channel} offers {}, not newer than {current}: no downgrade", update.version)); return 0; } let main = app.get_webview_window(policy::MAIN); let visible = main.as_ref().and_then(|w| w.is_visible().ok()).unwrap_or(false); let focused = main.as_ref().and_then(|w| w.is_focused().ok()).unwrap_or(false); if updates::defer_install(visible, focused, defers) { logs::host("info", &format!("auto-update ({why}): {} on {channel} waits, the window is in use ({}/{})", update.version, defers + 1, updates::MAX_DEFERS)); return defers + 1; } logs::host("info", &format!("auto-update ({why}): installing {} from {channel} (this is {current})", update.version)); // the sidecar binary lives inside the app bundle that is about to be replaced state.sidecar.shutdown().await; match update.download_and_install(|_, _| {}, || {}).await { Ok(()) => { logs::host("info", &format!("auto-update: {} installed, restarting", update.version)); app.restart() } Err(e) => { logs::host("warn", &format!("auto-update: install of {} failed: {}", update.version, updates::error_text(&e.to_string()))); 0 } } } fn show_main(app: &AppHandle) { if let Some(w) = app.get_webview_window(policy::MAIN) { let _ = w.unminimize(); let _ = w.show(); let _ = w.set_focus(); // the last notification's target (the plugin has no click callback on desktop): the window goes there now if let Some(route) = tray::take_pending() { let _ = app.emit_to(policy::MAIN, "tray-route", route); } } } /// macOS menu: app menu with Quit (Cmd+Q really quits), Edit (copy / paste in text fields), and a Window menu that /// keeps a permanent entry for the main window, since the automatic list drops a hidden window. fn menu(app: &AppHandle) -> tauri::Result> { let name = "storebound"; let app_menu = Submenu::with_items( app, name, true, &[ &PredefinedMenuItem::about(app, Some("About storebound"), None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::services(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::hide(app, None)?, &PredefinedMenuItem::hide_others(app, None)?, &PredefinedMenuItem::show_all(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::quit(app, Some("Quit storebound"))?, ], )?; let edit = Submenu::with_items( app, "Edit", true, &[ &PredefinedMenuItem::undo(app, None)?, &PredefinedMenuItem::redo(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::cut(app, None)?, &PredefinedMenuItem::copy(app, None)?, &PredefinedMenuItem::paste(app, None)?, &PredefinedMenuItem::select_all(app, None)?, ], )?; let window = Submenu::with_id_and_items( app, WINDOW_SUBMENU_ID, "Window", true, &[ &PredefinedMenuItem::minimize(app, None)?, &PredefinedMenuItem::maximize(app, Some("Zoom"))?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "show-main", "storebound", true, Some("CmdOrCtrl+0"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::close_window(app, None)?, ], )?; Menu::with_items(app, &[&app_menu, &edit, &window]) } fn handle_deep_link(app: &AppHandle, urls: &[url::Url]) { if let Some(dir) = urls.iter().find_map(policy::deep_link_open_path) { show_main(app); tauri::async_runtime::spawn(commands::open_folder_link(app.clone(), dir)); } else if let Some(screen) = urls.iter().find_map(policy::deep_link_screen) { show_main(app); let _ = app.emit_to(policy::MAIN, "deep-link", screen); } } /// Debug builds only: STOREBOUND_DEV_PROJECTS (folders separated by the OS path separator) seeds the app folder list /// once, for screenshots and manual checks with the demo apps. Release builds ignore it. fn dev_seed(data_dir: &std::path::Path) { if !cfg!(debug_assertions) { return; } let Some(v) = std::env::var_os("STOREBOUND_DEV_PROJECTS") else { return }; let mut st = store::load(data_dir); let mut changed = false; for dir in std::env::split_paths(&v) { let dir = std::fs::canonicalize(&dir).unwrap_or(dir); let path = dir.to_string_lossy().to_string(); if !commands::has_config(&dir) || st.projects.iter().any(|p| p.path == path) { continue; } let name = dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); if let Ok(id) = store::new_id() { st.projects.push(store::Project { id, path, name, play_account: None, apple_account: None, added: store::now_iso() }); changed = true; } } if changed { let _ = store::save(data_dir, &st); } } /// One line before Tauri starts: a start at logon that dies early (before the host log exists) still leaves a trace /// with its arguments and working folder (a logon start once left no line at all). Best effort, never fails. fn early_trace() { use std::io::Write; let base = std::env::var_os("APPDATA").or_else(|| std::env::var_os("XDG_DATA_HOME")).map(std::path::PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share"))); let Some(base) = base else { return }; let dir = base.join("dev.storebound.desktop").join("logs"); if std::fs::create_dir_all(&dir).is_err() { return } let args: Vec = std::env::args().skip(1).collect(); let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default(); let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("start.log")) { let _ = writeln!(f, "{secs} start {} args={args:?} cwd={cwd} pid={}", env!("CARGO_PKG_VERSION"), std::process::id()); } } pub fn run() { early_trace(); linux_webkit_env(); let app = tauri::Builder::default() // single-instance first: a second start (or a storebound:// link) focuses the running app instead .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) .plugin(tauri_plugin_deep_link::init()) // never restore "visible": a --hidden start would otherwise pop the window up .plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .setup(|app| { #[cfg(all(windows, not(debug_assertions)))] if let Some(w) = app.get_webview_window(policy::MAIN) { harden_webview(&w); } let data_dir = app.path().app_data_dir()?; logs::init(&data_dir, app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string())); logs::panic_hook(); logs::host("info", &format!("storebound desktop {} starting ({} {}{})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH, if cfg!(debug_assertions) { ", debug" } else { "" })); dev_seed(&data_dir); let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?; let (saved, report) = store::load_report(&data_dir); let home = app.path().home_dir().ok(); logs::host("info", &format!("saved state: {} app folders ({} not found), {} accounts, {} studios, format {}", saved.projects.len(), saved.projects.iter().filter(|p| !std::path::Path::new(&p.path).is_dir()).count(), saved.accounts.len(), saved.studios.len(), saved.version)); if let Some(from) = report.migrated_from { logs::host("info", &format!("saved state migrated from format {from} to {}", store::VERSION)); } for n in &report.notes { logs::host("warn", &format!("saved state: {n}")); } if let Some(b) = &report.backup { logs::host("info", &format!("saved state: original kept as {}", b.display())); } let keep_days = saved.settings.cache.keep_days; let state = commands::AppState { sidecar: sidecar::Sidecar::default(), saved: Mutex::new(saved), data_dir, profiles: profiles::ProfileStore::new(profiles::default_dir(home.as_deref()), home.clone()), legacy: Box::new(accounts::Keychain), registry: registry::default_file(home.as_deref()), cache, downloads: downloads::Jobs::default(), }; // Settings > Offline cache: drop saved reads older than the chosen number of days commands::prune_cache(&state, keep_days); // the machine's shared accounts and app folders (CLI and app); old keychain accounts move into profiles once if let Err(e) = commands::sync_machine(&state) { logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message)); } // start at logon, in the tray: rewritten on every start (Windows: the HKCU Run entry then points at the // running exe, so a moved or updated install fixes itself) match app.autolaunch().enable() { Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"), Err(e) => logs::host("warn", &format!("autostart not set: {e}")), } app.manage(state); app.manage(menu_panel::MenuPanels::default()); tray(app)?; // pinned tray menu panels of the last session come back menu_panel::restore(app.handle()); if let Err(e) = card::create(app.handle()) { logs::host("warn", &format!("tray card not created: {e}")); } if !started_hidden() { show_main(app.handle()); } auto_update(app.handle().clone()); #[cfg(target_os = "macos")] app.set_menu(menu(app.handle())?)?; #[cfg(not(target_os = "macos"))] let _ = menu::; app.on_menu_event(|app, e| { if e.id() == "show-main" { show_main(app); } }); // Linux: registering runs update-desktop-database and xdg-mime, which a minimal system may not have; a // missing tool must not stop the app (it then only misses storebound:// links) #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] if let Err(e) = app.deep_link().register_all() { logs::host("warn", &format!("storebound:// links not registered: {e}")); } let handle = app.handle().clone(); app.deep_link().on_open_url(move |e| handle_deep_link(&handle, &e.urls())); if let Ok(Some(urls)) = app.deep_link().get_current() { handle_deep_link(app.handle(), &urls); } Ok(()) }) .on_window_event(|w, e| { // the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's // Quit (macOS: Cmd+Q) really quits if w.label() == card::LABEL { match e { WindowEvent::Focused(false) => { let _ = w.hide(); card::note_hidden(); } WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = w.hide(); card::note_hidden(); } _ => {} } } if w.label() == policy::MAIN { if let WindowEvent::CloseRequested { api, .. } = e { api.prevent_close(); let _ = w.hide(); } } }) .invoke_handler(tauri::generate_handler![ commands::app_info, commands::sidecar_status, commands::sidecar_call, commands::cache_get, commands::cache_clear, commands::job_cancel, commands::write_with_confirm, commands::projects_list, commands::project_add, commands::project_init, commands::project_remove, commands::project_set_accounts, commands::accounts_list, commands::account_add_play, commands::account_add_apple, commands::account_remove, commands::portfolio_status, commands::open_external, commands::tools_info, commands::tools_install, commands::tools_uninstall, commands::workspace_get, commands::studio_save, commands::studio_remove, commands::studio_logo, commands::account_update, commands::account_set_play_developer, commands::account_check, commands::store_apps_refresh, commands::store_apps_cached, commands::overrides_set, commands::settings_set, commands::cache_info, commands::data_reveal, commands::notify, commands::tray_update, commands::card_snapshot, commands::app_reset, commands::update_check, commands::update_install, commands::project_import_fastlane, commands::export_save, commands::price_sheet_pick, commands::image_pick, commands::project_reveal, commands::project_locate, commands::state_reset, commands::log_webview, commands::ai_fix_info, commands::ai_fix_run, commands::ai_fix_undo, commands::card_get, commands::card_action, commands::menu_state, commands::menu_ready, commands::menu_open_sub, commands::menu_pin_sub, commands::menu_close_subs, commands::menu_close, commands::menu_dismiss, commands::menu_pin, commands::menu_select, ]) .build(tauri::generate_context!()) .expect("storebound desktop failed to start"); app.run(|app, e| match e { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main(app), // no window left is not a reason to quit (tray app); an explicit exit or the updater's restart carries a code RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(), RunEvent::Exit => { if let Some(s) = app.try_state::() { s.sidecar.shutdown_now(); } ai_fix::kill_all(); } _ => {} }); } /// Release builds on Windows: no WebView2 browser context menu (Back, Refresh, Save as, Print, More tools) and no /// browser accelerator keys (Ctrl+R, Ctrl+P, F5, Ctrl+Shift+I...). The page draws its own menus; dev keeps devtools. #[cfg(all(windows, not(debug_assertions)))] fn harden_webview(w: &tauri::WebviewWindow) { use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3; use windows_core::Interface; let r = w.with_webview(|wv| unsafe { let Ok(core) = wv.controller().CoreWebView2() else { return }; let Ok(s) = core.Settings() else { return }; let _ = s.SetAreDefaultContextMenusEnabled(false); if let Ok(s3) = s.cast::() { let _ = s3.SetAreBrowserAcceleratorKeysEnabled(false); } }); if let Err(e) = r { logs::host("warn", &format!("webview menus not turned off: {e}")); } }