Host: the store's commands (install links, dropped files, the signed Store, the install screen) are served for every app that sets AppConfig::with_store; commands::STORE, testkit::assert_shell_only_group and assert_store_guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 03:40:37 +07:00
parent 06e830b132
commit 3450eac09a
5 changed files with 230 additions and 6 deletions

View file

@ -80,6 +80,10 @@ pub struct AppConfig {
pub plugins: &'static PluginHost,
/// The home page before the owner sets one (default: the site's first origin).
pub home_url: Option<&'static str>,
/// The store this app installs themes and plugins from (`waybetter://install` links, dropped
/// files, the signed catalog): serves [`commands::STORE`]. Needs `deep_link` for the links and
/// the `net` feature for downloads.
pub store: Option<&'static crate::store::StoreConfig>,
}
impl AppConfig {
@ -101,9 +105,16 @@ impl AppConfig {
page_string_prefixes: &["kit.", "plugin."],
plugins,
home_url: None,
store: None,
}
}
/// Serves the store's commands ([`commands::STORE`]) with this configuration.
pub const fn with_store(mut self, store: &'static crate::store::StoreConfig) -> Self {
self.store = Some(store);
self
}
/// The home page before the owner sets one.
pub fn home(&self) -> String {
self.home_url.map(str::to_owned).unwrap_or_else(|| format!("{}/", self.site.primary_origin()))
@ -1061,6 +1072,17 @@ fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 's
]
}
/// The store's commands ([`commands::STORE`]): with the `net` feature (downloads) only.
#[cfg(feature = "net")]
fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::store_commands::handler()
}
#[cfg(not(feature = "net"))]
fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|_| false
}
type Setup = Box<dyn FnOnce(&AppHandle) -> Result<(), Box<dyn std::error::Error>> + Send>;
/// The app builder: config, site hooks, the app's own setup, then [`App::run`].
@ -1091,6 +1113,7 @@ impl App {
{
let App { config, hooks, setup } = self;
let host_handler = host_handler();
let store_handler = store_handler();
let builder = tauri::Builder::<Wry>::default()
// Must be first: a second launch focuses the running window instead of fighting over
// the app's data and WebView profile (and hands over its link).
@ -1168,6 +1191,8 @@ impl App {
});
tauri_app.manage(Page(Mutex::new(PageState { plugins, features, settings, modes })));
tauri_app.manage(Host { config, hooks });
#[cfg(feature = "net")]
crate::store_commands::init(&handle, &config);
// Safe mode for this run: the launch argument, or Shift held while starting (Windows).
crate::safe::set(crate::safe::at_launch(std::env::args(), host::shift_held()));
#[cfg(debug_assertions)]
@ -1178,7 +1203,9 @@ impl App {
open_window(tauri_app, &config)
})
.invoke_handler(move |invoke| {
if commands::is_host(invoke.message.command()) {
if commands::STORE.contains(&invoke.message.command()) {
store_handler(invoke)
} else if commands::is_host(invoke.message.command()) {
host_handler(invoke)
} else {
handler(invoke)

View file

@ -45,9 +45,25 @@ pub const TAB: &[&str] = &["tab_shortcut", "tab_link_drag"];
/// the shell to open the app's panel). Grant these in a capability limited to the site's URLs.
pub const SITE: &[&str] = &["open_panel"];
/// Shell-only, optional: the store's commands (install links, dropped files, the signed Store and
/// the install screen), served when the app sets `AppConfig::store`. An app that uses the store
/// grants these to the shell (`testkit::Capabilities::assert_shell_only_group`).
pub const STORE: &[&str] = &[
"install_pending",
"install_stage_link",
"install_stage_file",
"install_cancel",
"install_commit",
"plugin_remove",
"install_records",
"install_forget",
"store_catalog",
"store_stage",
];
/// Whether `command` is one of the host's (dispatched to the host, never to the app's handler).
pub fn is_host(command: &str) -> bool {
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command)
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -62,8 +78,8 @@ pub fn permission(command: &str) -> String {
/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["draft_list"]))
/// ```
pub fn manifest(app: &[&'static str]) -> &'static [&'static str] {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE) {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE) {
if !all.contains(command) {
all.push(command);
}
@ -80,7 +96,7 @@ mod tests {
let all = manifest(&["draft_list", "tabs_sync"]);
assert_eq!(all[0], "draft_list");
assert_eq!(all.iter().filter(|c| **c == "tabs_sync").count(), 1);
for command in SHELL.iter().chain(TAB).chain(SITE) {
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -90,7 +106,7 @@ mod tests {
#[test]
fn shell_and_tab_commands_do_not_overlap() {
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command)));
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command)));
assert!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

@ -80,6 +80,8 @@ pub mod zoom;
#[cfg(feature = "app")]
pub mod app;
#[cfg(all(feature = "app", feature = "net"))]
mod store_commands;
#[cfg(all(feature = "app", debug_assertions))]
pub mod devloop;
#[cfg(feature = "tauri")]

View file

@ -0,0 +1,158 @@
//! 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::{InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, EVENT_LINK};
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());
}
}
}
/// 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::<Store>() else { return };
emit_shell(app, EVENT_LINK, store.receive_link(raw));
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
fn store(app: &AppHandle) -> Result<State<'_, Store>, String> {
app.try_state::<Store>().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<Vec<LinkEvent>, 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<InstallPreview, String> {
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<InstallPreview, String> {
shell_only(&webview)?;
store(&app)?.stage_file(&name, text)
}
#[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<String>,
) -> Result<InstallOutcome, String> {
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<Vec<ScriptPlugin>, 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<InstallRecords, String> {
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<StoreView, String> {
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<InstallPreview, String> {
shell_only(&webview)?;
store(&app)?.stage_item(&item).await
}
/// The store's commands ([`crate::commands::STORE`]).
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
install_pending,
install_stage_link,
install_stage_file,
install_cancel,
install_commit,
plugin_remove,
install_records,
install_forget,
store_catalog,
store_stage
]
}

View file

@ -125,6 +125,14 @@ impl Capabilities {
}
}
/// Every command of an optional group (e.g. [`commands::STORE`]) is granted, and only to the
/// local shell webview.
pub fn assert_shell_only_group(&self, group: &[&str]) {
for command in group {
self.assert_shell_only(command);
}
}
/// `command` is granted to no capability at all.
pub fn assert_not_granted(&self, command: &str) {
let permission = commands::permission(command);
@ -203,10 +211,23 @@ pub fn assert_host_guards() {
}
}
/// The store commands' wrappers check their caller first. Runs against the host's source.
pub fn assert_store_guards() {
let source = include_str!("store_commands.rs");
for command in commands::STORE {
assert_guarded(source, command, "shell_only(&webview)?");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_store_commands_check_their_caller_first() {
assert_store_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {
let host_shell: Vec<String> = commands::SHELL.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
let host_tab: Vec<String> = commands::TAB.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();