//! The store's Tauri commands for every app that sets [`crate::app::AppConfig::store`]: the //! `waybetter://install` deep link, dropped files, the signed wayBetter Store and the install //! screen's commands ([`crate::commands::STORE`]). Every command is shell-only; site tabs and web //! pages have no way to install anything. A deep link only *proposes* an install: the shell shows //! the confirmation screen and nothing is written until the owner confirms there. //! //! The rules, the catalog format and the install state are [`crate::store`]'s; this module only //! wraps them. An app without a store configuration registers nothing and every command here //! answers that it is not available. use crate::app::AppConfig; use crate::host::{emit_shell, eval_in_site_tabs, shell_only}; use crate::plugins::ScriptPlugin; use crate::policy; use crate::store::{delivery_script, InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, StringsList, EVENT_LINK}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, State, Webview}; use tauri_plugin_deep_link::DeepLinkExt; /// Makes the [`Store`] for the app's configuration and listens for install links (launch argument, /// open-URL event, a second launch forwarded by single-instance). pub(crate) fn init(app: &AppHandle, config: &AppConfig) { let Some(store) = config.store else { return }; let dir = app.path().app_data_dir().unwrap_or_default(); app.manage(Store::new(store, config.plugins, dir)); // Windows installs register the scheme in the NSIS installer and macOS in Info.plist; a Linux // AppImage and a Windows development build register it here (HKCU / xdg, current user only). #[cfg(any(target_os = "linux", all(windows, debug_assertions)))] if let Err(error) = app.deep_link().register_all() { eprintln!("{}: deep link registration failed: {error}", config.title); } let handle = app.clone(); app.deep_link().on_open_url(move |event| { for url in event.urls() { received(&handle, url.as_str()); } }); // Launched by a link (Windows/Linux pass it as an argument; macOS as an open-URL event). if let Ok(Some(urls)) = app.deep_link().get_current() { for url in urls { received(app, url.as_str()); } } // Launched by a double-click or "Open with" on a file (Windows/Linux pass its path as an argument). let args: Vec = std::env::args().collect(); opened_args(app, &args, ""); } /// Most files taken from one launch's arguments. const MAX_OPENED_ARGS: usize = 8; /// The files named in a launch's arguments (the first launch's own, or a second launch forwarded /// by single-instance, with its working directory): flags and links are not files and are skipped; /// each file that exists is kept under a random id and offered to the shell /// ([`Store::receive_file`]), and the window comes forward. Nothing is read or installed here. pub(crate) fn opened_args(app: &AppHandle, args: &[String], cwd: &str) { let paths = file_args(args, cwd); if paths.is_empty() { return; } let Some(store) = app.try_state::() else { return }; for path in paths { emit_shell(app, EVENT_LINK, store.receive_file(&path)); } bring_forward(app); } /// The existing files among a launch's arguments (the first argument is the program itself). fn file_args(args: &[String], cwd: &str) -> Vec { args.iter() .skip(1) .filter(|arg| !arg.starts_with('-') && !arg.contains("://")) .map(|arg| if Path::new(arg).is_absolute() || cwd.is_empty() { PathBuf::from(arg) } else { Path::new(cwd).join(arg) }) .filter(|path| path.is_file()) .take(MAX_OPENED_ARGS) .collect() } fn bring_forward(app: &AppHandle) { if let Some(window) = app.get_window(policy::MAIN_LABEL) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } /// A `waybetter://` link reached the app: validate it, queue it for the shell and bring the window /// forward. Nothing is downloaded or installed until the shell's screen asks for it. fn received(app: &AppHandle, raw: &str) { let Some(store) = app.try_state::() else { return }; // macOS hands a double-clicked file over as an open-URL event with a `file://` URL. let event = match url::Url::parse(raw).ok().filter(|url| url.scheme() == "file") { Some(url) => match url.to_file_path() { Ok(path) => store.receive_file(&path), Err(()) => store.receive_file(Path::new("")), }, None => store.receive_link(raw), }; emit_shell(app, EVENT_LINK, event); bring_forward(app); } fn store(app: &AppHandle) -> Result, String> { app.try_state::().ok_or_else(|| "UNAVAILABLE: this app has no store".to_owned()) } fn eval_in_site(app: &AppHandle, script: &str) { eval_in_site_tabs(app, crate::app::config(app).site, script); } /// Links that arrived before the shell listened (drained). #[tauri::command] pub(crate) async fn install_pending(webview: Webview, app: AppHandle) -> Result, String> { shell_only(&webview)?; Ok(store(&app)?.take_pending()) } /// Downloads what a validated install link points at and stages it for the confirmation screen. #[tauri::command] pub(crate) async fn install_stage_link(webview: Webview, app: AppHandle, kind: String, url: String) -> Result { shell_only(&webview)?; store(&app)?.stage_link(&kind, &url).await } /// A file dropped on the window (the shell read it with the File API; native code validates it). #[tauri::command] pub(crate) async fn install_stage_file(webview: Webview, app: AppHandle, name: String, text: String) -> Result { shell_only(&webview)?; store(&app)?.stage_file(&name, text) } /// A file the OS handed to the app (a double-click or "Open with"), asked for by the random id the /// `install-link` event carried: native code reads it and stages it exactly like a dropped file, /// for the same install screen. The shell never supplies a path; an unknown id is refused. #[tauri::command] pub(crate) async fn install_stage_opened(webview: Webview, app: AppHandle, id: String) -> Result { shell_only(&webview)?; store(&app)?.stage_opened(&id) } #[tauri::command] pub(crate) async fn install_cancel(webview: Webview, app: AppHandle, token: String) -> Result<(), String> { shell_only(&webview)?; store(&app)?.cancel(&token); Ok(()) } /// The owner confirmed the screen for `token`. Plugins are written (validated again) and, when /// `enable` (or they were enabled before an update), started in every site tab. Themes are handed /// back for the shell to add under `theme_id`. #[tauri::command] pub(crate) async fn install_commit( webview: Webview, app: AppHandle, token: String, enable: bool, theme_id: Option, ) -> Result { shell_only(&webview)?; store(&app)?.commit(&token, enable, theme_id, |script| eval_in_site(&app, script)) } /// Deletes a plugin (stopped in every site tab first). Shell-only, like every plugin command. #[tauri::command] pub(crate) async fn plugin_remove(webview: Webview, app: AppHandle, file: String) -> Result, String> { shell_only(&webview)?; store(&app)?.remove_plugin(&file, |script| eval_in_site(&app, script)) } /// Where installed plugins and themes came from (for badges and update checks). #[tauri::command] pub(crate) async fn install_records(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; Ok(store(&app)?.records()) } /// A theme was removed in Appearance: forget where it came from. #[tauri::command] pub(crate) async fn install_forget(webview: Webview, app: AppHandle, kind: String, name: String) -> Result<(), String> { shell_only(&webview)?; store(&app)?.forget(&kind, &name) } /// The signed catalog, this app's items only (fetched at most every 10 minutes unless `refresh`), /// with what is installed. #[tauri::command] pub(crate) async fn store_catalog(webview: Webview, app: AppHandle, refresh: bool) -> Result { shell_only(&webview)?; Ok(store(&app)?.catalog(refresh).await) } /// Downloads a store item, checks it against the signed catalog (size + sha256) and stages it. #[tauri::command] pub(crate) async fn store_stage(webview: Webview, app: AppHandle, item: StoreItemRef) -> Result { shell_only(&webview)?; store(&app)?.stage_item(&item).await } /// Installed string packs, in application order, with which are on. #[tauri::command] pub(crate) async fn strings_list(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; Ok(store(&app)?.strings().list()) } /// One installed string pack's JSON (the shell checks it against the site's inventory). #[tauri::command] pub(crate) async fn strings_read(webview: Webview, app: AppHandle, id: String) -> Result { shell_only(&webview)?; let bytes = store(&app)?.strings().read(&id).ok_or("STRINGS: no such string pack")?; String::from_utf8(bytes).map_err(|_| "STRINGS: not UTF-8".to_owned()) } /// Sets the order of the installed string packs and which are on (at most 8). #[tauri::command] pub(crate) async fn strings_set(webview: Webview, app: AppHandle, order: Vec, enabled: Vec) -> Result { shell_only(&webview)?; store(&app)?.strings().set(&order, &enabled) } /// Removes an installed string pack. #[tauri::command] pub(crate) async fn strings_remove(webview: Webview, app: AppHandle, id: String) -> Result { shell_only(&webview)?; store(&app)?.remove_strings(&id) } /// The shell computed what site tabs get (the packs that are on, in order, with the slice of the /// site's inventory their keys need; `None`: no pack on). It is kept for new tabs, and every site /// tab gets it in its session storage and reloads, so X registers its strings through it from the /// start. Data only; a site tab gains no command. #[tauri::command] pub(crate) async fn strings_apply(webview: Webview, app: AppHandle, delivery: Option) -> Result<(), String> { shell_only(&webview)?; let rev = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); let stored = store(&app)?.strings().set_delivery(delivery.as_deref(), rev)?; eval_in_site(&app, &delivery_script(crate::app::config(&app).site.hosts, &stored, true)); Ok(()) } /// The store's commands ([`crate::commands::STORE`]). pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ install_pending, install_stage_link, install_stage_file, install_stage_opened, install_cancel, install_commit, plugin_remove, install_records, install_forget, store_catalog, store_stage, strings_list, strings_read, strings_set, strings_remove, strings_apply ] } #[cfg(test)] mod tests { use super::*; fn args(list: &[&str]) -> Vec { list.iter().map(|arg| (*arg).to_owned()).collect() } #[test] fn launch_arguments_name_files_never_flags_or_links() { let dir = std::env::temp_dir().join(format!("wb-opened-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let theme = dir.join("nord.json"); std::fs::write(&theme, "{}").unwrap(); let absolute = theme.to_string_lossy().into_owned(); let found = file_args(&args(&["wayBetter.exe", "--diag", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json", "file:///x.json", "missing.json", &absolute]), ""); assert_eq!(found, [theme.clone()], "the program, flags, links and missing files are skipped"); // A second launch's relative name is resolved against its own working directory. let found = file_args(&args(&["wayBetter.exe", "nord.json"]), &dir.to_string_lossy()); assert_eq!(found, [dir.join("nord.json")]); assert!(file_args(&args(&["wayBetter.exe", &dir.to_string_lossy()]), "").is_empty(), "a folder is not a file"); let many: Vec = std::iter::once("wayBetter.exe".to_owned()).chain((0..20).map(|_| absolute.clone())).collect(); assert_eq!(file_args(&many, "").len(), MAX_OPENED_ARGS); std::fs::remove_dir_all(&dir).unwrap(); } }