1const GRIMOIRES_SCOPE = '/hub/'; 2const ASSET_VERSION = '0.1.061'; 3const FORCE_ACTIVATE_VERSION = '0.1.020'; 4const SHELL_CACHE = `grimoires-shell-${ASSET_VERSION}`; 5const SHELL_URL = '/hub/'; 6const VERSION_URL = '/hub/version.json'; 7 8// The production Gradle task replaces this list after webpack has produced the 9// hashed Wasm filenames. Keeping a small source fallback makes local/dev serving safe. 10const GRIMOIRES_PRECACHE = Object.freeze([ 11 "/hub/", 12 "/hub/284a3fcef66a1adb6321.wasm", 13 "/hub/6e23e5428398b92da386.wasm", 14 "/hub/assets/auth_grimoireshub_logo.png", 15 "/hub/assets/auth_grimoireshub_logo_dark.png", 16 "/hub/assets/push/badge.png", 17 "/hub/assets/push/grimoireshub.png", 18 "/hub/assets/push/guild_placeholder.png", 19 "/hub/assets/push/mention.png", 20 "/hub/assets/push/reply.png", 21 "/hub/assets/pwa/apple-touch-icon-180.png", 22 "/hub/assets/pwa/icon-192.png", 23 "/hub/assets/pwa/icon-512.png", 24 "/hub/assets/pwa/icon-maskable-512.png", 25 "/hub/config.js", 26 "/hub/grimoiresHub.js", 27 "/hub/manifest.webmanifest", 28 "/hub/replay-bridge.js" 29]); 30 31const PRECACHE_PATHS = new Set( 32 GRIMOIRES_PRECACHE.map(value => new URL(value, self.location.origin).pathname) 33); 34const ASSETS = Object.freeze({ 35 app: `/hub/assets/push/grimoireshub.png?v=${ASSET_VERSION}`, 36 badge: `/hub/assets/push/badge.png?v=${ASSET_VERSION}`, 37 mention: `/hub/assets/push/mention.png?v=${ASSET_VERSION}`, 38 reply: `/hub/assets/push/reply.png?v=${ASSET_VERSION}`, 39 guild: `/hub/assets/push/guild_placeholder.png?v=${ASSET_VERSION}` 40}); 41 42async function fetchForPrecache(path) { 43 const request = new Request(path, { 44 method: 'GET', 45 cache: 'reload', 46 credentials: 'same-origin' 47 }); 48 const response = await fetch(request); 49 if (!response || !response.ok) { 50 throw new Error(`Precache failed for ${path}: ${response?.status || 'network'}`); 51 } 52 return response; 53} 54 55self.addEventListener('install', event => { 56 // A new worker is allowed to wait only after it owns one complete, coherent 57 // HTML + JS + Wasm shell. One missing binary aborts installation instead of 58 // activating the familiar white rectangle of despair. 59 event.waitUntil((async () => { 60 const cache = await caches.open(SHELL_CACHE); 61 await Promise.all(GRIMOIRES_PRECACHE.map(async path => { 62 const response = await fetchForPrecache(path); 63 await cache.put(new Request(path), response); 64 })); 65 if (ASSET_VERSION === FORCE_ACTIVATE_VERSION) await self.skipWaiting(); 66 })()); 67}); 68 69self.addEventListener('activate', event => { 70 event.waitUntil((async () => { 71 const names = await caches.keys(); 72 await Promise.all(names 73 .filter(name => name.startsWith('grimoires-shell-') && name !== SHELL_CACHE) 74 .map(name => caches.delete(name))); 75 await self.clients.claim(); 76 if (ASSET_VERSION === FORCE_ACTIVATE_VERSION) { 77 const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 78 await Promise.all(windows.map(async client => { 79 if (!('navigate' in client)) return; 80 try { await client.navigate(client.url); } catch (_) {} 81 })); 82 } 83 })()); 84}); 85 86self.addEventListener('message', event => { 87 if (event.data?.type === 'SKIP_WAITING') self.skipWaiting(); 88}); 89 90async function cachedShellResponse(request) { 91 const cache = await caches.open(SHELL_CACHE); 92 return (await cache.match(request, { ignoreSearch: true })) 93 || (await cache.match(SHELL_URL, { ignoreSearch: true })); 94} 95 96async function networkResponseWithTimeout(request, timeoutMs = 5000) { 97 const controller = new AbortController(); 98 const timer = setTimeout(() => controller.abort(), timeoutMs); 99 try { 100 return await fetch(request, { signal: controller.signal }); 101 } finally { 102 clearTimeout(timer); 103 } 104} 105 106self.addEventListener('fetch', event => { 107 const request = event.request; 108 if (request.method !== 'GET') return; 109 110 const url = new URL(request.url); 111 if (url.origin !== self.location.origin) return; 112
113 // version.json is deliberately never served from the application cache. It is 114 // the authority for the manual update notice in the menu. 115 if (url.pathname === VERSION_URL) return; 116 117 if (request.mode === 'navigate' && url.pathname.startsWith('/hub/')) { 118 event.respondWith((async () => { 119 const cached = await cachedShellResponse(request); 120 if (cached) return cached; 121 try { 122 const response = await networkResponseWithTimeout(request); 123 if (response && response.ok) { 124 const cache = await caches.open(SHELL_CACHE); 125 await cache.put(SHELL_URL, response.clone()).catch(() => {}); 126 return response; 127 } 128 return response || Response.error(); 129 } catch (_) { 130 return (await cachedShellResponse(request)) || Response.error(); 131 } 132 })()); 133 return; 134 } 135 136 if (!PRECACHE_PATHS.has(url.pathname)) return; 137 event.respondWith((async () => { 138 const cache = await caches.open(SHELL_CACHE); 139 const cached = await cache.match(request, { ignoreSearch: true }); 140 if (cached) return cached; 141 try { 142 const response = await networkResponseWithTimeout(request); 143 if (response && response.ok) { 144 await cache.put(new Request(url.pathname), response.clone()).catch(() => {}); 145 } 146 return response || Response.error(); 147 } catch (_) { 148 return Response.error(); 149 } 150 })()); 151}); 152 153function stringValue(value) { 154 return String(value == null ? '' : value).trim(); 155} 156 157function notificationArtwork(payload, data) { 158 const eventType = stringValue(data.event_type || data.eventType || payload.event_type || payload.type || data.kind || payload.kind).toLowerCase(); 159 const conversationType = stringValue(data.conversation_type || data.conversationType || payload.conversation_type).toLowerCase(); 160 if (eventType.includes('mention')) return { icon: ASSETS.mention, badge: ASSETS.badge }; 161 if (eventType.includes('reply')) return { icon: ASSETS.reply, badge: ASSETS.badge }; 162 if (conversationType === 'direct') { 163 return { 164 icon: stringValue(data.sender_avatar_url || data.senderAvatarUrl || payload.sender_avatar_url) || ASSETS.app, 165 badge: ASSETS.badge 166 }; 167 } 168 if (conversationType === 'guild' || conversationType === 'group' || data.topic_id || data.topicId) { 169 return { 170 icon: stringValue(data.guild_avatar_url || data.guildAvatarUrl || payload.guild_avatar_url) || ASSETS.guild, 171 badge: ASSETS.badge 172 }; 173 } 174 return { icon: stringValue(payload.icon) || ASSETS.app, badge: stringValue(payload.badge) || ASSETS.badge }; 175} 176 177self.addEventListener('push', event => { 178 let payload = {}; 179 try { 180 payload = event.data ? event.data.json() : {}; 181 } catch (_) { 182 payload = { body: event.data ? event.data.text() : '' }; 183 } 184 185 const nestedData = payload && typeof payload.data === 'object' ? payload.data : {}; 186 const title = payload.title || nestedData.title || 'GrimoiresHub'; 187 const targetUrl = payload.url || payload.route || nestedData.url || nestedData.route || GRIMOIRES_SCOPE; 188 const artwork = notificationArtwork(payload, nestedData); 189 const options = { 190 body: payload.body || payload.message || nestedData.body || nestedData.message || '', 191 tag: payload.tag || payload.type || nestedData.tag || nestedData.event_type || 'grimoires-update', 192 data: { ...nestedData, url: targetUrl }, 193 icon: artwork.icon, 194 badge: artwork.badge, 195 renotify: payload.renotify !== false, 196 silent: Boolean(payload.silent), 197 requireInteraction: Boolean(payload.requireInteraction) 198 }; 199 200 event.waitUntil((async () => { 201 const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 202 // Suppress a duplicate system notification only while the user is actively 203 // focused on GrimoiresHub. A merely visible but unfocused desktop window 204 // must still receive real notifications. 205 const activeWindow = windows.find(client => client.focused === true); 206 const notificationKind = stringValue(nestedData.kind || payload.kind).toLowerCase(); 207 const forceSystemNotification = notificationKind === 'notification_test' || payload.force_show === true || nestedData.force_show === true; 208 if (activeWindow) { 209 try { activeWindow.postMessage({ type: 'grimoires-push', payload: { ...payload, data: options.data } }); } catch (_) {} 210 if (!forceSystemNotification) return; 211 } 212 await self.registration.showNotification(title, options); 213 })()); 214}); 215 216self.addEventListener('notificationclick', event => { 217 event.notification.close(); 218 const rawUrl = event.notification?.data?.url || GRIMOIRES_SCOPE; 219 const targetUrl = new URL(rawUrl, self.registration.scope).href; 220 event.waitUntil((async () => { 221 const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); 222 for (const client of windows) { 223 if (!('focus' in client)) continue; 224 try { 225 if ('navigate' in client) await client.navigate(targetUrl); 226 } catch (_) {} 227 return client.focus(); 228 } 229 return self.clients.openWindow(targetUrl); 230 })()); 231});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.