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:
CRTkafa 2026-10-23 23:14:08 +08:00
parent 4dff7d9b2e
commit 39b2f73ad7
25 changed files with 732 additions and 47 deletions

View file

@ -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(&current.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())))

View file

@ -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)?;

View file

@ -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/")

View file

@ -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(())
}

View file

@ -88,6 +88,7 @@
},
"updater": {
"endpoints": ["https://storebound.dev/desktop/latest.json"],
"windows": { "installMode": "quiet" },
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK"
}
}

View file

@ -41,10 +41,13 @@ fn updater_has_a_real_minisign_key_and_only_storebound_dev() {
assert_eq!(c["plugins"]["updater"]["endpoints"], serde_json::json!([updates::STABLE]));
// updater bundles are signed by tools/desktop-release.ts on the release Mac, never by a CI runner
assert_eq!(c["bundle"]["createUpdaterArtifacts"], false);
for ch in ["stable", "beta", "nightly", ""] {
assert_eq!(c["plugins"]["updater"]["windows"]["installMode"], "quiet");
for ch in ["stable", "beta", "dev", "nightly", ""] {
assert!(updates::endpoint_allowed(&Url::parse(updates::endpoint(ch)).unwrap()), "{ch}");
}
assert_eq!(updates::endpoint("beta"), updates::BETA);
assert_eq!(updates::endpoint("dev"), updates::DEV);
assert_eq!(updates::DEV, "https://storebound.dev/desktop/dev/latest.json");
assert_eq!(updates::endpoint("anything"), updates::STABLE);
for bad in ["http://storebound.dev/desktop/latest.json", "https://storebound.dev:8443/desktop/latest.json", "https://evil.test/desktop/latest.json", "https://storebound.dev/latest.json"] {
assert!(!updates::endpoint_allowed(&Url::parse(bad).unwrap()), "{bad}");
@ -103,3 +106,39 @@ fn deep_links_to_folders_ask_before_adding() {
let body = &cmds[at..];
assert!(body.find("confirm_dialog_with(").unwrap() < body.find("download_and_install(").unwrap());
}
#[test]
fn dev_versions_sort_and_never_downgrade() {
// a dev build of the next patch is above the current stable, below that next stable, and later stamps go up
assert!(updates::newer("0.8.48-dev.20261023120000", "0.8.47"));
assert!(updates::newer("0.8.48", "0.8.48-dev.20261023120000"));
assert!(updates::newer("0.8.48-dev.20261023120001", "0.8.48-dev.20261023120000"));
assert!(updates::newer("0.8.48-dev.20261024000000", "0.8.48-dev.9"), "numeric identifiers compare as numbers");
assert!(updates::newer("0.8.49-dev.1", "0.8.48"));
assert!(updates::newer("0.10.0", "0.9.9"));
assert!(!updates::newer("0.8.48", "0.8.48"));
assert!(!updates::newer("nonsense", "0.8.48"));
// switched from dev back to stable: stays on the dev build until a stable release passes it
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.47"));
assert!(updates::should_install("0.8.48-dev.20261023120000", "0.8.48"));
assert!(updates::should_install("0.8.47", "0.8.48-dev.20261023120000"));
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.48-dev.20261023120000"));
}
#[test]
fn background_updater_schedule_and_defer() {
assert!(updates::check_due(true, false, 0, 0), "at start");
assert!(updates::check_due(false, true, 0, 0), "channel switched");
assert!(updates::check_due(false, false, 3600, 5), "resume from sleep");
assert!(updates::check_due(false, false, 5, updates::CHECK_EVERY_SECS));
assert!(!updates::check_due(false, false, 5, 60));
assert_eq!(updates::CHECK_EVERY_SECS, 900);
assert!(updates::defer_install(true, true, 0));
assert!(!updates::defer_install(true, true, updates::MAX_DEFERS), "an open window still updates after a few checks");
assert!(!updates::defer_install(false, true, 0), "a hidden window can report focus");
assert!(!updates::defer_install(true, false, 0));
let _ = updates::take_kick();
updates::kick();
assert!(updates::take_kick());
assert!(!updates::take_kick());
}

View file

@ -36,7 +36,8 @@ export interface Settings {
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
/** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */
notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } };
channel: 'stable' | 'beta';
/** Update channel: stable (npm releases) or dev (every push to main); beta is kept for saved states. */
channel: 'stable' | 'dev' | 'beta';
}
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' };
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
@ -47,7 +48,7 @@ export interface CacheEntry<T = unknown> { value: T; at: string }
export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string }
export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string }
export type { IconResult as Icon };
export interface UpdateInfo { current: string; channel: 'stable' | 'beta'; available: string | null; notes: string | null; date: string | null }
export interface UpdateInfo { current: string; channel: 'stable' | 'dev' | 'beta'; available: string | null; notes: string | null; date: string | null }
/** What a failed command rejects with (sidecar.rs `Fail`). */
export interface Fail { code: number; message: string; data?: unknown }

