From dbfe48796b9121dad87d3cf3fd365fc19c163cbd Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 17:12:16 +0100 Subject: [PATCH] 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. --- .env.example | 5 + AGENTS.md | 16 +- bun.lock | 26 ++ db/schema.ts | 88 +++++ db/types/db.ts | 74 ++++- package.json | 2 + public/apple-touch-icon.png | Bin 3732 -> 3398 bytes public/icons/icon-192.png | Bin 0 -> 3760 bytes public/icons/icon-512.png | Bin 0 -> 14191 bytes public/icons/maskable-192.png | Bin 0 -> 2727 bytes public/icons/maskable-512.png | Bin 0 -> 12280 bytes public/manifest.webmanifest | 73 +++++ public/offline.html | 92 ++++++ public/sw.js | 206 ++++++++++++ src/bots-queue/push-bot-notice.ts | 18 + src/components/general/pwa-init-script.tsx | 67 ++++ .../general/service-worker-registrar.tsx | 134 ++++++++ src/data/app-data.ts | 14 + src/data/pwa-data.test.ts | 63 ++++ src/data/pwa-data.ts | 77 +++++ src/dict/env-dict.ts | 14 + src/dict/push-dict.test.ts | 35 ++ src/dict/push-dict.ts | 41 +++ src/dict/settings-pages-dict.ts | 6 + .../db/push/delete-push-subscription-by-id.ts | 18 + .../db/push/delete-push-subscription.ts | 18 + .../backend/db/push/grab-push-overview.ts | 32 ++ .../db/push/grab-push-subscriptions.ts | 20 ++ .../backend/db/push/is-user-present.ts | 24 ++ .../backend/db/push/mark-push-subscription.ts | 36 ++ .../backend/db/push/save-push-subscription.ts | 60 ++++ .../backend/db/push/touch-user-presence.ts | 33 ++ src/functions/server/pwa/handle-pwa-file.ts | 53 +++ src/hooks/use-connection-epoch.ts | 33 ++ src/hooks/use-install-prompt.ts | 107 ++++++ src/hooks/use-online-status.ts | 26 ++ src/hooks/use-push-notifications.ts | 245 ++++++++++++++ src/layouts/auth/index.tsx | 2 +- .../bots/(data)/rail-drawer-context.ts | 17 + src/layouts/bots/(partials)/bot-row.tsx | 6 +- .../bots/(partials)/install-app-modal.tsx | 70 ++++ .../bots/(partials)/offline-banner.tsx | 24 ++ .../bots/(partials)/rail-add-button.tsx | 2 +- .../bots/(partials)/rail-back-link.tsx | 6 +- .../bots/(partials)/rail-bots-section.tsx | 2 +- .../bots/(partials)/rail-drawer-button.tsx | 23 ++ src/layouts/bots/(partials)/rail-header.tsx | 6 +- .../bots/(partials)/rail-pages-list.tsx | 2 +- .../bots/(partials)/rail-section-label.tsx | 4 +- .../bots/(partials)/rail-see-all-row.tsx | 8 +- .../bots/(partials)/rail-settings-aside.tsx | 2 +- .../bots/(partials)/rail-settings-link.tsx | 6 +- .../bots/(partials)/rail-settings-list.tsx | 2 +- .../(partials)/rail-settings-record-row.tsx | 4 +- .../bots/(partials)/rail-user-menu-action.tsx | 49 +++ .../bots/(partials)/rail-user-menu.tsx | 37 ++- src/layouts/bots/(partials)/rail-user-row.tsx | 8 +- .../bots/(partials)/rail-workspace-list.tsx | 2 +- .../(partials)/rail-workspaces-section.tsx | 2 +- src/layouts/bots/(partials)/workspace-row.tsx | 6 +- src/layouts/bots/(sections)/bots-rail.tsx | 72 ++-- src/layouts/bots/(sections)/live-socket.tsx | 13 + src/layouts/bots/index.tsx | 67 +++- src/pages/__root.tsx | 17 + .../[bot_id]/(partials)/bot-profile-modal.tsx | 6 +- .../(partials)/composer-attachments.tsx | 2 +- .../[bot_id]/(partials)/composer-tools.tsx | 2 +- .../bots/[bot_id]/(partials)/composer.tsx | 7 +- .../(partials)/conversation-header.tsx | 34 +- .../[bot_id]/(partials)/message-thread.tsx | 2 +- .../settings/(partials)/computer-list-row.tsx | 12 +- .../(partials)/settings-pane-header.tsx | 7 +- .../settings/(partials)/settings-shell.tsx | 2 +- .../(partials)/notification-device-row.tsx | 46 +++ .../(partials)/notifications-device-group.tsx | 99 ++++++ .../notifications-devices-group.tsx | 62 ++++ .../(sections)/notifications-section.tsx | 61 ++++ .../settings/notifications/index.server.ts | 27 ++ .../admin/settings/notifications/index.tsx | 16 + src/pages/api/admin/push/overview.ts | 41 +++ src/pages/api/admin/push/remove-device.ts | 43 +++ src/pages/api/admin/push/subscribe.ts | 49 +++ src/pages/api/admin/push/test.ts | 54 +++ src/pages/api/admin/push/unsubscribe.ts | 42 +++ src/pages/auth/logout/index.tsx | 2 +- src/push/deliver-to-user.ts | 23 ++ src/push/notify-bot-face.ts | 65 ++++ src/push/notify-bot-message.ts | 81 +++++ src/push/send-web-push.ts | 68 ++++ src/push/vapid.ts | 102 ++++++ src/schemas/push-schema.ts | 36 ++ src/scripts/generate-brand-assets.ts | 309 +++++++++++++++++- src/server.ts | 18 + src/styles/main.css | 3 + src/types/index.ts | 21 ++ src/utils/describe-push-device.test.ts | 32 ++ src/utils/describe-push-device.ts | 49 +++ src/utils/url-base64-to-uint8-array.test.ts | 21 ++ src/utils/url-base64-to-uint8-array.ts | 18 + src/websocket/index.ts | 13 + 100 files changed, 3573 insertions(+), 117 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/maskable-192.png create mode 100644 public/icons/maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/offline.html create mode 100644 public/sw.js create mode 100644 src/components/general/pwa-init-script.tsx create mode 100644 src/components/general/service-worker-registrar.tsx create mode 100644 src/data/pwa-data.test.ts create mode 100644 src/data/pwa-data.ts create mode 100644 src/dict/push-dict.test.ts create mode 100644 src/dict/push-dict.ts create mode 100644 src/functions/backend/db/push/delete-push-subscription-by-id.ts create mode 100644 src/functions/backend/db/push/delete-push-subscription.ts create mode 100644 src/functions/backend/db/push/grab-push-overview.ts create mode 100644 src/functions/backend/db/push/grab-push-subscriptions.ts create mode 100644 src/functions/backend/db/push/is-user-present.ts create mode 100644 src/functions/backend/db/push/mark-push-subscription.ts create mode 100644 src/functions/backend/db/push/save-push-subscription.ts create mode 100644 src/functions/backend/db/push/touch-user-presence.ts create mode 100644 src/functions/server/pwa/handle-pwa-file.ts create mode 100644 src/hooks/use-connection-epoch.ts create mode 100644 src/hooks/use-install-prompt.ts create mode 100644 src/hooks/use-online-status.ts create mode 100644 src/hooks/use-push-notifications.ts create mode 100644 src/layouts/bots/(data)/rail-drawer-context.ts create mode 100644 src/layouts/bots/(partials)/install-app-modal.tsx create mode 100644 src/layouts/bots/(partials)/offline-banner.tsx create mode 100644 src/layouts/bots/(partials)/rail-drawer-button.tsx create mode 100644 src/layouts/bots/(partials)/rail-user-menu-action.tsx create mode 100644 src/layouts/bots/(sections)/live-socket.tsx create mode 100644 src/pages/admin/settings/notifications/(partials)/notification-device-row.tsx create mode 100644 src/pages/admin/settings/notifications/(partials)/notifications-device-group.tsx create mode 100644 src/pages/admin/settings/notifications/(partials)/notifications-devices-group.tsx create mode 100644 src/pages/admin/settings/notifications/(sections)/notifications-section.tsx create mode 100644 src/pages/admin/settings/notifications/index.server.ts create mode 100644 src/pages/admin/settings/notifications/index.tsx create mode 100644 src/pages/api/admin/push/overview.ts create mode 100644 src/pages/api/admin/push/remove-device.ts create mode 100644 src/pages/api/admin/push/subscribe.ts create mode 100644 src/pages/api/admin/push/test.ts create mode 100644 src/pages/api/admin/push/unsubscribe.ts create mode 100644 src/push/deliver-to-user.ts create mode 100644 src/push/notify-bot-face.ts create mode 100644 src/push/notify-bot-message.ts create mode 100644 src/push/send-web-push.ts create mode 100644 src/push/vapid.ts create mode 100644 src/schemas/push-schema.ts create mode 100644 src/utils/describe-push-device.test.ts create mode 100644 src/utils/describe-push-device.ts create mode 100644 src/utils/url-base64-to-uint8-array.test.ts create mode 100644 src/utils/url-base64-to-uint8-array.ts diff --git a/.env.example b/.env.example index 2e532f1..8e59297 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ DATA_DIR=/data # Set to 1 on the one container that should run cron tasks in production MAIN_SERVER= +# Web Push: generated into Settings > Environment on first start. Set a +# fixed pair here only if you want to keep one across installs. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + # Optional public envs exposed to the client through __root.server.ts PAYSTACK_PUBLIC_KEY= R2_PUBLIC_DOMAIN= diff --git a/AGENTS.md b/AGENTS.md index 10f6155..caa8073 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w | Production | `bun run start` (`NODE_ENV=production bun src/server.ts`) | | Type-check only | `bun run typecheck` | | Run tests | `bun run test` | -| Regenerate favicon/brand assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | +| Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | | DB schema (tables) | `bun run db:schema` | | DB admin UI | `bun run db:admin` | | DB backup | `bun run db:backup` | @@ -116,10 +116,20 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). -- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL` and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens. +- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens. - **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`). - **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back). +### PWA and notifications + +- **Generated assets:** `public/manifest.webmanifest`, `public/icons/*.png` and `public/offline.html` are generated from `public/logos/logo.svg` and the theme tokens by `bun run gen:favicon` (`src/scripts/generate-brand-assets.ts` reads `src/styles/main.css`, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons. `src/data/pwa-data.ts` holds the shared paths, icon list, start path (`/admin/bots`) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS). +- **Head and install:** `src/pages/__root.tsx` `Head` links the manifest, sets the apple/mobile meta tags and renders `PwaInitScript`, which captures `beforeinstallprompt` before hydration (twui's hook reads it later), points `theme-color` at `--c-paper` (following the theme without repeating the color), and rewrites bunext's fixed viewport meta to add `viewport-fit=cover` and `interactive-widget=resizes-content`. `use-install-prompt` exposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opens `install-app-modal` with the Safari Share steps on iOS. +- **Service worker:** `public/sw.js`, served by `src/server.ts` through `handlePwaFile` with `no-store` and `Service-Worker-Allowed: /` (bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only with `public/offline.html` as the fallback; authenticated HTML is never cached. `/.bunext/`, `/public/`, icons and logos are stale-while-revalidate; `/api/`, `/ws`, `/media/`, `/computers/` are never intercepted. `ServiceWorkerRegistrar` in `__root.tsx` registers it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off. +- **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding. +- **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline. +- **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app. +- **Risks and notes:** push needs HTTPS (or localhost). Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap. + ### Harness (the model layer) `src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`. @@ -149,7 +159,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings - `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` -- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry +- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/bun.lock b/bun.lock index d409741..052d3d2 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "tailwind-merge": "^3.6.0", "turndown": "^7.2.4", "unpdf": "^1.8.0", + "web-push": "^3.6.7", "xlsx": "^0.18.5", "zod": "4.1.12", }, @@ -46,6 +47,7 @@ "@types/react-dom": "^19.2.4", "@types/sanitize-html": "^2.16.2", "@types/turndown": "^5.0.6", + "@types/web-push": "^3.6.4", "tailwindcss": "^4.3.3", }, "peerDependencies": { @@ -340,6 +342,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -394,6 +398,8 @@ "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -404,6 +410,8 @@ "array-find-index": ["array-find-index@1.0.2", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -412,10 +420,14 @@ "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "body-scroll-lock": ["body-scroll-lock@3.1.5", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], @@ -520,6 +532,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -652,6 +666,10 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], @@ -714,6 +732,10 @@ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "launder": ["launder@1.7.2", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="], @@ -892,6 +914,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1206,6 +1230,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], diff --git a/db/schema.ts b/db/schema.ts index 590cf36..f872d8b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -2077,6 +2077,94 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, ], }, + { + tableName: "user_presence", + fields: [ + { + fieldName: "user_id", + fieldDescription: `The user. One row each`, + dataType: "INTEGER", + unique: true, + foreignKey: { + cascadeDelete: true, + destinationTableName: "users", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "user_presence_user_fk", + }, + }, + { + fieldName: "last_seen_at", + fieldDescription: `Unix timestamp of the last open socket or keepalive, for skipping push notifications while the app is open`, + dataType: "INTEGER", + }, + ], + indexes: [ + { + indexName: "idx_user_presence_last_seen_at", + indexTableFields: ["last_seen_at"], + }, + ], + }, + { + tableName: "push_subscriptions", + fields: [ + { + fieldName: "user_id", + fieldDescription: `The user the device belongs to`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "users", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "push_subscriptions_user_fk", + }, + }, + { + fieldName: "endpoint", + fieldDescription: `The push service URL the browser gave. Unique: one device, one row`, + dataType: "TEXT", + unique: true, + }, + { + fieldName: "p256dh", + fieldDescription: `The subscription's public key`, + dataType: "TEXT", + }, + { + fieldName: "auth", + fieldDescription: `The subscription's auth secret`, + dataType: "TEXT", + }, + { + fieldName: "user_agent", + fieldDescription: `What the device said it was, for the settings list`, + dataType: "TEXT", + }, + { + fieldName: "platform", + fieldDescription: `The browser's platform, for the settings list`, + dataType: "TEXT", + }, + { + fieldName: "last_used_at", + fieldDescription: `Unix timestamp of the last notification sent to it`, + dataType: "INTEGER", + }, + { + fieldName: "last_error", + fieldDescription: `Why the last send failed, if it did`, + dataType: "TEXT", + }, + ], + indexes: [ + { + indexName: "idx_push_subscriptions_user_id", + indexTableFields: ["user_id"], + }, + ], + }, { tableName: "envs", fields: [ diff --git a/db/types/db.ts b/db/types/db.ts index 96c4808..eea8fce 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -36,6 +36,8 @@ export const BunSQLiteTables = [ "bot_custom_tools", "workspace_custom_tools", "logs", + "user_presence", + "push_subscriptions", "envs", ] as const @@ -1262,6 +1264,76 @@ export type BUN_SQLITE_COBBLE_LOGS = { ip_address?: string; } +export type BUN_SQLITE_COBBLE_USER_PRESENCE = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + /** + * The user. One row each + */ + user_id?: number | ""; + /** + * Unix timestamp of the last open socket or keepalive, for skipping push notifications while the app is open + */ + last_seen_at?: number | ""; +} + +export type BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + /** + * The user the device belongs to + */ + user_id?: number | ""; + /** + * The push service URL the browser gave. Unique: one device, one row + */ + endpoint?: string; + /** + * The subscription's public key + */ + p256dh?: string; + /** + * The subscription's auth secret + */ + auth?: string; + /** + * What the device said it was, for the settings list + */ + user_agent?: string; + /** + * The browser's platform, for the settings list + */ + platform?: string; + /** + * Unix timestamp of the last notification sent to it + */ + last_used_at?: number | ""; + /** + * Why the last send failed, if it did + */ + last_error?: string; +} + export type BUN_SQLITE_COBBLE_ENVS = { /** * The unique identifier of the record. @@ -1309,4 +1381,4 @@ export type BUN_SQLITE_COBBLE_ENVS = { expose_to_computers?: 0 | 1 | ""; } -export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS \ No newline at end of file +export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS \ No newline at end of file diff --git a/package.json b/package.json index 54cddf9..3d3e2d1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/react-dom": "^19.2.4", "@types/sanitize-html": "^2.16.2", "@types/turndown": "^5.0.6", + "@types/web-push": "^3.6.4", "tailwindcss": "^4.3.3" }, "peerDependencies": { @@ -61,6 +62,7 @@ "tailwind-merge": "^3.6.0", "turndown": "^7.2.4", "unpdf": "^1.8.0", + "web-push": "^3.6.7", "xlsx": "^0.18.5", "zod": "4.1.12" } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 2fcda5016f08de0a3256cf2da2971386cbb9d65b..c6113e655ceb51d76c016189e775605e4707c398 100644 GIT binary patch literal 3398 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 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9eaea03b200e448225890cd4345a973c034cb628 GIT binary patch literal 3760 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2a7d3c6362920dcf7049d695c37312d4d6ec5ea1 GIT binary patch literal 14191 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 b^h?USNyRqb?(p{m_|Ng9r_|maG4cLCA-vlU literal 0 HcmV?d00001 diff --git a/public/icons/maskable-192.png b/public/icons/maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b92bc7d272dffe1e04f16aa893120074e5081006 GIT binary patch literal 2727 zcmZWrc{o)2A3kHo%wWcrWl}PeU5r7ut1#Jx>_j4DNk!QPWgB~j(n8^?LD{Y>S+d3m zbuFnh!%t)@vRwP+$};XbbAQkC`{Vb=`JVUrzMtp)yyttKb3Sj1wWSG`SArJ+0Ica5 zLtF69fG#u=Ty01FP~eRx;LQ180D#Xz7pyMA_8I{2o;Nkrvk%W*d@_E+-dp0`PreiP zQ#WW96LA)!ULWH=84?5F7qhsU#cgYC(S+wEh*Z9p!_<^(XQT@VNqIcyu{J3w4+QTB zJzEM5b3Jc#T=7^}2D(4pviNgoPi}UY&sgf^;C|w8o@i32B|WTvr*UgckZ4I?{~0|_ z|B%i7$^&5oU$}x5^F0j|AjRMSOwSz$qf+rzXJf&Y^n!zx<6=SlCl?H4hmqV|af|?d z0D*{|#$(WEt`1X@pdjK=nV_z&Aa03M)QCZW^yCQzh*wV<__3u zw>TPyk;8RH%WXgCsBiZ9WJj(W@w6VvFfM$~|IpvSgDNIkeQVVvW2sSos0+UPzIN`q zB1W6wc>1Qvyd5{C%I`Ncye~g|c{ zEW4poGR9Onn*Fioh2hWL23&iYdQWSFjzsA8#1}K$Ywn`qqb0g^w2ZNc#p00+9_xm9 zFQPyhyhH`FqLIim`N!Wk>`}j*qg37&TP4#K-Khs@!bFMBznz0&F=(wceWeH zSo1(rL5k;xn>EChXheyN=CR@OW32pOi>{7{&KzAyYmA_$kdk<7#-ESA`v<-<`d zd?SvVOrL+STRLMLhpuBcnL9bKy!>^>#!T zU?I{TxvyeJ$DOs7JZPIN4nJ;_@bYqfsl1P{Xym#8C0vbRL9-_Bl%7&Bb)dpV9oci( z`8*j17&1V6uWido@`21T25`4{ztx0AFs+|!)@~p`fJy63g5w6Aj7lv$qUi^S6CsDICG*X%{j^toxNsOBaiL0SF z#WRMs*R@}_&Of){q`dHmNULr3Ou>Y*ea2wS=(_;Cp?KG>EqyU@zltE+yzH!8uhQZg z9XO&|X}5L5ilUstwi=yiTssUltQ^+at#xxz)lOJ?$JlcsLWK%h4jfK6=cg@6NO5prtPp}t>>iN5) zr+=h?c*c8WvN*BDl|6XVWQr~w4;!_OQkPojy?!F0QQQ#{&Yl6e$C8ujK8z5XjZ%D?3^R-7))0G3MDmSf&) zQCq>6#DCCzmOih$RRm6e_;eVF`b1hQWftYm+lh9MI>Cq}#@Wym@1>9ADRfCN99YF~ zfefRna4l}yjuOJ`CExd^pmZIe#4nykm)7kY%D15jF@7ZBGTs2>dcy({24fkUee! zrB0%*4gtG{!glzD8rar-Z478iS0P$1)2M1=7}&Kwfvh4Q!rMo?2}EpyChCz2F{^4w z1~63)E3lcE!)8(ANvnfE`*52(fWePR;FUB9k5!ujn)IO>0k`j|^ ziw~Y&W{?W=IiE1l?Rb!TMsuUoD(!b@=R(3Q2VS_9d)zQ?;y!%i`TEj= zD8*V$7h_c7vSnL+VW|t@s?l*ey2R=Q50lUZsEOqVO)5+44V~?7lV4ql2o{dia8P}c z{%Xtlrt_En9L-Iaf?^$rvy~0JT`m$CaBgFu!9IzcJ~K6`pvE3pnjI;eWGXiM zv#jdROSRg8$NuooyT5$eNc;bRTKV^qKVKJmf?=+U1T%nfRT==Lf+8pzI1O^JSUZG6 zIWfjB9-8qdQ2-QO1d(7In3jQJM~4F%iWFg-B9|m6l>{9Ufe4B(MC@^tBrZXLC?o+2 z6TT@3uxQ`_2JuMZqlf_F?SjOL0~&RaOC1NGNpao?s0E3e3tX#_5UxTaAAwxiYC<$LqTOEk3$&1L1K<${G%Wo)F_mOGM9n53{>>g y9bd(y7KP(U7l){o`kE`k_u2tns?ZC4EE|pz5UB|a=rjbACtzx1X;`7}9`_Fqs-Q&x literal 0 HcmV?d00001 diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..1182ff7383bed70e6bcfdca67057ba86ab3e5a1a GIT binary patch literal 12280 zcmeHtc{J4R|M!PsQjxk<#5Z(zmo`yIS*O&U7E!2V8%bnolEm1Cl!`>TOUPEqlC31W zA*o~;StENf_HD?_n3>OWeLgcy?(cJ-=bYa;&pFTY$K5#{%v{&|dSCDDdcBw1M#m0{ zEswi*yg&8~=ZFFmSoIA68XGB@6> zZsaD#@OFDQl^9Q|{R7gB)#!l@FmwVViia>#Ww;HHzyAvK zR|vahD;;sC&65SKDF>-!Jm5sME~%HaAhLs1_)EN z5%-Im%S#q)S#V|=P3;n3R0Cd)UI;gg7ZdV_4CK|gEd8g<`S|DJ5K!7iK zX9;<}u;n!u$oJ!2fonWv;S9w(Fnq!#!LUM*S>2b=|e#vb6e$qVg#2S0SAXjo(B zApZoKI5(C(Th{WgVnJ~HA4S8-3DFwLKt_x#B}bg4Y6uUuXc4Q`X>@l|e4fSBkQVa> zxoUm9jy8cw-%Oy|nHJ>ekb} zT2o_L{ZaFzaH_(tZ~l__1bgJ+O7I54=r1@Z_c5ZIeNwh$hCr()ei#U72=rQ^c8PUg z%cm=t6a2nutaV^GkG!Q@;zXd8w`0#L^cK#x3DQ1)82ZA@;{I1*)I^nKN;=n{UZ_Y@ z@2{1Nk5M2L70TV!(u`XxYJx=cz3bPcoSz<5B0c_)$+D2upg!uuytyEK#@9r5TP^GR zkB*qwZQX|805?axFo?@3=4yimcL!37F4n}rlx)YUbP5I&Wp zDpt-Pbqv4Oiu!>gNo+X4M4Ww3?FFsplU@H!yRYsIul&k}Zw3s#>|-ma=?gf?LqZOM zxp`PO@$OdZCw`w)W?y^S^z^ySCo2qxK1hm^SJU6?$X)aTJDa5bt<`G!-5wu39TTzP z>bt{7g5TT~T}i#(B)=*%0D|83Eq^nV#r~Wt=lr37^=9N<(n(=KEfteh8Q{x(;=RX| zjV83>3)#_}HS9jU(5orf3c1twlE?EiNl$;*?OBwn$9-(03nEB;16e?ZoSGh&Lz7>! zhqJY=*0yWuzInOZ`Wg1Z3cwQ-+NQM#JCG+zeMzu5XKWqgK0K}Uqn5H%uy{va304p& z#Yhhet2xnv8b=2v?i}0m8g72as12-StXD%H)$+dhZaMP65w?wykNokMs{)wc{pW?~ z1V=NmbVM$~>3h{0$%InQKuP&lpP4nMXWw&5!=gj`eC|u5#&*rJuB=Ae<9ni8m(8RM z%do1fzTm?^RD2dyyI4WJ1bY%e4riMZr0E8*bACBhi8E${|DIz%kP#TRlKQZT3!OCI z;|S+}53^(od(6nNDB7&p*XiVg&(t-08Q;naE~sS-E>Bo&l8K#7_Vf*EwPtC`_ytk? zyyF;^opE-4Et{|iEJ~=%DrdbL;H=0B_!fD3S`xat7=!sB*F;{@eqf)LqS`Z5AoYDM zcxIO-t$v8GgD%Dqw!shHsfedfC)k^ddA;OXgJNG1XR9tX%P)|0{3K?Wf-BPWCnuG0 zHM6=DnlP<*Z50jb@+ zJz9Phx-(`o>9nGiDO^V0y3Bm_x#z~dW_YJZ<<)ujIJxfcL@II5VH6Q0+%TK&zSOd! z(jdO6XU=MIy3Q7gwh!4pZlYg=%g9w1vFGE3FG+htdY$M&=kWcnwA??q$9Z=}9s$-5 z*?61O&Ui2+)jdxHj^PtXhZCZ=!yQKBj$HDwS$o+R-5V7QRXneU{kbE1=&dQcWQ&%+ z_(lN`26_rqj7Z^0oPFjOR20;{tiT3Q%f31cGiEc$iehQQ#}=~xV@;19#DtGR$a^+sj@09H!i;DAiL`ZMji22q^iqf6@xDC9Ljep|l zGR2l+*86Zo_>kg*Rfy5pX9n28(|Wf)wcyWoo?-}P)qv2XVJ8&P%?RqXg*@T{ybS$A z1NzyUa*v|M99M`n9%*eGBhWqk=mcAQ4}HU3k?CsW0EtM}ZYqJk7-x94=UozUpsOfY zH{K-o3D*n!$k^EjDYF?4HP%pB8R>jNDf?BaU<_fh4gmQCgFmy~E64?3yL;5>dEdSY z=*XLNWu2(7LjvF6ZRw`?18X^DO9qr6JYG{uN@L=SmafiQT~CMA5U$=~{h(bk$cHTqUl=i{QM!E zNNITlq!&Kxn(>s?n0G)$BCtjZJRcn~QJXl$#;XdsyAM6JW(c7-6A`@w9^uM(kF2{XBmr-8jDx`o-5VwJJd zW&Xf<;@UzspoG$*=KYL1n)n-x&u6yG*;^g(fkQiw$ zM}Ci_x1fYlUOKhxw5pKAh@C}tbn|wsSBEon(()6z(iy*5GH@7V!PlQ(81RdMMP+-R zUJyMkuxTQ8^CKY;x*VeaZ1u~|kK)U+7nS9qVFMS}2{L#Sc7=nqwV$mb9D$goK|@ol z`bzbkAg30Al1h|wafm`x53P^aj$lZaNjb=4PC}pN?U!(c_N42(gRDhCdj35g{hO)6 zDE-aROwR0`*2zko-kNMdqb@z3<*W=%H?!<`#~03TsBna4&nrxqRXv2oB06zN%Q1XkVOIU&fPLORv`QKLu)oHx?@73!H(>}7e!)z8i+3o%c~othve%ZeoUh}I zYpo@!x?UEH?iH3{Yd!FswX7oNTH$f&dUI`CVdu&hA#mKsBWzM3#Y)qXs_VsBO4~sA zl0~1my%8`M&tvY@MnXwpzUzaAZxeeq#m`6kdU(mRGk<*b4dXanZy+m153^S)fxj%z zjLebGcl(JSf!}QU4Vcpft~vegMB5;@{h~#Z31Row2bc>nRx@uqY4D!&a35JI*w3?u zL(CfcV*GMFGe+-+B4_@IKD22fnQJJw(gCX$5MuU1+0mU)giQ%dj)YsT?20XFafcm1|x9i#k(M~q!y_+2;LRzQ>If% za=H+k4Rw;`pOb8%~a`GpJC-Gy%~1$l`N`L>hujeHh!TE zQC~7oeNVh{BDj&L<*Xz7^Slz%Xn5GkKd)r8@_?i7oMvHO`zr$=G;5yF_Sb4Najq3- zoGm#6k&5HLJ7oFnJ-ZOt$}2{8xIPItOHeeW7Jf(~kv9Kr ztyk5-V40S)Bc$RtHA`?Aq>=haLAgDd9ZPT}jO>{Sr48VoSwHW&%g@;k;oFxptF(3J zUsvI$ox$*r%9c!eVc*@skhPq{3E;6i#SK}@kL%h0$EqWHwfV?hPrG}GGYa#grl=qd z*GHzz8XPQrMJN1?{^PVLt@@Yi{d4`;;}{xk3CqrF;QqMp%glE-j!;OzbqHOZv9%8k z9|HF?=eeJ%%UZA3`DPCqR)lzJkL|K#Q?oX4iX_cXvD>z;E~%Ssu1F$z90FG^;fd$7 z>U_1Fr22~!g;xS}F|2dV)ZP(W*Cf*1*^!sumenY=xwsN6i$@JQePQb(z)doEZt{Fc zuFIk${cx&I7#uCB*tJ1Tl08%%42+OzO?u@SvpsS&*Ips8W#aI{O;zTZ+b*8mFgu&K ztuEAmQM2Aq{`j&aW**rqt8%gVsnR6xEVDQyPUZ1r9Q{7i%p4q(#=+O@Q0n&HyQqN{ zmuJqQt}iTF?;De;&057aoZWsd1MXYZIp6Zu_|U@RK36Mkx_B~acCtvk=4P=cMOfxA z>_+s6Vp4q_2Tg!a&_Q&4@94hB&@0t^w;(b?JJ|09KwoLyU5BZYI z6m8}l#bd~gtofqwq!cHk-wLKox^EaUCO3+Gh_aQ(H>O#pFD+(_+5&xp1VcBYHS z{}(}qlo2s!Bgg{U=JU<}&HRY}$fC7dsTX z@EsErzfN_e^O4bI1Q{(iFyBrNM}gOxzcG7(6|J;NeJdZPSs*ZNf8%$L(sBeHzrKTj zx?iz^T@e%VzboN~HR5^(P|(doN&B4qwu@(a*}BrWmenEdJ$4^a(dS%7pBw6eL1@DB z6EhZWBow6O6Z!hFL-m7#Vl;jE8z|oBFqpnRL`I4)@juX9BtbFK`Xgd1s*e!M@>S)f z3-vXWQtOT~KIgw9oYy0Gp4PMZ=1#mG$am08O^z3$j8#Dq%T>3?kt@k2q%S8uNqrvkw zSp|q76K_iwomZr(`R}NgM-9D}Yv`)S%@1OydloN}R9EA=DO*|11kI*QW0W*vP{oR% z_IRF`O>K!0sE*M{4FKVGsj(i)%~N$uoOk-Vb0Rhg0%-V);)cJ(s%X}DUa-usEOryB zCy>RSlht4=Blal28#Nm{9`(#AQt9jb0=^m8pxh5d%Y7Z3hp^cK|9OnYjGbJ~y3~Jp z5Wn@}CBFHFBj)Q6h}4nGjU!bM&F)3TslXHGYBS+YglVLHN>n;3b`Q~#%V%MstO^Jc zk2?fr0a%0KB@i}z`EP$A2WkI2=zf^c19h(x7=RumpJVlr%xqVxdTu3mEjK&e!D9*u zaK}4Ff12A$H0-@LC~?#fkM&hWV$gkqy0ew3B!j5pL z74tY!rb>V?<{VdpDQbLLIG3ZP(V*xXgcHC)r_cRUndwQ}e2e!gc_9&jkaswCy9=mX zhv?3k+yLDKa9hiU!BaXHAQ1tn(?ZzyoH_dt-3b#=AVt4B;k!1~>6#dBor4W1j?I1H zEPmWT(KJm&OWgZfRp)yshz-jKUW2f!uLV4~`Mu!ggc9em@v2gwX#b5@j!TS`eqsqj zqT3*hggR^tYIbRVf=3i+@tC9E_ONs_7v$(KH$?BG@5zCkSCL9Lt0!{zG97x%4oV}^ z`sbpcU9FBM0e-$yuYUQvvSZ4Z8g@>wF?2IWw6HN)_(RDF8#ApF|A$-psWu%(D$0b&g!#dIY zQ5s;2dLgeTSeir(ZF12`DTFOhp-!aTtp#l|R^ZL^pyZvQd@VzUqewXI{s<)!6$B66 z?9b2(TTQY`b-coDhp_#`&dV{0vRaH>tp9TVfMh(r=er%dQN<_F1F4(4s9pngZ;E@l zpg%Nu4tvZZ+J3y`1)X-tbSt-oHJIw8oa<*PyW8*fI(ZR%0!-}QKxB>+XiMXwgrVhE z-im;T9t^G?YYz&`fz#hzPDzqvQyopY=WDx(z0V@2(=a%Z!K)%Ex^a~Xo*?Gq>JO}c z1lobF5*yVvm6y?Ht+#Vn$D2QKZ~mL$^`*UUC#g4pr~+`4Goqer@kUqsW8rrOrzQny z^5)1ug=fQiTr%d9_~tVSy>R3aXZklr8<^*iVi{JH%`js+B=916O4lFfHyzh4jUN+$ z0(!}7BZZsuo~%-LIT2`CURIS=+c?)XR6u2$L4e+uW!Up_tOCL!5&4Y#uVhf?o|r26 zz7pvi{7}hupS-7aW&CBO!Q&?UK`AL6!VU5HNft6BA<9{M4)5n<+%;3xC~BjZe_YhL z@RBL4@})RzE=dp>hnZ}*)oDH|TQu!fX%O3l_m=u{x$T2n*58^Nmga#-moQMF?LV7t z35or_HL^cdGn3XqE42W%VMxS0mFYbMYSmAQ+ajb{%P=7Ne**nKYZgNu_$991aeYd6 z#>LV>3yi+V;&M>4ZU()Pr1+&L0%u?+JaCDzUDp`}TCTL#dCBy@WTB?d(vL4)k|p=b zF}JNI$?A)F!N)NIWth)Vrv&xv;qRAccob52^Om;*_KT8I$1$4J=&3@m`VbHAFtn)`oteTX@w@EFxZt-BZ zV}M2yprNl(wD%J5i8s>A@p#aB9}bR%K;3%39*!V~>?`$}4lQ zvylq+ex+Ts_ik>DKym=$ZNiIzESmVWnLY73Qg-*sXV@2AU&tnD=b!G5e}>gYsq9@d zXy7tL{r+z?XtJ{k$sW(+Tfe_)21x+uY_gi_@*=2Wcr6v29e5OxLow+I+R)#wxsGaXTFEo zq8^yP*5+JAS_Sik*7oVmu+Q<_O~4q+25i1Y$t!C{C0`_O6p!_Ao)Y*|^JHMJc=|_U z4~s}|H{&>AzehzTXjMIBty&71dD2?goWyBXUM8!5%%Yu4OC;HoDKo1{S5vltw-EA? zEw!vEYcU9;PB|%6fd;>sU;XC^I2Uduj7zz){50sr1W0SqD~qdMi>WE*7N*nF zHZ_V*P=LTJ-s`XcMgb>027sFB!6edGvJ*Z*uZX$muJ!$!ps9FJiR})bbxYm34I*bc=Lc8&?r$?u++_QpqpY&MgDoLh*PwvzT#ToO+u;Qlxai zfm*sR+j4r_%5a8A^eHQA1pN;DsRi>CGw|82nJMGA{G0M8cJKZ_O;XIZs@x5TP$9jv7egLzAJW*T3N{TqB@s48k3wz@vkVAguoy)k9jYym3W&;(`e}{U3;k(G6hg#lZjPT$z6W z8f$-U*@G1kf + + + + + +Offline | cobble + + + + +
+ + + ember-v2 + + + + + + + +

