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

@ -223,6 +223,33 @@ jobs:
name: storebound-desktop-linux
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): the same commit again with the version <next patch>-dev.<commit UTC>, only in
# this workspace (never committed); a failure here never touches the release candidate above
- name: Dev build (AppImage + deb, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
set -euo pipefail
V=$(bun tools/desktop-dev.ts set)
bun tools/build-sidecars.ts --target bun-linux-x64
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
(cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci)
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
chmod +x dev/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts dev/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; git checkout -- . ; exit $rc
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-linux
path: dev/
retention-days: 3
windows:
needs: gate
@ -288,6 +315,78 @@ jobs:
name: storebound-desktop-windows
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): see the linux job
- name: Dev build (NSIS, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
shell: pwsh
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
$ErrorActionPreference = 'Stop'
$V = bun tools/desktop-dev.ts set
if ($LASTEXITCODE -ne 0) { throw "dev version stamp refused" }
bun tools/build-sidecars.ts --target bun-windows-x64
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
Push-Location apps/desktop
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
if ($LASTEXITCODE -ne 0) { throw "dev build failed" }
Pop-Location
New-Item -ItemType Directory -Force dev | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" dev\
Start-Process -Wait -FilePath "dev\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
& "$env:LOCALAPPDATA\storebound\storebound-desktop.exe" --self-test
if ($LASTEXITCODE -ne 0) { throw "dev self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts dev
$rc = $LASTEXITCODE
Remove-Item -Force $t
git checkout -- .
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-windows
path: dev/
retention-days: 3
# dev channel: the dev bundles of both jobs go to a Forgejo draft dev-<version> (no tag; the newest 5 kept); the VDS
# (tools/vds/storebound-desktop-dev, a root timer) signs them with the updater key and publishes
# storebound.dev/desktop/dev/latest.json. macOS has no dev build: Developer ID signing needs the Mac's GUI session.
dev:
needs: [linux, windows]
if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
runs-on: ubuntu-22.04
timeout-minutes: 40
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-linux
path: dev
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-windows
path: dev
- name: Draft dev-<version> (newest 5 kept), then wait for the VDS to publish it
run: |
ls -la dev
bun tools/desktop-dev.ts record --dir dev --yes
V=$(ls dev | sed -n 's/^storebound_\(.*\)_x64-setup\.exe$/\1/p')
bun tools/desktop-dev.ts wait "$V" --minutes 20
record:
needs: [gate, linux, windows]

View file

@ -3,6 +3,7 @@
## Unreleased
- Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `<next patch>-dev.<UTC commit time>` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session).
- `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`.
- `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID.

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>}

View file

@ -380,6 +380,34 @@ candidate on a tag (Jenkins still publishes site/, and `publish` storebound.dev/
4. **Windows code signing (later).** Buy a code-signing certificate, then sign the installer before `add` and pass
`--code-signed`.
## Dev channel (every push to main)
Installs set to **Dev** in Settings > Updates follow `https://storebound.dev/desktop/dev/latest.json`; everything else
stays on the stable channel (`latest.json`, the npm releases).
- **Version.** `bun tools/desktop-dev.ts stamp`: `<next patch>-dev.<UTC yyyymmddHHMMSS of the commit>`, e.g.
`0.8.48-dev.20261023120000` while 0.8.47 is current. semver puts it above 0.8.47 and below 0.8.48, so the next
stable release passes every dev build of it, and later commits sort higher. The stamp refuses a version that is not
above the live dev `latest.json`. `set` writes it into every version copy of the CI workspace only (never committed).
- **CI** (`.forgejo/workflows/ci.yml`). After the release-candidate build, the `linux` and `windows` jobs build the
same commit again with the dev version (AppImage + deb, NSIS), self-test and privacy-scan them (failures never touch
the candidate). The `dev` job attaches them to a Forgejo **draft** `dev-<version>` (no git tag), deletes all but
the newest 5 dev drafts, and waits up to 20 minutes for the VDS to publish it. No macOS dev build: Developer ID
signing needs the Mac's GUI session.
- **VDS.** `tools/vds/storebound-desktop-dev` (root, `/usr/local/sbin`, run every 2 minutes by
`storebound-desktop-dev.timer`) reads the drafts with a read-only Forgejo token (`/root/.storebound-keys/forgejo-read-token`),
takes the newest complete one above the live dev version, checks its SHA256SUMS, signs every file with the updater key
that lives there, writes latest.json and runs `storebound-desktop-publish <version> dev` (a dev version can only go
to the dev channel and the dev channel takes only dev versions). It keeps the newest 5 dev versions in `dl/`.
Logs: `journalctl -u storebound-desktop-dev`.
Install: copy the script to `/usr/local/sbin/` (755), the `.service` and `.timer` to `/etc/systemd/system/`, then
`systemctl daemon-reload && systemctl enable --now storebound-desktop-dev.timer`.
- **App.** The background updater (src-tauri `lib.rs` `auto_update`, pure rules in `updates.rs`) asks the chosen
channel at start, every 15 minutes, after a resume from sleep and right after a channel switch; it installs only a
strictly newer version (no downgrade: a dev install switched back to stable stays until a stable release passes it),
waits while the main window is visible and focused (at most 4 checks in a row), installs quietly on Windows and
restarts. Each step is an `auto-update (...)` line in `host.log`. Autostart at logon is rewritten on every start.
## Hosting on storebound.dev
| URL | served from | how it gets there |

39
tests/desktop-dev.test.ts Normal file
View file

@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { compare, devVersion, guardProblem, nextPatch, pruneDev, utcStamp } from '../tools/desktop-dev';
describe('desktop dev channel', () => {
test('stamps the next patch with the UTC commit time', () => {
expect(nextPatch('0.8.47')).toBe('0.8.48');
expect(nextPatch('0.8.48-rc.1')).toBe('0.8.48');
expect(utcStamp(new Date('2026-10-23T09:05:07+03:00'))).toBe('20261023060507');
expect(devVersion('0.8.47', new Date('2026-10-23T06:05:07Z'))).toBe('0.8.48-dev.20261023060507');
});
test('dev builds sort above the current stable, below the next stable, monotonic in time', () => {
const a = devVersion('0.8.47', new Date('2026-10-23T06:05:07Z'));
const b = devVersion('0.8.47', new Date('2026-10-23T06:05:08Z'));
expect(compare(a, '0.8.47')).toBe(1);
expect(compare('0.8.48', a)).toBe(1);
expect(compare(b, a)).toBe(1);
expect(compare(a, a)).toBe(0);
// after 0.8.48 ships, its successor's dev builds pass it
expect(compare(devVersion('0.8.48', new Date('2026-10-24T00:00:00Z')), '0.8.48')).toBe(1);
expect(compare('0.8.48-dev.10', '0.8.48-dev.9')).toBe(1);
expect(compare('0.10.0', '0.9.9')).toBe(1);
});
test('the publish guard refuses a version not above the live dev build', () => {
expect(guardProblem('0.8.48-dev.20261023060507', null)).toBeNull();
expect(guardProblem('0.8.48-dev.20261023060508', '0.8.48-dev.20261023060507')).toBeNull();
expect(guardProblem('0.8.48-dev.20261023060507', '0.8.48-dev.20261023060507')).toContain('refused');
expect(guardProblem('0.8.48-dev.20261023060506', '0.8.48-dev.20261023060507')).toContain('refused');
expect(guardProblem('0.8.48', null)).toContain('not a dev version');
});
test('prunes dev drafts to the newest 5, never other releases', () => {
const rel = (t: string) => ({ tag_name: t });
const dev = [1, 2, 3, 4, 5, 6, 7].map(i => rel(`dev-0.8.48-dev.2026102300000${i}`));
const out = pruneDev([rel('v0.8.47'), rel('rc-' + 'a'.repeat(40)), ...dev.reverse()]);
expect(out.map(r => r.tag_name)).toEqual(['dev-0.8.48-dev.20261023000002', 'dev-0.8.48-dev.20261023000001']);
});
});

123
tools/desktop-dev.ts Normal file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env bun
// Dev update channel of the desktop app (docs/desktop-release.md, "Dev channel"). Every push to main builds Windows
// and Linux bundles stamped `<next patch>-dev.<UTC yyyymmddHHMMSS of the commit>` (ci.yml, linux / windows jobs), the
// `dev` job attaches them to a Forgejo draft `dev-<version>` (no git tag), and the VDS (tools/vds/storebound-desktop-dev,
// a root timer) signs them with the updater key that lives there and publishes storebound.dev/desktop/dev/latest.json.
// Installs set to the dev channel (Settings > Updates) follow it; everyone else stays on stable (the npm releases).
// bun tools/desktop-dev.ts stamp print the dev version of HEAD; refuses one not above the live dev
// bun tools/desktop-dev.ts set stamp, then write it into every version copy (CI workspace only)
// bun tools/desktop-dev.ts record --dir <d> [--yes] draft dev-<version> with the files of <d>, prune to the newest 5
// bun tools/desktop-dev.ts wait <version> [--minutes 15] until storebound.dev serves it on the dev channel
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { setVersion, versions } from './desktop-version';
export const DEV_URL = 'https://storebound.dev/desktop/dev/latest.json';
export const KEEP = 5;
export const DEV_TAG = /^dev-(\d+\.\d+\.\d+-dev\.\d{14})$/;
/** The next patch of a release version: dev builds of 0.8.47's successor are 0.8.48-dev.*, below 0.8.48. */
export function nextPatch(v: string): string {
const m = /^(\d+)\.(\d+)\.(\d+)(?:-.*)?$/.exec(v);
if (!m) throw new Error(`not a version: ${v}`);
// a prerelease base (0.8.48-rc.1) already stands below 0.8.48: its dev builds keep that core
return v.includes('-') ? `${m[1]}.${m[2]}.${m[3]}` : `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
}
/** UTC yyyymmddHHMMSS. */
export const utcStamp = (d: Date): string => d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
export function devVersion(base: string, at: Date): string {
return `${nextPatch(base)}-dev.${utcStamp(at)}`;
}
/** semver 2.0 precedence: -1, 0, 1 (numeric identifiers as numbers, a release above its prereleases). */
export function compare(a: string, b: string): number {
const p = (v: string) => { const [core, ...pre] = v.split('+')[0]!.split('-'); return { n: core!.split('.').map(Number), pre: pre.length ? pre.join('-').split('.') : [] }; };
const x = p(a), y = p(b);
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]! ? 1 : -1;
if (!x.pre.length || !y.pre.length) return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1;
for (let i = 0; i < Math.min(x.pre.length, y.pre.length); i++) {
const s = x.pre[i]!, t = y.pre[i]!;
if (s === t) continue;
const ns = /^\d+$/.test(s), nt = /^\d+$/.test(t);
if (ns && nt) return Number(s) > Number(t) ? 1 : -1;
if (ns !== nt) return ns ? -1 : 1;
return s > t ? 1 : -1;
}
return Math.sign(x.pre.length - y.pre.length);
}
/** The publish guard: a dev version goes out only above the live dev latest.json (null: nothing published yet). */
export function guardProblem(next: string, live: string | null): string | null {
if (!/^\d+\.\d+\.\d+-dev\.\d{14}$/.test(next)) return `${next} is not a dev version`;
if (live && compare(next, live) <= 0) return `${next} is not above the live dev build ${live}: refused (the updater installs only a newer version)`;
return null;
}
/** Dev drafts to delete: all but the newest `keep` by version (other releases are never touched). */
export function pruneDev<T extends { tag_name: string }>(releases: T[], keep = KEEP): T[] {
const dev = releases.filter(r => DEV_TAG.test(r.tag_name)).sort((a, b) => compare(b.tag_name.slice(4), a.tag_name.slice(4)));
return dev.slice(keep);
}
async function liveDev(): Promise<string | null> {
const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
if (!r || r.status === 404) return null;
if (!r.ok) throw new Error(`${DEV_URL}: HTTP ${r.status}`);
return ((await r.json()) as { version?: string }).version ?? null;
}
function commitDate(): Date {
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%cI'], { stdout: 'pipe' });
const d = new Date(r.stdout.toString().trim());
if (r.exitCode !== 0 || Number.isNaN(d.getTime())) throw new Error('git log: no commit date');
return d;
}
async function stamp(): Promise<string> {
const { version, drift } = versions();
if (!version || drift.length) throw new Error('desktop version drift (bun tools/desktop-version.ts)');
const v = devVersion(version, commitDate());
const problem = guardProblem(v, await liveDev());
if (problem) throw new Error(problem);
return v;
}
const arg = (argv: string[], n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
if (import.meta.main) {
const argv = process.argv.slice(2);
try {
if (argv[0] === 'stamp') console.log(await stamp());
else if (argv[0] === 'set') { const v = await stamp(); setVersion(v); console.log(v); }
else if (argv[0] === 'record') {
const { upsert, listReleases, api } = await import('./forgejo-release');
const dir = arg(argv, 'dir') ?? 'dev';
const files = readdirSync(dir).filter(f => /\.(exe|AppImage|deb)$/.test(f)).map(f => join(dir, f));
const vs = [...new Set(files.map(f => /_(\d+\.\d+\.\d+-dev\.\d{14})_/.exec(f)?.[1]))];
if (vs.length !== 1 || !vs[0]) throw new Error(`${dir}: expected the files of one dev version, got ${vs.join(', ') || 'none'}`);
const v = vs[0];
for (const kind of [/_x64-setup\.exe$/, /_amd64\.AppImage$/, /_amd64\.deb$/]) if (!files.some(f => kind.test(f))) throw new Error(`${dir}: no ${kind.source} of ${v}`);
const problem = guardProblem(v, await liveDev());
if (problem) throw new Error(problem);
const yes = argv.includes('--yes');
await upsert({ tag: `dev-${v}`, title: `dev ${v}`, notes: `Dev build of ${process.env.SHA ?? 'HEAD'} (signed and published by the VDS to ${DEV_URL}).`, draft: true, prerelease: true, files, yes });
const old = pruneDev(await listReleases());
for (const r of old) {
console.log(`${yes ? 'delete' : 'would delete'} draft ${r.tag_name}`);
if (yes) await api('DELETE', `/releases/${(r as { id: number }).id}`);
}
} else if (argv[0] === 'wait') {
const v = argv[1] ?? ''; const minutes = Number(arg(argv, 'minutes') ?? 15);
const t0 = Date.now();
for (;;) {
const live = await liveDev().catch(() => null);
if (live && compare(live, v) >= 0) { console.log(`live on the dev channel: ${live}`); break; }
if (Date.now() - t0 > minutes * 60_000) throw new Error(`${v} not on ${DEV_URL} after ${minutes} min (live: ${live ?? 'none'}); see journalctl -u storebound-desktop-dev on the VDS`);
console.log(`waiting for the VDS to publish ${v} (live: ${live ?? 'none'}, ${Math.round((Date.now() - t0) / 1000)} s)`);
await Bun.sleep(20_000);
}
} else { console.log('usage: stamp | set | record --dir <d> [--yes] | wait <version> [--minutes n]'); process.exit(2); }
} catch (e) { console.error(`desktop-dev: ${(e as Error).message}`); process.exit(1); }
}

View file

@ -0,0 +1,171 @@
#!/usr/bin/env python3
# Root-owned dev channel publisher for storebound desktop (install as /usr/local/sbin/storebound-desktop-dev, root:root
# 755, run by storebound-desktop-dev.timer every 2 minutes; see docs/desktop-release.md, "Dev channel").
#
# Every push to main attaches its dev bundles (Windows NSIS setup, Linux AppImage + deb, SHA256SUMS) to a Forgejo
# draft release `dev-<x.y.z-dev.yyyymmddHHMMSS>` (ci.yml `dev` job, tools/desktop-dev.ts). This script, with a
# read-only Forgejo token (/root/.storebound-keys/forgejo-read-token):
# 1. takes the newest complete dev draft whose version is above the live dev/latest.json (never a lower one),
# 2. downloads its files into /var/lib/storebound-desktop/incoming/<version>/ and checks them against SHA256SUMS,
# 3. signs each with the updater key (/root/.storebound-keys/updater.key; it never leaves this host),
# 4. writes latest.json + SHA256SUMS and runs storebound-desktop-publish <version> dev (latest.json last),
# 5. keeps the newest 5 dev versions in /var/www/storebound-desktop/dl/ (and whatever dev/latest.json names).
# Nothing it downloads is executed. One run at a time (flock).
import fcntl, hashlib, json, os, re, shutil, subprocess, sys, urllib.request
from datetime import datetime, timezone
FORGEJO = "https://git.crt.fyi"
REPO = "CRT/storebound"
KEYS = "/root/.storebound-keys"
TOKEN = f"{KEYS}/forgejo-read-token"
KEY = f"{KEYS}/updater.key"
KEY_PASSWORD = f"{KEYS}/updater.key.password"
TAURI_CLI = "@tauri-apps/cli@2.12.1"
INCOMING = "/var/lib/storebound-desktop/incoming"
ROOT = "/var/www/storebound-desktop"
SITE = "https://storebound.dev/desktop"
KEEP = 5
DEV = re.compile(r"^(\d+)\.(\d+)\.(\d+)-dev\.(\d{14})$")
SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def log(msg):
print(f"storebound-desktop-dev: {msg}", flush=True)
def key(v):
m = DEV.match(v)
return tuple(int(x) for x in m.groups()) if m else None
def get(url, binary=False):
req = urllib.request.Request(url, headers={"Authorization": f"token {open(TOKEN).read().strip()}"})
with urllib.request.urlopen(req, timeout=60) as r:
return r.read() if binary else json.loads(r.read())
def live_version():
try:
return json.load(open(f"{ROOT}/dev/latest.json")).get("version")
except FileNotFoundError:
return None
def kind(name, v):
if name == f"storebound_{v}_x64-setup.exe":
return "nsis", f"storebound_{v}_x64-setup-unsigned.exe"
if name == f"storebound_{v}_amd64.AppImage":
return "appimage", name
if name == f"storebound_{v}_amd64.deb":
return "deb", name
return None, None
def sign(path):
pw = open(KEY_PASSWORD).read().strip() if os.path.exists(KEY_PASSWORD) else ""
env = dict(os.environ, TAURI_SIGNING_PRIVATE_KEY_PASSWORD=pw)
if os.path.exists(path + ".sig"):
os.remove(path + ".sig")
subprocess.run(["timeout", "300", "bunx", TAURI_CLI, "signer", "sign", "-f", KEY, path], env=env, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, check=True)
s = open(path + ".sig").read().strip()
os.remove(path + ".sig")
return s
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for b in iter(lambda: f.read(1 << 20), b""):
h.update(b)
return h.hexdigest()
def publish(rel, v):
assets = {a["name"]: a for a in rel["assets"]}
sums = {}
for line in get(f"{FORGEJO}/attachments/{assets['SHA256SUMS']['uuid']}", binary=True).decode().splitlines():
m = re.match(r"^([0-9a-f]{64})\s+\*?(\S+)$", line.strip())
if m:
sums[m.group(2)] = m.group(1)
files = [(n,) + kind(n, v) for n in assets if kind(n, v)[0]]
if sorted(k for _, k, _ in files) != ["appimage", "deb", "nsis"]:
log(f"{v}: draft incomplete ({', '.join(assets)}), waiting")
return False
inc = f"{INCOMING}/{v}"
shutil.rmtree(inc, ignore_errors=True)
os.makedirs(inc, mode=0o755)
manifest = {"version": v, "notes": f"storebound desktop {v} (dev channel: every push to main)",
"pub_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "platforms": {}, "downloads": {}}
out_sums = []
for name, k, dest in files:
assert SAFE.match(dest) and name in sums, name
p = f"{inc}/{dest}"
log(f"{v}: fetching {name}")
req = urllib.request.Request(f"{FORGEJO}/attachments/{assets[name]['uuid']}", headers={"Authorization": f"token {open(TOKEN).read().strip()}"})
with urllib.request.urlopen(req, timeout=900) as r, open(p, "wb") as f:
shutil.copyfileobj(r, f, 1 << 20)
h = sha256(p)
if h != sums[name]:
raise SystemExit(f"{name}: SHA-256 differs from the draft's SHA256SUMS")
sig = sign(p)
url = f"{SITE}/dl/{v}/{dest}"
d = {"file": dest, "url": url, "sha256": h, "size": os.path.getsize(p), "signature": sig, "codeSigned": False}
if k == "nsis":
manifest["downloads"]["windows-x86_64-nsis"] = d
manifest["platforms"]["windows-x86_64"] = {"signature": sig, "url": url}
elif k == "appimage":
manifest["downloads"]["linux-x86_64-appimage"] = d
manifest["platforms"]["linux-x86_64"] = {"signature": sig, "url": url}
else:
manifest["downloads"]["linux-x86_64-deb"] = d
out_sums.append(f"{h} {dest}")
log(f"{v}: {dest} checked and signed")
with open(f"{inc}/latest.json", "w") as f:
json.dump(manifest, f, indent=2)
out_sums.append(f"{sha256(inc + '/latest.json')} latest.json")
with open(f"{inc}/SHA256SUMS", "w") as f:
f.write("\n".join(sorted(out_sums, key=lambda l: l.split()[1])) + "\n")
for n in os.listdir(inc):
os.chmod(f"{inc}/{n}", 0o644)
subprocess.run(["/usr/local/sbin/storebound-desktop-publish", v, "dev"], check=True)
return True
def prune():
live = live_version()
dl = f"{ROOT}/dl"
dev = sorted((d for d in os.listdir(dl) if key(d)), key=key, reverse=True)
for d in dev[KEEP:]:
if d != live:
shutil.rmtree(f"{dl}/{d}")
log(f"pruned dl/{d}")
def main():
os.makedirs(INCOMING, exist_ok=True)
lock = open(f"{INCOMING}/.dev.lock", "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
rels = get(f"{FORGEJO}/api/v1/repos/{REPO}/releases?draft=true&limit=50")
cands = [(r, r["tag_name"][4:]) for r in rels if r["tag_name"].startswith("dev-") and key(r["tag_name"][4:])]
if not cands:
return
rel, v = max(cands, key=lambda c: key(c[1]))
live = live_version()
if live and key(live) and key(v) <= key(live):
prune()
return
log(f"publishing {v} (live: {live or 'none'})")
if publish(rel, v):
prune()
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as e:
log(f"failed: {e}")
sys.exit(1)

View file

@ -0,0 +1,11 @@
# /etc/systemd/system/storebound-desktop-dev.service (docs/desktop-release.md, "Dev channel")
[Unit]
Description=storebound desktop dev channel: sign and publish the newest dev build
After=network-online.target
[Service]
Type=oneshot
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/local/sbin/storebound-desktop-dev
TimeoutStartSec=30min
Nice=10

View file

@ -0,0 +1,10 @@
# /etc/systemd/system/storebound-desktop-dev.timer
[Unit]
Description=storebound desktop dev channel every 2 minutes
[Timer]
OnBootSec=2min
OnUnitInactiveSec=2min
[Install]
WantedBy=timers.target

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Root-owned publish step for storebound desktop releases (install as /usr/local/sbin/storebound-desktop-publish,
# root:root 755; see docs/desktop-release.md). tools/desktop-release.ts publish uploads one version folder to
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal]
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal|dev]
# (rehearsal: tools/release.ts --rehearsal, a throwaway prerelease at /desktop/rehearsal/latest.json that no app reads)
#
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
@ -21,10 +21,12 @@ ROOT=/var/www/storebound-desktop
SITE=https://storebound.dev/desktop
die() { echo "storebound-desktop-publish: $*" >&2; exit 1; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal]"
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal ]] || die "channel: stable, beta or rehearsal"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal|dev]"
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal || "$CHANNEL" == dev ]] || die "channel: stable, beta, rehearsal or dev"
# a stable or beta version is never a rehearsal, and a rehearsal never reaches stable or beta
if [[ "$VERSION" == *-rehearsal.* ]]; then [ "$CHANNEL" = rehearsal ] || die "a rehearsal version goes to the rehearsal channel only"; else [ "$CHANNEL" != rehearsal ] || die "the rehearsal channel takes rehearsal versions only"; fi
# a dev build (storebound-desktop-dev, every push to main) goes to the dev channel only, and the dev channel takes only those
if [[ "$VERSION" =~ -dev\.[0-9]{14}$ ]]; then [ "$CHANNEL" = dev ] || die "a dev version goes to the dev channel only"; else [ "$CHANNEL" != dev ] || die "the dev channel takes dev versions only"; fi
IN="$INCOMING/$VERSION"
DL="$ROOT/dl/$VERSION"
[ -d "$IN" ] && [ ! -L "$IN" ] || die "nothing uploaded at $IN"
@ -54,7 +56,7 @@ for e in entries:
assert e.get("signature"), url
PY
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal"
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal" "$ROOT/dev"
if [ ! -e "$DL" ]; then
TMP="$(mktemp -d "$ROOT/dl/.tmp-$VERSION.XXXXXX")"
for f in "$IN"/*; do n="$(basename "$f")"; [ "$n" = latest.json ] || install -m 644 "$f" "$TMP/$n"; done