- 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
272 lines
12 KiB
Rust
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();
|
|
}
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|