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 2fcda50..c6113e6 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..9eaea03 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..2a7d3c6 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-192.png b/public/icons/maskable-192.png new file mode 100644 index 0000000..b92bc7d Binary files /dev/null and b/public/icons/maskable-192.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000..1182ff7 Binary files /dev/null and b/public/icons/maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..f3512fd --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + ] + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..6fcfaa2 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,92 @@ + + + + + + +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 ( + <> +