Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests

This commit is contained in:
CRTkafa 2026-10-22 22:24:05 +08:00
parent 5d8a0f5e9a
commit 03ef999dac
27 changed files with 1050 additions and 55 deletions

View file

@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides {
function pick<T>(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; }
const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x);
const isBool = (x: unknown) => typeof x === 'boolean';
const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x);
export function normalizeSettings(v: unknown): Settings {
const d = DEFAULT_SETTINGS;
if (!isRec(v)) return d;
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
const q = isRec(n.quietHours) ? n.quietHours : {};
return {
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool),
autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) },
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
notify: {
reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool),
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',
};
}

View file

@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api';
import { sameVersion } from '../../../../cli/lib/portfolio';
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model';
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string }
/** Which Settings > Notifications toggle a release change belongs to. */
export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state';
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string }
/** The notification kind of a transition into phase `to`.*/
export function releaseKind(to: string): ReleaseKind {
if (to === 'rejected') return 'rejected';
if (to === 'approved') return 'approved';
if (to === 'live' || to === 'rollout') return 'live';
if (to === 'halted') return 'halted';
return 'state';
}
/** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */
export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] {
@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P
if (!r.version) continue;
const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version)));
if (!old || old.phase === r.phase) continue;
if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase });
// a version the developer withdrew on their own is not a store decision
if (r.state === 'DEVELOPER_REJECTED') continue;
if (WATCH.has(r.phase) || WATCH.has(old.phase)) {
const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined;
out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) });
}
}
}
return out;
@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal
for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k);
return out;
}
// ---- when a notification may show: kind toggle, quiet hours, mute ----
export interface QuietHours { enabled: boolean; from: string; to: string }
export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours }
export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test';
/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */
export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean {
switch (kind) {
case 'rejected': return n.rejected;
case 'approved': return n.approved;
case 'live': return n.live;
case 'halted': case 'crash': return n.crash;
case 'state': return n.reviewState;
case 'lowReviews': return n.lowReviews;
case 'test': return true;
}
}
const minutesOf = (hhmm: string): number | null => {
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm);
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
};
/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */
export function inQuietHours(q: QuietHours, now: Date): boolean {
if (!q.enabled) return false;
const from = minutesOf(q.from), to = minutesOf(q.to);
if (from === null || to === null || from === to) return false;
const cur = now.getHours() * 60 + now.getMinutes();
return from < to ? cur >= from && cur < to : cur >= from || cur < to;
}
/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */
export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now;
/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */
export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean {
if (kind === 'test') return true;
const now = o.now ?? new Date();
return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime());
}
/**
* Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most
* x16, capped at 2 hours before the jitter). `rand` is in [0, 1).
*/
export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number {
const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4);
return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2));
}

View file

@ -0,0 +1,48 @@
// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host
// (src-tauri/src/tray.rs) only draws it.
import type { AppStatus } from '../api';
import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol';
export interface TrayApp { key: string; name: string; state: string }
export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string }
export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels }
type Phase = ReleasePhase;
/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */
const RANK: Record<string, number> = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 };
/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */
export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } {
const out: { ios?: Phase; android?: Phase } = {};
for (const r of s?.release.all ?? []) {
const cur = out[r.store];
if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase;
}
return out;
}
/** True for a phase that needs the owner (rejected, halted). */
export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted';
export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent';
/**
* Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the
* caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps.
*/
export function traySummary(
apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean,
tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels },
): TraySummary {
let review = 0, attention = 0;
const list: TrayApp[] = [];
for (const a of apps) {
const ph = trayPhases(a.status);
if (inReview(ph.ios) || inReview(ph.android)) review++;
if (needsYou(ph.ios) || needsYou(ph.android)) attention++;
const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`);
list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none });
}
const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean);
return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels };
}