feat(pwa): adopt thread-ripper pattern with in-app install buttons, floating banner, and GET share_target

This commit is contained in:
chrisf committed 2026-10-22 19:57:22 -06:00
1 parent c739f74055
commit 4d4b8c860d
3 files changed
+135 -73

No files matched your search

+20 -52
View File
@@ -6,86 +6,54 @@
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#18181b", "background_color": "#18181b",
"theme_color": "#00a82d", "theme_color": "#00a82d",
"orientation": "any", "orientation": "any",
"categories": ["productivity", "utilities", "notes"], "categories": ["productivity", "utilities", "notes"],
"icons": [ "icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{ {
"src": "/icon-192.png", "src": "/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{ {
"src": "/icon-maskable-192.png", "src": "/icon-maskable-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{ {
"src": "/icon-maskable-512.png", "src": "/icon-maskable-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
} }
], ],
"share_target": { "share_target": {
"action": "/share", "action": "/",
"method": "POST", "method": "GET",
"enctype": "multipart/form-data", "enctype": "application/x-www-form-urlencoded",
"params": { "params": {
"title": "title", "title": "title",
"text": "text", "text": "text",
"url": "url", "url": "url"
"files": [
{
"name": "files",
"accept": [
"text/plain",
"text/html",
"text/markdown",
"text/csv",
".txt",
".md",
".html",
".csv",
"application/pdf",
".pdf",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/*",
".jpg",
".jpeg",
".png",
".gif",
".webp",
"audio/*",
".mp3",
".m4a",
".wav",
".ogg",
"video/*",
".mp4",
".webm"
]
}
]
} }
}, },
"shortcuts": [ "shortcuts": [
+26 -21
View File
@@ -1,18 +1,20 @@
// Everclone PWA Service Worker (v1.1.0) // Everclone PWA Service Worker (v2.0.0)
const CACHE_NAME = 'everclone-cache-v2'; const CACHE_NAME = 'everclone-cache-v3';
const PRECACHE_URLS = [ const STATIC_ASSETS = [
'/', '/',
'/manifest.json', '/manifest.json',
'/icon.svg',
'/icon-192.png', '/icon-192.png',
'/icon-512.png', '/icon-512.png',
'/apple-touch-icon.png' '/icon-maskable-192.png',
'/icon-maskable-512.png',
'/apple-touch-icon.png',
'/icon.svg'
]; ];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS).catch(() => {}); return cache.addAll(STATIC_ASSETS).catch(() => {});
}) })
); );
self.skipWaiting(); self.skipWaiting();
@@ -31,21 +33,31 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// CRITICAL: Never intercept POST/PUT/DELETE requests! // CRITICAL: Never intercept POST/PUT/DELETE requests!
// This ensures Android Web Share Target multipart POSTs stream directly to /api/hooks/note
if (event.request.method !== 'GET') { if (event.request.method !== 'GET') {
return; return;
} }
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Bypass API endpoints, hooks, and share endpoint to always get fresh data // Bypass API endpoints and share endpoint
if (url.pathname.startsWith('/api/') || url.pathname === '/share') { if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/share')) {
return; return;
} }
// Network-first strategy for navigation and static assets with cache fallback // For page navigations (including share target queries like /?url=... or /?noteId=...)
if (event.request.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request).catch(() => {
return caches.match('/');
})
);
return;
}
// Stale-while-revalidate for static assets
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request)
.then((networkResponse) => { .then((networkResponse) => {
if ( if (
networkResponse && networkResponse &&
@@ -59,16 +71,9 @@ self.addEventListener('fetch', (event) => {
} }
return networkResponse; return networkResponse;
}) })
.catch(() => { .catch(() => cachedResponse);
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) { return cachedResponse || fetchPromise;
return cachedResponse;
}
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return new Response('Network error occurred', { status: 503, statusText: 'Offline' });
});
}) })
); );
}); });
+89
View File
@@ -118,6 +118,41 @@ export default function EvercloneWorkspace() {
(filterOnlyPinned ? 1 : 0) + (filterOnlyPinned ? 1 : 0) +
(filterUpdatedDays > 0 ? 1 : 0); (filterUpdatedDays > 0 ? 1 : 0);
// PWA In-App Installation Listener
useEffect(() => {
if (typeof window === 'undefined') return;
if (window.__everclone_install_prompt) {
setCanInstallPwa(true);
}
const onInstallable = (e: Event) => {
const detail = (e as CustomEvent).detail;
setCanInstallPwa(Boolean(detail));
};
window.addEventListener('everclone:installable', onInstallable);
return () => window.removeEventListener('everclone:installable', onInstallable);
}, []);
const handleInstallPwa = async () => {
const promptEvent = window.__everclone_install_prompt;
if (!promptEvent) {
alert("To install Everclone on Android, tap your browser's menu (⋮) and select 'Install app' or 'Add to Home screen'.");
return;
}
try {
await promptEvent.prompt();
const choice = await promptEvent.userChoice;
if (choice.outcome === 'accepted') {
setCanInstallPwa(false);
window.__everclone_install_prompt = null;
setShareToast('Everclone installed! You can now share directly to Everclone.');
setTimeout(() => setShareToast(null), 5000);
}
} catch (err) {
console.error('Install prompt failed:', err);
}
};
const clearAllFilters = () => { const clearAllFilters = () => {
setFilterNotebookId(''); setFilterNotebookId('');
setFilterTagIds([]); setFilterTagIds([]);
@@ -397,6 +432,48 @@ export default function EvercloneWorkspace() {
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
return; return;
} }
// Android PWA Web Share Target GET Handler (/?url=... or /?text=... or /?title=...)
const sharedUrl = sp.get('url') || '';
const sharedText = sp.get('text') || '';
const sharedTitle = sp.get('title') || '';
if (sharedUrl || sharedText || sharedTitle) {
window.history.replaceState({}, '', '/');
setShareToast('Saving shared note to Everclone...');
(async () => {
try {
const res = await fetch('/api/hooks/note?source=share-target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: sharedUrl,
text: sharedText,
title: sharedTitle,
tags: ['inbox', 'share'],
}),
});
if (res.ok) {
const data = await res.json();
setShareToast('Saved note to Everclone from Share!');
setTimeout(() => setShareToast(null), 4000);
if (data.noteId) {
setSelectedNoteId(data.noteId);
setMobilePane('editor');
fetchNotesList(data.noteId);
}
} else {
setShareToast('Failed to save shared note');
setTimeout(() => setShareToast(null), 4000);
}
} catch (e) {
console.error('Error saving shared note:', e);
setShareToast('Error saving shared note');
setTimeout(() => setShareToast(null), 4000);
}
})();
return;
}
} }
fetchNotesList(); fetchNotesList();
}, [fetchNotesList]); }, [fetchNotesList]);
@@ -920,6 +997,18 @@ export default function EvercloneWorkspace() {
<Plus className="w-4 h-4 stroke-[2.5]" /> <Plus className="w-4 h-4 stroke-[2.5]" />
{!sidebarCollapsed && <span>New Note</span>} {!sidebarCollapsed && <span>New Note</span>}
</button> </button>
{canInstallPwa && (
<button
type="button"
onClick={handleInstallPwa}
title="Install Everclone App"
className="w-full flex items-center justify-center gap-2 py-2 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs rounded-full shadow transition"
>
<Download className="w-4 h-4 stroke-[2.5]" />
{!sidebarCollapsed && <span>Install App</span>}
</button>
)}
<button <button
type="button" type="button"