mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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';
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user