//! 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 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 workspace; include!("commands_list.rs"); use std::sync::Mutex; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent}; 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") }; } } 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(); } } /// 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); } } pub fn run() { 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()) .plugin(tauri_plugin_window_state::Builder::new().build()) .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| { 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)); } app.manage(state); #[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| { // macOS: the close button hides the window (the app stays in the Dock and the Window menu); // Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. if cfg!(target_os = "macos") && 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::app_reset, commands::update_check, commands::update_install, commands::project_import_fastlane, commands::export_save, commands::price_sheet_pick, commands::project_reveal, commands::project_locate, commands::state_reset, commands::log_webview, ]) .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), RunEvent::Exit => { if let Some(s) = app.try_state::() { s.sidecar.shutdown_now(); } } _ => {} }); }