feat: Add installable Android PWA and native Android Web Share Target support
This commit is contained in:
1 parent
44d1dec42b
commit
94ae568385
12 files changed
+241
-19
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
+51
-6
@@ -1,17 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "Everclone",
|
"name": "Everclone — Notes & Knowledge Base",
|
||||||
"short_name": "Everclone",
|
"short_name": "Everclone",
|
||||||
"description": "Self-hosted Evernote clone for notes, web clips, and attachments",
|
"description": "Self-hosted Evernote clone for rich notes, web clips, and attachments",
|
||||||
|
"id": "/",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#1a1a1a",
|
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||||
|
"background_color": "#18181b",
|
||||||
"theme_color": "#00a82d",
|
"theme_color": "#00a82d",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["productivity", "utilities", "notes"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any maskable"
|
"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-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
@@ -25,9 +54,25 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"name": "files",
|
"name": "files",
|
||||||
"accept": ["image/*", "audio/*", "application/pdf", "text/*"]
|
"accept": ["image/*", "audio/*", "video/*", "application/pdf", "text/*", "*/*"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "New Note",
|
||||||
|
"short_name": "New Note",
|
||||||
|
"description": "Create a new note in Everclone",
|
||||||
|
"url": "/?action=new-note",
|
||||||
|
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Search Notes",
|
||||||
|
"short_name": "Search",
|
||||||
|
"description": "Search notes across your library",
|
||||||
|
"url": "/?action=search",
|
||||||
|
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Everclone PWA Service Worker (v1.0.0)
|
||||||
|
const CACHE_NAME = 'everclone-cache-v1';
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icon.svg',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
'/apple-touch-icon.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll(PRECACHE_URLS).catch(() => {});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) => {
|
||||||
|
return Promise.all(
|
||||||
|
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 and file downloads to always get fresh data
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first strategy for navigation and static assets with cache fallback
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return caches.match('/');
|
||||||
|
}
|
||||||
|
return new Response('Network error occurred', { status: 503, statusText: 'Offline' });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -103,15 +103,24 @@ export async function POST(req: NextRequest) {
|
|||||||
uploadedFiles.push(...files);
|
uploadedFiles.push(...files);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Android shared a URL inside the `text` field
|
// Check if Android shared a URL inside the `text` or `content` (or `title`)
|
||||||
if (!url && content && /^https?:\/\/\S+$/i.test(content.trim())) {
|
if (!url && content) {
|
||||||
url = content.trim();
|
const match = /(https?:\/\/[^\s]+)/i.exec(content);
|
||||||
content = '';
|
if (match) {
|
||||||
|
url = match[1];
|
||||||
|
if (content.trim() === url) {
|
||||||
|
content = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!url && title && /^https?:\/\/\S+$/i.test(title.trim())) {
|
||||||
|
url = title.trim();
|
||||||
|
title = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const notebookId = notebookName.trim()
|
const notebookId = notebookName.trim()
|
||||||
? await getOrCreateNotebookByName(notebookName.trim())
|
? await getOrCreateNotebookByName(notebookName.trim(), userId)
|
||||||
: (await ensureDefaultNotebook()).id;
|
: (await ensureDefaultNotebook(userId)).id;
|
||||||
|
|
||||||
// 1. If Brave Extension sent live rendered DOM (`pageHtml`) in `article` mode:
|
// 1. If Brave Extension sent live rendered DOM (`pageHtml`) in `article` mode:
|
||||||
if (pageHtml && (clipMode === 'article' || !clipMode)) {
|
if (pageHtml && (clipMode === 'article' || !clipMode)) {
|
||||||
@@ -185,7 +194,7 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isShareTarget) {
|
if (isShareTarget) {
|
||||||
return NextResponse.redirect(new URL(`/?noteId=${clippedId}`, req.url));
|
return NextResponse.redirect(new URL(`/?noteId=${clippedId}&shared=1`, req.url), 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -290,7 +299,7 @@ export async function POST(req: NextRequest) {
|
|||||||
await applyAutoSortToNote(note.id, userId).catch(() => {});
|
await applyAutoSortToNote(note.id, userId).catch(() => {});
|
||||||
|
|
||||||
if (isShareTarget) {
|
if (isShareTarget) {
|
||||||
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
|
return NextResponse.redirect(new URL(`/?noteId=${note.id}&shared=1`, req.url), 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -370,8 +379,8 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
await applyAutoSortToNote(noteId, userId).catch(() => {});
|
await applyAutoSortToNote(noteId, userId).catch(() => {});
|
||||||
|
|
||||||
if (sp.get('redirect') === '1') {
|
if (sp.get('redirect') === '1' || sp.get('source') === 'share-target') {
|
||||||
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
|
return NextResponse.redirect(new URL(`/?noteId=${noteId}&shared=1`, req.url), 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
+32
-3
@@ -1,13 +1,34 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import PwaRegister from '@/components/PwaRegister';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Everclone — Notes, Web Clips & Knowledge Base',
|
title: 'Everclone — Notes, Web Clips & Knowledge Base',
|
||||||
description: 'Self-hosted modern Evernote clone with rich media, ENEX import, and Web/Android hooks',
|
description: 'Self-hosted modern Evernote clone with rich media, ENEX import, and Web/Android hooks',
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
icons: {
|
icons: {
|
||||||
icon: '/icon.svg',
|
icon: [
|
||||||
|
{ url: '/icon.svg', type: 'image/svg+xml' },
|
||||||
|
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
title: 'Everclone',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#00a82d',
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -17,7 +38,15 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<head>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<PwaRegister />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,8 @@ export default function EvercloneWorkspace() {
|
|||||||
const [newUserName, setNewUserName] = useState('');
|
const [newUserName, setNewUserName] = useState('');
|
||||||
const [newUserEmail, setNewUserEmail] = useState('');
|
const [newUserEmail, setNewUserEmail] = useState('');
|
||||||
const [triageModalOpen, setTriageModalOpen] = useState(false);
|
const [triageModalOpen, setTriageModalOpen] = useState(false);
|
||||||
|
const [canInstallPwa, setCanInstallPwa] = useState(false);
|
||||||
|
const [shareToast, setShareToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(filterNotebookId ? 1 : 0) +
|
(filterNotebookId ? 1 : 0) +
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__everclone_install_prompt?: BeforeInstallPromptEvent | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
readonly platforms: string[];
|
||||||
|
readonly userChoice: Promise<{
|
||||||
|
outcome: 'accepted' | 'dismissed';
|
||||||
|
platform: string;
|
||||||
|
}>;
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PwaRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Register Service Worker for PWA installability and Share Target
|
||||||
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('[Everclone PWA] Service Worker registered (scope):', registration.scope);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[Everclone PWA] Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Capture the install prompt for Android / Desktop PWA installation
|
||||||
|
const handleBeforeInstall = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const promptEvent = e as BeforeInstallPromptEvent;
|
||||||
|
window.__everclone_install_prompt = promptEvent;
|
||||||
|
window.dispatchEvent(new CustomEvent('everclone:installable', { detail: true }));
|
||||||
|
console.log('[Everclone PWA] captured beforeinstallprompt event');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
window.__everclone_install_prompt = null;
|
||||||
|
window.dispatchEvent(new CustomEvent('everclone:installable', { detail: false }));
|
||||||
|
console.log('[Everclone PWA] app was successfully installed');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -124,6 +124,8 @@ export default function SettingsView({
|
|||||||
const [newKeyName, setNewKeyName] = useState('Brave Clipper & Android');
|
const [newKeyName, setNewKeyName] = useState('Brave Clipper & Android');
|
||||||
const [generatedKey, setGeneratedKey] = useState<string>('');
|
const [generatedKey, setGeneratedKey] = useState<string>('');
|
||||||
const [copied, setCopied] = useState<string>('');
|
const [copied, setCopied] = useState<string>('');
|
||||||
|
const [canInstall, setCanInstall] = useState<boolean>(false);
|
||||||
|
const [isStandalone, setIsStandalone] = useState<boolean>(false);
|
||||||
|
|
||||||
// --- EMAIL-TO-NOTE STATE ---
|
// --- EMAIL-TO-NOTE STATE ---
|
||||||
const [emailCfg, setEmailCfg] = useState({
|
const [emailCfg, setEmailCfg] = useState({
|
||||||
|
|||||||
Reference in new issue
Block a user