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

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