Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 9 additions & 11 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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.
})
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1237,6 +1246,7 @@ export interface FileRouteTypes {
| '/api/crew-status'
| '/api/events'
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/history'
| '/api/integrations'
Expand Down Expand Up @@ -1369,6 +1379,7 @@ export interface FileRouteTypes {
| '/api/crew-status'
| '/api/events'
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/history'
| '/api/integrations'
Expand Down Expand Up @@ -1502,6 +1513,7 @@ export interface FileRouteTypes {
| '/api/crew-status'
| '/api/events'
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/history'
| '/api/integrations'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -2826,6 +2846,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiCrewStatusRoute: ApiCrewStatusRoute,
ApiEventsRoute: ApiEventsRoute,
ApiFilesRoute: ApiFilesRoute,
ApiGatewayReprobeRoute: ApiGatewayReprobeRoute,
ApiGatewayStatusRoute: ApiGatewayStatusRoute,
ApiHistoryRoute: ApiHistoryRoute,
ApiIntegrationsRoute: ApiIntegrationsRoute,
Expand Down
18 changes: 9 additions & 9 deletions src/routes/-root-runtime-guards.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
unregisterServiceWorkers,
registerAppServiceWorker,
wrapInlineScript,
} from './__root'

Expand All @@ -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: '/' })
})
})
25 changes: 9 additions & 16 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,40 +220,33 @@ export function wrapInlineScript(source: string): string {
}

type ServiceWorkerLike = {
getRegistrations: () => Promise<
ReadonlyArray<{
unregister: () => boolean | Promise<boolean> | void | Promise<void>
}>
>
register: (scriptURL: string, options?: RegistrationOptions) => Promise<unknown>
}

type CachesLike = {
keys: () => Promise<Array<string>>
delete: (name: string) => Promise<boolean> | boolean
}

export async function unregisterServiceWorkers({
export async function registerAppServiceWorker({
serviceWorker,
cachesApi,
}: {
serviceWorker?: ServiceWorkerLike
cachesApi?: CachesLike
}): Promise<void> {
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() {
Expand Down Expand Up @@ -315,7 +308,7 @@ function RootLayout() {
handleOnboardingCompleteChanged,
)

void unregisterServiceWorkers({
void registerAppServiceWorker({
serviceWorker:
'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
cachesApi: 'caches' in window ? caches : undefined,
Expand Down