storebound/apps/desktop/src-tauri/src/lib.rs
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

272 lines
12 KiB
Rust

//! 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<R: Runtime>(app: &AppHandle<R>) {
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<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
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::<tauri::Wry>;
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::<commands::AppState>() {
s.sidecar.shutdown_now();
}
}
_ => {}
});
}