capgo and adk autoupdates. card tags
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { apkInstaller, type PublishedApk } from './updater.js';
|
||||
import { ConfirmDialog } from './ui.js';
|
||||
|
||||
type State =
|
||||
| { step: 'idle' }
|
||||
| { step: 'permission' }
|
||||
| { step: 'downloading'; downloaded: number; total: number }
|
||||
| { step: 'installing' }
|
||||
| { step: 'confirm' }
|
||||
| { step: 'error'; message: string };
|
||||
|
||||
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
|
||||
|
||||
/** "App update available" on the card list: downloads the APK and hands it to Android's installer. */
|
||||
export function ApkUpdateBanner({ apk }: { apk: PublishedApk }) {
|
||||
const [state, setState] = useState<State>({ step: 'idle' });
|
||||
|
||||
const install = async () => {
|
||||
try {
|
||||
if (!(await apkInstaller.canInstall())) {
|
||||
setState({ step: 'permission' });
|
||||
return;
|
||||
}
|
||||
setState({ step: 'downloading', downloaded: 0, total: apk.size });
|
||||
await apkInstaller.install(apk, (e) => {
|
||||
if (e.kind === 'progress') setState({ step: 'downloading', downloaded: e.downloaded, total: e.total > 0 ? e.total : apk.size });
|
||||
else if (e.status === 'installing') setState({ step: 'installing' });
|
||||
else if (e.status === 'confirm') setState({ step: 'confirm' });
|
||||
else setState({ step: 'error', message: e.message ?? 'Installation failed' });
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ step: 'error', message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
let text: string;
|
||||
let action: string | undefined = 'Install';
|
||||
switch (state.step) {
|
||||
case 'downloading':
|
||||
text = `Downloading the update… ${mb(state.downloaded)} / ${mb(state.total)} MB`;
|
||||
action = undefined;
|
||||
break;
|
||||
case 'installing':
|
||||
text = 'Installing… the app will restart.';
|
||||
action = undefined;
|
||||
break;
|
||||
case 'confirm':
|
||||
text = 'Confirm the installation in the Android dialog.';
|
||||
action = 'Retry';
|
||||
break;
|
||||
case 'error':
|
||||
text = `Update failed: ${state.message}`;
|
||||
action = 'Retry';
|
||||
break;
|
||||
default:
|
||||
text = `A new version of the app is available (${apk.versionName}, ${mb(apk.size)} MB).`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="banner info update-banner" role="status">
|
||||
<span>{text}</span>
|
||||
{action && (
|
||||
<button className="text-button strong" onClick={install}>
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{state.step === 'permission' && (
|
||||
<ConfirmDialog
|
||||
title="Allow app updates"
|
||||
message="Android needs your permission for Cardy to install its own updates. Turn on “Allow from this source” on the next screen, come back and tap Install again."
|
||||
confirmLabel="Open settings"
|
||||
onCancel={() => setState({ step: 'idle' })}
|
||||
onConfirm={() => {
|
||||
setState({ step: 'idle' });
|
||||
void apkInstaller.openInstallSettings();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import { useNavigation, type LoginPrefill } from './navigation.js';
|
||||
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
|
||||
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
|
||||
import { refreshCardList, syncImages } from './sync.js';
|
||||
import {
|
||||
applyPendingUpdate,
|
||||
checkForApkUpdate,
|
||||
checkForUpdate,
|
||||
consumeUpdateReload,
|
||||
markAppReady,
|
||||
type PublishedApk,
|
||||
} from './updater.js';
|
||||
import { CardScreen } from './screens/CardScreen.js';
|
||||
import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
|
||||
import { EditScreen } from './screens/EditScreen.js';
|
||||
@@ -29,13 +37,17 @@ export function App() {
|
||||
);
|
||||
}, []);
|
||||
if (!boot) return <div className="splash" />;
|
||||
return <Main boot={boot} />;
|
||||
return <Main boot={boot} justUpdated={JUST_UPDATED} />;
|
||||
}
|
||||
|
||||
function Main({ boot }: { boot: Boot }) {
|
||||
/** Read once per page load (render functions may run twice). */
|
||||
const JUST_UPDATED = consumeUpdateReload();
|
||||
|
||||
function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
const [session, setSession] = useState(boot.session);
|
||||
const [pin, setPin] = useState(boot.pin);
|
||||
const [locked, setLocked] = useState(!!boot.pin);
|
||||
// Not locked after a reload that applied an update: the user was unlocked a moment ago.
|
||||
const [locked, setLocked] = useState(() => !!boot.pin && !justUpdated);
|
||||
const [cards, setCards] = useState(boot.cards);
|
||||
const [usage, setUsage] = useState(boot.usage);
|
||||
const [imageEpoch, setImageEpoch] = useState(0);
|
||||
@@ -184,6 +196,37 @@ function Main({ boot }: { boot: Boot }) {
|
||||
|
||||
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
|
||||
|
||||
// ---- live updates (Android app only) ----
|
||||
|
||||
// The app rendered: confirm this bundle works (a bundle that never gets here is rolled back).
|
||||
useEffect(() => void markAppReady(), []);
|
||||
|
||||
// Reloading is safe only on the unlocked card list with no download running: never while a card is
|
||||
// shown at the till, while editing, or on the lock screen (the reload skips the PIN).
|
||||
const safeToReload = !locked && !lockedLogin && route?.name === 'cards' && !sync.busy;
|
||||
const safeRef = useRef(safeToReload);
|
||||
safeRef.current = safeToReload;
|
||||
|
||||
// A newer APK (native part changed) is offered on the card list; installing it needs the user's OK.
|
||||
const [apkUpdate, setApkUpdate] = useState<PublishedApk>();
|
||||
const lastApkCheck = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
void checkForUpdate(() => safeRef.current);
|
||||
if (Date.now() - lastApkCheck.current >= 60_000) {
|
||||
lastApkCheck.current = Date.now();
|
||||
void checkForApkUpdate().then(setApkUpdate);
|
||||
}
|
||||
};
|
||||
check();
|
||||
return onResumeAfter(0, check);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (safeToReload) void applyPendingUpdate();
|
||||
}, [safeToReload]);
|
||||
|
||||
// Android back button = history back; on the lock screen and at the root it exits the app.
|
||||
const lockedRef = useRef({ locked, lockedLogin });
|
||||
lockedRef.current = { locked, lockedLogin };
|
||||
@@ -261,6 +304,7 @@ function Main({ boot }: { boot: Boot }) {
|
||||
imageEpoch={imageEpoch}
|
||||
sync={sync}
|
||||
session={session}
|
||||
apkUpdate={apkUpdate}
|
||||
onDismissSync={() => setSync({ busy: false })}
|
||||
onRefresh={refresh}
|
||||
onOpen={(id) => push({ name: 'card', id })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { PIN_LENGTH } from './pin.js';
|
||||
import { Icon } from './ui.js';
|
||||
import { Icon, VersionLabel } from './ui.js';
|
||||
|
||||
/**
|
||||
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
|
||||
@@ -81,6 +81,7 @@ export function PinPad({
|
||||
</button>
|
||||
</div>
|
||||
<div className="pin-footer">{footer}</div>
|
||||
<VersionLabel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Version of a web build, derived from the build time (UTC): "YYYY.MDD.Hmmss", e.g. 2026.929.151230
|
||||
* for 2026-09-29 15:12:30. Valid semver (no leading zeros) and ordered like the build time.
|
||||
*/
|
||||
export function buildVersionFromDate(date: Date): string {
|
||||
const md = (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
|
||||
const hms = date.getUTCHours() * 10000 + date.getUTCMinutes() * 100 + date.getUTCSeconds();
|
||||
return `${date.getUTCFullYear()}.${md}.${hms}`;
|
||||
}
|
||||
|
||||
/** Numeric comparison of dot-separated versions; non-numeric parts count as 0. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map((p) => Number.parseInt(p, 10) || 0);
|
||||
const pb = b.split('.').map((p) => Number.parseInt(p, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) return Math.sign(diff);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/** Build version, "YYYY.MDD.Hmmss" from the build time (UTC); "dev" on the dev server. */
|
||||
declare const __BUILD_VERSION__: string;
|
||||
/** Base URL of Android live updates (version.json and bundle zips), without a trailing slash. */
|
||||
declare const __UPDATE_URL__: string;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Card } from '@cardy/shared';
|
||||
import { normalizeServerUrl } from './api.js';
|
||||
import { buildVersionFromDate, compareVersions } from './buildVersion.js';
|
||||
import { detectedFormatToCodeType } from './codeFormats.js';
|
||||
import { checkPin, createPinRecord } from './pin.js';
|
||||
import { cardsNeedingImages, runPool, sortCards } from './sync.js';
|
||||
@@ -12,6 +13,7 @@ const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
|
||||
code: '1',
|
||||
codeType: 'QR',
|
||||
comment: '',
|
||||
tags: [],
|
||||
logo: null,
|
||||
images: [],
|
||||
imagesVersion: 0,
|
||||
@@ -78,6 +80,21 @@ describe('PIN', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('build versions', () => {
|
||||
it('derive a semver-compatible version from the build time (UTC)', () => {
|
||||
expect(buildVersionFromDate(new Date('2026-09-29T15:12:30Z'))).toBe('2026.929.151230');
|
||||
expect(buildVersionFromDate(new Date('2026-01-05T00:00:07Z'))).toBe('2026.105.7');
|
||||
});
|
||||
|
||||
it('orders versions like build times', () => {
|
||||
const times = ['2026-01-05T00:00:07Z', '2026-01-05T09:59:59Z', '2026-01-05T10:00:00Z', '2026-09-29T15:12:30Z', '2026-10-01T00:00:00Z', '2027-01-01T00:00:00Z'];
|
||||
const versions = times.map((t) => buildVersionFromDate(new Date(t)));
|
||||
for (let i = 1; i < versions.length; i++) expect(compareVersions(versions[i]!, versions[i - 1]!)).toBe(1);
|
||||
expect(compareVersions('2026.929.151230', '2026.929.151230')).toBe(0);
|
||||
expect(compareVersions('2026.929.1', 'dev')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalizes the server address', () => {
|
||||
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
|
||||
import { setMaxBrightness } from '../platform.js';
|
||||
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js';
|
||||
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, TagList, useStoredImage } from '../ui.js';
|
||||
|
||||
export function CardScreen({
|
||||
card,
|
||||
@@ -64,6 +64,7 @@ export function CardScreen({
|
||||
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
|
||||
</div>
|
||||
|
||||
{card.tags?.length > 0 && <TagList tags={card.tags} />}
|
||||
{card.comment && <p className="card-comment">{card.comment}</p>}
|
||||
|
||||
{card.images.length > 0 && (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { textColorOn, type Card } from '@cardy/shared';
|
||||
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
|
||||
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
|
||||
import type { Session, UsageMap } from '../storage.js';
|
||||
import type { PublishedApk } from '../updater.js';
|
||||
import { sortCards } from '../sync.js';
|
||||
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js';
|
||||
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
|
||||
|
||||
export interface SyncState {
|
||||
busy: boolean;
|
||||
@@ -17,6 +19,7 @@ export function CardsScreen({
|
||||
imageEpoch,
|
||||
sync,
|
||||
session,
|
||||
apkUpdate,
|
||||
onDismissSync,
|
||||
onRefresh,
|
||||
onOpen,
|
||||
@@ -28,6 +31,7 @@ export function CardsScreen({
|
||||
imageEpoch: number;
|
||||
sync: SyncState;
|
||||
session: Session;
|
||||
apkUpdate?: PublishedApk;
|
||||
onDismissSync: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
@@ -38,14 +42,12 @@ export function CardsScreen({
|
||||
const [confirmKey, setConfirmKey] = useState(false);
|
||||
const filterInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
const sorted = sortCards(cards, usage);
|
||||
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
|
||||
}, [cards, usage, filter]);
|
||||
// Search by name, tags and comment.
|
||||
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
|
||||
|
||||
return (
|
||||
<Screen
|
||||
className="cards-screen"
|
||||
title="Cards"
|
||||
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
|
||||
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
|
||||
@@ -59,8 +61,8 @@ export function CardsScreen({
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="Filter by name"
|
||||
aria-label="Filter by name"
|
||||
placeholder="Search by name, tag or comment"
|
||||
aria-label="Search by name, tag or comment"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
@@ -78,6 +80,8 @@ export function CardsScreen({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apkUpdate && <ApkUpdateBanner apk={apkUpdate} />}
|
||||
|
||||
{sync.busy && sync.progress && sync.progress.total > 0 && (
|
||||
<Banner kind="info">
|
||||
Downloading images {sync.progress.done}/{sync.progress.total}
|
||||
@@ -108,6 +112,8 @@ export function CardsScreen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VersionLabel />
|
||||
|
||||
<button className="fab" onClick={onAdd} aria-label="Add card">
|
||||
<Icon name="plus" size={28} />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CODE_TYPES,
|
||||
CODE_TYPE_LABELS,
|
||||
cardFieldsSchema,
|
||||
formatTags,
|
||||
parseTags,
|
||||
randomCardColor,
|
||||
type Card,
|
||||
type CardFields,
|
||||
@@ -14,7 +16,7 @@ import { ApiError, type Api } from '../api.js';
|
||||
import { compressImage, fileNameFor } from '../imageTools.js';
|
||||
import { ScannerView, scanImageFile } from '../ScannerView.js';
|
||||
import { images } from '../storage.js';
|
||||
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js';
|
||||
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
|
||||
|
||||
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
|
||||
interface NewImage {
|
||||
@@ -45,6 +47,8 @@ export function EditScreen({
|
||||
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
|
||||
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
|
||||
);
|
||||
// Edited as typed ("a, b"); parsed into the tag list on save.
|
||||
const [tagsText, setTagsText] = useState(formatTags(card?.tags));
|
||||
// Set once the card exists on the server (also after a partially failed first save).
|
||||
const [savedId, setSavedId] = useState(card?.id);
|
||||
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
|
||||
@@ -93,7 +97,7 @@ export function EditScreen({
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const parsed = cardFieldsSchema.safeParse(fields);
|
||||
const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((i) => i.message).join('. '));
|
||||
return;
|
||||
@@ -108,7 +112,9 @@ export function EditScreen({
|
||||
let addedDone = false;
|
||||
try {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v);
|
||||
for (const [k, v] of Object.entries(parsed.data)) {
|
||||
if (v !== undefined) form.append(k, Array.isArray(v) ? v.join(',') : v);
|
||||
}
|
||||
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
|
||||
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
|
||||
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
|
||||
@@ -272,6 +278,18 @@ export function EditScreen({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Tags</span>
|
||||
<input
|
||||
value={tagsText}
|
||||
onChange={(e) => setTagsText(e.target.value)}
|
||||
placeholder="Comma-separated, e.g. food, pharmacy"
|
||||
autoCapitalize="off"
|
||||
aria-label="Tags"
|
||||
/>
|
||||
{parseTags(tagsText).length > 0 && <TagList tags={parseTags(tagsText)} />}
|
||||
</label>
|
||||
|
||||
<div className="field">
|
||||
<span>Photos</span>
|
||||
<div className="photo-grid">
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { LoginPrefill } from '../navigation.js';
|
||||
import { defaultServerUrl } from '../platform.js';
|
||||
import { ScannerView } from '../ScannerView.js';
|
||||
import type { Session } from '../storage.js';
|
||||
import { Banner, Icon, IconButton, Screen } from '../ui.js';
|
||||
import { Banner, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
|
||||
|
||||
export type { LoginPrefill };
|
||||
|
||||
@@ -136,6 +136,7 @@ export function LoginScreen({
|
||||
{busy ? 'Checking…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<VersionLabel />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,29 @@ textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-screen .tags {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.card-comment {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -275,6 +298,15 @@ textarea:focus {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-banner .text-button {
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -291,6 +323,8 @@ textarea:focus {
|
||||
/* ---- login ---- */
|
||||
|
||||
.login .screen-body {
|
||||
/* No floating button here: the version label sits at the very bottom. */
|
||||
padding-bottom: calc(12px + var(--safe-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
@@ -413,6 +447,44 @@ textarea:focus {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Pinned to the bottom of the login and PIN screens. */
|
||||
.version-label {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Card list: the label is the footer below the tiles, level with the floating button. */
|
||||
.cards-screen .screen-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: calc(40px + var(--safe-bottom));
|
||||
}
|
||||
|
||||
.cards-screen .version-label {
|
||||
padding-top: 48px;
|
||||
padding-right: 72px;
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
/* The PIN screen is centered vertically: keep the label out of the flow. */
|
||||
.pin {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pin .version-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(10px + var(--safe-bottom));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- cards list ---- */
|
||||
|
||||
.filter {
|
||||
|
||||
@@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon:
|
||||
);
|
||||
}
|
||||
|
||||
export function TagList({ tags }: { tags: readonly string[] }) {
|
||||
return (
|
||||
<ul className="tags" aria-label="Tags">
|
||||
{tags.map((tag) => (
|
||||
<li key={tag} className="tag">
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Version of the running web build (built-in or live-updated bundle). */
|
||||
export function VersionLabel() {
|
||||
return <div className="version-label">Version {__BUILD_VERSION__}</div>;
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
|
||||
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
import { CapacitorUpdater } from '@capgo/capacitor-updater';
|
||||
import { compareVersions } from './buildVersion.js';
|
||||
import { isNative } from './platform.js';
|
||||
|
||||
/**
|
||||
* Live updates of the Android app's web part (Capgo updater in manual mode, own server):
|
||||
* the app always runs a local bundle (built-in or downloaded), so everything keeps working offline.
|
||||
* When dev.aggtaa.com is reachable and publishes a newer version, the bundle is downloaded in the
|
||||
* background and applied right away on a safe screen, otherwise via next() (on backgrounding).
|
||||
*/
|
||||
|
||||
export const BUILD_VERSION = __BUILD_VERSION__;
|
||||
const UPDATE_URL = __UPDATE_URL__;
|
||||
const CHECK_TIMEOUT_MS = 10_000;
|
||||
/** Do not check more often than this (launch + every return to the foreground). */
|
||||
const MIN_CHECK_INTERVAL_MS = 60_000;
|
||||
/** Set right before an immediate reload so the fresh bundle does not ask for the PIN again. */
|
||||
const RELOAD_MARK = 'cardy:update-reload';
|
||||
|
||||
interface PublishedVersion {
|
||||
version: string;
|
||||
/** Bundle zip, relative to version.json or absolute. */
|
||||
url: string;
|
||||
/** SHA-256 of the zip, hex. */
|
||||
checksum: string;
|
||||
/** Lowest native build (android versionCode) the bundle works with. */
|
||||
minNativeBuild?: number;
|
||||
}
|
||||
|
||||
// Imported statically on purpose: a Capacitor plugin object is a Proxy that answers any property,
|
||||
// including `then`, so it must never be the resolved value of a Promise (the Promise would call
|
||||
// CapacitorUpdater.then() -> "not implemented on android").
|
||||
const updater = CapacitorUpdater;
|
||||
|
||||
let checking = false;
|
||||
let lastCheck = 0;
|
||||
/** Downloaded bundle waiting for a safe moment (also queued with next()). */
|
||||
let pendingBundleId: string | undefined;
|
||||
|
||||
/** Must run early on every start: otherwise the updater rolls back to the previous bundle. */
|
||||
export async function markAppReady(): Promise<void> {
|
||||
if (!isNative) return;
|
||||
try {
|
||||
await updater.notifyAppReady();
|
||||
} catch (e) {
|
||||
console.warn('notifyAppReady failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** True once, right after this app reloaded itself to apply an update. */
|
||||
export function consumeUpdateReload(): boolean {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(RELOAD_MARK) ?? 0);
|
||||
localStorage.removeItem(RELOAD_MARK);
|
||||
return at > 0 && Date.now() - at < 60_000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the server for a newer bundle and downloads it. Applies it immediately when
|
||||
* `canApplyNow()` says so, otherwise queues it for the next backgrounding.
|
||||
*/
|
||||
export async function checkForUpdate(canApplyNow: () => boolean): Promise<void> {
|
||||
if (!isNative || BUILD_VERSION === 'dev' || checking) return;
|
||||
if (Date.now() - lastCheck < MIN_CHECK_INTERVAL_MS) return;
|
||||
checking = true;
|
||||
lastCheck = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${UPDATE_URL}/version.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
||||
if (!res.ok) return;
|
||||
const published = (await res.json()) as PublishedVersion;
|
||||
if (!published?.version || !published.url || compareVersions(published.version, BUILD_VERSION) <= 0) return;
|
||||
|
||||
const nativeBuild = Number((await App.getInfo()).build) || 0;
|
||||
if (published.minNativeBuild && nativeBuild < published.minNativeBuild) {
|
||||
console.info(`Update ${published.version} needs a newer app (build ${published.minNativeBuild}, installed ${nativeBuild})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { bundles } = await updater.list();
|
||||
let bundle = bundles.find((b) => b.version === published.version && b.status !== 'error' && b.status !== 'downloading');
|
||||
bundle ??= await updater.download({
|
||||
url: new URL(published.url, `${UPDATE_URL}/`).href,
|
||||
version: published.version,
|
||||
checksum: published.checksum,
|
||||
});
|
||||
|
||||
if (canApplyNow()) await applyNow(bundle.id);
|
||||
else {
|
||||
await updater.next({ id: bundle.id });
|
||||
pendingBundleId = bundle.id;
|
||||
}
|
||||
} catch (e) {
|
||||
// Offline, server down, bad bundle: keep running the current version.
|
||||
console.warn('Update check failed', e);
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- APK self-update (native part changed: plugins, manifest, Java) ----
|
||||
|
||||
export interface PublishedApk {
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
/** APK file, relative to apk.json or absolute. */
|
||||
url: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type ApkInstallEvent =
|
||||
| { kind: 'progress'; downloaded: number; total: number }
|
||||
| { kind: 'status'; status: 'installing' | 'confirm' | 'failed'; message?: string };
|
||||
|
||||
interface ApkUpdaterPlugin {
|
||||
canInstall(): Promise<{ allowed: boolean }>;
|
||||
openInstallSettings(): Promise<void>;
|
||||
downloadAndInstall(options: { url: string; sha256: string }): Promise<void>;
|
||||
addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise<PluginListenerHandle>;
|
||||
addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */
|
||||
const ApkUpdater = registerPlugin<ApkUpdaterPlugin>('ApkUpdater');
|
||||
|
||||
/** A newer APK than the installed one, if the server publishes it. */
|
||||
export async function checkForApkUpdate(): Promise<PublishedApk | undefined> {
|
||||
if (!isNative || BUILD_VERSION === 'dev') return undefined;
|
||||
try {
|
||||
const res = await fetch(`${UPDATE_URL}/apk.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
||||
if (!res.ok) return undefined;
|
||||
const apk = (await res.json()) as PublishedApk;
|
||||
const installed = Number((await App.getInfo()).build) || 0;
|
||||
return apk?.versionCode > installed && apk.url && apk.sha256 ? apk : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const apkInstaller = {
|
||||
canInstall: async () => (await ApkUpdater.canInstall()).allowed,
|
||||
openInstallSettings: () => ApkUpdater.openInstallSettings(),
|
||||
|
||||
/** Downloads and hands the APK to Android; `onEvent` reports progress and the installer's status. */
|
||||
async install(apk: PublishedApk, onEvent: (e: ApkInstallEvent) => void): Promise<void> {
|
||||
const handles: PluginListenerHandle[] = [];
|
||||
const cleanup = () => void Promise.all(handles.splice(0).map((h) => h.remove()));
|
||||
handles.push(
|
||||
await ApkUpdater.addListener('progress', (e) => onEvent({ kind: 'progress', ...e })),
|
||||
await ApkUpdater.addListener('status', (e) => {
|
||||
onEvent({ kind: 'status', ...e });
|
||||
if (e.status === 'failed') cleanup();
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await ApkUpdater.downloadAndInstall({ url: new URL(apk.url, `${UPDATE_URL}/`).href, sha256: apk.sha256 });
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
// Listeners stay until the installer reports "failed"; a successful install restarts the app.
|
||||
},
|
||||
};
|
||||
|
||||
/** Applies a queued update now (call when the app reaches a safe screen). */
|
||||
export async function applyPendingUpdate(): Promise<void> {
|
||||
if (pendingBundleId) await applyNow(pendingBundleId);
|
||||
}
|
||||
|
||||
async function applyNow(id: string): Promise<void> {
|
||||
try {
|
||||
localStorage.setItem(RELOAD_MARK, String(Date.now()));
|
||||
} catch {
|
||||
// the PIN will simply be asked again
|
||||
}
|
||||
// Reloads the WebView from the new bundle; nothing after this runs.
|
||||
await updater.set({ id });
|
||||
}
|
||||
Reference in New Issue
Block a user