Host: native update commands (update_status, update_check, apply_update, app_ready) and the update loop are served for every app that sets AppConfig::with_updates; commands::UPDATE, testkit::assert_update_guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 04:18:34 +07:00
parent 1904031e6f
commit e88339bda6
6 changed files with 103 additions and 5 deletions

View file

@ -89,6 +89,10 @@ pub struct AppConfig {
/// Google Fonts (install, remove, preview; the cache in app data): serves [`commands::GFONTS`]
/// and calls [`SiteHooks::fonts_changed`]. Needs the `net` feature.
pub fonts: bool,
/// Native updates and the hotfix channel: serves [`commands::UPDATE`] and runs the update loop.
/// Needs `updater` (the plugin) and the `net` feature.
#[cfg(feature = "net")]
pub updates: Option<&'static crate::updater::UpdaterConfig>,
}
impl AppConfig {
@ -113,9 +117,18 @@ impl AppConfig {
store: None,
media: false,
fonts: false,
#[cfg(feature = "net")]
updates: None,
}
}
/// Serves the update commands ([`commands::UPDATE`]) and runs the update loop.
#[cfg(feature = "net")]
pub const fn with_updates(mut self, updates: &'static crate::updater::UpdaterConfig) -> Self {
self.updates = Some(updates);
self
}
/// Serves the Google Fonts commands ([`commands::GFONTS`]).
pub const fn with_fonts(mut self) -> Self {
self.fonts = true;
@ -1134,6 +1147,17 @@ fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + '
|_| false
}
/// The update commands ([`commands::UPDATE`]): with the `net` feature only.
#[cfg(feature = "net")]
fn update_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::update_commands::handler()
}
#[cfg(not(feature = "net"))]
fn update_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|_| false
}
/// The Google Fonts commands ([`commands::GFONTS`]): with the `net` feature (downloads) only.
#[cfg(feature = "net")]
fn fonts_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
@ -1183,6 +1207,7 @@ impl App {
let store_handler = store_handler();
let media_handler = media_handler();
let fonts_handler = fonts_handler();
let update_handler = update_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).
@ -1265,6 +1290,8 @@ impl App {
crate::media_commands::init(&handle);
#[cfg(feature = "net")]
crate::fonts_commands::init(&handle);
#[cfg(feature = "net")]
crate::update_commands::init(&handle);
// 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()));
// Focus and layering diagnostics for this run: `--diag` or WAYBETTER_DIAG=1 (app log folder, diag.log).
@ -1285,6 +1312,8 @@ impl App {
media_handler(invoke)
} else if commands::GFONTS.contains(&invoke.message.command()) {
fonts_handler(invoke)
} else if commands::UPDATE.contains(&invoke.message.command()) {
update_handler(invoke)
} else if commands::is_host(invoke.message.command()) {
host_handler(invoke)
} else {

View file

@ -86,9 +86,13 @@ pub const MEDIA: &[&str] = &[
/// a preview), served when the app sets `AppConfig::fonts`; needs the `net` feature.
pub const GFONTS: &[&str] = &["gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview"];
/// Shell-only, optional: native updates (status, check now, apply, the shell reached a working
/// state), served when the app sets `AppConfig::updates`; needs the `net` feature.
pub const UPDATE: &[&str] = &["update_status", "update_check", "apply_update", "app_ready"];
/// 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) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command)
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -103,8 +107,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() + STORE.len() + MEDIA.len() + GFONTS.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS) {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE) {
if !all.contains(command) {
all.push(command);
}
@ -121,7 +125,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).chain(STORE).chain(MEDIA).chain(GFONTS) {
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -131,7 +135,7 @@ mod tests {
#[test]
fn shell_and_tab_commands_do_not_overlap() {
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command)));
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command)));
assert!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

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

View file

@ -235,6 +235,14 @@ pub fn assert_fonts_guards() {
}
}
/// The update commands' wrappers check their caller first. Runs against the host's source.
pub fn assert_update_guards() {
let source = include_str!("update_commands.rs");
for command in commands::UPDATE {
assert_guarded(source, command, "shell_only(&webview)?");
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -244,6 +252,7 @@ mod tests {
assert_store_guards();
assert_media_guards();
assert_fonts_guards();
assert_update_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {

View file

@ -0,0 +1,53 @@
//! Native updates, for every app that sets [`crate::app::AppConfig::updates`]: the shell's four
//! narrow commands ([`crate::commands::UPDATE`]) over [`crate::updater`] (signed native updates and
//! the signed data-only hotfix channel, configured by the app's [`crate::updater::UpdaterConfig`]).
//! Everything runs in Rust; site tabs get no updater IPC at all, only the verified hotfix data the
//! app injects ([`crate::updater::hotfix_script`]).
use crate::app::config;
use crate::host::shell_only;
use crate::update::UpdateStatus;
use crate::updater;
use tauri::{AppHandle, Webview};
/// Runs the launch handshake, loads the cached hotfix and starts the update loop. Does nothing
/// unless the app set [`crate::app::AppConfig::updates`].
pub(crate) fn init(app: &AppHandle) {
if let Some(updates) = config(app).updates {
updater::init(app, updates);
}
}
#[tauri::command]
pub(crate) async fn update_status(webview: Webview, app: AppHandle) -> Result<UpdateStatus, String> {
shell_only(&webview)?;
updater::status(&app)
}
/// "Check for updates" in the app menu: the same check the timer runs, once, now (the same updater
/// endpoint; never queued, never retried). Returns the status when the check has finished.
#[tauri::command]
pub(crate) async fn update_check(webview: Webview, app: AppHandle) -> Result<UpdateStatus, String> {
shell_only(&webview)?;
updater::check_now(&app).await
}
/// Installs the downloaded update and restarts. On Windows the quiet NSIS installer replaces
/// the app and relaunches it (`/UPDATE /R`), and this process exits inside `install`.
#[tauri::command]
pub(crate) async fn apply_update(webview: Webview, app: AppHandle) -> Result<(), String> {
shell_only(&webview)?;
updater::apply(&app)
}
/// The shell reached a working state: confirms a just-installed update and returns the status.
#[tauri::command]
pub(crate) async fn app_ready(webview: Webview, app: AppHandle) -> Result<UpdateStatus, String> {
shell_only(&webview)?;
updater::ready(&app)
}
/// The update commands ([`crate::commands::UPDATE`]).
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![update_status, update_check, apply_update, app_ready]
}

View file

@ -23,6 +23,7 @@ const HOTFIX_SIG_FILE: &str = "hotfix.json.sig";
/// What differs between apps: the hotfix channel, the page names hotfix data is delivered under,
/// the manual download page and what to do when an update is ready.
#[derive(Debug)]
pub struct UpdaterConfig {
/// Hotfix data goes to this site's tabs.
pub site: &'static SiteSpec,