The Appview for the kipclip.com atproto bookmarking service

Add PWA support with popup OAuth and share target

- Add PWA detection and popup OAuth flow for Android standalone mode
- Add localStorage fallback when window.opener is lost during OAuth
- Add PWA manifest with share_target configuration
- Add minimal service worker for PWA requirements
- Add share-target route handler for receiving shared URLs

Fixes #4

Changed files
+343 -7
frontend
routes
static
+1 -1
deno.json
··· 10 10 "@std/dotenv": "jsr:@std/dotenv@0.225", 11 11 "@std/media-types": "jsr:@std/media-types@1", 12 12 "@std/path": "jsr:@std/path@1", 13 - "@tijs/atproto-oauth": "jsr:@tijs/atproto-oauth@2.4.0", 13 + "@tijs/atproto-oauth": "jsr:@tijs/atproto-oauth@2.5.1", 14 14 "@tijs/atproto-sessions": "jsr:@tijs/atproto-sessions@2.1.0", 15 15 "@tijs/atproto-storage": "jsr:@tijs/atproto-storage@1.0.0", 16 16 "@libsql/client": "npm:@libsql/client@0.15.15",
+11
frontend/components/App.tsx
··· 24 24 useEffect(() => { 25 25 checkSession(); 26 26 27 + // Check for share target data in URL params and redirect to Save page 28 + const params = new URLSearchParams(globalThis.location.search); 29 + if (params.get("action") === "share") { 30 + const sharedUrl = params.get("url"); 31 + if (sharedUrl) { 32 + // Redirect to Save page with the shared URL 33 + globalThis.location.href = `/save?url=${encodeURIComponent(sharedUrl)}`; 34 + return; 35 + } 36 + } 37 + 27 38 // Listen for popstate events to update route 28 39 const handlePopState = () => { 29 40 setCurrentPath(globalThis.location.pathname);
+23 -2
frontend/components/Login.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 + import { isStandalonePwa, openOAuthPopup } from "../utils/pwa.ts"; 2 3 3 4 /** 4 5 * Validate an AT Protocol handle format. ··· 63 64 return () => input.removeEventListener("input", handleInput); 64 65 }, []); 65 66 66 - function handleLogin(e: React.FormEvent) { 67 + async function handleLogin(e: React.FormEvent) { 67 68 e.preventDefault(); 68 69 setError(null); 69 70 ··· 92 93 loginUrl += `&redirect=${encodeURIComponent(redirect)}`; 93 94 } 94 95 95 - // Redirect to OAuth login 96 + // PWA mode: use popup OAuth to avoid losing PWA context 97 + if (isStandalonePwa()) { 98 + loginUrl += "&pwa=true"; 99 + try { 100 + await openOAuthPopup(loginUrl); 101 + // Success - reload to pick up the new session cookie 102 + globalThis.location.reload(); 103 + } catch (popupError) { 104 + const message = popupError instanceof Error 105 + ? popupError.message 106 + : "Login failed"; 107 + // Don't show "cancelled" as an error 108 + if (message !== "Login cancelled") { 109 + setError(message); 110 + } 111 + setLoading(false); 112 + } 113 + return; 114 + } 115 + 116 + // Regular web mode: redirect to OAuth login 96 117 globalThis.location.href = loginUrl; 97 118 } catch (error) { 98 119 console.error("Login failed:", error);
+1 -4
frontend/index.html
··· 73 73 rel="apple-touch-icon" 74 74 href="https://cdn.kipclip.com/favicons/apple-touch-icon.png" 75 75 > 76 - <link 77 - rel="manifest" 78 - href="https://cdn.kipclip.com/favicons/site.webmanifest" 79 - > 76 + <link rel="manifest" href="/static/manifest.webmanifest"> 80 77 81 78 <!-- Mobile meta --> 82 79 <meta name="theme-color" content="#FF6B6B">
+12
frontend/index.tsx
··· 4 4 5 5 // Only run in browser environment 6 6 if (typeof document !== "undefined") { 7 + // Register service worker for PWA support 8 + if ("serviceWorker" in navigator) { 9 + navigator.serviceWorker.register("/static/sw.js").then( 10 + (registration) => { 11 + console.log("SW registered:", registration.scope); 12 + }, 13 + (error) => { 14 + console.error("SW registration failed:", error); 15 + }, 16 + ); 17 + } 18 + 7 19 const root = createRoot(document.getElementById("root")!); 8 20 root.render( 9 21 <AppProvider>
+125
frontend/utils/pwa.ts
··· 1 + /** 2 + * PWA detection and OAuth utilities 3 + */ 4 + 5 + /** 6 + * Check if the app is running as a standalone PWA (installed to home screen) 7 + */ 8 + export function isStandalonePwa(): boolean { 9 + // Check display-mode media query (works on Android Chrome) 10 + if ( 11 + globalThis.matchMedia && 12 + globalThis.matchMedia("(display-mode: standalone)").matches 13 + ) { 14 + return true; 15 + } 16 + 17 + // Check iOS standalone mode 18 + // deno-lint-ignore no-explicit-any 19 + if ((globalThis.navigator as any).standalone === true) { 20 + return true; 21 + } 22 + 23 + // Check if launched from TWA (Trusted Web Activity on Android) 24 + if (document.referrer.includes("android-app://")) { 25 + return true; 26 + } 27 + 28 + return false; 29 + } 30 + 31 + /** 32 + * Open OAuth login in a popup window and wait for the result. 33 + * Uses both postMessage and localStorage to receive the result, 34 + * since window.opener can be lost after navigating through external OAuth providers. 35 + * 36 + * @param loginUrl The login URL with handle and pwa=true params 37 + * @returns Promise that resolves with session data or rejects on error/cancel 38 + */ 39 + export function openOAuthPopup( 40 + loginUrl: string, 41 + ): Promise<{ did: string; handle: string }> { 42 + return new Promise((resolve, reject) => { 43 + // Clear any previous OAuth result 44 + localStorage.removeItem("pwa-oauth-result"); 45 + 46 + // Calculate popup position (centered) 47 + const width = 500; 48 + const height = 600; 49 + const left = Math.max(0, (screen.width - width) / 2); 50 + const top = Math.max(0, (screen.height - height) / 2); 51 + 52 + // Open popup 53 + const popup = globalThis.open( 54 + loginUrl, 55 + "oauth-popup", 56 + `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=yes,status=no`, 57 + ); 58 + 59 + if (!popup) { 60 + reject( 61 + new Error("Could not open popup. Please allow popups for this site."), 62 + ); 63 + return; 64 + } 65 + 66 + // Handle successful OAuth result 67 + function handleSuccess(data: { did: string; handle: string }) { 68 + cleanup(); 69 + localStorage.removeItem("pwa-oauth-result"); 70 + resolve(data); 71 + } 72 + 73 + // Listen for postMessage from popup (works if opener relationship preserved) 74 + function handleMessage(event: MessageEvent) { 75 + const data = event.data; 76 + if (data?.type === "oauth-callback" && data.success) { 77 + handleSuccess({ did: data.did, handle: data.handle }); 78 + } 79 + } 80 + 81 + // Listen for localStorage changes (fallback when opener is lost) 82 + function handleStorage(event: StorageEvent) { 83 + if (event.key === "pwa-oauth-result" && event.newValue) { 84 + try { 85 + const data = JSON.parse(event.newValue); 86 + if (data?.type === "oauth-callback" && data.success) { 87 + handleSuccess({ did: data.did, handle: data.handle }); 88 + } 89 + } catch { 90 + // Ignore parse errors 91 + } 92 + } 93 + } 94 + 95 + // Check if popup was closed - also check localStorage on close 96 + const checkClosed = setInterval(() => { 97 + if (popup.closed) { 98 + // Check localStorage one more time before giving up 99 + const result = localStorage.getItem("pwa-oauth-result"); 100 + if (result) { 101 + try { 102 + const data = JSON.parse(result); 103 + if (data?.type === "oauth-callback" && data.success) { 104 + handleSuccess({ did: data.did, handle: data.handle }); 105 + return; 106 + } 107 + } catch { 108 + // Ignore parse errors 109 + } 110 + } 111 + cleanup(); 112 + reject(new Error("Login cancelled")); 113 + } 114 + }, 500); 115 + 116 + function cleanup() { 117 + globalThis.removeEventListener("message", handleMessage); 118 + globalThis.removeEventListener("storage", handleStorage); 119 + clearInterval(checkClosed); 120 + } 121 + 122 + globalThis.addEventListener("message", handleMessage); 123 + globalThis.addEventListener("storage", handleStorage); 124 + }); 125 + }
+4
main.ts
··· 26 26 import { registerTagRoutes } from "./routes/api/tags.ts"; 27 27 import { registerOAuthRoutes } from "./routes/oauth.ts"; 28 28 import { registerRssRoutes } from "./routes/share/rss.ts"; 29 + import { registerShareTargetRoutes } from "./routes/share-target.ts"; 29 30 import { registerStaticRoutes } from "./routes/static.ts"; 30 31 31 32 // Run database migrations on startup ··· 112 113 113 114 // RSS feed routes 114 115 app = registerRssRoutes(app); 116 + 117 + // Share target routes (PWA share functionality) 118 + app = registerShareTargetRoutes(app); 115 119 116 120 // Static files and SPA routing (must be last) 117 121 app = registerStaticRoutes(app, import.meta.url);
+62
routes/share-target.ts
··· 1 + /** 2 + * Share target route handler for PWA share functionality. 3 + * This is a fallback for when the service worker doesn't intercept the request. 4 + */ 5 + 6 + import type { App } from "@fresh/core"; 7 + 8 + /** 9 + * Create a redirect response with mutable headers. 10 + * Response.redirect() creates immutable headers which conflicts with 11 + * the security headers middleware. 12 + */ 13 + function redirect(location: string, status = 303): Response { 14 + return new Response(null, { 15 + status, 16 + headers: { Location: location }, 17 + }); 18 + } 19 + 20 + // deno-lint-ignore no-explicit-any 21 + export function registerShareTargetRoutes(app: App<any>): App<any> { 22 + // Handle share target POST requests 23 + app = app.post("/share-target", async (ctx) => { 24 + try { 25 + const formData = await ctx.req.formData(); 26 + const url = formData.get("url"); 27 + const title = formData.get("title"); 28 + const text = formData.get("text"); 29 + 30 + // If we have a URL, redirect to the save page 31 + if (url && typeof url === "string") { 32 + const saveUrl = new URL("/save", new URL(ctx.req.url).origin); 33 + saveUrl.searchParams.set("url", url); 34 + if (title && typeof title === "string") { 35 + saveUrl.searchParams.set("title", title); 36 + } 37 + if (text && typeof text === "string") { 38 + saveUrl.searchParams.set("text", text); 39 + } 40 + return redirect(saveUrl.toString()); 41 + } 42 + 43 + // If no URL, try to extract from text (some apps put URL in text field) 44 + if (text && typeof text === "string") { 45 + const urlMatch = text.match(/https?:\/\/[^\s]+/); 46 + if (urlMatch) { 47 + const saveUrl = new URL("/save", new URL(ctx.req.url).origin); 48 + saveUrl.searchParams.set("url", urlMatch[0]); 49 + return redirect(saveUrl.toString()); 50 + } 51 + } 52 + 53 + // Fallback: redirect to home 54 + return redirect("/"); 55 + } catch (error) { 56 + console.error("Share target error:", error); 57 + return redirect("/"); 58 + } 59 + }); 60 + 61 + return app; 62 + }
+43
static/manifest.webmanifest
··· 1 + { 2 + "name": "kipclip", 3 + "short_name": "kipclip", 4 + "description": "Bookmark manager for Bluesky and the AT Protocol. Save, organize, and share your bookmarks.", 5 + "start_url": "/", 6 + "display": "standalone", 7 + "background_color": "#ffffff", 8 + "theme_color": "#FF6B6B", 9 + "orientation": "portrait-primary", 10 + "icons": [ 11 + { 12 + "src": "https://cdn.kipclip.com/favicons/android-chrome-192x192.png", 13 + "sizes": "192x192", 14 + "type": "image/png", 15 + "purpose": "any" 16 + }, 17 + { 18 + "src": "https://cdn.kipclip.com/favicons/android-chrome-512x512.png", 19 + "sizes": "512x512", 20 + "type": "image/png", 21 + "purpose": "any" 22 + }, 23 + { 24 + "src": "https://cdn.kipclip.com/favicons/android-chrome-512x512.png", 25 + "sizes": "512x512", 26 + "type": "image/png", 27 + "purpose": "maskable" 28 + } 29 + ], 30 + "share_target": { 31 + "action": "/share-target", 32 + "method": "POST", 33 + "enctype": "application/x-www-form-urlencoded", 34 + "params": { 35 + "title": "title", 36 + "text": "text", 37 + "url": "url" 38 + } 39 + }, 40 + "categories": ["productivity", "utilities"], 41 + "screenshots": [], 42 + "prefer_related_applications": false 43 + }
+61
static/sw.js
··· 1 + /** 2 + * Minimal service worker for kipclip PWA 3 + * 4 + * This service worker provides: 5 + * - PWA installability requirements 6 + * - Share target handling 7 + * 8 + * It does NOT provide offline caching (by design - we want fresh data). 9 + */ 10 + 11 + const SW_VERSION = '1.0.0'; 12 + 13 + // Install event - take control immediately 14 + self.addEventListener('install', (event) => { 15 + console.log('[SW] Installing service worker v' + SW_VERSION); 16 + self.skipWaiting(); 17 + }); 18 + 19 + // Activate event - claim all clients 20 + self.addEventListener('activate', (event) => { 21 + console.log('[SW] Activating service worker v' + SW_VERSION); 22 + event.waitUntil(self.clients.claim()); 23 + }); 24 + 25 + // Fetch event - pass through to network (no caching) 26 + self.addEventListener('fetch', (event) => { 27 + // Handle share target POST requests 28 + if (event.request.method === 'POST' && event.request.url.includes('/share-target')) { 29 + event.respondWith(handleShareTarget(event.request)); 30 + return; 31 + } 32 + 33 + // All other requests: pass through to network 34 + event.respondWith(fetch(event.request)); 35 + }); 36 + 37 + /** 38 + * Handle share target POST requests 39 + * Converts POST form data to GET request with query params 40 + */ 41 + async function handleShareTarget(request) { 42 + try { 43 + const formData = await request.formData(); 44 + const title = formData.get('title') || ''; 45 + const text = formData.get('text') || ''; 46 + const url = formData.get('url') || ''; 47 + 48 + // Build redirect URL with shared data as query params 49 + const redirectUrl = new URL('/', self.location.origin); 50 + redirectUrl.searchParams.set('action', 'share'); 51 + if (url) redirectUrl.searchParams.set('url', url); 52 + if (title) redirectUrl.searchParams.set('title', title); 53 + if (text) redirectUrl.searchParams.set('text', text); 54 + 55 + // Redirect to the app with the shared data 56 + return Response.redirect(redirectUrl.toString(), 303); 57 + } catch (error) { 58 + console.error('[SW] Share target error:', error); 59 + return Response.redirect('/', 303); 60 + } 61 + }