diff --git a/README.md b/README.md index d551f91..9bbc976 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,30 @@ code. See [owner login](docs/login.md) for configuration, sessions, and limits. `GET /health` returns `{"status":"ok"}` without authentication or database access. +## Install as an app + +The production build supports installation as **Personal Workspace** through +the browser's install menu (or Safari's **Add to Home Screen**). Open the app +over HTTPS, such as its Tailscale Serve origin; localhost also works for testing. +The installed app opens Home in a standalone window and uses the same owner +login as the browser. Access to the private network is still required. + +A service worker displays a small offline page when a page cannot be reached. +Reconnect and select **Try again** to reopen the same URL. Only this generic +page is cached: personal data, authenticated pages, API responses, and mutations +are never stored by the service worker. Offline reading/editing and background +sync are not supported. Updates activate after existing app windows/tabs close, +so an update does not reload an active draft. When changing `public/offline.html`, +bump the cache version in `public/sw.js`. + +Service worker registration is disabled in development. Verify the production +behavior with: + +```bash +nix develop -c pnpm build +nix develop -c env TWITTER_LITE_E2E_PRODUCTION=1 pnpm test:e2e e2e/integrations/pwa.spec.ts +``` + ## Requirements and setup Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc` diff --git a/e2e/integrations/pwa.spec.ts b/e2e/integrations/pwa.spec.ts new file mode 100644 index 0000000..f770290 --- /dev/null +++ b/e2e/integrations/pwa.spec.ts @@ -0,0 +1,103 @@ +import { chromium, expect, test } from "@playwright/test"; + +test.skip(process.env.TWITTER_LITE_E2E_PRODUCTION !== "1", "PWA requires the production build."); + +test("publishes an installable manifest and correctly sized icons before login", async ({ + page, + request, +}) => { + await page.goto("/"); + await expect(page.locator('link[rel="manifest"]')).toHaveAttribute( + "href", + "/manifest.webmanifest", + ); + const response = await request.get("/manifest.webmanifest"); + expect(response.ok()).toBe(true); + const manifest = await response.json(); + expect(manifest).toMatchObject({ + id: "/", + name: "Personal Workspace", + start_url: "/", + scope: "/", + display: "standalone", + }); + expect(manifest.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sizes: "192x192", purpose: "any" }), + expect.objectContaining({ sizes: "512x512", purpose: "any" }), + expect.objectContaining({ sizes: "512x512", purpose: "maskable" }), + ]), + ); + for (const icon of manifest.icons) { + const dimensions = await page.evaluate(async (src: string) => { + const image = new Image(); + image.src = src; + await image.decode(); + return `${image.naturalWidth}x${image.naturalHeight}`; + }, icon.src); + expect(dimensions).toBe(icon.sizes); + } +}); + +test("passes Chromium installation checks in a regular browser profile", async ({ + baseURL, +}, testInfo) => { + // Playwright's default incognito contexts cannot install apps. + const context = await chromium.launchPersistentContext("", { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + baseURL, + extraHTTPHeaders: { "Tailscale-User-Login": "owner@twitter-lite.invalid" }, + viewport: testInfo.project.use.viewport, + isMobile: testInfo.project.use.isMobile, + }); + try { + const page = await context.newPage(); + await page.goto("/"); + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)); + const client = await context.newCDPSession(page); + expect((await client.send("Page.getAppManifest")).errors).toEqual([]); + await expect + .poll(async () => (await client.send("Page.getInstallabilityErrors")).installabilityErrors) + .toEqual([]); + } finally { + await context.close(); + } +}); + +test("shows a private offline fallback and retries the original URL online", async ({ + page, + context, + baseURL, +}) => { + await page.goto("/"); + await expect + .poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)) + .toBe(new URL("/sw.js", baseURL).href); + + await context.setOffline(true); + const destination = "/login?returnTo=%2Fjournal"; + await page.goto(destination); + await expect(page).toHaveURL(new URL(destination, baseURL).href); + await expect(page.getByRole("heading", { name: "You’re offline" })).toBeVisible(); + expect( + await page.evaluate(() => + fetch("/api/auth").then( + () => false, + () => true, + ), + ), + ).toBe(true); + + const cachedPaths = await page.evaluate(async () => { + const names = await caches.keys(); + const requests = await Promise.all(names.map(async (name) => (await caches.open(name)).keys())); + return requests.flat().map((request) => new URL(request.url).pathname); + }); + expect(cachedPaths).toEqual(["/offline.html"]); + + await context.setOffline(false); + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page).toHaveURL(/\/(?:login|setup)(?:\?|$)/); + await expect(page.getByRole("heading", { name: "You’re offline" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Create account|Sign in/i })).toBeVisible(); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 97fa90d..ddef800 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,10 @@ import { defineConfig, devices } from "@playwright/test"; const appPort = 4173; const relayPort = 6911; +const appCommand = + process.env.TWITTER_LITE_E2E_PRODUCTION === "1" + ? `HOST=127.0.0.1 PORT=${appPort} node .output/server/index.mjs` + : `pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`; const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE; const databasePath = process.env.TWITTER_LITE_E2E_DB_PATH ?? @@ -36,7 +40,7 @@ export default defineConfig({ timeout: 120_000, }, { - command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_PATH= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, + command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_PATH= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e ${appCommand}`, port: appPort, reuseExistingServer: false, timeout: 120_000, diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..9fe436a Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..0e3c6e6 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..26fe3f3 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000..26fe3f3 Binary files /dev/null and b/public/icons/maskable-512.png differ diff --git a/public/icons/workspace.svg b/public/icons/workspace.svg new file mode 100644 index 0000000..d214341 --- /dev/null +++ b/public/icons/workspace.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..05477ec --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "id": "/", + "name": "Personal Workspace", + "short_name": "Workspace", + "description": "Your day, messages, notes, reading, and research in one place.", + "lang": "en", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#171717", + "theme_color": "#171717", + "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/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..c06ce4f --- /dev/null +++ b/public/offline.html @@ -0,0 +1,56 @@ + + + + + + + Offline · Personal Workspace + + + +
+

