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
+148 -86

No files matched your search

+20 -52
View File
@@ -6,86 +6,54 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#18181b",
"theme_color": "#00a82d",
"orientation": "any",
"categories": ["productivity", "utilities", "notes"],
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"action": "/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text",
"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"
]
}
]
"url": "url"
}
},
"shortcuts": [
+39 -34
View File
@@ -1,18 +1,20 @@
// Everclone PWA Service Worker (v1.1.0)
const CACHE_NAME = 'everclone-cache-v2';
const PRECACHE_URLS = [
// Everclone PWA Service Worker (v2.0.0)
const CACHE_NAME = 'everclone-cache-v3';
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/icon.svg',
'/icon-192.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) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS).catch(() => {});
return cache.addAll(STATIC_ASSETS).catch(() => {});
})
);
self.skipWaiting();
@@ -31,44 +33,47 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => {
// 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') {
return;
}
const url = new URL(event.request.url);
// Bypass API endpoints, hooks, and share endpoint to always get fresh data
if (url.pathname.startsWith('/api/') || url.pathname === '/share') {
// Bypass API endpoints and share endpoint
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/share')) {
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(
fetch(event.request).catch(() => {
return caches.match('/');
})
);
return;
}
// Stale-while-revalidate for static assets
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
if (
networkResponse &&
networkResponse.status === 200 &&
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
})
.catch(() => {
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request)
.then((networkResponse) => {
if (
networkResponse &&
networkResponse.status === 200 &&
(url.pathname === '/' || url.pathname.startsWith('/_next/static/'))
) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return new Response('Network error occurred', { status: 503, statusText: 'Offline' });
});
})
return networkResponse;
})
.catch(() => cachedResponse);
return cachedResponse || fetchPromise;
})
);
});
+89
View File
@@ -118,6 +118,41 @@ export default function EvercloneWorkspace() {
(filterOnlyPinned ? 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 = () => {
setFilterNotebookId('');
setFilterTagIds([]);
@@ -397,6 +432,48 @@ export default function EvercloneWorkspace() {
window.history.replaceState({}, '', '/');
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]);
@@ -920,6 +997,18 @@ export default function EvercloneWorkspace() {
<Plus className="w-4 h-4 stroke-[2.5]" />
{!sidebarCollapsed && <span>New Note</span>}
</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
type="button"