diff --git a/public/manifest.json b/public/manifest.json index 0ccbbdf05e..e1f792dd28 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,12 +1,16 @@ { "name": "Hermes Workspace", "short_name": "Hermes", - "description": "Native web control surface for Hermes Agent", - "start_url": "/", + "description": "Installable control surface for Hermes Agent chat, tools, files, memory, jobs, and agent workflows.", + "id": "/?app=hermes-workspace", + "start_url": "/?source=pwa", + "scope": "/", "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "browser"], + "orientation": "any", "background_color": "#0A0E1A", - "theme_color": "#6366F1", - "categories": ["productivity", "utilities"], + "theme_color": "#031A1A", + "categories": ["productivity", "utilities", "developer"], "icons": [ { "src": "/claude-icon-192.png", diff --git a/public/sw.js b/public/sw.js index 8d1adb4150..3a63060824 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,7 @@ -// Hermes Workspace Service Worker — DISABLED -// Unregisters itself and clears all caches to prevent stale asset issues -// after Docker image updates or reverse proxy deployments. +// Hermes Workspace Service Worker +// Network-only PWA registration: enables installability without caching app assets. +// This avoids stale bundles after PM2/Vite preview deploys while keeping iOS/Chrome +// standalone launches on the normal live application shell. self.addEventListener('install', () => { self.skipWaiting() @@ -11,14 +12,11 @@ self.addEventListener('activate', (event) => { caches .keys() .then((names) => Promise.all(names.map((name) => caches.delete(name)))) - .then(() => self.clients.claim()) - .then(() => { - // Tell all open tabs to reload so they get fresh assets - self.clients.matchAll({ type: 'window' }).then((clients) => { - clients.forEach((client) => client.navigate(client.url)) - }) - }), + .then(() => self.clients.claim()), ) }) -// Don't intercept any fetches — let the browser/server handle everything +self.addEventListener('fetch', () => { + // Deliberately do not call event.respondWith(). Every request goes to the + // browser/network stack directly, so the app never serves stale cached JS/CSS. +}) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1856dfd3d4..8f2325dcb3 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -78,6 +78,7 @@ import { Route as ApiLocalProvidersRouteImport } from './routes/api/local-provid import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status' +import { Route as ApiGatewayReprobeRouteImport } from './routes/api/gateway-reprobe' import { Route as ApiFilesRouteImport } from './routes/api/files' import { Route as ApiEventsRouteImport } from './routes/api/events' import { Route as ApiCrewStatusRouteImport } from './routes/api/crew-status' @@ -487,6 +488,11 @@ const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({ path: '/api/gateway-status', getParentRoute: () => rootRouteImport, } as any) +const ApiGatewayReprobeRoute = ApiGatewayReprobeRouteImport.update({ + id: '/api/gateway-reprobe', + path: '/api/gateway-reprobe', + getParentRoute: () => rootRouteImport, +} as any) const ApiFilesRoute = ApiFilesRouteImport.update({ id: '/api/files', path: '/api/files', @@ -836,6 +842,7 @@ export interface FileRoutesByFullPath { '/api/crew-status': typeof ApiCrewStatusRoute '/api/events': typeof ApiEventsRoute '/api/files': typeof ApiFilesRoute + '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -968,6 +975,7 @@ export interface FileRoutesByTo { '/api/crew-status': typeof ApiCrewStatusRoute '/api/events': typeof ApiEventsRoute '/api/files': typeof ApiFilesRoute + '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -1102,6 +1110,7 @@ export interface FileRoutesById { '/api/crew-status': typeof ApiCrewStatusRoute '/api/events': typeof ApiEventsRoute '/api/files': typeof ApiFilesRoute + '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -1237,6 +1246,7 @@ export interface FileRouteTypes { | '/api/crew-status' | '/api/events' | '/api/files' + | '/api/gateway-reprobe' | '/api/gateway-status' | '/api/history' | '/api/integrations' @@ -1369,6 +1379,7 @@ export interface FileRouteTypes { | '/api/crew-status' | '/api/events' | '/api/files' + | '/api/gateway-reprobe' | '/api/gateway-status' | '/api/history' | '/api/integrations' @@ -1502,6 +1513,7 @@ export interface FileRouteTypes { | '/api/crew-status' | '/api/events' | '/api/files' + | '/api/gateway-reprobe' | '/api/gateway-status' | '/api/history' | '/api/integrations' @@ -1636,6 +1648,7 @@ export interface RootRouteChildren { ApiCrewStatusRoute: typeof ApiCrewStatusRoute ApiEventsRoute: typeof ApiEventsRoute ApiFilesRoute: typeof ApiFilesRoute + ApiGatewayReprobeRoute: typeof ApiGatewayReprobeRoute ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiIntegrationsRoute: typeof ApiIntegrationsRoute @@ -2193,6 +2206,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGatewayStatusRouteImport parentRoute: typeof rootRouteImport } + '/api/gateway-reprobe': { + id: '/api/gateway-reprobe' + path: '/api/gateway-reprobe' + fullPath: '/api/gateway-reprobe' + preLoaderRoute: typeof ApiGatewayReprobeRouteImport + parentRoute: typeof rootRouteImport + } '/api/files': { id: '/api/files' path: '/api/files' @@ -2826,6 +2846,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCrewStatusRoute: ApiCrewStatusRoute, ApiEventsRoute: ApiEventsRoute, ApiFilesRoute: ApiFilesRoute, + ApiGatewayReprobeRoute: ApiGatewayReprobeRoute, ApiGatewayStatusRoute: ApiGatewayStatusRoute, ApiHistoryRoute: ApiHistoryRoute, ApiIntegrationsRoute: ApiIntegrationsRoute, diff --git a/src/routes/-root-runtime-guards.test.ts b/src/routes/-root-runtime-guards.test.ts index 4f553aba28..6a98a5cd1a 100644 --- a/src/routes/-root-runtime-guards.test.ts +++ b/src/routes/-root-runtime-guards.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { - unregisterServiceWorkers, + registerAppServiceWorker, wrapInlineScript, } from './__root' @@ -12,18 +12,18 @@ describe('root runtime guards', () => { expect(wrapped).toContain("console.error('Inline bootstrap script failed'") }) - it('swallows getRegistrations rejections', async () => { - const getRegistrations = vi.fn().mockRejectedValue(new Error('boom')) - const unregister = vi.fn() + it('clears old caches and registers the network-only PWA service worker', async () => { + const register = vi.fn().mockResolvedValue(undefined) + const deleteCache = vi.fn().mockResolvedValue(true) await expect( - unregisterServiceWorkers({ - serviceWorker: { getRegistrations }, - cachesApi: { keys: vi.fn().mockResolvedValue(['stale']), delete: unregister }, + registerAppServiceWorker({ + serviceWorker: { register }, + cachesApi: { keys: vi.fn().mockResolvedValue(['stale']), delete: deleteCache }, }), ).resolves.toBeUndefined() - expect(getRegistrations).toHaveBeenCalledTimes(1) - expect(unregister).toHaveBeenCalledWith('stale') + expect(deleteCache).toHaveBeenCalledWith('stale') + expect(register).toHaveBeenCalledWith('/sw.js', { scope: '/' }) }) }) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5e3844f582..3b923658dc 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -220,11 +220,7 @@ export function wrapInlineScript(source: string): string { } type ServiceWorkerLike = { - getRegistrations: () => Promise< - ReadonlyArray<{ - unregister: () => boolean | Promise | void | Promise - }> - > + register: (scriptURL: string, options?: RegistrationOptions) => Promise } type CachesLike = { @@ -232,28 +228,25 @@ type CachesLike = { delete: (name: string) => Promise | boolean } -export async function unregisterServiceWorkers({ +export async function registerAppServiceWorker({ serviceWorker, cachesApi, }: { serviceWorker?: ServiceWorkerLike cachesApi?: CachesLike }): Promise { - await serviceWorker - ?.getRegistrations() - .then((registrations) => - Promise.allSettled( - registrations.map((registration) => registration.unregister()), - ), - ) - .catch(() => undefined) - await cachesApi ?.keys() .then((names) => Promise.allSettled(names.map((name) => cachesApi.delete(name))), ) .catch(() => undefined) + + await serviceWorker + ?.register('/sw.js', { scope: '/' }) + .catch((error: unknown) => { + console.warn('PWA service worker registration failed', error) + }) } function RootLayout() { @@ -315,7 +308,7 @@ function RootLayout() { handleOnboardingCompleteChanged, ) - void unregisterServiceWorkers({ + void registerAppServiceWorker({ serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined, cachesApi: 'caches' in window ? caches : undefined,