Personal Workspace

+

You’re offline

+

Connect to the internet and try again to open your workspace.

+ +
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..70fb202 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,47 @@ +// Only this generic page is cached. Authenticated HTML, API responses, and +// mutations always use the network. Bump the version when offline.html changes. +const CACHE = "workspace-offline-v1"; +const OFFLINE_URL = "/offline.html"; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.add(new Request(OFFLINE_URL, { cache: "reload" }))), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => key.startsWith("workspace-offline-") && key !== CACHE) + .map((key) => caches.delete(key)), + ); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + if ( + request.method !== "GET" || + request.mode !== "navigate" || + url.origin !== self.location.origin || + url.pathname.startsWith("/api/") || + url.pathname.startsWith("/_serverFn/") || + url.pathname.startsWith("/oauth/") || + url.pathname === "/mcp" || + url.pathname === "/health" + ) { + return; + } + event.respondWith( + fetch(request).catch(async () => { + const cache = await caches.open(CACHE); + return (await cache.match(OFFLINE_URL)) ?? Response.error(); + }), + ); +}); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 60807c8..48edf0a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { createRootRouteWithContext, HeadContent, @@ -44,14 +45,15 @@ export const Route = createRootRouteWithContext()({ content: "width=device-width, initial-scale=1", }, { name: "referrer", content: "no-referrer" }, + { name: "theme-color", content: "#171717" }, + { name: "apple-mobile-web-app-title", content: "Workspace" }, { title: "Personal Workspace" }, ], links: [ { rel: "stylesheet", href: appCss }, - { - rel: "icon", - href: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23255f6e'/%3E%3C/svg%3E", - }, + { rel: "manifest", href: "/manifest.webmanifest", crossOrigin: "use-credentials" }, + { rel: "icon", href: "/icons/workspace.svg", type: "image/svg+xml" }, + { rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }, ], }), shellComponent: RootDocument, @@ -67,6 +69,15 @@ function RootContent() { } function RootDocument({ children }: { children: React.ReactNode }) { + useEffect(() => { + if (import.meta.env.PROD && "serviceWorker" in navigator) { + void navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch((error) => { + // oxlint-disable-next-line no-console -- Keep registration failures diagnosable without interrupting the app. + console.error("Service worker registration failed", error); + }); + } + }, []); + return (