feat: make Grimoire installable as a PWA

- Created web manifest (site.webmanifest) with app metadata
  - Added app name, description, theme colors
  - Configured standalone display mode for native-like experience
  - Included all required icon sizes (192x192, 512x512)
  - Added keyboard shortcut for command palette
- Generated 192x192 icon for PWA requirements
- Added manifest and theme-color meta tags to index.html
- Implemented service worker (sw.js) for offline functionality
  - Network-first caching strategy for optimal performance
  - Precaches core assets on install
  - Provides offline fallback for navigation requests
- Registered service worker in main.tsx

Users can now install Grimoire as a standalone app on desktop and mobile devices.
This commit is contained in:
Claude
2026-01-18 17:28:49 +00:00
parent d1d29bcedb
commit 52f08964da
5 changed files with 149 additions and 0 deletions

View File

@@ -6,6 +6,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#b366ff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

BIN
public/favicon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

49
public/site.webmanifest Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "Grimoire - A Nostr Client for Magicians",
"short_name": "Grimoire",
"description": "A tiling window manager interface for exploring the Nostr protocol. Each window is a Nostr app (profile viewer, event feed, NIP documentation, etc.). Commands are launched Unix-style via Cmd+K palette.",
"start_url": "/",
"display": "standalone",
"background_color": "#020817",
"theme_color": "#b366ff",
"orientation": "any",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["social", "utilities", "productivity"],
"shortcuts": [
{
"name": "Command Palette",
"short_name": "Commands",
"description": "Open the command palette to launch Nostr apps",
"url": "/?action=command-palette"
}
]
}

84
public/sw.js Normal file
View File

@@ -0,0 +1,84 @@
// Grimoire Service Worker - v1.0.0
const CACHE_NAME = "grimoire-v1";
const RUNTIME_CACHE = "grimoire-runtime";
// Core assets to cache on install
const PRECACHE_URLS = [
"/",
"/index.html",
"/favicon.ico",
"/favicon-192x192.png",
"/favicon-512x512.png",
];
// Install event - precache core assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS);
}),
);
// Activate immediately
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map((name) => caches.delete(name)),
);
}),
);
// Take control immediately
self.clients.claim();
});
// Fetch event - network first, fallback to cache
self.addEventListener("fetch", (event) => {
// Skip non-GET requests
if (event.request.method !== "GET") return;
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) return;
// Network first strategy for app shell and assets
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response before caching
const responseClone = response.clone();
// Cache successful responses
if (response.status === 200) {
caches.open(RUNTIME_CACHE).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache if network fails
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// If no cache, return offline page for navigation requests
if (event.request.mode === "navigate") {
return caches.match("/index.html");
}
// For other requests, just fail
return new Response("Offline", {
status: 503,
statusText: "Service Unavailable",
});
});
}),
);
});

View File

@@ -35,3 +35,17 @@ createRoot(document.getElementById("root")!).render(
</ThemeProvider>
</ErrorBoundary>,
);
// Register service worker for PWA functionality
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("SW registered:", registration);
})
.catch((error) => {
console.log("SW registration failed:", error);
});
});
}