//! 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 commands; pub mod confirm; pub mod downloads; pub mod logs; 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_updater::UpdaterExt; 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 click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their /// state, mutes notifications, checks for updates, opens Settings and quits (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`). fn tray(app: &tauri::App) -> tauri::Result<()> { let menu = tray::initial_menu(app.handle())?; let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) { 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 => {} }) .on_tray_icon_event(|t, e| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e { show_main(t.app_handle()); } }) .build(app)?; Ok(()) } /// 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. fn auto_update(app: AppHandle) { tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(60)).await; loop { match app.updater() { Ok(u) => match u.check().await { Ok(Some(update)) => { logs::host("info", &format!("auto-update: {} available, installing", update.version)); match update.download_and_install(|_, _| {}, || {}).await { Ok(()) => app.restart(), Err(e) => logs::host("warn", &format!("auto-update: install failed: {e}")), } } Ok(None) => {} Err(e) => logs::host("warn", &format!("auto-update: check failed: {e}")), }, Err(e) => logs::host("warn", &format!("auto-update: no updater: {e}")), } tokio::time::sleep(std::time::Duration::from_secs(30 * 60)).await; } }); } 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_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: set once (first run); the owner can turn it off in the OS afterwards let marker = state.data_dir.join("autostart-set"); if !marker.exists() { match app.autolaunch().enable() { Ok(()) => { let _ = std::fs::write(&marker, "1"); logs::host("info", "autostart at logon enabled (--hidden, tray)"); } Err(e) => logs::host("warn", &format!("autostart not enabled: {e}")), } } app.manage(state); tray(app)?; 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() == 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_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::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, ]) .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}")); } }