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:
parent
06e830b132
commit
3450eac09a
5 changed files with 230 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
158
crates/waybetter-host/src/store_commands.rs
Normal file
158
crates/waybetter-host/src/store_commands.rs
Normal 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
|
||||
]
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue