feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater

This commit is contained in:
Linus Rath
2026-08-29 18:04:21 +02:00
parent 3d0d440673
commit 431facef0b
6 changed files with 505 additions and 94 deletions
+111
View File
@@ -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"
}
}
+99
View File
@@ -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<string, unknown> = {}) => ({
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();
});
});
+81 -14
View File
@@ -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: <value>` 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: <url>` 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<string | null> {
async function fetchCompanionSha256(asset: ReleaseAsset | null): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
@@ -54,12 +84,47 @@ async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<st
}
}
/**
* The checksum to verify the APK against: the release body's hash when it
* lists one, else the companion `.sha256` asset (one extra request, made
* only when the user actually installs).
*/
export async function resolveApkSha256(release: LatestRelease): Promise<string | null> {
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<LatestRelease | null> {
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<La
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => 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),
};
}
+58 -13
View File
@@ -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 (
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
<Download size={16} color={c.primaryForeground} />
<View
style={[styles.banner, mandatory && styles.bannerMandatory, { paddingTop: spacing.md + insets.top }]}
accessibilityRole="alert"
>
{mandatory
? <ShieldAlert size={16} color={c.primaryForeground} />
: <Download size={16} color={c.primaryForeground} />}
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
v{cachedLatest.tag} is ready to install.
</Text>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
{cachedLatest.advisoryUrl ? (
<Pressable
onPress={() => void Linking.openURL(cachedLatest.advisoryUrl!)}
accessibilityRole="link"
hitSlop={4}
>
<Text style={styles.link}>{t('updates.banner.advisory', 'Read the advisory')}</Text>
</Pressable>
) : null}
</View>
<Pressable
style={styles.installButton}
onPress={() => void installLatest()}
disabled={installing}
accessibilityRole="button"
accessibilityLabel={t('updates.install', 'Install')}
>
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={c.primaryForeground} />
<Text style={styles.installText}>{installing ? '…' : t('updates.install', 'Install')}</Text>
</Pressable>
{!mandatory && (
<Pressable
style={styles.dismiss}
onPress={dismissCurrent}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.dismiss', 'Dismiss')}
>
<X size={14} color={c.primaryForeground} />
</Pressable>
)}
</View>
);
}
@@ -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,
+93 -40
View File
@@ -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 (
<View style={styles.container}>
<SettingsSection title="App updates" description="Check for new versions and install them.">
<SettingItem label="Current version">
<SettingsSection
title={t('updates.title', 'App updates')}
description={t('updates.description', 'Check for new versions and install them.')}
>
<SettingItem label={t('updates.current_version', 'Current version')}>
<Text style={styles.value}>v{currentVersion()}</Text>
</SettingItem>
<SettingItem
label="Latest version"
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
label={t('updates.latest_version', 'Latest version')}
description={cachedLatest?.publishedAt
? t('updates.published', 'Published {date}', { date: new Date(cachedLatest.publishedAt).toLocaleDateString() })
: undefined}
>
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
<View style={styles.valueRow}>
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
{updateAvailable && severity !== 'normal' && (
<View style={styles.severityPill}>
<Text style={styles.severityText}>
{severity === 'security'
? t('updates.severity_security', 'Security')
: t('updates.severity_deprecated', 'Required')}
</Text>
</View>
)}
</View>
</SettingItem>
<SettingItem
label="Last checked"
description={error ? `Last error: ${error}` : undefined}
label={t('updates.last_checked', 'Last checked')}
description={error
? t('updates.last_error', 'Last error: {message}', { message: error })
: rateLimited
? t('updates.rate_limited', 'GitHub is rate-limiting update checks; trying again later.')
: undefined}
>
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
</SettingItem>
<SettingItem label="Check automatically" description="Check for updates every few hours when the app starts.">
<SettingItem
label={t('updates.auto_check', 'Check automatically')}
description={t('updates.auto_check_desc', 'Check for updates every few hours when the app starts.')}
>
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
</SettingItem>
<SettingItem
label={
apkPending
? 'Update building'
? t('updates.building', 'Update building')
: updateAvailable
? 'Update available'
: 'Check for updates'
? t('updates.available', 'Update available')
: t('updates.check', 'Check for updates')
}
description={
apkPending
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
? t('updates.building_desc', 'v{version} was published, but the APK is still being built. This page will check again every 30 seconds.', { version: cachedLatest?.tag ?? '' })
: updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
? t('updates.available_desc', "Tap install to download v{version} and open it with Android's package installer.", { version: cachedLatest?.tag ?? '' })
: undefined
}
>
@@ -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')}
</Button>
) : updateAvailable ? (
<Button
@@ -132,7 +164,7 @@ export function UpdatesSettings() {
onPress={onInstall}
disabled={installing}
>
{installButtonLabel(installing, installProgress)}
{installButtonLabel(installing, installProgress, t)}
</Button>
) : (
<Button
@@ -141,29 +173,42 @@ export function UpdatesSettings() {
onPress={() => void checkNow({ force: true })}
disabled={checking}
>
{checking ? 'Checking…' : 'Check now'}
{checking ? t('updates.checking', 'Checking…') : t('updates.check_now', 'Check now')}
</Button>
)}
</SettingItem>
{apkPending && cachedLatest?.htmlUrl ? (
{cachedLatest?.advisoryUrl && updateAvailable ? (
<SettingItem
label="Release page"
description="Watch the build progress or download the APK manually."
label={t('updates.advisory', 'Security advisory')}
description={t('updates.advisory_desc', 'Details about the issue this release fixes.')}
>
<Button variant="ghost" size="sm" onPress={() => void Linking.openURL(cachedLatest.advisoryUrl!)}>
{t('updates.open', 'Open')}
</Button>
</SettingItem>
) : null}
{cachedLatest?.htmlUrl ? (
<SettingItem
label={t('updates.release_page', 'Release page')}
description={apkPending
? t('updates.release_page_pending_desc', 'Watch the build progress or download the APK manually.')
: t('updates.release_page_desc', 'Full release notes and downloads on GitHub.')}
>
<Button
variant="ghost"
size="sm"
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
>
Open on GitHub
{t('updates.open_github', 'Open on GitHub')}
</Button>
</SettingItem>
) : null}
{installing && installProgress ? (
<View style={styles.progressBox}>
<View style={styles.progressTrack}>
<View style={styles.progressTrack} accessibilityRole="progressbar">
<View
style={[
styles.progressFill,
@@ -178,17 +223,17 @@ export function UpdatesSettings() {
</View>
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? 'Opening installer…'
: formatProgressLabel(installProgress)}
? t('updates.opening_installer', 'Opening installer…')
: formatProgressLabel(installProgress, t)}
</Text>
</View>
) : null}
{cachedLatest?.body ? (
{notes ? (
<View style={styles.notesBox}>
<Text style={styles.notesTitle}>Release notes</Text>
<Text style={styles.notesBody} numberOfLines={20}>
{cachedLatest.body.trim()}
<Text style={styles.notesTitle}>{t('updates.release_notes', 'Release notes')}</Text>
<Text style={styles.notesBody} numberOfLines={30}>
{notes}
</Text>
</View>
) : 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,
+63 -27
View File
@@ -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<UpdatesState>((set, get) => ({
...DEFAULT_PERSISTED,
hydrated: false,
@@ -59,7 +93,12 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
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<UpdatesState>((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<UpdatesState>((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<UpdatesState>((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<UpdatesState>((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<UpdatesState>((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<UpdatesState>((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';
},
}));