Make the app an installable PWA with phone layout and Web Push
Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "cobble",
|
||||
"short_name": "cobble",
|
||||
"description": "Your crew of small AI worker bots.",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"start_url": "/admin/bots",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"background_color": "#fbfaf7",
|
||||
"theme_color": "#fbfaf7",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Routines",
|
||||
"url": "/admin/routines",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Help",
|
||||
"url": "/admin/help",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Cobble's service worker.
|
||||
*
|
||||
* The app is server-rendered and private, so authenticated HTML is never
|
||||
* cached: navigating offline lands on the generated offline page instead
|
||||
* of a stale conversation or a cached login redirect. Only the shell
|
||||
* (icons, manifest, offline page) and versioned app assets are cached.
|
||||
*
|
||||
* Bump `CACHE_VERSION` when this file changes in a way that must drop the
|
||||
* old caches.
|
||||
*/
|
||||
const CACHE_VERSION = "cobble-v1";
|
||||
const CACHE_NAME = `cobble-shell-${CACHE_VERSION}`;
|
||||
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
|
||||
const SHELL = [
|
||||
OFFLINE_URL,
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/maskable-192.png",
|
||||
"/icons/maskable-512.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/favicon.ico",
|
||||
"/logos/logo.svg",
|
||||
];
|
||||
|
||||
/**
|
||||
* Versioned app code and public assets: safe to refresh in the background.
|
||||
*/
|
||||
const ASSET_PREFIXES = ["/.bunext/", "/public/", "/icons/", "/logos/", "/brand/"];
|
||||
const ASSET_PATHS = ["/favicon.ico", "/favicon.png", "/apple-touch-icon.png"];
|
||||
|
||||
/**
|
||||
* Never intercepted: live data, sockets, media bytes, proxied computers,
|
||||
* auth side effects, and the worker itself.
|
||||
*/
|
||||
const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"];
|
||||
const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout"];
|
||||
|
||||
const DEFAULT_ICON = "/icons/icon-192.png";
|
||||
const DEFAULT_BADGE = "/icons/icon-192.png";
|
||||
|
||||
function isAssetRequest(url) {
|
||||
return (
|
||||
ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)) ||
|
||||
ASSET_PATHS.includes(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
function isBypassRequest(url) {
|
||||
return (
|
||||
BYPASS_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)) ||
|
||||
BYPASS_PATHS.includes(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) =>
|
||||
Promise.allSettled(SHELL.map((url) => cache.add(url))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (isBypassRequest(url)) return;
|
||||
|
||||
/**
|
||||
* A navigation: network only. Anything private stays out of storage;
|
||||
* offline gets the shell's offline page.
|
||||
*/
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(async () => {
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
return offline || new Response("You're offline.", { status: 503 });
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAssetRequest(url)) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
cache.put(request, response.clone()).catch(() => undefined);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return cached || (await network) || Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The page asks for a new version from its update notice. Nothing skips
|
||||
* the wait until the user says so.
|
||||
*/
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const title = payload.title || "Cobble";
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
icon: payload.icon || DEFAULT_ICON,
|
||||
badge: payload.badge || DEFAULT_BADGE,
|
||||
tag: payload.tag || undefined,
|
||||
renotify: Boolean(payload.tag),
|
||||
requireInteraction: Boolean(payload.requireInteraction),
|
||||
data: {
|
||||
url: payload.url || "/admin/bots",
|
||||
...(payload.data || {}),
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const target = new URL(
|
||||
event.notification.data?.url || "/admin/bots",
|
||||
self.location.origin,
|
||||
).href;
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then(async (clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url.startsWith(self.location.origin)) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(target).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await self.clients.openWindow(target);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The browser rotated the endpoint. The page holds the VAPID key and the
|
||||
* session, so it re-subscribes; this only tells it to.
|
||||
*/
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
for (const client of clients) {
|
||||
client.postMessage({
|
||||
type: "PUSH_RESUBSCRIBE",
|
||||
oldEndpoint: event.oldSubscription?.endpoint || null,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user