mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 00:06:53 +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"
|
"participant_placeholder": "Add participant by email"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"back": "Back",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
},
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
"color_tag": "Tag",
|
"color_tag": "Tag",
|
||||||
"remove_color": "Remove tag"
|
"remove_color": "Remove tag"
|
||||||
@@ -76,12 +80,32 @@
|
|||||||
"title": "Get notified about new mail"
|
"title": "Get notified about new mail"
|
||||||
},
|
},
|
||||||
"settings": {
|
"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": {
|
"appearance": {
|
||||||
"language": {
|
"language": {
|
||||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||||
"system_default": "System default"
|
"system_default": "System default"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"badges": {
|
||||||
|
"experimental": "Experimental",
|
||||||
|
"not_implemented": "Not implemented",
|
||||||
|
"unavailable": "Unavailable"
|
||||||
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"reset_defaults": "Reset to Defaults"
|
"reset_defaults": "Reset to Defaults"
|
||||||
},
|
},
|
||||||
@@ -105,10 +129,97 @@
|
|||||||
"status_android_only": "Background notifications are available on Android only."
|
"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": {
|
"vacation": {
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
"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;
|
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 {
|
export interface LatestRelease {
|
||||||
tag: string;
|
tag: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,14 +18,39 @@ export interface LatestRelease {
|
|||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
body: string;
|
body: string;
|
||||||
apkAsset: ReleaseAsset | null;
|
apkAsset: ReleaseAsset | null;
|
||||||
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
|
// Companion `*.apk.sha256` asset, fetched lazily on Install (see
|
||||||
// asset or from the release body. Null when the release didn't publish one;
|
// resolveApkSha256) so the periodic check costs one request.
|
||||||
// the installer falls back to relying solely on Android's package signing
|
sha256Asset: ReleaseAsset | null;
|
||||||
// check in that case (still strong, but less defense-in-depth).
|
// 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;
|
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 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 {
|
function extractSha256(body: string, apkName: string | undefined): string | null {
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
@@ -41,7 +71,7 @@ function extractSha256(body: string, apkName: string | undefined): string | null
|
|||||||
return m ? m[1].toLowerCase() : 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;
|
if (!asset) return null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(asset.browser_download_url);
|
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> {
|
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||||
headers: { Accept: 'application/vnd.github+json' },
|
headers: { Accept: 'application/vnd.github+json' },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404) return null;
|
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}`);
|
throw new Error(`GitHub API ${res.status}`);
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as {
|
||||||
@@ -70,22 +135,24 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
|||||||
body: string;
|
body: string;
|
||||||
assets: ReleaseAsset[];
|
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
|
const sha256Asset = apkAsset
|
||||||
? json.assets.find(
|
? assets.find(
|
||||||
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
|
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
|
||||||
)
|
) ?? null
|
||||||
: undefined;
|
: null;
|
||||||
const apkSha256 =
|
const body = json.body || '';
|
||||||
(await fetchCompanionSha256(sha256Asset)) ??
|
|
||||||
extractSha256(json.body || '', apkAsset?.name);
|
|
||||||
return {
|
return {
|
||||||
tag: json.tag_name,
|
tag: json.tag_name,
|
||||||
name: json.name || json.tag_name,
|
name: json.name || json.tag_name,
|
||||||
htmlUrl: json.html_url,
|
htmlUrl: json.html_url,
|
||||||
publishedAt: json.published_at,
|
publishedAt: json.published_at,
|
||||||
body: json.body || '',
|
body,
|
||||||
apkAsset,
|
apkAsset,
|
||||||
apkSha256,
|
sha256Asset,
|
||||||
|
apkSha256: extractSha256(body, apkAsset?.name),
|
||||||
|
severity: parseSeverity(body),
|
||||||
|
advisoryUrl: parseAdvisoryUrl(body),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,88 @@
|
|||||||
import React from 'react';
|
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 { 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 { useUpdatesStore } from '../stores/updates-store';
|
||||||
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
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 {
|
export function UpdateBanner(): React.ReactElement | null {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||||
const installing = useUpdatesStore((s) => s.installing);
|
const installing = useUpdatesStore((s) => s.installing);
|
||||||
const installLatest = useUpdatesStore((s) => s.installLatest);
|
const installLatest = useUpdatesStore((s) => s.installLatest);
|
||||||
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
|
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
|
||||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||||
|
const isMandatory = useUpdatesStore((s) => s.isMandatory);
|
||||||
|
|
||||||
if (!hasUpdate()) return null;
|
if (!hasUpdate()) return null;
|
||||||
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
|
|
||||||
if (!cachedLatest?.apkAsset) 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 (
|
return (
|
||||||
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
|
<View
|
||||||
<Download size={16} color={c.primaryForeground} />
|
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 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.title}>Update available</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
v{cachedLatest.tag} is ready to install.
|
{cachedLatest.advisoryUrl ? (
|
||||||
</Text>
|
<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>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.installButton}
|
style={styles.installButton}
|
||||||
onPress={() => void installLatest()}
|
onPress={() => void installLatest()}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('updates.install', 'Install')}
|
||||||
>
|
>
|
||||||
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
<Text style={styles.installText}>{installing ? '…' : t('updates.install', 'Install')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
{!mandatory && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.dismiss}
|
||||||
|
onPress={dismissCurrent}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.dismiss', 'Dismiss')}
|
||||||
|
>
|
||||||
<X size={14} color={c.primaryForeground} />
|
<X size={14} color={c.primaryForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,8 +97,10 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
backgroundColor: c.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
|
bannerMandatory: { backgroundColor: c.error },
|
||||||
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||||
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
|
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
|
||||||
|
link: { ...typography.captionMedium, color: c.primaryForeground, textDecorationLine: 'underline', marginTop: 2 },
|
||||||
installButton: {
|
installButton: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
|
|||||||
@@ -5,41 +5,43 @@ import Button from '../Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useUpdatesStore } from '../../stores/updates-store';
|
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';
|
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 {
|
function formatBytes(n: number): string {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
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 pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null;
|
||||||
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
|
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
|
||||||
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
|
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
|
||||||
const sizes = written && total ? `${written} of ${total}` : written ?? '';
|
const sizes = written && total
|
||||||
return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…';
|
? t('updates.progress_of', '{written} of {total}', { written, total })
|
||||||
|
: written ?? '';
|
||||||
|
return [pct, sizes].filter(Boolean).join(' · ') || t('updates.downloading', 'Downloading…');
|
||||||
}
|
}
|
||||||
|
|
||||||
function installButtonLabel(
|
function installButtonLabel(
|
||||||
installing: boolean,
|
installing: boolean,
|
||||||
progress: InstallProgress | null,
|
progress: InstallProgress | null,
|
||||||
|
t: TranslateFn,
|
||||||
): string {
|
): string {
|
||||||
if (!installing) return 'Install';
|
if (!installing) return t('updates.install', 'Install');
|
||||||
if (progress?.phase === 'installing') return 'Installing…';
|
if (progress?.phase === 'installing') return t('updates.installing', 'Installing…');
|
||||||
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
|
if (progress?.progress != null) {
|
||||||
return 'Downloading…';
|
return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) });
|
||||||
|
}
|
||||||
|
return t('updates.downloading', 'Downloading…');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpdatesSettings() {
|
export function UpdatesSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const hydrated = useUpdatesStore((s) => s.hydrated);
|
const hydrated = useUpdatesStore((s) => s.hydrated);
|
||||||
const hydrate = useUpdatesStore((s) => s.hydrate);
|
const hydrate = useUpdatesStore((s) => s.hydrate);
|
||||||
const checking = useUpdatesStore((s) => s.checking);
|
const checking = useUpdatesStore((s) => s.checking);
|
||||||
@@ -47,6 +49,7 @@ export function UpdatesSettings() {
|
|||||||
const installProgress = useUpdatesStore((s) => s.installProgress);
|
const installProgress = useUpdatesStore((s) => s.installProgress);
|
||||||
const error = useUpdatesStore((s) => s.error);
|
const error = useUpdatesStore((s) => s.error);
|
||||||
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
||||||
|
const rateLimitedUntil = useUpdatesStore((s) => s.rateLimitedUntil);
|
||||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||||
const autoCheck = useUpdatesStore((s) => s.autoCheck);
|
const autoCheck = useUpdatesStore((s) => s.autoCheck);
|
||||||
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
|
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
|
||||||
@@ -62,6 +65,8 @@ export function UpdatesSettings() {
|
|||||||
const updateAvailable = hasUpdate();
|
const updateAvailable = hasUpdate();
|
||||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||||
const apkPending = updateAvailable && !apkAsset;
|
const apkPending = updateAvailable && !apkAsset;
|
||||||
|
const severity = cachedLatest?.severity ?? 'normal';
|
||||||
|
const rateLimited = rateLimitedUntil > Date.now();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!apkPending) return;
|
if (!apkPending) return;
|
||||||
@@ -75,44 +80,71 @@ export function UpdatesSettings() {
|
|||||||
void installLatest();
|
void installLatest();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (ts: number): string => (ts ? new Date(ts).toLocaleString() : t('updates.never', 'never'));
|
||||||
|
|
||||||
|
const notes = cachedLatest?.body ? stripMarkdown(cachedLatest.body) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SettingsSection title="App updates" description="Check for new versions and install them.">
|
<SettingsSection
|
||||||
<SettingItem label="Current version">
|
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>
|
<Text style={styles.value}>v{currentVersion()}</Text>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Latest version"
|
label={t('updates.latest_version', 'Latest version')}
|
||||||
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
|
description={cachedLatest?.publishedAt
|
||||||
|
? t('updates.published', 'Published {date}', { date: new Date(cachedLatest.publishedAt).toLocaleDateString() })
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
|
<View style={styles.valueRow}>
|
||||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
|
<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>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Last checked"
|
label={t('updates.last_checked', 'Last checked')}
|
||||||
description={error ? `Last error: ${error}` : undefined}
|
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>
|
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
|
||||||
</SettingItem>
|
</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} />
|
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={
|
label={
|
||||||
apkPending
|
apkPending
|
||||||
? 'Update building'
|
? t('updates.building', 'Update building')
|
||||||
: updateAvailable
|
: updateAvailable
|
||||||
? 'Update available'
|
? t('updates.available', 'Update available')
|
||||||
: 'Check for updates'
|
: t('updates.check', 'Check for updates')
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
apkPending
|
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
|
: 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
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -123,7 +155,7 @@ export function UpdatesSettings() {
|
|||||||
onPress={() => void checkNow({ force: true })}
|
onPress={() => void checkNow({ force: true })}
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
>
|
>
|
||||||
{checking ? 'Checking…' : 'Check again'}
|
{checking ? t('updates.checking', 'Checking…') : t('updates.check_again', 'Check again')}
|
||||||
</Button>
|
</Button>
|
||||||
) : updateAvailable ? (
|
) : updateAvailable ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -132,7 +164,7 @@ export function UpdatesSettings() {
|
|||||||
onPress={onInstall}
|
onPress={onInstall}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
>
|
>
|
||||||
{installButtonLabel(installing, installProgress)}
|
{installButtonLabel(installing, installProgress, t)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -141,29 +173,42 @@ export function UpdatesSettings() {
|
|||||||
onPress={() => void checkNow({ force: true })}
|
onPress={() => void checkNow({ force: true })}
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
>
|
>
|
||||||
{checking ? 'Checking…' : 'Check now'}
|
{checking ? t('updates.checking', 'Checking…') : t('updates.check_now', 'Check now')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{apkPending && cachedLatest?.htmlUrl ? (
|
{cachedLatest?.advisoryUrl && updateAvailable ? (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label="Release page"
|
label={t('updates.advisory', 'Security advisory')}
|
||||||
description="Watch the build progress or download the APK manually."
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
|
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
|
||||||
>
|
>
|
||||||
Open on GitHub
|
{t('updates.open_github', 'Open on GitHub')}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{installing && installProgress ? (
|
{installing && installProgress ? (
|
||||||
<View style={styles.progressBox}>
|
<View style={styles.progressBox}>
|
||||||
<View style={styles.progressTrack}>
|
<View style={styles.progressTrack} accessibilityRole="progressbar">
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.progressFill,
|
styles.progressFill,
|
||||||
@@ -178,17 +223,17 @@ export function UpdatesSettings() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.progressLabel}>
|
<Text style={styles.progressLabel}>
|
||||||
{installProgress.phase === 'installing'
|
{installProgress.phase === 'installing'
|
||||||
? 'Opening installer…'
|
? t('updates.opening_installer', 'Opening installer…')
|
||||||
: formatProgressLabel(installProgress)}
|
: formatProgressLabel(installProgress, t)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{cachedLatest?.body ? (
|
{notes ? (
|
||||||
<View style={styles.notesBox}>
|
<View style={styles.notesBox}>
|
||||||
<Text style={styles.notesTitle}>Release notes</Text>
|
<Text style={styles.notesTitle}>{t('updates.release_notes', 'Release notes')}</Text>
|
||||||
<Text style={styles.notesBody} numberOfLines={20}>
|
<Text style={styles.notesBody} numberOfLines={30}>
|
||||||
{cachedLatest.body.trim()}
|
{notes}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -201,6 +246,14 @@ function makeStyles(c: ThemePalette) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
value: { ...typography.bodyMedium, color: c.text },
|
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: {
|
notesBox: {
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
|
|||||||
+63
-27
@@ -1,7 +1,12 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import Constants from 'expo-constants';
|
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 { isNewer } from '../lib/version-compare';
|
||||||
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
|
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
|
||||||
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
||||||
@@ -9,12 +14,16 @@ import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
|||||||
const STORAGE_KEY = 'webmail:updates:v1';
|
const STORAGE_KEY = 'webmail:updates:v1';
|
||||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
const PENDING_APK_INTERVAL_MS = 2 * 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 {
|
interface PersistedUpdates {
|
||||||
autoCheck: boolean;
|
autoCheck: boolean;
|
||||||
lastCheckedAt: number;
|
lastCheckedAt: number;
|
||||||
cachedLatest: LatestRelease | null;
|
cachedLatest: LatestRelease | null;
|
||||||
dismissedTag: string | null;
|
dismissedTag: string | null;
|
||||||
|
rateLimitedUntil: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PERSISTED: PersistedUpdates = {
|
const DEFAULT_PERSISTED: PersistedUpdates = {
|
||||||
@@ -22,6 +31,7 @@ const DEFAULT_PERSISTED: PersistedUpdates = {
|
|||||||
lastCheckedAt: 0,
|
lastCheckedAt: 0,
|
||||||
cachedLatest: null,
|
cachedLatest: null,
|
||||||
dismissedTag: null,
|
dismissedTag: null,
|
||||||
|
rateLimitedUntil: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UpdatesState extends PersistedUpdates {
|
export interface UpdatesState extends PersistedUpdates {
|
||||||
@@ -38,6 +48,8 @@ export interface UpdatesState extends PersistedUpdates {
|
|||||||
dismissCurrent: () => void;
|
dismissCurrent: () => void;
|
||||||
currentVersion: () => string;
|
currentVersion: () => string;
|
||||||
hasUpdate: () => boolean;
|
hasUpdate: () => boolean;
|
||||||
|
// Security / deprecated releases cannot be dismissed (webmail parity).
|
||||||
|
isMandatory: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persist(state: PersistedUpdates): void {
|
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) => ({
|
export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||||
...DEFAULT_PERSISTED,
|
...DEFAULT_PERSISTED,
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
@@ -59,7 +93,12 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
|
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 {
|
} else {
|
||||||
set({ hydrated: true });
|
set({ hydrated: true });
|
||||||
}
|
}
|
||||||
@@ -71,13 +110,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
|
|
||||||
setAutoCheck: (enabled) => {
|
setAutoCheck: (enabled) => {
|
||||||
set({ autoCheck: enabled });
|
set({ autoCheck: enabled });
|
||||||
const s = get();
|
persist(snapshot(get()));
|
||||||
persist({
|
|
||||||
autoCheck: enabled,
|
|
||||||
lastCheckedAt: s.lastCheckedAt,
|
|
||||||
cachedLatest: s.cachedLatest,
|
|
||||||
dismissedTag: s.dismissedTag,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
checkNow: async (opts) => {
|
checkNow: async (opts) => {
|
||||||
@@ -88,6 +121,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
const s = get();
|
const s = get();
|
||||||
if (s.checking) return;
|
if (s.checking) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (!opts?.force && now < s.rateLimitedUntil) return;
|
||||||
const waitingForApk =
|
const waitingForApk =
|
||||||
s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate();
|
s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate();
|
||||||
const interval = waitingForApk ? PENDING_APK_INTERVAL_MS : MIN_CHECK_INTERVAL_MS;
|
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 });
|
set({ checking: true, error: null });
|
||||||
try {
|
try {
|
||||||
const latest = await fetchLatestRelease();
|
const latest = await fetchLatestRelease();
|
||||||
const next: PersistedUpdates = {
|
set({ lastCheckedAt: now, cachedLatest: latest, rateLimitedUntil: 0, checking: false });
|
||||||
autoCheck: s.autoCheck,
|
persist(snapshot(get()));
|
||||||
lastCheckedAt: now,
|
|
||||||
cachedLatest: latest,
|
|
||||||
dismissedTag: s.dismissedTag,
|
|
||||||
};
|
|
||||||
set({ ...next, checking: false });
|
|
||||||
persist(next);
|
|
||||||
} catch (err) {
|
} 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) });
|
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;
|
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||||
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||||
try {
|
try {
|
||||||
|
// The checksum companion is only fetched now, not on every check.
|
||||||
|
const expectedSha256 = await resolveApkSha256(s.cachedLatest);
|
||||||
await downloadAndInstallApk(
|
await downloadAndInstallApk(
|
||||||
{
|
{ asset: s.cachedLatest.apkAsset, expectedSha256 },
|
||||||
asset: s.cachedLatest.apkAsset,
|
|
||||||
expectedSha256: s.cachedLatest.apkSha256,
|
|
||||||
},
|
|
||||||
(p) => {
|
(p) => {
|
||||||
set({ installProgress: p });
|
set({ installProgress: p });
|
||||||
},
|
},
|
||||||
@@ -135,14 +169,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
|
|
||||||
dismissCurrent: () => {
|
dismissCurrent: () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
|
if (s.isMandatory()) return;
|
||||||
const tag = s.cachedLatest?.tag ?? null;
|
const tag = s.cachedLatest?.tag ?? null;
|
||||||
set({ dismissedTag: tag });
|
set({ dismissedTag: tag });
|
||||||
persist({
|
persist(snapshot(get()));
|
||||||
autoCheck: s.autoCheck,
|
|
||||||
lastCheckedAt: s.lastCheckedAt,
|
|
||||||
cachedLatest: s.cachedLatest,
|
|
||||||
dismissedTag: tag,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
|
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';
|
const current = Constants.expoConfig?.version ?? '0.0.0';
|
||||||
return isNewer(s.cachedLatest.tag, current);
|
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