diff --git a/README.md b/README.md index 27134d3..7d31a90 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ Then open `http://:5000` and register — **the first account becomes the - 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) · `:` (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://:5000` won't show the install prompt. ## Milestones diff --git a/frontend/index.html b/frontend/index.html index 3d7f9cc..5727c4e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,17 @@ + + + + + + + + ThoughtSync diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..6b05fca Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000..8461f46 Binary files /dev/null and b/frontend/public/favicon-32.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..0316119 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..ffc0257 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 0000000..304847e Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/public/icon-maskable.svg b/frontend/public/icon-maskable.svg new file mode 100644 index 0000000..e8257e4 --- /dev/null +++ b/frontend/public/icon-maskable.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..f1d1229 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..e22cd3d --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..589ca4b --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,114 @@ + + + + + + + Offline · ThoughtSync + + + +
+ +

You're offline

+

ThoughtSync needs a connection to your server to load. Reconnect and try again.

+ +
+ + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..32c0ad8 --- /dev/null +++ b/frontend/public/sw.js @@ -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))); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c6ced36..ce512d1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,3 +8,12 @@ const app = createApp(App); app.use(createPinia()); // before router: the nav guard reads the session store app.use(router); 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(() => {}); + }); +} diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index da5218d..ebf6a6b 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import mimetypes import os import secrets 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") +# `.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: # static_folder=None: the SPA catch-all below owns static serving.