cobble

+

You're offline.

+

Reconnect to keep working with your bots. Anything you sent before you lost the connection is still on its way.

+ +
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..a594a81 --- /dev/null +++ b/public/sw.js @@ -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, + }); + } + }), + ); +}); diff --git a/src/bots-queue/push-bot-notice.ts b/src/bots-queue/push-bot-notice.ts index f6fb83f..cbbf125 100644 --- a/src/bots-queue/push-bot-notice.ts +++ b/src/bots-queue/push-bot-notice.ts @@ -1,5 +1,7 @@ import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face"; +import notifyBotMessage from "@/src/push/notify-bot-message"; +import notifyBotFace from "@/src/push/notify-bot-face"; import pushBotUpdated from "./push-bot-updated"; import pushToSessionUser from "./push-to-session-user"; @@ -29,6 +31,11 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par session_id: message.session_id, data: { event: "server:bot-message", data: message }, }); + + /** + * Not waited on: a push service's latency must not slow the job. + */ + void notifyBotMessage({ message }); } if (relay) { @@ -36,9 +43,20 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par session_id: relay.message.session_id, data: { event: "server:bot-message", data: relay.message }, }); + + void notifyBotMessage({ message: relay.message }); } if (bot) { await pushBotUpdated({ bot_id: bot.id || bot_id, user_id: bot.user_id }); + + /** + * A message that carries needs-you already notified; this is the + * face-only case (a task running into something only the user + * can solve). + */ + if (!message && bot.face == "needs-you") { + void notifyBotFace({ bot }); + } } } diff --git a/src/components/general/pwa-init-script.tsx b/src/components/general/pwa-init-script.tsx new file mode 100644 index 0000000..0903b23 --- /dev/null +++ b/src/components/general/pwa-init-script.tsx @@ -0,0 +1,67 @@ +import { PwaManifestPath } from "@/src/data/pwa-data"; + +/** + * Runs before React mounts, in ``: + * - keeps `beforeinstallprompt` (it can fire before hydration, and only + * one prompt can ever be held) + * - points the browser's theme color at the current theme's paper token, + * so it follows light/dark without repeating the color here + * - lets the page use safe-area insets and resize under the on-screen + * keyboard (bunext emits a fixed viewport meta) + */ +const PwaInitScriptSource = `(function () { + try { + window.__cobbleInstallPrompt = null; + window.addEventListener("beforeinstallprompt", function (event) { + event.preventDefault(); + window.__cobbleInstallPrompt = event; + window.dispatchEvent(new CustomEvent("cobble:install-available")); + }); + window.addEventListener("appinstalled", function () { + window.__cobbleInstallPrompt = null; + window.dispatchEvent(new CustomEvent("cobble:installed")); + }); + } catch (e) {} + + try { + var meta = document.querySelector('meta[name="viewport"]'); + if (meta) { + meta.setAttribute( + "content", + "width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content", + ); + } + } catch (e) {} + + try { + var theme_meta = document.querySelector('meta[name="theme-color"]'); + if (!theme_meta) { + theme_meta = document.createElement("meta"); + theme_meta.setAttribute("name", "theme-color"); + document.head.appendChild(theme_meta); + } + var apply_theme_color = function () { + var paper = getComputedStyle(document.documentElement) + .getPropertyValue("--c-paper") + .trim(); + if (paper) theme_meta.setAttribute("content", paper); + }; + apply_theme_color(); + window.addEventListener("load", apply_theme_color); + var dark_query = window.matchMedia("(prefers-color-scheme: dark)"); + if (dark_query.addEventListener) dark_query.addEventListener("change", apply_theme_color); + new MutationObserver(apply_theme_color).observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + } catch (e) {} +})();`; + +export default function PwaInitScript() { + return ( + <> +