Aller au contenu

๐Ÿ“ฑ PWA & Mobile Optimizations

Overview

LibreFolio is installable as a Progressive Web App (PWA). This page documents the technical implementation for developers.


Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ app.html                                         โ”‚
โ”‚  โ”œโ”€โ”€ viewport meta (no-zoom, viewport-fit)       โ”‚
โ”‚  โ”œโ”€โ”€ manifest link                               โ”‚
โ”‚  โ”œโ”€โ”€ Apple meta tags + apple-touch-icon link     โ”‚
โ”‚  โ”œโ”€โ”€ theme-color                                 โ”‚
โ”‚  โ”œโ”€โ”€ early beforeinstallprompt capture           โ”‚
โ”‚  โ””โ”€โ”€ Service Worker registration                 โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ static/manifest.json                             โ”‚
โ”‚  โ””โ”€โ”€ display: standalone, icons, colors          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ static/sw.js                                     โ”‚
โ”‚  โ”œโ”€โ”€ offline fallback (navigate fail โ†’ cache)    โ”‚
โ”‚  โ””โ”€โ”€ // build: <md5(offline.html)[:8]> stamp     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ static/offline.html                              โ”‚
โ”‚  โ””โ”€โ”€ branded fallback page (i18n, dark mode)     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ static/icons/ (generated by dev.py)              โ”‚
โ”‚  โ”œโ”€โ”€ icon-192.png           (any, white)         โ”‚
โ”‚  โ”œโ”€โ”€ icon-512.png           (any, white)         โ”‚
โ”‚  โ”œโ”€โ”€ icon-maskable-192.png  (maskable, beige)    โ”‚
โ”‚  โ”œโ”€โ”€ icon-maskable-512.png  (maskable, beige)    โ”‚
โ”‚  โ””โ”€โ”€ apple-touch-icon.png   (iOS, 180ร—180)       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ HelpMenu.svelte                                  โ”‚
โ”‚  โ””โ”€โ”€ Install App button + platform detection     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ app.css                                          โ”‚
โ”‚  โ””โ”€โ”€ Mobile CSS (overscroll, touch-action, etc)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Key Files

File Purpose
frontend/src/app.html Viewport, manifest link, Apple meta tags, apple-touch-icon link, early prompt capture, SW registration
frontend/src/app.css Mobile CSS optimizations
frontend/static/manifest.json PWA manifest (display, icons, colors)
frontend/static/sw.js Service Worker (offline fallback only)
frontend/static/offline.html Offline fallback page (static, i18n, dark mode)
frontend/static/icons/icon-{192,512}.png App icons, manifest purpose any (generated)
frontend/static/icons/icon-maskable-{192,512}.png App icons, manifest purpose maskable (generated)
frontend/static/icons/apple-touch-icon.png iOS home-screen icon, 180ร—180 (generated)
frontend/src/lib/components/layout/HelpMenu.svelte Install button logic
dev.py โ†’ generate_pwa_icons() Icon generation from logo_square.png
frontend/generated-icons.json Committed record of the source sha256 and recipe each generated icon set was drawn from: an icon set is redrawn only when it no longer matches
dev.py โ†’ stamp_service_worker() Writes the // build: stamp of sw.js from offline.html
backend/test_scripts/test_utilities/test_pwa_assets.py Gate: icons, manifest icon entries, apple-touch-icon link, sw.js stamp

Mobile CSS

Applied globally via app.css (excerpt):

html {
    overscroll-behavior-x: none;          /* Disable swipe-back on Android */
    scrollbar-gutter: stable;             /* A modal's scroll lock never shifts the layout */
}

body {
    overscroll-behavior: contain;          /* No pull-to-refresh */
    -webkit-tap-highlight-color: transparent; /* No blue flash on tap */
}

/* Prevent iOS auto-zoom on input focus; dense toolbar inputs opt out with .zoom-guard-exempt */
@media (max-width: 768px) {
    input:not(.zoom-guard-exempt),
    select:not(.zoom-guard-exempt),
    textarea:not(.zoom-guard-exempt) {
        font-size: 16px !important;
    }
}

/* Disable double-tap zoom on interactive elements */
a, button, input, select, textarea, [role='button'] {
    touch-action: manipulation;
}

/* iOS standalone mode: content below the status bar / notch */
.safe-top {
    padding-top: env(safe-area-inset-top, 0px);
}

The other global classes are listed in Styling โ†’ Utility Classes.

The viewport meta in app.html:

<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" name="viewport" />


PWA Manifest

Excerpt of frontend/static/manifest.json (description, start_url, scope and orientation left out):

