desktop dev update channel: every push to main builds dev bundles (<next patch>-dev.<UTC>), the VDS signs and publishes dev/latest.json (newest 5); app channel setting stable|dev, background updater at start / 15 min / resume / channel switch, deferred while in use, no downgrade, autostart rewritten on every start, card shows version and channel
This commit is contained in:
parent
4dff7d9b2e
commit
39b2f73ad7
25 changed files with 732 additions and 47 deletions
|
|
@ -1246,6 +1246,10 @@ pub fn settings_set(window: WebviewWindow, state: State<'_, AppState>, settings:
|
|||
guard(&window)?;
|
||||
workspace::check_settings(&settings).map_err(Fail::host)?;
|
||||
let mut st = saved(&state)?;
|
||||
if st.settings.channel != settings.channel {
|
||||
// a switched channel is checked right away by the background updater (lib.rs auto_update)
|
||||
crate::updates::kick();
|
||||
}
|
||||
st.settings = settings.clone();
|
||||
persist(&state, &st)?;
|
||||
drop(st);
|
||||
|
|
@ -1480,7 +1484,7 @@ pub struct UpdateInfo {
|
|||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
|
||||
pub async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
let url = url::Url::parse(updates::endpoint(channel)).map_err(|e| Fail::host(e.to_string()))?;
|
||||
if !updates::endpoint_allowed(&url) {
|
||||
|
|
@ -1491,6 +1495,8 @@ async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_
|
|||
.endpoints(vec![url])
|
||||
.map_err(|e| Fail::host(e.to_string()))?
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// the no-downgrade rule (updates::should_install), also after a switch from dev back to stable
|
||||
.version_comparator(|current, remote| updates::should_install(¤t.to_string(), &remote.version.to_string()))
|
||||
.build()
|
||||
.map_err(|e| Fail::host(updates::error_text(&e.to_string())))?;
|
||||
updater.check().await.map_err(|e| Fail::host(updates::error_text(&e.to_string())))
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID
|
|||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent};
|
||||
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
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
|
||||
|
|
@ -86,29 +85,79 @@ fn tray(app: &tauri::App) -> tauri::Result<()> {
|
|||
|
||||
/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is
|
||||
/// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged.
|
||||
/// Background updater (docs/desktop-release.md, "Dev channel"): asks the chosen channel's latest.json at start, every
|
||||
/// 15 minutes, right after a resume from sleep and when Settings switched the channel; installs a strictly newer, signed
|
||||
/// build silently (the plugin checks the minisign signature first), deferred while the main window is in use (at most
|
||||
/// updates::MAX_DEFERS checks in a row). Every step goes to host.log as `auto-update ...`.
|
||||
fn auto_update(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
let mut first = true;
|
||||
let mut last_check = Instant::now();
|
||||
let mut last_wall = SystemTime::now();
|
||||
let mut defers = 0u32;
|
||||
loop {
|
||||
match app.updater() {
|
||||
Ok(u) => match u.check().await {
|
||||
Ok(Some(update)) => {
|
||||
logs::host("info", &format!("auto-update: {} available, installing", update.version));
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => app.restart(),
|
||||
Err(e) => logs::host("warn", &format!("auto-update: install failed: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => logs::host("warn", &format!("auto-update: check failed: {e}")),
|
||||
},
|
||||
Err(e) => logs::host("warn", &format!("auto-update: no updater: {e}")),
|
||||
let now_wall = SystemTime::now();
|
||||
let gap = now_wall.duration_since(last_wall).map(|d| d.as_secs()).unwrap_or(0);
|
||||
last_wall = now_wall;
|
||||
let kicked = updates::take_kick();
|
||||
if updates::check_due(first, kicked, gap, last_check.elapsed().as_secs()) {
|
||||
let why = if first { "start" } else if kicked { "channel switched" } else if gap > updates::TICK_SECS + 60 { "resume" } else { "every 15 min" };
|
||||
first = false;
|
||||
last_check = Instant::now();
|
||||
defers = auto_update_once(&app, why, defers).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30 * 60)).await;
|
||||
tokio::time::sleep(Duration::from_secs(updates::TICK_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One check of the background updater; returns the new count of deferred installs.
|
||||
async fn auto_update_once(app: &AppHandle, why: &str, defers: u32) -> u32 {
|
||||
let state = app.state::<commands::AppState>();
|
||||
let channel = match state.saved.lock() {
|
||||
Ok(s) => s.settings.channel.clone(),
|
||||
Err(_) => return defers,
|
||||
};
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
let update = match commands::find_update(app, &channel).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
logs::host("info", &format!("auto-update ({why}): {current} is current on {channel}"));
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
logs::host("warn", &format!("auto-update ({why}): check of {channel} failed: {}", e.message));
|
||||
return defers;
|
||||
}
|
||||
};
|
||||
if !updates::should_install(current, &update.version) {
|
||||
logs::host("info", &format!("auto-update ({why}): {channel} offers {}, not newer than {current}: no downgrade", update.version));
|
||||
return 0;
|
||||
}
|
||||
let main = app.get_webview_window(policy::MAIN);
|
||||
let visible = main.as_ref().and_then(|w| w.is_visible().ok()).unwrap_or(false);
|
||||
let focused = main.as_ref().and_then(|w| w.is_focused().ok()).unwrap_or(false);
|
||||
if updates::defer_install(visible, focused, defers) {
|
||||
logs::host("info", &format!("auto-update ({why}): {} on {channel} waits, the window is in use ({}/{})", update.version, defers + 1, updates::MAX_DEFERS));
|
||||
return defers + 1;
|
||||
}
|
||||
logs::host("info", &format!("auto-update ({why}): installing {} from {channel} (this is {current})", update.version));
|
||||
// the sidecar binary lives inside the app bundle that is about to be replaced
|
||||
state.sidecar.shutdown().await;
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => {
|
||||
logs::host("info", &format!("auto-update: {} installed, restarting", update.version));
|
||||
app.restart()
|
||||
}
|
||||
Err(e) => {
|
||||
logs::host("warn", &format!("auto-update: install of {} failed: {}", update.version, updates::error_text(&e.to_string())));
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
||||
let _ = w.unminimize();
|
||||
|
|
@ -281,13 +330,11 @@ pub fn run() {
|
|||
if let Err(e) = commands::sync_machine(&state) {
|
||||
logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message));
|
||||
}
|
||||
// start at logon, in the tray: set once (first run); the owner can turn it off in the OS afterwards
|
||||
let marker = state.data_dir.join("autostart-set");
|
||||
if !marker.exists() {
|
||||
match app.autolaunch().enable() {
|
||||
Ok(()) => { let _ = std::fs::write(&marker, "1"); logs::host("info", "autostart at logon enabled (--hidden, tray)"); }
|
||||
Err(e) => logs::host("warn", &format!("autostart not enabled: {e}")),
|
||||
}
|
||||
// start at logon, in the tray: rewritten on every start (Windows: the HKCU Run entry then points at the
|
||||
// running exe, so a moved or updated install fixes itself)
|
||||
match app.autolaunch().enable() {
|
||||
Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"),
|
||||
Err(e) => logs::host("warn", &format!("autostart not set: {e}")),
|
||||
}
|
||||
app.manage(state);
|
||||
tray(app)?;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,111 @@
|
|||
//! Signed updates from storebound.dev. The updater plugin checks `latest.json` of the chosen channel, downloads the
|
||||
//! platform's bundle and refuses it unless its minisign signature matches the public key in tauri.conf.json (the
|
||||
//! private key never leaves the release machine, ~/.storebound-keys). Nothing installs without the owner's click in
|
||||
//! a native dialog (commands::update_install). Pure parts here so tests can pin them.
|
||||
//! a native dialog (commands::update_install), or by the background updater (lib.rs auto_update). Pure parts here so tests can pin them.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use url::Url;
|
||||
|
||||
/// Set when Settings > Updates switched the channel: the background updater checks on its next tick.
|
||||
pub static KICK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn kick() {
|
||||
KICK.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn take_kick() -> bool {
|
||||
KICK.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// `latest.json` of each channel (static files on storebound.dev, published by tools/desktop-release.ts).
|
||||
pub const STABLE: &str = "https://storebound.dev/desktop/latest.json";
|
||||
pub const BETA: &str = "https://storebound.dev/desktop/beta/latest.json";
|
||||
/// Every push to main (Forgejo ci `dev` job, signed on the VDS): `<next patch>-dev.<UTC yyyymmddHHMMSS>`.
|
||||
pub const DEV: &str = "https://storebound.dev/desktop/dev/latest.json";
|
||||
|
||||
/// The channels Settings > Updates accepts (beta is kept for saved states; the window offers stable and dev).
|
||||
pub const CHANNELS: &[&str] = &["stable", "dev", "beta"];
|
||||
|
||||
/// The endpoint of a channel (Settings > Updates); anything unknown is the stable channel.
|
||||
pub fn endpoint(channel: &str) -> &'static str {
|
||||
if channel == "beta" {
|
||||
BETA
|
||||
} else {
|
||||
STABLE
|
||||
match channel {
|
||||
"beta" => BETA,
|
||||
"dev" => DEV,
|
||||
_ => STABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the background updater asks the channel (plus once at start and right after a resume from sleep).
|
||||
pub const CHECK_EVERY_SECS: u64 = 15 * 60;
|
||||
/// The background loop's tick; a wall-clock jump of more than this plus a minute between two ticks is a resume.
|
||||
pub const TICK_SECS: u64 = 5;
|
||||
/// An install waits while the main window is visible and focused, at most this many checks in a row (so an app that
|
||||
/// stays open still updates).
|
||||
pub const MAX_DEFERS: u32 = 4;
|
||||
|
||||
/// A semver version as (major, minor, patch) and its prerelease identifiers; None when it is not one.
|
||||
fn parse(v: &str) -> Option<([u64; 3], Vec<String>)> {
|
||||
let v = v.trim().trim_start_matches('v');
|
||||
let v = v.split('+').next()?;
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, p)) => (c, p.split('.').map(str::to_string).collect::<Vec<_>>()),
|
||||
None => (v, Vec::new()),
|
||||
};
|
||||
let mut n = core.split('.').map(|x| x.parse::<u64>().ok());
|
||||
let r = [n.next()??, n.next()??, n.next()??];
|
||||
if n.next().is_some() || pre.iter().any(String::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some((r, pre))
|
||||
}
|
||||
|
||||
/// semver 2.0 precedence, a > b: a release is above its own prereleases (0.8.48 > 0.8.48-dev.20261023120000), numeric
|
||||
/// identifiers compare as numbers and sort below alphanumeric ones, a longer prerelease is above its prefix.
|
||||
pub fn newer(a: &str, b: &str) -> bool {
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let (Some((ac, ap)), Some((bc, bp))) = (parse(a), parse(b)) else { return false };
|
||||
match ac.cmp(&bc) {
|
||||
Greater => return true,
|
||||
Less => return false,
|
||||
Equal => {}
|
||||
}
|
||||
match (ap.is_empty(), bp.is_empty()) {
|
||||
(true, true) | (false, true) => return false,
|
||||
(true, false) => return true,
|
||||
_ => {}
|
||||
}
|
||||
for (x, y) in ap.iter().zip(bp.iter()) {
|
||||
let o = match (x.parse::<u64>(), y.parse::<u64>()) {
|
||||
(Ok(i), Ok(j)) => i.cmp(&j),
|
||||
(Ok(_), Err(_)) => Less,
|
||||
(Err(_), Ok(_)) => Greater,
|
||||
_ => x.cmp(y),
|
||||
};
|
||||
if o != Equal {
|
||||
return o == Greater;
|
||||
}
|
||||
}
|
||||
ap.len() > bp.len()
|
||||
}
|
||||
|
||||
/// The no-downgrade rule: an update installs only when the channel's version is strictly newer than this build. A dev
|
||||
/// install switched back to stable stays on its dev build until a stable release passes it.
|
||||
pub fn should_install(current: &str, offered: &str) -> bool {
|
||||
newer(offered, current)
|
||||
}
|
||||
|
||||
/// The background updater defers an install while the owner works in the main window (visible and focused), at most
|
||||
/// MAX_DEFERS checks in a row. A hidden window can report focus, so only a visible one counts.
|
||||
pub fn defer_install(visible: bool, focused: bool, defers_so_far: u32) -> bool {
|
||||
visible && focused && defers_so_far < MAX_DEFERS
|
||||
}
|
||||
|
||||
/// Whether the background updater asks the channel now: the first tick, a kick (channel switched), a resume from sleep
|
||||
/// (the wall clock moved much further than one tick), or CHECK_EVERY_SECS since the last check.
|
||||
pub fn check_due(first: bool, kicked: bool, wall_gap_secs: u64, since_check_secs: u64) -> bool {
|
||||
first || kicked || wall_gap_secs > TICK_SECS + 60 || since_check_secs >= CHECK_EVERY_SECS
|
||||
}
|
||||
|
||||
/// Only storebound.dev over HTTPS, without port or user info, may serve updates.
|
||||
pub fn endpoint_allowed(url: &Url) -> bool {
|
||||
url.scheme() == "https" && url.host_str() == Some("storebound.dev") && url.port().is_none() && url.username().is_empty() && url.password().is_none() && url.path().starts_with("/desktop/")
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ pub struct Settings {
|
|||
pub refresh: RefreshSettings,
|
||||
pub cache: CacheSettings,
|
||||
pub notify: NotifySettings,
|
||||
/// Update channel (placeholder until the updater ships): stable | beta.
|
||||
/// Update channel (Settings > Updates): stable | dev (beta kept for saved states). crate::updates::endpoint.
|
||||
pub channel: String,
|
||||
}
|
||||
|
||||
|
|
@ -360,8 +360,8 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
|
|||
if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) {
|
||||
return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into());
|
||||
}
|
||||
if !["stable", "beta"].contains(&s.channel.as_str()) {
|
||||
return Err("channel: stable or beta".into());
|
||||
if !crate::updates::CHANNELS.contains(&s.channel.as_str()) {
|
||||
return Err("channel: stable or dev".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue