+1
-1
deno.json
+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
+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
+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
+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
+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
+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
+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);
+43
static/manifest.webmanifest
+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
+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
+
}