View file

@ -126,6 +126,7 @@ export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
</Section>
)}
</div>
{s?.build && <footer className="tc-foot" data-testid="card-build">{T('build', { version: s.build.version, channel: T(s.build.channel === 'dev' ? 'chDev' : s.build.channel === 'beta' ? 'chBeta' : 'chStable') })}</footer>}
</div>
);
}

View file

@ -51,7 +51,7 @@ export function TrayCardBridge() {
asc: ios ? ascUrl(ios.ascAppId) : null, play: st?.app?.platforms.android || a.android ? playUrlFor(d, a) : null,
};
});
return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now() });
return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now(), build: d.info ? { version: d.info.version, channel: d.settings.channel } : null });
}, [visible, d, extra, all.busy]);
const last = useRef('');

View file

@ -56,3 +56,4 @@ body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
.tc-stars { display: inline-flex; gap: 1px; align-items: center; color: var(--warn-solid); }
.tc-rname { font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-rtext { grid-column: 1 / -1; font-size: var(--ds-t-meta); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-foot { padding: 6px 12px 8px; color: var(--muted); font-size: var(--ds-t-micro); border-top: 1px solid var(--ds-stroke); font-variant-numeric: tabular-nums; }

View file

@ -23,6 +23,8 @@ export interface CardSnapshot {
health: CardHealth[];
/** Apps with something on the card, for the quick actions (console links). */
links: CardApp[];
/** This app's version and update channel (Settings > Updates), shown at the card's foot. */
build?: { version: string; channel: string } | null;
}
export interface CardInput {
@ -48,7 +50,7 @@ export function healthAlerts(h: HealthSnapshot | null | undefined): ('crash' | '
return out;
}
export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number }): CardSnapshot {
export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number; build?: { version: string; channel: string } | null }): CardSnapshot {
const now = o.now ?? Date.now();
const rejections: CardRejection[] = [], releases: CardRelease[] = [], low: CardLowReview[] = [], health: CardHealth[] = [];
const touched = new Set<string>();
@ -82,5 +84,6 @@ export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: bo
rejections: rejections.slice(0, MAX_ROWS), releases: releases.slice(0, MAX_ROWS),
reviews: { recent, low: low.slice(0, MAX_LOW) }, health: health.slice(0, MAX_ROWS),
links: apps.filter(a => touched.has(a.key)).map(a => ({ key: a.key, name: a.name, hue: a.hue, asc: a.asc ?? null, play: a.play ?? null })),
build: o.build ?? null,
};
}

View file

