Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,21 @@ Lens is a static single-page app that speaks directly to your vault over its HTT

## Status

v1, in active use. Launching alongside Parachute Vault.
v1 shipped; v0.2 in progress — offline-capable PWA.

## Install Parachute Lens

Lens is installable as a Progressive Web App. Once installed, it runs in its own window, launches from your home screen or dock, and (from v0.2 onward) keeps working when you're offline.

- **Desktop Chrome / Edge** — visit your hosted Lens, click **Install app** in the header, or use the browser's install icon in the address bar.
- **Android Chrome** — tap **Install app**, or use the browser menu → **Install app**.
- **iOS Safari** — tap the Share icon, then **Add to Home Screen**. (Safari doesn't expose a JS install prompt, so Lens shows a hint with the steps.)

A few iOS quirks worth knowing:

- iOS caps PWA storage at roughly 50 MB per app.
- Apple may evict data from a PWA that hasn't been opened in a while.
- There is no `beforeinstallprompt` event on iOS — the Add to Home Screen flow is manual.

## Quick start

Expand Down
655 changes: 654 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@types/node": "^22",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.1",
"remark-parse": "^11.0.0",
Expand All @@ -54,6 +55,8 @@
"typescript": "~5.6.0",
"unified": "^11.0.5",
"vite": "^6.0.0",
"vitest": "^3"
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3",
"workbox-window": "^7.4.0"
}
}
Binary file added public/apple-touch-icon-180x180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon.ico
Binary file not shown.
6 changes: 6 additions & 0 deletions public/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/maskable-icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/pwa-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/pwa-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/pwa-64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions pwa-assets.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineConfig, minimal2023Preset } from "@vite-pwa/assets-generator/config";

export default defineConfig({
preset: {
...minimal2023Preset,
maskable: {
...minimal2023Preset.maskable,
padding: 0.2,
resizeOptions: {
...minimal2023Preset.maskable.resizeOptions,
background: "#4a7c59",
},
},
apple: {
...minimal2023Preset.apple,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.apple.resizeOptions,
background: "#4a7c59",
},
},
},
images: ["public/icon.svg"],
});
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Header } from "@/components/Header";
import { Toaster } from "@/components/Toaster";
import { UpdateBanner } from "@/components/UpdateBanner";
import { QueryProvider } from "@/providers/QueryProvider";
import { BrowserRouter, Route, Routes } from "react-router";
import { AddVault } from "./routes/AddVault";
Expand All @@ -19,6 +20,7 @@ export function App() {
<BrowserRouter>
<div className="min-h-dvh bg-bg text-fg">
<Toaster />
<UpdateBanner />
<Header />
<main>
<Routes>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InstallPrompt } from "@/components/InstallPrompt";
import { ThemeToggle } from "@/components/ThemeToggle";
import { type VaultRecord, useVaultStore } from "@/lib/vault";
import { Link, useNavigate } from "react-router";
Expand Down Expand Up @@ -62,11 +63,13 @@ export function Header() {
>
Manage
</button>
<InstallPrompt />
<ThemeToggle />
</>
) : (
<>
<span className="text-sm text-fg-dim">No vault connected</span>
<InstallPrompt />
<ThemeToggle />
</>
)}
Expand Down
80 changes: 80 additions & 0 deletions src/components/InstallPrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { InstallPrompt } from "@/components/InstallPrompt";
import type { BeforeInstallPromptEvent } from "@/lib/pwa";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

