From 67afc7bf158388a91cadd9399741c397fd3db1e0 Mon Sep 17 00:00:00 2001 From: yuta Date: Thu, 8 Oct 2026 00:47:03 +0900 Subject: [PATCH] feat: make Personal Workspace installable as a PWA --- README.md | 24 +++++++ e2e/integrations/pwa.spec.ts | 103 ++++++++++++++++++++++++++++++ playwright.config.ts | 6 +- public/icons/apple-touch-icon.png | Bin 0 -> 5434 bytes public/icons/icon-192.png | Bin 0 -> 4126 bytes public/icons/icon-512.png | Bin 0 -> 1910 bytes public/icons/maskable-512.png | Bin 0 -> 1910 bytes public/icons/workspace.svg | 8 +++ public/manifest.webmanifest | 22 +++++++ public/offline.html | 56 ++++++++++++++++ public/sw.js | 47 ++++++++++++++ src/routes/__root.tsx | 19 ++++-- 12 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 e2e/integrations/pwa.spec.ts create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/maskable-512.png create mode 100644 public/icons/workspace.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/offline.html create mode 100644 public/sw.js 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 0000000000000000000000000000000000000000..9fe436a2611accd08bae5b85db95c31c9e655779 GIT binary patch literal 5434 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 + + + + + + + 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 (