@ -28,6 +28,10 @@ export const en = {
refresh: 'Refresh',
refreshing: 'Reading…',
openMain: 'Open storebound',
build: 'storebound {version} · {channel} channel',
chStable: 'stable',
chDev: 'dev',
chBeta: 'beta',
more: 'More',
mute: 'Mute notifications for 1 hour',
unmute: 'Unmute notifications',
@ -70,6 +74,10 @@ export const tr: Record<CardKey, string> = {
refresh: 'Yenile',
refreshing: 'Okunuyor…',
openMain: 'storebound\'u aç',
build: 'storebound {version} · {channel} kanalı',
chStable: 'kararlı',
chDev: 'geliştirme',
chBeta: 'beta',
more: 'Diğer',
mute: 'Bildirimleri 1 saat sustur',
unmute: 'Bildirimleri aç',

View file

@ -974,7 +974,9 @@ export const en = {
'set.channel': 'Channel',
'set.stable': 'Stable',
'set.beta': 'Beta',
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything, and installs only when you press Install. Nothing is checked in the background.',
'set.dev': 'Dev (every change)',
'set.devToStable': 'This is the dev build {version}: it stays until a stable release is newer (no downgrade).',
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything. It looks for a newer version of the chosen channel at start, every 15 minutes and after sleep, and installs it in the background (not while you work in this window). Stable follows the npm releases; Dev gets every change on main.',
'set.updateCheck': 'Check for updates',
'set.updateChecking': 'Checking…',
'set.updateNone': 'storebound {version} is the newest version on the {channel} channel.',

View file

@ -976,7 +976,9 @@ export const tr: Record<keyof typeof en, string> = {
'set.channel': 'Kanal',
'set.stable': 'Kararlı',
'set.beta': 'Beta',
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler ve yalnızca siz Yükle\'ye bastığınızda yükler. Arka planda denetim yapılmaz.',
'set.dev': 'Geliştirme (her değişiklik)',
'set.devToStable': 'Bu, geliştirme sürümü {version}: daha yeni bir kararlı sürüm çıkana kadar kalır (eski sürüme dönülmez).',
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler. Seçili kanalda daha yeni bir sürümü açılışta, 15 dakikada bir ve uykudan sonra arar, arka planda yükler (siz bu pencerede çalışırken değil). Kararlı, npm sürümlerini izler; Geliştirme, main\'deki her değişikliği alır.',
'set.updateCheck': 'Güncellemeleri denetle',
'set.updateChecking': 'Denetleniyor…',
'set.updateNone': 'storebound {version}, {channel} kanalındaki en yeni sürüm.',

View file

@ -65,7 +65,7 @@ export function normalizeSettings(v: unknown): Settings {
lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool),
quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) },
},
channel: v.channel === 'beta' ? 'beta' : 'stable',
channel: v.channel === 'dev' || v.channel === 'beta' ? v.channel : 'stable',
};
}

View file

@ -257,7 +257,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
<Section id="set-updates" title={t('set.updates')} icon="download" className="set-card">
<div className="form">
<div className="field"><span className="legend">{t('set.channel')}</span>
<Seg value={s.channel} label={t('set.channel')} onChange={channel => set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} /></div>
<Seg value={s.channel} label={t('set.channel')} onChange={channel => set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['dev', t('set.dev')]]} /></div>
{s.channel === 'stable' && /-dev\./.test(d.info?.version ?? '') && <p className="small muted">{t('set.devToStable', { version: d.info!.version })}</p>}
<Updates />
<p className="small muted">{t('set.updatesText')}</p>
</div>
@ -279,7 +280,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
);
}
/** Settings > Updates: checks on click only; installs after the host's native dialog (the signature is checked there). */
/** Settings > Updates: Check asks the channel now; Install runs after the host's native dialog (the signature is checked there). The host also checks and installs in the background (lib.rs auto_update). */
function Updates() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [busy, setBusy] = useState<'check' | 'install' | null>(null);
@ -294,7 +295,7 @@ function Updates() {
setBusy('install'); setError(null);
try { await api.updateInstall(version); } catch (e) { setError(errorText(e)); } finally { setBusy(null); setGot(null); }
};
const channel = info ? t(info.channel === 'beta' ? 'set.beta' : 'set.stable') : '';
const channel = info ? t(info.channel === 'beta' ? 'set.beta' : info.channel === 'dev' ? 'set.dev' : 'set.stable') : '';
return (
<div className="stack" style={{ gap: 8 }}>
{error && <Notice tone="bad">{error}</Notice>}