mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension)
243 lines
7.3 KiB
JavaScript
243 lines
7.3 KiB
JavaScript
// Bambuddy Service Worker
|
|
const CACHE_NAME = 'bambuddy-v29';
|
|
const STATIC_CACHE = 'bambuddy-static-v28';
|
|
|
|
// Static assets to cache on install
|
|
const STATIC_ASSETS = [
|
|
'/',
|
|
'/manifest.json',
|
|
'/img/favicon.png',
|
|
'/img/favicon-16x16.png',
|
|
'/img/favicon-32x32.png',
|
|
'/img/android-chrome-192x192.png',
|
|
'/img/android-chrome-512x512.png',
|
|
'/img/apple-touch-icon.png',
|
|
'/img/bambuddy_logo_dark.png',
|
|
// Self-hosted Inter font (#1460) - cached so the UI renders offline.
|
|
'/fonts/inter-latin.woff2',
|
|
'/fonts/inter-latin-ext.woff2',
|
|
];
|
|
|
|
// Install event - cache static assets
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[SW] Installing service worker...');
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE).then((cache) => {
|
|
console.log('[SW] Caching static assets');
|
|
return cache.addAll(STATIC_ASSETS);
|
|
})
|
|
);
|
|
// Activate immediately
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// Activate event - clean up old caches, then force-reload any controlled
|
|
// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
|
|
// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
|
|
// without this hop, restarting Chromium installs the new SW but the existing
|
|
// document keeps running the previously-cached bundle until a navigation
|
|
// happens — which on a locked kiosk never occurs.
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('[SW] Activating service worker...');
|
|
event.waitUntil(
|
|
(async () => {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(
|
|
cacheNames
|
|
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
|
.map((name) => {
|
|
console.log('[SW] Deleting old cache:', name);
|
|
return caches.delete(name);
|
|
}),
|
|
);
|
|
// Take control immediately.
|
|
await self.clients.claim();
|
|
// Force a fresh navigation in any window that this SW now controls.
|
|
// ``client.navigate(client.url)`` re-requests the page through the
|
|
// network-first fetch handler, picking up the new index.html + the
|
|
// new content-hashed JS bundle. Guarded so the very first install on
|
|
// a never-controlled client doesn't trigger an unwanted reload.
|
|
const clients = await self.clients.matchAll({ type: 'window' });
|
|
for (const client of clients) {
|
|
try {
|
|
if (client.url && typeof client.navigate === 'function') {
|
|
await client.navigate(client.url);
|
|
}
|
|
} catch (e) {
|
|
// Some browsers reject navigate on cross-origin or detached
|
|
// clients — swallow so one bad client doesn't break the rest.
|
|
console.warn('[SW] Forced reload skipped for client:', client.url, e);
|
|
}
|
|
}
|
|
})(),
|
|
);
|
|
});
|
|
|
|
// Fetch event - network-first for API, cache-first for static
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Skip cross-origin requests - let the browser handle them directly.
|
|
// Without this the catch-all HTML branch below would answer a failed
|
|
// cross-origin request with our cached index.html, so e.g. a blocked
|
|
// Google Fonts request came back as text/html (#1460).
|
|
if (url.origin !== self.location.origin) {
|
|
return;
|
|
}
|
|
|
|
// Skip WebSocket connections
|
|
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
|
return;
|
|
}
|
|
|
|
// Skip camera stream/snapshot requests - Safari has issues with streaming through SW
|
|
if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
|
|
return;
|
|
}
|
|
|
|
// API requests - network first, no cache (real-time data is critical)
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(
|
|
fetch(request).catch(() => {
|
|
// Return offline response for API failures
|
|
return new Response(
|
|
JSON.stringify({ error: 'offline', message: 'You are currently offline' }),
|
|
{
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets - cache first, then network
|
|
if (
|
|
url.pathname.startsWith('/img/') ||
|
|
url.pathname.startsWith('/icons/') ||
|
|
url.pathname.startsWith('/fonts/') ||
|
|
url.pathname.endsWith('.png') ||
|
|
url.pathname.endsWith('.jpg') ||
|
|
url.pathname.endsWith('.svg') ||
|
|
url.pathname.endsWith('.ico') ||
|
|
url.pathname.endsWith('.woff2')
|
|
) {
|
|
event.respondWith(
|
|
caches.match(request).then((cached) => {
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
return fetch(request).then((response) => {
|
|
// Cache successful responses
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(STATIC_CACHE).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// JS/CSS assets - network first (Vite content-hashes filenames, so
|
|
// cache-busting is built in; network-first ensures new builds load immediately)
|
|
if (
|
|
url.pathname.startsWith('/assets/') ||
|
|
url.pathname.endsWith('.js') ||
|
|
url.pathname.endsWith('.css')
|
|
) {
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
return caches.match(request);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// HTML pages - network first, fall back to cache
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(request, clone);
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
return caches.match(request).then((cached) => {
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
// Return cached index for SPA navigation
|
|
return caches.match('/');
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Handle push notifications (for future use)
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return;
|
|
|
|
const data = event.data.json();
|
|
const options = {
|
|
body: data.body || 'New notification from Bambuddy',
|
|
icon: '/img/android-chrome-192x192.png',
|
|
badge: '/img/favicon-32x32.png',
|
|
vibrate: [100, 50, 100],
|
|
data: {
|
|
url: data.url || '/',
|
|
},
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'Bambuddy', options)
|
|
);
|
|
});
|
|
|
|
// Handle notification clicks
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
const url = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
// Check if there's already a window open
|
|
for (const client of windowClients) {
|
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
|
client.navigate(url);
|
|
return client.focus();
|
|
}
|
|
}
|
|
// Open a new window if none exists
|
|
if (clients.openWindow) {
|
|
return clients.openWindow(url);
|
|
}
|
|
})
|
|
);
|
|
});
|