Compare commits

3 Commits

Author SHA1 Message Date
b6f3f5c0aa Move update manifest generator into cinny for standalone Android builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:26:27 +10:00
25be638c5e Fix auto-show of What's New on first launch after an app update.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-23 08:23:15 +10:00
a20c871726 Fix updates dialog on Android by portaling above Settings and resolving Capacitor asset URLs.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:19:51 +10:00
7 changed files with 159 additions and 24 deletions

View File

@@ -9,7 +9,7 @@
},
"scripts": {
"start": "vite",
"build": "vite build",
"build": "node scripts/generate-update-manifest.mjs && vite build",
"lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Regenerates public/update/manifest.json from markdown files.
*
* - currentupdate.md is always the "current" entry
* - Other *.md files matching X.Y.Z.md become "older" (sorted newest first)
* - title, date, version come from YAML frontmatter when present
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const UPDATE_DIR = path.join(ROOT, 'public', 'update');
const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json');
const CURRENT_FILE = 'currentupdate.md';
const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/;
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(source) {
const match = FRONTMATTER_RE.exec(source.trimStart());
if (!match) return {};
const frontmatter = {};
match[1].split('\n').forEach((line) => {
const colon = line.indexOf(':');
if (colon <= 0) return;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
});
return frontmatter;
}
function compareVersions(a, b) {
const pa = a.split('.').map((part) => Number.parseInt(part, 10));
const pb = b.split('.').map((part) => Number.parseInt(part, 10));
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na !== nb) return nb - na;
}
return 0;
}
function readMarkdownMeta(fileName) {
const filePath = path.join(UPDATE_DIR, fileName);
const source = fs.readFileSync(filePath, 'utf8');
const frontmatter = parseFrontmatter(source);
const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1];
return {
file: fileName,
version: frontmatter.version ?? versionFromName,
title: frontmatter.title,
date: frontmatter.date,
};
}
function buildManifest() {
if (!fs.existsSync(UPDATE_DIR)) {
throw new Error(`Update directory not found: ${UPDATE_DIR}`);
}
const currentPath = path.join(UPDATE_DIR, CURRENT_FILE);
if (!fs.existsSync(currentPath)) {
throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`);
}
const older = fs
.readdirSync(UPDATE_DIR, { withFileTypes: true })
.filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name))
.map((entry) => readMarkdownMeta(entry.name))
.filter((entry) => entry.version)
.sort((a, b) => compareVersions(a.version, b.version));
return {
current: CURRENT_FILE,
older: older.map(({ file, version, title, date }) => {
const item = { file };
if (version) item.version = version;
if (title) item.title = title;
if (date) item.date = date;
return item;
}),
};
}
function main() {
const manifest = buildManifest();
const json = `${JSON.stringify(manifest, null, 2)}\n`;
let previous = null;
if (fs.existsSync(MANIFEST_PATH)) {
previous = fs.readFileSync(MANIFEST_PATH, 'utf8');
}
if (previous === json) {
console.log('[update-manifest] manifest.json is already up to date');
return;
}
fs.writeFileSync(MANIFEST_PATH, json, 'utf8');
console.log(
`[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))`
);
}
main();

View File

@@ -3,7 +3,13 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
/** Dialog height cap — 85% of the viewport, minus safe areas. */
export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
});
/** Dialog + overlay padding must never exceed the viewport height. */
const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
IconButton,
Overlay,
@@ -46,18 +48,19 @@ export function UpdatesDialog({
setShowOlderList(false);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" className={css.DialogShell}>
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" className={css.DialogShell}>
<Box className={css.Hero}>
<Box className={css.HeroRow}>
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
@@ -177,5 +180,7 @@ export function UpdatesDialog({
</FocusTrap>
</OverlayCenter>
</Overlay>
</div>,
document.body
);
}

View File

@@ -41,6 +41,11 @@ export function UpdatesDialogHost() {
setManifest(null);
setActiveDoc(null);
setLoading(false);
getAppVersion().then((version) => {
if (version !== 'unknown') {
setLastSeenAppVersion(version);
}
});
}, [setDialogState]);
const selectFile = useCallback(async (file: string) => {
@@ -73,7 +78,7 @@ export function UpdatesDialogHost() {
if (cancelled || currentVersion === 'unknown') return;
const lastSeen = getLastSeenAppVersion();
const shouldAutoShow = Boolean(lastSeen && lastSeen !== currentVersion);
const shouldAutoShow = lastSeen !== currentVersion;
if (shouldAutoShow) {
const loadedManifest = await loadUpdateManifest();
@@ -87,10 +92,6 @@ export function UpdatesDialogHost() {
setDialogState({ open: true, manual: false });
clearPendingReleaseNotes();
}
if (!lastSeen || lastSeen !== currentVersion) {
setLastSeenAppVersion(currentVersion);
}
};
const timer = window.setTimeout(checkVersion, 1500);

View File

@@ -1,6 +1,15 @@
import { trimTrailingSlash } from '../utils/common';
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
function getUpdateBaseUrl(): string {
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
const relative =
basePath === '.' || basePath === '' ? 'update/' : `${basePath}/update/`;
return new URL(relative, window.location.href).href.replace(/\/$/, '');
}
function getUpdateFileUrl(file: string): string {
return new URL(file, `${getUpdateBaseUrl()}/`).href;
}
export type UpdateManifestEntry = {
file: string;
@@ -61,12 +70,12 @@ export function resolveUpdateAssetUrl(src: string | undefined): string | undefin
if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, '');
return `${UPDATE_BASE_PATH}/${normalized}`;
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
}
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try {
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' });
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
if (!response.ok) return null;
const data = (await response.json()) as UpdateManifest;
@@ -86,7 +95,7 @@ export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc
if (!safeFile || safeFile.includes('..')) return null;
try {
const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' });
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (!response.ok) return null;
const source = await response.text();

View File

@@ -699,10 +699,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications />
<BackgroundSyncSetup />
<PaarrotAPIInitializer />
<UpdatesDialogHost />
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}
<UpdatesDialogHost />
</>
);
}