{
    "name": "LibreFolio",
    "short_name": "LibreFolio",
    "display": "standalone",
    "theme_color": "#1a4031",
    "background_color": "#f5f4ef",
    "icons": [
        { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
        { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
        { "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
        { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
    ]
}

One entry per purpose and size, each naming its own file: no file serves both any and maskable.


Splash and Theme Colours

Surface Colour Set by
Android splash screen Beige #f5f4ef, with the app icon Manifest background_color
Title bar of the installed app #1a4031; #156534 in dark mode Manifest theme_color, and the <meta name="theme-color"> of app.html, which the anti-FOUC script rewrites in dark mode (offline.html does the same)
iOS status bar Translucent, over the page apple-mobile-web-app-status-bar-style: black-translucent; .safe-top pads content below it
In-app loading splash (#app-splash) Beige #f5f4ef; #0f172a in dark mode Inline in app.html: the logo on a white tile and a spinner, faded out and removed by routes/+layout.svelte once the translations are loaded

The iOS home-screen icon is apple-touch-icon.png (180ร—180), linked from app.html.


Icon Generation

dev.py โ†’ generate_pwa_icons() draws five icons with PIL from frontend/static/logo_square.png (944ร—944 RGBA) into frontend/static/icons/:

File Manifest purpose Background Logo
icon-192.png, icon-512.png any White Side = size / 1.24, a margin of ~12 % of the logo per side
icon-maskable-192.png, icon-maskable-512.png maskable Splash beige #f5f4ef (the manifest background_color) Scaled so its farthest pixel sits at 0.37 ร— size from the centre
apple-touch-icon.png (180ร—180) โ€” (linked from app.html) White As the any icons

Every icon is saved as opaque RGB, with no alpha channel. The logo is resized premultiplied (RGBa), so its transparent black pixels do not bleed a dark fringe into its edge, then laid on the background with Image.alpha_composite.

The generator runs inside copy_docs_assets(), right before stamp_service_worker(): on ./dev.py mkdocs build, serve and deploy, and when ./dev.py server or ./dev.py docker build rebuilds a stale docs site. ./dev.py front build does not run it. The PNGs are committed, and mirrored into mkdocs_src/docs/static/icons/: after changing logo_square.png, run ./dev.py mkdocs build and commit the regenerated icons together with frontend/generated-icons.json, which records the sha256 of the logo they were drawn from. The generator redraws only when an icon is missing or when that hash, or PWA_ICONS_RECIPE in dev.py, no longer matches the record: Pillow does not write the same PNG bytes on every platform, so the decision never looks at the PNGs themselves. A change to the code that draws the icons must therefore bump PWA_ICONS_RECIPE, or nothing is regenerated.

Icons and the splash screen

  • Maskable safe zone: a launcher crops a maskable icon to its own shape (circle, squircleโ€ฆ), and only a centred circle of radius 0.40 ร— size is sure to survive. The logo reaches 0.37 ร— size, about 0.38 with its antialiased edge, so the crop never bites into it. An any icon is shown whole instead, which is why each purpose has its own files.
  • Beige: the maskable icons are beige to the edges because #f5f4ef is the colour of the Android splash screen (background_color), so the cropped icon meets it without a seam. Change both or neither.
  • Opacity: Android's splash screen and iOS paint transparent pixels black, so every icon is opaque RGB โ€” no alpha channel and no tRNS colour key. That is what keeps black corners off the Android splash and off the iPhone home-screen icon.
  • Service-worker stamp: stamp_service_worker() writes // build: <md5(offline.html)[:8]> as line 2 of sw.js. Any byte change in sw.js makes browsers update the worker, so every offline.html edit must be restamped (see Updating the Offline Page).

The gate is backend/test_scripts/test_utilities/test_pwa_assets.py, a pure pytest module. It reads the committed files and checks the icons are opaque RGB at their exact sizes, the maskable logo stays inside 0.40 ร— size on the beige, the manifest has the four icon entries, app.html links the 180ร—180 apple-touch-icon.png, and the stamp equals md5(offline.html)[:8]. Run it with ./dev.py test utils pwa-assets.


Install Button Logic

The HelpMenu.svelte component handles three platform scenarios:

1. Chrome/Edge/Android (beforeinstallprompt)

app.html (early) โ†’ captures event on window.__pwaInstallPrompt
     โ†“
HelpMenu onMount โ†’ reads window.__pwaInstallPrompt as fallback
     โ†“
User clicks "Install App" โ†’ calls deferredPrompt.prompt()
     โ†“
Chrome shows its native install dialog

Race Condition

beforeinstallprompt can fire BEFORE Svelte components mount. That's why we capture it early in app.html on window.__pwaInstallPrompt, then read it in onMount.

Both listeners โ€” the early one in app.html and the one HelpMenu.svelte adds in onMount โ€” call e.preventDefault(), so the browser keeps the event instead of showing its automatic install prompt (the mini-infobar of Chrome on Android); the app calls prompt() only from Install App. A captured prompt is used once (deferredPrompt = null afterwards), and an accepted one hides the button (isStandalone = true).

2. iOS (Safari only)

  • Detected via user agent: /iPad|iPhone|iPod/ or MacIntel + maxTouchPoints > 1
  • Shows localized instructions: "Tap Share โ†’ Add to Home Screen"
  • No programmatic install possible on iOS

3. Desktop Fallback

  • If deferredPrompt not captured (e.g. Firefox, or event missed)
  • Shows hint: "Look for โŠ• in address bar"
  • On Android (/Android/ in the user agent) the same fallback shows help.installAppAndroid instead: HTTPS required for install. Use Chrome menu โ‹ฎ โ†’ "Add to Home Screen" as shortcut โ€” typically on a plain-HTTP LAN address

Standalone Detection

isStandalone = window.matchMedia('(display-mode: standalone)').matches
    || (window.navigator as any).standalone === true; // iOS property

When isStandalone === true, the Install button is hidden entirely.


Service Worker (Offline Fallback)

LibreFolio uses a minimal Service Worker for offline fallback only โ€” it does NOT cache app assets.

How It Works

User opens PWA โ†’ browser checks sw.js for updates (byte-diff)
     โ†“
SW install event โ†’ pre-caches /offline.html (~22 KB)
     โ†“
User navigates โ†’ SW intercepts (mode === 'navigate')
     โ†“
fetch(request) succeeds โ†’ normal response (no caching)
fetch(request) fails โ†’ serve cached /offline.html

Key Design Decisions

Decision Rationale
No app caching Updates are instant โ€” no stale cache issues
Only navigate intercept API calls, CSS, JS pass through untouched
skipWaiting() + clients.claim() New SW activates immediately
// build: stamp in sw.js (fixed cache name offline-fallback) Editing offline.html changes sw.js, so clients install the new SW, which re-fetches the page
Auto-retry (10s interval) Page reloads automatically when server returns

Files

File Purpose
frontend/static/sw.js Service Worker (~30 lines); line 2 is the // build: stamp
frontend/static/offline.html Fallback page (static, inline styles, i18n)
backend/test_scripts/test_utilities/test_pwa_assets.py Gate: fails when the stamp is not md5(offline.html)[:8]

Offline Page Features

  • Inline CSS and script; the only external resource it loads is /lf-flags.css (the flag font face, see Fonts), which the service worker does not pre-cache
  • Dark mode from the app's stored theme (librefolio-theme), falling back to prefers-color-scheme, with its own theme toggle
  • i18n: detects navigator.language โ†’ EN/IT/FR/ES, with a language menu
  • Auto-retry: pings server every 10s, reloads on success
  • Same animated background as the login page, and a toolbar with Buy Me a Coffee, theme, language and a link to the online documentation

Updating the Offline Page

  1. Edit frontend/static/offline.html
  2. Restamp sw.js with ./dev.py mkdocs build, which runs stamp_service_worker() (./dev.py front build does not)
  3. Commit sw.js together with the page: the gate test_pwa_assets.py fails while the stamp and the page disagree
  4. Deploy โ€” browser auto-updates SW on next PWA open

iOS Notes

  • Service Workers supported since iOS 11.3 (2018)
  • Cache eviction possible after 7 days of non-use โ€” irrelevant here (1 file, re-cached on next visit)
  • All iOS browsers use WebKit โ†’ same SW behavior everywhere

HTTP vs HTTPS Behavior

Feature HTTPS HTTP localhost HTTP LAN
Manifest loaded โœ… โœ… โœ…
Service Worker (offline page) โœ… โœ… โŒ
beforeinstallprompt โœ… โœ… โŒ
Browser's own automatic install banner โŒ suppressed by preventDefault() โŒ suppressed by preventDefault() โŒ no event
Native install dialog from Install App (prompt()) โœ… โœ… โŒ a hint instead
Manual "Add to Home" โœ… โœ… โœ… (Android)
iOS Add to Home โœ… โœ… โœ…

Service workers and the install prompt need a secure context: HTTPS, or localhost. On a plain-HTTP LAN address the browser exposes no navigator.serviceWorker, so app.html skips the registration and there is no offline page.


Future: Share Target (File Receiving)

Not Implemented

This section documents a potential future feature.

The Web Share Target API would allow LibreFolio to appear in the OS "Share" menu when sharing files (e.g., broker CSV exports).

Requirements: - Service Worker (to handle the POST request) - HTTPS mandatory - Works on: Android Chrome โœ…, iOS โŒ (Apple doesn't support share_target)

iOS Alternative: iOS Shortcuts automation โ€” a Shortcut that accepts files from the Share Sheet and posts them to the broker-report upload endpoint, POST /api/v1/brokers/import/upload (multipart: file, broker_id), with an authenticated session.


Updating the PWA

Updates flow automatically:

  1. Admin updates Docker container (new sw.js and/or app code)
  2. User reopens the installed PWA
  3. Browser detects sw.js changed (byte-diff) โ†’ installs new SW
  4. New SW activates โ†’ pre-caches updated offline.html
  5. App loads fresh assets from server (no caching layer)

No manual "update app" action needed. If offline.html content changes, restamp sw.js (see Updating the Offline Page) to force a refresh.