feat: make Personal Workspace installable as a PWA
This commit is contained in:
@@ -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.
|
`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
|
## Requirements and setup
|
||||||
|
|
||||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||||
|
|||||||
@@ -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,6 +5,10 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const appPort = 4173;
|
const appPort = 4173;
|
||||||
const relayPort = 6911;
|
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 chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE;
|
||||||
const databasePath =
|
const databasePath =
|
||||||
process.env.TWITTER_LITE_E2E_DB_PATH ??
|
process.env.TWITTER_LITE_E2E_DB_PATH ??
|
||||||
@@ -36,7 +40,7 @@ export default defineConfig({
|
|||||||
timeout: 120_000,
|
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,
|
port: appPort,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
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 |
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
HeadContent,
|
HeadContent,
|
||||||
@@ -44,14 +45,15 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
content: "width=device-width, initial-scale=1",
|
content: "width=device-width, initial-scale=1",
|
||||||
},
|
},
|
||||||
{ name: "referrer", content: "no-referrer" },
|
{ name: "referrer", content: "no-referrer" },
|
||||||
|
{ name: "theme-color", content: "#171717" },
|
||||||
|
{ name: "apple-mobile-web-app-title", content: "Workspace" },
|
||||||
{ title: "Personal Workspace" },
|
{ title: "Personal Workspace" },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{ rel: "stylesheet", href: appCss },
|
{ rel: "stylesheet", href: appCss },
|
||||||
{
|
{ rel: "manifest", href: "/manifest.webmanifest", crossOrigin: "use-credentials" },
|
||||||
rel: "icon",
|
{ rel: "icon", href: "/icons/workspace.svg", type: "image/svg+xml" },
|
||||||
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: "apple-touch-icon", href: "/icons/apple-touch-icon.png" },
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
@@ -67,6 +69,15 @@ function RootContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
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 (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
Reference in New Issue
Block a user