m4: PWA install — manifest, icons, service worker
Make ThoughtSync installable ("Add to Home Screen") without going
offline-first (the Android app is the real offline client, M5):
- web app manifest (name, icons incl. maskable + SVG, standalone, theme)
- generated PNG icon set + apple-touch-icon + favicon, from committed
SVG sources (a linked-thoughts constellation on the brand tile)
- minimal service worker: installable shell only — caches just an
offline fallback page, never the app shell / hashed assets / API, so
data stays fresh and deploys never serve a stale shell
- register the SW in main.ts (progressive enhancement; failures ignored)
- index.html: manifest/icon links, apple-mobile meta, description
- backend: register the .webmanifest MIME type so it serves as
application/manifest+json
- README: note that install needs a secure context (HTTPS/localhost)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
@@ -101,6 +101,10 @@ Then open `http://<host>:5000` and register — **the first account becomes the
|
|||||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
||||||
`:<git-sha>` (immutable, for pinning / rollback).
|
`:<git-sha>` (immutable, for pinning / rollback).
|
||||||
|
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
|
||||||
|
browser's install button) for an app-like window. Browsers only offer install over a
|
||||||
|
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
||||||
|
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#F5C518" />
|
<meta name="theme-color" content="#F5C518" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Capture a fleeting thought in a second — a self-hosted Keep-style board that grows into a lightweight second brain."
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ThoughtSync" />
|
||||||
<title>ThoughtSync</title>
|
<title>ThoughtSync</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 781 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ThoughtSync">
|
||||||
|
<!-- Maskable variant: full-bleed background so platform masks (circle, squircle,
|
||||||
|
rounded-square) never clip the glyph. The graph sits inside the central 80%
|
||||||
|
safe zone. Same mark as icon.svg. -->
|
||||||
|
<rect width="512" height="512" fill="#F5C518"/>
|
||||||
|
<g stroke="#18181b" stroke-linecap="round" fill="none">
|
||||||
|
<g stroke-width="14" opacity="0.26">
|
||||||
|
<path d="M256 150 L166 336"/>
|
||||||
|
<path d="M166 336 L346 336"/>
|
||||||
|
<path d="M346 336 L256 150"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-width="22">
|
||||||
|
<path d="M256 268 L256 150"/>
|
||||||
|
<path d="M256 268 L166 336"/>
|
||||||
|
<path d="M256 268 L346 336"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g fill="#18181b">
|
||||||
|
<circle cx="256" cy="150" r="34"/>
|
||||||
|
<circle cx="166" cy="336" r="34"/>
|
||||||
|
<circle cx="346" cy="336" r="34"/>
|
||||||
|
<circle cx="256" cy="268" r="26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 918 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ThoughtSync">
|
||||||
|
<!-- Rounded-tile app icon: brand-yellow background + a small knowledge-graph
|
||||||
|
constellation (a hub node linked to three thoughts) evoking linked notes. -->
|
||||||
|
<rect width="512" height="512" rx="112" fill="#F5C518"/>
|
||||||
|
<g stroke="#18181b" stroke-linecap="round" fill="none">
|
||||||
|
<!-- faint outer web -->
|
||||||
|
<g stroke-width="14" opacity="0.26">
|
||||||
|
<path d="M256 150 L166 336"/>
|
||||||
|
<path d="M166 336 L346 336"/>
|
||||||
|
<path d="M346 336 L256 150"/>
|
||||||
|
</g>
|
||||||
|
<!-- hub-and-spoke links -->
|
||||||
|
<g stroke-width="22">
|
||||||
|
<path d="M256 268 L256 150"/>
|
||||||
|
<path d="M256 268 L166 336"/>
|
||||||
|
<path d="M256 268 L346 336"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g fill="#18181b">
|
||||||
|
<circle cx="256" cy="150" r="34"/>
|
||||||
|
<circle cx="166" cy="336" r="34"/>
|
||||||
|
<circle cx="346" cy="336" r="34"/>
|
||||||
|
<circle cx="256" cy="268" r="26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 942 B |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "ThoughtSync",
|
||||||
|
"short_name": "ThoughtSync",
|
||||||
|
"description": "Capture a fleeting thought in a second — a self-hosted Keep-style board that grows into a lightweight second brain.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#F5C518",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
||||||
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#F5C518" />
|
||||||
|
<title>Offline · ThoughtSync</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #171717;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 22rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 1.25rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #737373;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: #f5c518;
|
||||||
|
color: #18181b;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid #18181b;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
button:focus-visible {
|
||||||
|
outline-color: #f5c518;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<svg class="icon" viewBox="0 0 512 512" aria-hidden="true">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#F5C518" />
|
||||||
|
<g stroke="#18181b" stroke-linecap="round" fill="none">
|
||||||
|
<g stroke-width="14" opacity="0.26">
|
||||||
|
<path d="M256 150 L166 336" />
|
||||||
|
<path d="M166 336 L346 336" />
|
||||||
|
<path d="M346 336 L256 150" />
|
||||||
|
</g>
|
||||||
|
<g stroke-width="22">
|
||||||
|
<path d="M256 268 L256 150" />
|
||||||
|
<path d="M256 268 L166 336" />
|
||||||
|
<path d="M256 268 L346 336" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g fill="#18181b">
|
||||||
|
<circle cx="256" cy="150" r="34" />
|
||||||
|
<circle cx="166" cy="336" r="34" />
|
||||||
|
<circle cx="346" cy="336" r="34" />
|
||||||
|
<circle cx="256" cy="268" r="26" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<h1>You're offline</h1>
|
||||||
|
<p>ThoughtSync needs a connection to your server to load. Reconnect and try again.</p>
|
||||||
|
<button type="button" onclick="location.reload()">Retry</button>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// ThoughtSync service worker — installable shell, deliberately NOT offline-first.
|
||||||
|
//
|
||||||
|
// This exists so the web app satisfies the PWA install criteria ("Add to Home
|
||||||
|
// Screen") and shows a friendly page when a navigation happens with no network.
|
||||||
|
// The real offline client is the future Android app (M5); to stay always-fresh
|
||||||
|
// and avoid deploy staleness, this SW does NOT cache the app shell, hashed build
|
||||||
|
// assets, or any /api response — everything but the offline fallback goes
|
||||||
|
// straight to the network.
|
||||||
|
const CACHE = "thoughtsync-shell-v1";
|
||||||
|
const OFFLINE_URL = "/offline.html";
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE)
|
||||||
|
.then((cache) => cache.add(OFFLINE_URL))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only intercept page navigations: try the network, fall back to the cached
|
||||||
|
// offline page when the server is unreachable. All other requests (hashed
|
||||||
|
// assets, /api/*) are left untouched and hit the network normally.
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.mode !== "navigate") return;
|
||||||
|
event.respondWith(fetch(event.request).catch(() => caches.match(OFFLINE_URL)));
|
||||||
|
});
|
||||||
@@ -8,3 +8,12 @@ const app = createApp(App);
|
|||||||
app.use(createPinia()); // before router: the nav guard reads the session store
|
app.use(createPinia()); // before router: the nav guard reads the session store
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
// Register the service worker so the app is installable (PWA). It's a
|
||||||
|
// progressive enhancement — installability only, not offline-first (see
|
||||||
|
// public/sw.js) — so failures are non-fatal and intentionally ignored.
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
void navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -18,6 +19,10 @@ from .settings_api import bp as settings_bp
|
|||||||
|
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
||||||
|
# `.webmanifest` isn't in every base image's mime map; register it so the PWA
|
||||||
|
# manifest is served as application/manifest+json instead of octet-stream.
|
||||||
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Quart:
|
def create_app() -> Quart:
|
||||||
# static_folder=None: the SPA catch-all below owns static serving.
|
# static_folder=None: the SPA catch-all below owns static serving.
|
||||||
|
|||||||