From 431facef0bc900664768f6e197f626d1038469a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:04:21 +0200 Subject: [PATCH] feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater --- locales/rn/en.json | 111 ++++++++++++++++ src/api/__tests__/updates.test.ts | 99 +++++++++++++++ src/api/updates.ts | 95 +++++++++++--- src/components/UpdateBanner.tsx | 71 +++++++++-- src/components/settings/UpdatesSettings.tsx | 133 ++++++++++++++------ src/stores/updates-store.ts | 90 +++++++++---- 6 files changed, 505 insertions(+), 94 deletions(-) create mode 100644 src/api/__tests__/updates.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 643b9180..476abaf3 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -4,6 +4,10 @@ "participant_placeholder": "Add participant by email" } }, + "common": { + "back": "Back", + "dismiss": "Dismiss" + }, "context_menu": { "color_tag": "Tag", "remove_color": "Remove tag" @@ -76,12 +80,32 @@ "title": "Get notified about new mail" }, "settings": { + "advanced": { + "about": { + "deprecated_update": "Update required: {version}", + "mobile_title": "Bulwark Mobile", + "security_update": "Security update: {version}", + "update_available": "Update: {version}", + "update_store_hint": "available in TestFlight / App Store" + }, + "export_settings": { + "saved_to": "Saved to {path}" + }, + "reset_settings": { + "done": "All settings have been restored to their defaults." + } + }, "appearance": { "language": { "restart_for_direction": "Restart the app to apply the new text direction.", "system_default": "System default" } }, + "badges": { + "experimental": "Experimental", + "not_implemented": "Not implemented", + "unavailable": "Unavailable" + }, "keywords": { "reset_defaults": "Reset to Defaults" }, @@ -105,10 +129,97 @@ "status_android_only": "Background notifications are available on Android only." } }, + "offline": { + "cached_count": "{count, plural, one {# message} other {# messages}}", + "clear": "Clear", + "clear_cache": "Clear cache", + "clear_message": "Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?", + "clear_title": "Clear offline mail", + "days_ago": "{count} d ago", + "description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.", + "downloading": "Downloading {completed}/{total}…", + "enabled": "Cache recent mail", + "enabled_desc": "When on, the app keeps message bodies for the selected window on this device.", + "hours_ago": "{count} h ago", + "just_now": "just now", + "last_sync": "Last sync {when}", + "last_sync_failed": "Last sync failed: {message}", + "max_size": "Maximum size", + "max_size_desc": "Oldest messages are removed when the cache grows past this.", + "minutes_ago": "{count} min ago", + "never": "never", + "nothing_cached": "Nothing cached yet", + "queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync", + "scanning": "Scanning recent mail…", + "sync_now": "Sync now", + "syncing": "Syncing…", + "title": "Offline mail", + "window": "Window", + "window_24h": "Last 24 hours", + "window_days": "Last {count} days", + "window_desc": "How far back to cache, measured by message receipt date." + }, + "plugins": { + "mobile_description": "Plugins run inside the webmail and extend it in the browser.", + "mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here." + }, + "smime": { + "mobile_description": "Sign and encrypt mail with S/MIME certificates.", + "mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here." + }, + "tabs": { + "plugins": "Plugins" + }, "vacation": { "warnings": { "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" } } + }, + "updates": { + "advisory": "Security advisory", + "advisory_desc": "Details about the issue this release fixes.", + "auto_check": "Check automatically", + "auto_check_desc": "Check for updates every few hours when the app starts.", + "available": "Update available", + "available_desc": "Tap install to download v{version} and open it with Android's package installer.", + "banner": { + "advisory": "Read the advisory", + "deprecated_subtitle": "Install v{version} to keep using the app.", + "deprecated_title": "This version is no longer supported", + "security_subtitle": "v{version} fixes a security issue. Install it as soon as possible.", + "security_title": "Security update available", + "subtitle": "v{version} is ready to install.", + "title": "Update available" + }, + "building": "Update building", + "building_desc": "v{version} was published, but the APK is still being built. This page will check again every 30 seconds.", + "check": "Check for updates", + "check_again": "Check again", + "check_now": "Check now", + "checking": "Checking…", + "current_version": "Current version", + "description": "Check for new versions and install them.", + "downloading": "Downloading…", + "downloading_pct": "Downloading {pct}%", + "install": "Install", + "installing": "Installing…", + "last_checked": "Last checked", + "last_error": "Last error: {message}", + "latest_version": "Latest version", + "never": "never", + "open": "Open", + "open_github": "Open on GitHub", + "opening_installer": "Opening installer…", + "progress_of": "{written} of {total}", + "published": "Published {date}", + "rate_limited": "GitHub is rate-limiting update checks; trying again later.", + "release_notes": "Release notes", + "release_page": "Release page", + "release_page_desc": "Full release notes and downloads on GitHub.", + "release_page_pending_desc": "Watch the build progress or download the APK manually.", + "severity_deprecated": "Required", + "severity_security": "Security", + "title": "App updates" } } diff --git a/src/api/__tests__/updates.test.ts b/src/api/__tests__/updates.test.ts new file mode 100644 index 00000000..e19280ce --- /dev/null +++ b/src/api/__tests__/updates.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + fetchLatestRelease, + parseSeverity, + parseAdvisoryUrl, + resolveApkSha256, + stripMarkdown, + UpdateRateLimitedError, + type LatestRelease, +} from '../updates'; + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +describe('parseSeverity', () => { + it('reads a severity line from the release body', () => { + expect(parseSeverity('## Notes\nseverity: security\n- fix')).toBe('security'); + expect(parseSeverity('- Severity: Deprecated')).toBe('deprecated'); + expect(parseSeverity('nothing here')).toBe('normal'); + expect(parseSeverity('severity: bogus')).toBe('normal'); + }); + + it('reads an advisory url', () => { + expect(parseAdvisoryUrl('advisory: https://github.com/x/y/security/advisories/GHSA-1')).toBe( + 'https://github.com/x/y/security/advisories/GHSA-1', + ); + expect(parseAdvisoryUrl('')).toBeNull(); + }); +}); + +describe('stripMarkdown', () => { + it('unwraps headings, emphasis, links and bullets and drops control lines', () => { + const out = stripMarkdown('# Title\nseverity: security\n\n- **bold** and [link](https://x.y) `code`\n> quote'); + expect(out).toBe('Title\n\n• bold and link code\nquote'); + }); +}); + +describe('fetchLatestRelease', () => { + const release = (overrides: Record = {}) => ({ + tag_name: '1.2.3', + name: 'v1.2.3', + html_url: 'https://github.com/bulwarkmail/native/releases/tag/1.2.3', + published_at: '2026-01-01T00:00:00Z', + body: '', + assets: [ + { name: 'app.apk', browser_download_url: 'https://x/app.apk', size: 10 }, + { name: 'app.apk.sha256', browser_download_url: 'https://x/app.apk.sha256', size: 65 }, + ], + ...overrides, + }); + + it('does not download the checksum companion during the check', async () => { + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => release() })); + global.fetch = fetchMock as unknown as typeof fetch; + const latest = await fetchLatestRelease(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(latest?.sha256Asset?.name).toBe('app.apk.sha256'); + expect(latest?.apkSha256).toBeNull(); + expect(latest?.severity).toBe('normal'); + }); + + it('parses severity from the body', async () => { + global.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => release({ body: 'severity: security\nadvisory: https://a.b/c' }), + })) as unknown as typeof fetch; + const latest = await fetchLatestRelease(); + expect(latest?.severity).toBe('security'); + expect(latest?.advisoryUrl).toBe('https://a.b/c'); + }); + + it('throws UpdateRateLimitedError on 403/429', async () => { + global.fetch = vi.fn(async () => ({ ok: false, status: 403 })) as unknown as typeof fetch; + await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError); + global.fetch = vi.fn(async () => ({ ok: false, status: 429 })) as unknown as typeof fetch; + await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError); + }); +}); + +describe('resolveApkSha256', () => { + const base: LatestRelease = { + tag: '1', name: '1', htmlUrl: '', publishedAt: '', body: '', + apkAsset: null, sha256Asset: null, apkSha256: null, severity: 'normal', advisoryUrl: null, + }; + + it('prefers the body hash and otherwise fetches the companion asset', async () => { + const hash = 'a'.repeat(64); + expect(await resolveApkSha256({ ...base, apkSha256: hash })).toBe(hash); + global.fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => `${'b'.repeat(64)} app.apk` })) as unknown as typeof fetch; + expect(await resolveApkSha256({ + ...base, + sha256Asset: { name: 'app.apk.sha256', browser_download_url: 'https://x', size: 1 }, + })).toBe('b'.repeat(64)); + expect(await resolveApkSha256(base)).toBeNull(); + }); +}); diff --git a/src/api/updates.ts b/src/api/updates.ts index 6f08a8cf..7256912f 100644 --- a/src/api/updates.ts +++ b/src/api/updates.ts @@ -6,6 +6,11 @@ export interface ReleaseAsset { size: number; } +// Mirrors the webmail's version-check severities: `security` and `deprecated` +// releases show a non-dismissable banner. Read from a `severity: ` line +// in the release body (GitHub Releases has no structured field for it). +export type UpdateSeverity = 'normal' | 'security' | 'deprecated'; + export interface LatestRelease { tag: string; name: string; @@ -13,14 +18,39 @@ export interface LatestRelease { publishedAt: string; body: string; apkAsset: ReleaseAsset | null; - // Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion - // asset or from the release body. Null when the release didn't publish one; - // the installer falls back to relying solely on Android's package signing - // check in that case (still strong, but less defense-in-depth). + // Companion `*.apk.sha256` asset, fetched lazily on Install (see + // resolveApkSha256) so the periodic check costs one request. + sha256Asset: ReleaseAsset | null; + // Lower-case hex SHA-256 of the APK when the release body lists one. Null + // when it doesn't; the installer then relies on the companion asset or, + // failing that, solely on Android's package signing check. apkSha256: string | null; + severity: UpdateSeverity; + // Optional advisory link from an `advisory: ` line in the body. + advisoryUrl: string | null; +} + +/** GitHub answered 403/429 (unauthenticated limit is 60 req/h/IP). */ +export class UpdateRateLimitedError extends Error { + constructor(status: number) { + super(`GitHub API rate limited (${status})`); + this.name = 'UpdateRateLimitedError'; + } } const SHA256_RE = /\b([a-f0-9]{64})\b/i; +const SEVERITY_RE = /^[ \t]*(?:[-*][ \t]*)?severity[ \t]*:[ \t]*(normal|security|deprecated)\b/im; +const ADVISORY_RE = /^[ \t]*(?:[-*][ \t]*)?advisory[ \t]*:[ \t]*(https?:\/\/\S+)/im; + +export function parseSeverity(body: string): UpdateSeverity { + const m = SEVERITY_RE.exec(body || ''); + return (m?.[1]?.toLowerCase() as UpdateSeverity | undefined) ?? 'normal'; +} + +export function parseAdvisoryUrl(body: string): string | null { + const m = ADVISORY_RE.exec(body || ''); + return m ? m[1] : null; +} function extractSha256(body: string, apkName: string | undefined): string | null { if (!body) return null; @@ -41,7 +71,7 @@ function extractSha256(body: string, apkName: string | undefined): string | null return m ? m[1].toLowerCase() : null; } -async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise { +async function fetchCompanionSha256(asset: ReleaseAsset | null): Promise { if (!asset) return null; try { const res = await fetch(asset.browser_download_url); @@ -54,12 +84,47 @@ async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise { + if (release.apkSha256) return release.apkSha256; + return fetchCompanionSha256(release.sha256Asset); +} + +/** + * Turn GitHub release markdown into readable plain text: headings, emphasis, + * links (kept as their label), code spans and list bullets are unwrapped. + * Also strips the `severity:` / `advisory:` control lines. + */ +export function stripMarkdown(body: string): string { + return (body || '') + .replace(/\r\n/g, '\n') + .replace(SEVERITY_RE, '') + .replace(ADVISORY_RE, '') + .replace(/```[\s\S]*?```/g, (block) => block.replace(/```\w*\n?/g, '')) + .replace(/^#{1,6}\s+/gm, '') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, '$2') + .replace(/`([^`]+)`/g, '$1') + .replace(/^[ \t]*[-*+][ \t]+/gm, '• ') + .replace(/^[ \t]*>[ \t]?/gm, '') + .replace(/<\/?[a-z][^>]*>/gi, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise { const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: { Accept: 'application/vnd.github+json' }, }); if (!res.ok) { if (res.status === 404) return null; + if (res.status === 403 || res.status === 429) throw new UpdateRateLimitedError(res.status); throw new Error(`GitHub API ${res.status}`); } const json = (await res.json()) as { @@ -70,22 +135,24 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise a.name.endsWith('.apk')) ?? null; + const assets = Array.isArray(json.assets) ? json.assets : []; + const apkAsset = assets.find((a) => a.name.endsWith('.apk')) ?? null; const sha256Asset = apkAsset - ? json.assets.find( + ? assets.find( (a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`, - ) - : undefined; - const apkSha256 = - (await fetchCompanionSha256(sha256Asset)) ?? - extractSha256(json.body || '', apkAsset?.name); + ) ?? null + : null; + const body = json.body || ''; return { tag: json.tag_name, name: json.name || json.tag_name, htmlUrl: json.html_url, publishedAt: json.published_at, - body: json.body || '', + body, apkAsset, - apkSha256, + sha256Asset, + apkSha256: extractSha256(body, apkAsset?.name), + severity: parseSeverity(body), + advisoryUrl: parseAdvisoryUrl(body), }; } diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx index d2a969d5..d3c89948 100644 --- a/src/components/UpdateBanner.tsx +++ b/src/components/UpdateBanner.tsx @@ -1,45 +1,88 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Download, X } from 'lucide-react-native'; +import { Download, ShieldAlert, X } from 'lucide-react-native'; import { useUpdatesStore } from '../stores/updates-store'; +import { useLocaleStore } from '../stores/locale-store'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +/** + * Sideload-update banner. Normal releases can be dismissed per tag; security + * and deprecated releases stay until installed (webmail parity, where the + * version server's severity drives a red non-dismissable notice). + */ export function UpdateBanner(): React.ReactElement | null { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); const cachedLatest = useUpdatesStore((s) => s.cachedLatest); const dismissedTag = useUpdatesStore((s) => s.dismissedTag); const installing = useUpdatesStore((s) => s.installing); const installLatest = useUpdatesStore((s) => s.installLatest); const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent); const hasUpdate = useUpdatesStore((s) => s.hasUpdate); + const isMandatory = useUpdatesStore((s) => s.isMandatory); if (!hasUpdate()) return null; - if (cachedLatest && dismissedTag === cachedLatest.tag) return null; if (!cachedLatest?.apkAsset) return null; + const mandatory = isMandatory(); + if (!mandatory && dismissedTag === cachedLatest.tag) return null; + + const severity = cachedLatest.severity; + const title = severity === 'security' + ? t('updates.banner.security_title', 'Security update available') + : severity === 'deprecated' + ? t('updates.banner.deprecated_title', 'This version is no longer supported') + : t('updates.banner.title', 'Update available'); + const subtitle = severity === 'security' + ? t('updates.banner.security_subtitle', 'v{version} fixes a security issue. Install it as soon as possible.', { version: cachedLatest.tag }) + : severity === 'deprecated' + ? t('updates.banner.deprecated_subtitle', 'Install v{version} to keep using the app.', { version: cachedLatest.tag }) + : t('updates.banner.subtitle', 'v{version} is ready to install.', { version: cachedLatest.tag }); return ( - - + + {mandatory + ? + : } - Update available - - v{cachedLatest.tag} is ready to install. - + {title} + {subtitle} + {cachedLatest.advisoryUrl ? ( + void Linking.openURL(cachedLatest.advisoryUrl!)} + accessibilityRole="link" + hitSlop={4} + > + {t('updates.banner.advisory', 'Read the advisory')} + + ) : null} void installLatest()} disabled={installing} + accessibilityRole="button" + accessibilityLabel={t('updates.install', 'Install')} > - {installing ? '…' : 'Install'} - - - + {installing ? '…' : t('updates.install', 'Install')} + {!mandatory && ( + + + + )} ); } @@ -54,8 +97,10 @@ function makeStyles(c: ThemePalette) { paddingVertical: spacing.md, backgroundColor: c.primary, }, + bannerMandatory: { backgroundColor: c.error }, title: { ...typography.bodyMedium, color: c.primaryForeground }, subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 }, + link: { ...typography.captionMedium, color: c.primaryForeground, textDecorationLine: 'underline', marginTop: 2 }, installButton: { paddingHorizontal: spacing.md, paddingVertical: 6, diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx index 7766fc8b..cf6e25f5 100644 --- a/src/components/settings/UpdatesSettings.tsx +++ b/src/components/settings/UpdatesSettings.tsx @@ -5,41 +5,43 @@ import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useUpdatesStore } from '../../stores/updates-store'; +import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; +import { stripMarkdown } from '../../api/updates'; import type { InstallProgress } from '../../lib/install-update'; -function formatTime(ts: number): string { - if (!ts) return 'never'; - const date = new Date(ts); - return date.toLocaleString(); -} - function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } -function formatProgressLabel(p: InstallProgress): string { +function formatProgressLabel(p: InstallProgress, t: TranslateFn): string { const pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null; const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null; const total = p.totalBytes ? formatBytes(p.totalBytes) : null; - const sizes = written && total ? `${written} of ${total}` : written ?? ''; - return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…'; + const sizes = written && total + ? t('updates.progress_of', '{written} of {total}', { written, total }) + : written ?? ''; + return [pct, sizes].filter(Boolean).join(' · ') || t('updates.downloading', 'Downloading…'); } function installButtonLabel( installing: boolean, progress: InstallProgress | null, + t: TranslateFn, ): string { - if (!installing) return 'Install'; - if (progress?.phase === 'installing') return 'Installing…'; - if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`; - return 'Downloading…'; + if (!installing) return t('updates.install', 'Install'); + if (progress?.phase === 'installing') return t('updates.installing', 'Installing…'); + if (progress?.progress != null) { + return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) }); + } + return t('updates.downloading', 'Downloading…'); } export function UpdatesSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const hydrated = useUpdatesStore((s) => s.hydrated); const hydrate = useUpdatesStore((s) => s.hydrate); const checking = useUpdatesStore((s) => s.checking); @@ -47,6 +49,7 @@ export function UpdatesSettings() { const installProgress = useUpdatesStore((s) => s.installProgress); const error = useUpdatesStore((s) => s.error); const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt); + const rateLimitedUntil = useUpdatesStore((s) => s.rateLimitedUntil); const cachedLatest = useUpdatesStore((s) => s.cachedLatest); const autoCheck = useUpdatesStore((s) => s.autoCheck); const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck); @@ -62,6 +65,8 @@ export function UpdatesSettings() { const updateAvailable = hasUpdate(); const apkAsset = cachedLatest?.apkAsset ?? null; const apkPending = updateAvailable && !apkAsset; + const severity = cachedLatest?.severity ?? 'normal'; + const rateLimited = rateLimitedUntil > Date.now(); useEffect(() => { if (!apkPending) return; @@ -75,44 +80,71 @@ export function UpdatesSettings() { void installLatest(); }; + const formatTime = (ts: number): string => (ts ? new Date(ts).toLocaleString() : t('updates.never', 'never')); + + const notes = cachedLatest?.body ? stripMarkdown(cachedLatest.body) : ''; + return ( - - + + v{currentVersion()} - {cachedLatest ? cachedLatest.tag : '-'} + + {cachedLatest ? cachedLatest.tag : '-'} + {updateAvailable && severity !== 'normal' && ( + + + {severity === 'security' + ? t('updates.severity_security', 'Security') + : t('updates.severity_deprecated', 'Required')} + + + )} + {formatTime(lastCheckedAt)} - + @@ -123,7 +155,7 @@ export function UpdatesSettings() { onPress={() => void checkNow({ force: true })} disabled={checking} > - {checking ? 'Checking…' : 'Check again'} + {checking ? t('updates.checking', 'Checking…') : t('updates.check_again', 'Check again')} ) : updateAvailable ? ( ) : ( )} - {apkPending && cachedLatest?.htmlUrl ? ( + {cachedLatest?.advisoryUrl && updateAvailable ? ( + + + ) : null} + + {cachedLatest?.htmlUrl ? ( + ) : null} {installing && installProgress ? ( - + {installProgress.phase === 'installing' - ? 'Opening installer…' - : formatProgressLabel(installProgress)} + ? t('updates.opening_installer', 'Opening installer…') + : formatProgressLabel(installProgress, t)} ) : null} - {cachedLatest?.body ? ( + {notes ? ( - Release notes - - {cachedLatest.body.trim()} + {t('updates.release_notes', 'Release notes')} + + {notes} ) : null} @@ -201,6 +246,14 @@ function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { gap: spacing.xxxl }, value: { ...typography.bodyMedium, color: c.text }, + valueRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + severityPill: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.full, + backgroundColor: c.errorBg, + }, + severityText: { ...typography.caption, color: c.error, fontWeight: '600' }, notesBox: { padding: spacing.md, borderRadius: radius.md, diff --git a/src/stores/updates-store.ts b/src/stores/updates-store.ts index 22ddf67e..621def9b 100644 --- a/src/stores/updates-store.ts +++ b/src/stores/updates-store.ts @@ -1,7 +1,12 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Constants from 'expo-constants'; -import { fetchLatestRelease, type LatestRelease } from '../api/updates'; +import { + fetchLatestRelease, + resolveApkSha256, + UpdateRateLimitedError, + type LatestRelease, +} from '../api/updates'; import { isNewer } from '../lib/version-compare'; import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update'; import { supportsSideloadUpdates } from '../lib/platform-capabilities'; @@ -9,12 +14,16 @@ import { supportsSideloadUpdates } from '../lib/platform-capabilities'; const STORAGE_KEY = 'webmail:updates:v1'; const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; const PENDING_APK_INTERVAL_MS = 2 * 60 * 1000; +// After GitHub rate-limits us, stay quiet for a while instead of retrying on +// every launch (the unauthenticated limit resets hourly). +const RATE_LIMIT_BACKOFF_MS = 60 * 60 * 1000; interface PersistedUpdates { autoCheck: boolean; lastCheckedAt: number; cachedLatest: LatestRelease | null; dismissedTag: string | null; + rateLimitedUntil: number; } const DEFAULT_PERSISTED: PersistedUpdates = { @@ -22,6 +31,7 @@ const DEFAULT_PERSISTED: PersistedUpdates = { lastCheckedAt: 0, cachedLatest: null, dismissedTag: null, + rateLimitedUntil: 0, }; export interface UpdatesState extends PersistedUpdates { @@ -38,6 +48,8 @@ export interface UpdatesState extends PersistedUpdates { dismissCurrent: () => void; currentVersion: () => string; hasUpdate: () => boolean; + // Security / deprecated releases cannot be dismissed (webmail parity). + isMandatory: () => boolean; } function persist(state: PersistedUpdates): void { @@ -46,6 +58,28 @@ function persist(state: PersistedUpdates): void { }); } +function snapshot(s: UpdatesState): PersistedUpdates { + return { + autoCheck: s.autoCheck, + lastCheckedAt: s.lastCheckedAt, + cachedLatest: s.cachedLatest, + dismissedTag: s.dismissedTag, + rateLimitedUntil: s.rateLimitedUntil, + }; +} + +// Releases cached by an older build lack the newer fields. +function normalizeRelease(release: LatestRelease | null | undefined): LatestRelease | null { + if (!release || typeof release !== 'object' || typeof release.tag !== 'string') return null; + return { + ...release, + sha256Asset: release.sha256Asset ?? null, + apkSha256: release.apkSha256 ?? null, + severity: release.severity === 'security' || release.severity === 'deprecated' ? release.severity : 'normal', + advisoryUrl: release.advisoryUrl ?? null, + }; +} + export const useUpdatesStore = create((set, get) => ({ ...DEFAULT_PERSISTED, hydrated: false, @@ -59,7 +93,12 @@ export const useUpdatesStore = create((set, get) => ({ const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as Partial; - set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true }); + set({ + ...DEFAULT_PERSISTED, + ...parsed, + cachedLatest: normalizeRelease(parsed.cachedLatest), + hydrated: true, + }); } else { set({ hydrated: true }); } @@ -71,13 +110,7 @@ export const useUpdatesStore = create((set, get) => ({ setAutoCheck: (enabled) => { set({ autoCheck: enabled }); - const s = get(); - persist({ - autoCheck: enabled, - lastCheckedAt: s.lastCheckedAt, - cachedLatest: s.cachedLatest, - dismissedTag: s.dismissedTag, - }); + persist(snapshot(get())); }, checkNow: async (opts) => { @@ -88,6 +121,7 @@ export const useUpdatesStore = create((set, get) => ({ const s = get(); if (s.checking) return; const now = Date.now(); + if (!opts?.force && now < s.rateLimitedUntil) return; const waitingForApk = s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate(); const interval = waitingForApk ? PENDING_APK_INTERVAL_MS : MIN_CHECK_INTERVAL_MS; @@ -95,15 +129,16 @@ export const useUpdatesStore = create((set, get) => ({ set({ checking: true, error: null }); try { const latest = await fetchLatestRelease(); - const next: PersistedUpdates = { - autoCheck: s.autoCheck, - lastCheckedAt: now, - cachedLatest: latest, - dismissedTag: s.dismissedTag, - }; - set({ ...next, checking: false }); - persist(next); + set({ lastCheckedAt: now, cachedLatest: latest, rateLimitedUntil: 0, checking: false }); + persist(snapshot(get())); } catch (err) { + if (err instanceof UpdateRateLimitedError) { + // Not an error the user can act on: back off quietly and keep + // whatever we last cached. + set({ checking: false, rateLimitedUntil: now + RATE_LIMIT_BACKOFF_MS, lastCheckedAt: now }); + persist(snapshot(get())); + return; + } set({ checking: false, error: err instanceof Error ? err.message : String(err) }); } }, @@ -114,11 +149,10 @@ export const useUpdatesStore = create((set, get) => ({ if (s.installing || !s.cachedLatest?.apkAsset) return; set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } }); try { + // The checksum companion is only fetched now, not on every check. + const expectedSha256 = await resolveApkSha256(s.cachedLatest); await downloadAndInstallApk( - { - asset: s.cachedLatest.apkAsset, - expectedSha256: s.cachedLatest.apkSha256, - }, + { asset: s.cachedLatest.apkAsset, expectedSha256 }, (p) => { set({ installProgress: p }); }, @@ -135,14 +169,10 @@ export const useUpdatesStore = create((set, get) => ({ dismissCurrent: () => { const s = get(); + if (s.isMandatory()) return; const tag = s.cachedLatest?.tag ?? null; set({ dismissedTag: tag }); - persist({ - autoCheck: s.autoCheck, - lastCheckedAt: s.lastCheckedAt, - cachedLatest: s.cachedLatest, - dismissedTag: tag, - }); + persist(snapshot(get())); }, currentVersion: () => Constants.expoConfig?.version ?? '0.0.0', @@ -154,4 +184,10 @@ export const useUpdatesStore = create((set, get) => ({ const current = Constants.expoConfig?.version ?? '0.0.0'; return isNewer(s.cachedLatest.tag, current); }, + + isMandatory: () => { + const s = get(); + if (!s.hasUpdate() || !s.cachedLatest) return false; + return s.cachedLatest.severity === 'security' || s.cachedLatest.severity === 'deprecated'; + }, }));