Compare commits

4 Commits

Author SHA1 Message Date
c264de53b5 Fix View updates on Android: bundled notes, portal stack, tap handling.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bundle update markdown at build time for Capacitor, portal into
portalContainer above Settings, defer open to avoid FocusTrap closing
on the same tap, and disable click-outside dismiss.
2026-08-23 08:38:23 +10:00
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 211 additions and 39 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,14 @@ 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,
pointerEvents: 'auto',
});
/** 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,4 +1,5 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import {
Box,
@@ -46,18 +47,21 @@ 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}>
const portalTarget =
document.getElementById('portalContainer') ?? document.body;
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
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 +181,7 @@ export function UpdatesDialog({
</FocusTrap>
</OverlayCenter>
</Overlay>
</div>,
portalTarget
);
}

View File

@@ -26,7 +26,10 @@ type UpdaterInfo = {
export function useOpenReleaseNotesDialog(): () => void {
const setDialogState = useSetAtom(releaseNotesDialogAtom);
return useCallback(() => {
setDialogState({ open: true, manual: true });
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
window.requestAnimationFrame(() => {
setDialogState({ open: true, manual: true });
});
}, [setDialogState]);
}
@@ -41,6 +44,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 +81,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 +95,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,22 @@
import bundledManifest from '../../../public/update/manifest.json';
import { trimTrailingSlash } from '../utils/common';
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
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;
@@ -55,27 +71,50 @@ function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
};
}
function loadBundledManifest(): UpdateManifest {
const manifest = bundledManifest as UpdateManifest;
return {
current: manifest.current,
older: (manifest.older ?? []).filter((entry) => entry?.file),
};
}
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
const safeFile = file.replace(/^\/+/, '');
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
path.endsWith(`/${safeFile}`)
);
if (!entry) return null;
return parseUpdateMarkdown(safeFile, entry[1]);
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:)/i.test(src)) return src;
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
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' });
if (!response.ok) return null;
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
if (response.ok) {
const data = (await response.json()) as UpdateManifest;
if (data?.current && Array.isArray(data.older)) {
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
}
}
} catch {
// fall through to bundled copy (Capacitor / offline)
}
const data = (await response.json()) as UpdateManifest;
if (!data?.current || !Array.isArray(data.older)) return null;
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
try {
return loadBundledManifest();
} catch {
return null;
}
@@ -86,14 +125,16 @@ 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' });
if (!response.ok) return null;
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (response.ok) {
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
}
} catch {
return null;
// fall through to bundled copy
}
return loadBundledDocument(safeFile);
}
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {

View File

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