function stubMatchMedia(standalone: boolean) {
const mm = (q: string) => ({
matches: q === "(display-mode: standalone)" ? standalone : false,
media: q,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
vi.stubGlobal("matchMedia", mm);
Object.defineProperty(window, "matchMedia", { configurable: true, value: mm });
}

function stubUserAgent(ua: string) {
Object.defineProperty(window.navigator, "userAgent", { configurable: true, value: ua });
}

function fireBeforeInstallPrompt(prompt = vi.fn<() => Promise<void>>(async () => {})) {
const event = new Event("beforeinstallprompt") as unknown as BeforeInstallPromptEvent;
Object.assign(event, {
platforms: ["web"],
userChoice: Promise.resolve({ outcome: "accepted" as const, platform: "web" }),
prompt,
});
window.dispatchEvent(event);
return { event, prompt };
}

describe("InstallPrompt", () => {
beforeEach(() => {
stubMatchMedia(false);
stubUserAgent("Mozilla/5.0 (Linux; Android 13) Chrome/120");
});
afterEach(() => {
vi.unstubAllGlobals();
});

it("renders nothing when already standalone", () => {
stubMatchMedia(true);
render(<InstallPrompt />);
expect(screen.queryByRole("button", { name: /install app/i })).not.toBeInTheDocument();
});

it("renders nothing on a non-iOS browser until beforeinstallprompt fires", () => {
const { unmount } = render(<InstallPrompt />);
expect(screen.queryByRole("button", { name: /install app/i })).not.toBeInTheDocument();
unmount();
});

it("shows the Install button and triggers prompt() on click", async () => {
render(<InstallPrompt />);
let prompt: ReturnType<typeof vi.fn> | undefined;
act(() => {
prompt = fireBeforeInstallPrompt().prompt;
});
const btn = screen.getByRole("button", { name: /install app/i });
await act(async () => {
fireEvent.click(btn);
});
expect(prompt).toHaveBeenCalledOnce();
});

it("shows iOS Add-to-Home-Screen dialog when clicked on iPhone with no prompt", async () => {
stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)");
render(<InstallPrompt />);
const btn = await screen.findByRole("button", { name: /install app/i });
await act(async () => {
fireEvent.click(btn);
});
expect(screen.getByText(/add lens to your home screen/i)).toBeInTheDocument();
expect(screen.getByText(/share icon/i)).toBeInTheDocument();
});
});
82 changes: 82 additions & 0 deletions src/components/InstallPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { type BeforeInstallPromptEvent, isIOS, isStandalone } from "@/lib/pwa";
import { useEffect, useState } from "react";

export function InstallPrompt() {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [standalone, setStandalone] = useState(true);
const [iosDevice, setIosDevice] = useState(false);
const [iosHintOpen, setIosHintOpen] = useState(false);

useEffect(() => {
setStandalone(isStandalone());
setIosDevice(isIOS());
const onPrompt = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setDeferred(null);
setStandalone(true);
};
window.addEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);

if (standalone) return null;

const handleInstall = async () => {
if (deferred) {
await deferred.prompt();
const choice = await deferred.userChoice;
if (choice.outcome === "accepted") setDeferred(null);
return;
}
if (iosDevice) setIosHintOpen(true);
};

const showButton = deferred !== null || iosDevice;
if (!showButton) return null;

return (
<>
<button
type="button"
onClick={handleInstall}
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-fg-muted hover:text-accent focus-visible:outline-2 focus-visible:outline-accent"
>
Install app
</button>
{iosHintOpen ? (
<dialog
open
aria-labelledby="ios-install-title"
className="fixed inset-0 z-50 m-auto max-w-sm rounded-md border border-border bg-card p-6 text-fg shadow-lg backdrop:bg-black/40"
>
<h2 id="ios-install-title" className="mb-3 font-serif text-xl">
Add Lens to your home screen
</h2>
<ol className="mb-5 list-decimal space-y-2 pl-5 text-sm text-fg-muted">
<li>Tap the Share icon in Safari's toolbar.</li>
<li>
Choose <strong className="text-fg">Add to Home Screen</strong>.
</li>
<li>Tap Add. Lens will open standalone from your home screen.</li>
</ol>
<div className="flex justify-end">
<button
type="button"
onClick={() => setIosHintOpen(false)}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-hover"
>
Got it
</button>
</div>
</dialog>
) : null}
</>
);
}
14 changes: 14 additions & 0 deletions src/components/UpdateBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { UpdateBanner } from "@/components/UpdateBanner";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

// The `virtual:pwa-register/react` module is aliased to a test stub (see
// vitest.config.ts) that returns needRefresh=false by default. This smoke
// test proves the component imports the stub, renders without crashing,
// and correctly renders nothing when there's no pending update.
describe("UpdateBanner", () => {
it("renders nothing when there is no pending service-worker update", () => {
render(<UpdateBanner />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
41 changes: 41 additions & 0 deletions src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useRegisterSW } from "virtual:pwa-register/react";

export function UpdateBanner() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW(_url, registration) {
// Check for a fresh SW hourly while the app is open.
if (!registration) return;
const hour = 60 * 60 * 1000;
setInterval(() => {
registration.update().catch(() => {});
}, hour);
},
});

if (!needRefresh) return null;

return (
<output className="fixed inset-x-0 bottom-4 z-40 mx-auto flex max-w-sm items-center justify-between gap-3 rounded-md border border-border bg-card px-4 py-3 shadow-lg">
<p className="text-sm text-fg">A new version of Lens is available.</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setNeedRefresh(false)}
className="text-sm text-fg-muted hover:text-accent"
>
Later
</button>
<button
type="button"
onClick={() => updateServiceWorker(true)}
className="rounded-md bg-accent px-3 py-1 text-sm font-medium text-white hover:bg-accent-hover"
>
Reload
</button>
</div>
</output>
);
}
40 changes: 40 additions & 0 deletions src/lib/pwa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { isIOS, isStandalone } from "./pwa";

describe("isIOS", () => {
it("is true for iPhone UA", () => {
expect(isIOS("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit")).toBe(true);
});
it("is true for iPad UA", () => {
expect(isIOS("Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)")).toBe(true);
});
it("is true for iPadOS 13+ which reports a Mac UA but has touch", () => {
expect(isIOS("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", true)).toBe(true);
});
it("is false for desktop Chrome on macOS (no touch)", () => {
expect(isIOS("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Chrome/120", false)).toBe(false);
});
it("is false for Android UA", () => {
expect(isIOS("Mozilla/5.0 (Linux; Android 13; Pixel 7)", true)).toBe(false);
});
});

describe("isStandalone", () => {
it("is true when display-mode: standalone matches", () => {
const nav = { userAgent: "x" } as Navigator;
const win = {
matchMedia: (q: string) => ({ matches: q === "(display-mode: standalone)" }),
} as unknown as Window;
expect(isStandalone(nav, win)).toBe(true);
});
it("is true when navigator.standalone is true (iOS installed)", () => {
const nav = { userAgent: "x", standalone: true } as Navigator & { standalone: boolean };
const win = { matchMedia: () => ({ matches: false }) } as unknown as Window;
expect(isStandalone(nav, win)).toBe(true);
});
it("is false in a regular browser tab", () => {
const nav = { userAgent: "x" } as Navigator;
const win = { matchMedia: () => ({ matches: false }) } as unknown as Window;
expect(isStandalone(nav, win)).toBe(false);
});
});
21 changes: 21 additions & 0 deletions src/lib/pwa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// BeforeInstallPromptEvent isn't in lib.dom yet — declare what we use.
export interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
prompt(): Promise<void>;
}

export function isStandalone(nav: Navigator = navigator, win: Window = window): boolean {
if (win.matchMedia?.("(display-mode: standalone)").matches) return true;
// iOS Safari exposes navigator.standalone on installed PWAs.
return (nav as Navigator & { standalone?: boolean }).standalone === true;
}

export function isIOS(
ua: string = navigator.userAgent,
hasTouch: boolean = typeof document !== "undefined" && navigator.maxTouchPoints > 1,
): boolean {
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+ reports a Mac UA but has a touch screen — use that signal.
return /Macintosh/.test(ua) && hasTouch;
}
Loading