feat: make Personal Workspace installable as a PWA

This commit is contained in:
2026-10-08 00:47:03 +09:00
parent aabeb33f41
commit 67afc7bf15
12 changed files with 280 additions and 5 deletions
+24
View File
@@ -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`
+103
View File
@@ -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": "[email protected]" },
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();
});
+5 -1
View File
@@ -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} [email protected] 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} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e ${appCommand}`,
port: appPort,
reuseExistingServer: false,
timeout: 120_000,
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#255f6e"/>
<g fill="#fafafa">
<rect x="144" y="144" width="80" height="224" rx="16"/>
<rect x="248" y="144" width="120" height="96" rx="16"/>
<rect x="248" y="264" width="120" height="104" rx="16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 329 B

+22
View File
@@ -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"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#171717" />
<title>Offline · Personal Workspace</title>
<style>
:root {
color-scheme: dark;
font-family: system-ui, sans-serif;
background: #171717;
color: #fafafa;
}
body {
margin: 0;
min-height: 100svh;
display: grid;
place-items: center;
}
main {
max-width: 28rem;
padding: 2rem;
}
h1 {
font-size: 1.75rem;
}
p {
color: #b4b4b4;
line-height: 1.6;
}
button {
margin-top: 1rem;
border: 0;
border-radius: 0.625rem;
padding: 0.75rem 1.25rem;
background: #fafafa;
color: #171717;
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid #fafafa;
outline-offset: 4px;
}
</style>
</head>
<body>
<main>
<p>Personal Workspace</p>
<h1>You’re offline</h1>
<p>Connect to the internet and try again to open your workspace.</p>
<button type="button" onclick="window.location.reload()">Try again</button>
</main>
</body>
</html>
+47
View File
@@ -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();
}),
);
});
+15 -4
View File
@@ -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<RouterContext>()({
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 (
<html lang="en" className="dark">
<head>