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

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