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.
This commit is contained in:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
+5
View File
@@ -22,6 +22,11 @@ DATA_DIR=/data
# Set to 1 on the one container that should run cron tasks in production # Set to 1 on the one container that should run cron tasks in production
MAIN_SERVER= 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 # Optional public envs exposed to the client through __root.server.ts
PAYSTACK_PUBLIC_KEY= PAYSTACK_PUBLIC_KEY=
R2_PUBLIC_DOMAIN= R2_PUBLIC_DOMAIN=
+13 -3
View File
@@ -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`) | | Production | `bun run start` (`NODE_ENV=production bun src/server.ts`) |
| Type-check only | `bun run typecheck` | | Type-check only | `bun run typecheck` |
| Run tests | `bun run test` | | 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 schema (tables) | `bun run db:schema` |
| DB admin UI | `bun run db:admin` | | DB admin UI | `bun run db:admin` |
| DB backup | `bun run db:backup` | | DB backup | `bun run db:backup` |
@@ -116,10 +116,20 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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). 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]`). - **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). - **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) ### 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`. `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) - `/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 - 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 <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/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 <file>` 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`). 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`).
+26
View File
@@ -31,6 +31,7 @@
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"turndown": "^7.2.4", "turndown": "^7.2.4",
"unpdf": "^1.8.0", "unpdf": "^1.8.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "4.1.12", "zod": "4.1.12",
}, },
@@ -46,6 +47,7 @@
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sanitize-html": "^2.16.2", "@types/sanitize-html": "^2.16.2",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/web-push": "^3.6.4",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
}, },
"peerDependencies": { "peerDependencies": {
@@ -340,6 +342,8 @@
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -394,6 +398,8 @@
"adler-32": ["[email protected]", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], "adler-32": ["[email protected]", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -404,6 +410,8 @@
"array-find-index": ["[email protected]", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], "array-find-index": ["[email protected]", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="],
"asn1.js": ["[email protected]", "", { "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": ["[email protected]", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astring": ["[email protected]", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -412,10 +420,14 @@
"bluebird": ["[email protected]", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], "bluebird": ["[email protected]", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"bn.js": ["[email protected]", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
"body-parser": ["[email protected]", "", { "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-parser": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="], "body-scroll-lock": ["[email protected]", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], "bun-plugin-tailwind": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
@@ -520,6 +532,8 @@
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -652,6 +666,10 @@
"http-errors": ["[email protected]", "", { "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-errors": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"immediate": ["[email protected]", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immediate": ["[email protected]", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
@@ -714,6 +732,10 @@
"jszip": ["[email protected]", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jszip": ["[email protected]", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="], "launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
@@ -892,6 +914,8 @@
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimalistic-assert": ["[email protected]", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1206,6 +1230,8 @@
"web-namespaces": ["[email protected]", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-namespaces": ["[email protected]", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-push": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wmf": ["[email protected]", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], "wmf": ["[email protected]", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
+88
View File
@@ -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", tableName: "envs",
fields: [ fields: [
+73 -1
View File
@@ -36,6 +36,8 @@ export const BunSQLiteTables = [
"bot_custom_tools", "bot_custom_tools",
"workspace_custom_tools", "workspace_custom_tools",
"logs", "logs",
"user_presence",
"push_subscriptions",
"envs", "envs",
] as const ] as const
@@ -1262,6 +1264,76 @@ export type BUN_SQLITE_COBBLE_LOGS = {
ip_address?: string; 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 = { export type BUN_SQLITE_COBBLE_ENVS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1309,4 +1381,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
expose_to_computers?: 0 | 1 | ""; 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 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
+2
View File
@@ -29,6 +29,7 @@
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sanitize-html": "^2.16.2", "@types/sanitize-html": "^2.16.2",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/web-push": "^3.6.4",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
"peerDependencies": { "peerDependencies": {
@@ -61,6 +62,7 @@
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"turndown": "^7.2.4", "turndown": "^7.2.4",
"unpdf": "^1.8.0", "unpdf": "^1.8.0",
"web-push": "^3.6.7",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "4.1.12" "zod": "4.1.12"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+73
View File
@@ -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"
}
]
}
]
}
File diff suppressed because one or more lines are too long
+206
View File
@@ -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,
});
}
}),
);
});
+18
View File
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face"; 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 pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user"; 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, session_id: message.session_id,
data: { event: "server:bot-message", data: message }, 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) { if (relay) {
@@ -36,9 +43,20 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
session_id: relay.message.session_id, session_id: relay.message.session_id,
data: { event: "server:bot-message", data: relay.message }, data: { event: "server:bot-message", data: relay.message },
}); });
void notifyBotMessage({ message: relay.message });
} }
if (bot) { if (bot) {
await pushBotUpdated({ bot_id: bot.id || bot_id, user_id: bot.user_id }); 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 });
}
} }
} }
@@ -0,0 +1,67 @@
import { PwaManifestPath } from "@/src/data/pwa-data";
/**
* Runs before React mounts, in `<head>`:
* - 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 (
<>
<script dangerouslySetInnerHTML={{ __html: PwaInitScriptSource }} />
<link rel="manifest" href={PwaManifestPath} />
</>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import { RefreshCw } from "lucide-react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
const UpdateCheckMs = 60 * 60 * 1000;
/**
* Registers the app's service worker (production only, so development
* HMR is never cached) and offers a reload once a new version is
* installed. It never reloads on its own: a bot reply may be running.
*/
export default function ServiceWorkerRegistrar() {
const [update_ready, setUpdateReady] = useState(false);
const [reloading, setReloading] = useState(false);
const registration = useRef<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
const watch = (reg: ServiceWorkerRegistration) => {
if (reg.waiting && navigator.serviceWorker.controller) {
setUpdateReady(true);
}
reg.addEventListener("updatefound", () => {
const installing = reg.installing;
installing?.addEventListener("statechange", () => {
if (cancelled) return;
if (
installing.state == "installed" &&
navigator.serviceWorker.controller
) {
setUpdateReady(true);
}
});
});
};
navigator.serviceWorker
.register(PwaServiceWorkerPath, {
scope: "/",
updateViaCache: "none",
})
.then((reg) => {
if (cancelled) return;
registration.current = reg;
watch(reg);
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
})
.catch((error) => {
console.log("Service worker registration failed:", error);
});
const checkOnVisible = () => {
if (document.visibilityState == "visible") {
registration.current?.update().catch(() => {});
}
};
document.addEventListener("visibilitychange", checkOnVisible);
return () => {
cancelled = true;
if (interval) clearInterval(interval);
document.removeEventListener("visibilitychange", checkOnVisible);
};
}, []);
useEffect(() => {
if (!reloading) return;
const onControllerChange = () => window.location.reload();
navigator.serviceWorker?.addEventListener("controllerchange", onControllerChange);
registration.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
const fallback = setTimeout(() => window.location.reload(), 1500);
return () => {
clearTimeout(fallback);
navigator.serviceWorker?.removeEventListener("controllerchange", onControllerChange);
};
}, [reloading]);
if (!update_ready) return null;
return (
<Paper
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[250] w-[min(320px,calc(100vw-32px))] gap-3! p-4 bg-surface shadow-popover! border-hairline dark:bg-surface dark:border-hairline"
>
<Span className="text-sm font-bold leading-5 text-ink">
A new version is ready
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Reload to use it. Your conversations are saved.
</Span>
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Reload later"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={reloading}
onClick={() => setUpdateReady(false)}
>
Later
</Button>
<Button
title="Reload the app"
type="button"
size="small"
loading={reloading}
beforeIcon={<RefreshCw size={15} />}
onClick={() => setReloading(true)}
>
Reload
</Button>
</Row>
</Paper>
);
}
+14
View File
@@ -275,4 +275,18 @@ export const AppData = {
* Largest picture a bot or a workspace can have. * Largest picture a bot or a workspace can have.
*/ */
ProfileImageMaxBytes: 5 * 1024 * 1024, ProfileImageMaxBytes: 5 * 1024 * 1024,
/**
* A user with an open socket seen this recently gets no push: they
* are already looking at the app.
*/
PushPresenceWindowMs: 45_000,
/**
* A notification's body is one line, cut here.
*/
PushBodyMaxChars: 140,
/**
* How long a push service may hold a notification for a device that
* is offline.
*/
PushTtlSeconds: 60 * 60,
} as const; } as const;
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import path from "path";
import { SiteData } from "./site-data";
import {
PwaIcons,
PwaManifestName,
PwaOfflinePath,
PwaScope,
PwaStartPath,
} from "./pwa-data";
const ROOT = path.resolve(import.meta.dir, "../..");
describe("PwaData", () => {
test("has the sizes a browser needs to install", () => {
const any_sizes = PwaIcons.filter((icon) => icon.purpose == "any").map(
(icon) => icon.sizes,
);
const maskable_sizes = PwaIcons.filter(
(icon) => icon.purpose == "maskable",
).map((icon) => icon.sizes);
expect(any_sizes).toContain("192x192");
expect(any_sizes).toContain("512x512");
expect(maskable_sizes).toContain("192x192");
expect(maskable_sizes).toContain("512x512");
});
test("names the app from SiteData and starts on the crew", () => {
expect(PwaManifestName).toBe(SiteData["SiteName"]);
expect(PwaStartPath).toBe("/admin/bots");
expect(PwaScope).toBe("/");
});
test("the generated manifest matches this config", async () => {
const manifest = (await Bun.file(
path.join(ROOT, "public/manifest.webmanifest"),
).json()) as {
name: string;
start_url: string;
scope: string;
icons: { src: string; purpose: string }[];
};
expect(manifest.name).toBe(PwaManifestName);
expect(manifest.start_url).toBe(PwaStartPath);
expect(manifest.scope).toBe(PwaScope);
expect(
manifest.icons.map((icon) => `${icon.src}:${icon.purpose}`),
).toEqual(PwaIcons.map((icon) => `${icon.src}:${icon.purpose}`));
});
test("the offline page and every icon exist", async () => {
expect(
await Bun.file(path.join(ROOT, "public", PwaOfflinePath)).exists(),
).toBe(true);
for (const icon of PwaIcons) {
const file = Bun.file(path.join(ROOT, "public", icon.src));
expect(await file.exists()).toBe(true);
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { SiteData } from "./site-data";
/**
* What makes the app installable, shared by the pages that advertise it,
* the server that serves it and the script that generates it.
*/
export const PwaStartPath = "/admin/bots";
export const PwaScope = "/";
export const PwaManifestPath = "/manifest.webmanifest";
export const PwaServiceWorkerPath = "/sw.js";
export const PwaOfflinePath = "/offline.html";
export type PwaIcon = {
src: string;
sizes: string;
type: string;
purpose: "any" | "maskable";
};
/**
* Generated from `public/logos/logo.svg` by `bun run gen:favicon`.
*/
export const PwaIcons: PwaIcon[] = [
{
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",
},
];
export type PwaShortcut = {
name: string;
url: string;
icons: { src: string; sizes: string; type: string }[];
};
export const PwaShortcuts: PwaShortcut[] = [
{
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" }],
},
];
export const PwaIconPath = (name: `icon` | `maskable`, size: number) =>
`/icons/${name}-${size}.png`;
/**
* The app's install name: `SiteName` is lowercase, `PwaShortName` fits
* under an icon.
*/
export const PwaManifestName = SiteData["SiteName"];
export const PwaShortName = SiteData["SiteName"];
+14
View File
@@ -42,6 +42,20 @@ export type KnownEnv = {
* process environment. * process environment.
*/ */
export const EnvDict: readonly KnownEnv[] = [ export const EnvDict: readonly KnownEnv[] = [
{
key: "VAPID_PUBLIC_KEY",
description:
"Web Push public key, shared with browsers that subscribe. Generated on first start when missing.",
required: false,
secret: false,
},
{
key: "VAPID_PRIVATE_KEY",
description:
"Web Push private key, used to sign notifications. Generated on first start when missing; never share it.",
required: false,
secret: true,
},
{ {
key: "MAIN_COMPUTER_URL", key: "MAIN_COMPUTER_URL",
description: `The main computer's address, for a server running outside docker (e.g. http://localhost:8170). Empty uses the docker network.`, description: `The main computer's address, for a server running outside docker (e.g. http://localhost:8170). Empty uses the docker network.`,
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import {
pushMessageTag,
pushNeedsYouTag,
truncatePushBody,
} from "./push-dict";
describe("push tags", () => {
test("a conversation's notifications replace each other", () => {
expect(pushMessageTag({ bot_id: 3, session_id: 9 })).toBe(
"bot-3-session-9",
);
});
test("needs-you has its own tag, so it does not replace a message", () => {
expect(pushNeedsYouTag({ bot_id: 3 })).not.toBe(
pushMessageTag({ bot_id: 3, session_id: 9 }),
);
});
});
describe("truncatePushBody", () => {
test("keeps short text as is", () => {
expect(truncatePushBody("On it.")).toBe("On it.");
});
test("cuts long text at the limit, with one ellipsis", () => {
const long = "a".repeat(AppData.PushBodyMaxChars + 20);
const cut = truncatePushBody(long);
expect(cut.length).toBeLessThanOrEqual(AppData.PushBodyMaxChars);
expect(cut.endsWith("…")).toBe(true);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { AppData } from "@/src/data/app-data";
/**
* A notification's payload. The service worker turns it into a
* notification; `url` is where clicking it goes.
*/
export type PushPayload = {
title: string;
body: string;
url: string;
tag: string;
requireInteraction?: boolean;
};
/**
* One conversation replaces its previous notification, so a burst does not
* stack. A needs-you notice has its own tag.
*/
export function pushMessageTag({
bot_id,
session_id,
}: {
bot_id: number | string;
session_id: number | string;
}) {
return `bot-${bot_id}-session-${session_id}`;
}
export function pushNeedsYouTag({ bot_id }: { bot_id: number | string }) {
return `bot-${bot_id}-needs-you`;
}
export function truncatePushBody(text: string) {
const limit = AppData.PushBodyMaxChars;
if (text.length <= limit) return text;
return `${text.slice(0, limit - 1).trimEnd()}…`;
}
export const PushDefaultIcon = "/icons/icon-192.png";
+6
View File
@@ -1,4 +1,5 @@
import { import {
Bell,
Bot, Bot,
CpuAi, CpuAi,
KeyRound, KeyRound,
@@ -43,6 +44,11 @@ export const SettingsPages: SettingsPage[] = [
Icon: SlidersHorizontal, Icon: SlidersHorizontal,
}, },
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound }, { title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{
title: "Notifications",
url: "/admin/settings/notifications",
Icon: Bell,
},
{ {
title: "Change password", title: "Change password",
url: "/admin/settings/change-password", url: "/admin/settings/change-password",
@@ -0,0 +1,18 @@
import BunSQLite from "@moduletrace/bun-sqlite";
/**
* Removes one of the user's devices by its row id (the settings list
* never sees endpoints, which are capability URLs).
*/
export default async function deletePushSubscriptionById({
user_id,
id,
}: {
user_id: number | string;
id: number | string;
}) {
await BunSQLite.sql({
sql: `DELETE FROM push_subscriptions WHERE user_id = ? AND id = ?`,
values: [user_id, id],
});
}
@@ -0,0 +1,18 @@
import BunSQLite from "@moduletrace/bun-sqlite";
/**
* Removes one of the user's devices. The endpoint is the identity; a
* subscription another user saved can't be touched.
*/
export default async function deletePushSubscription({
user_id,
endpoint,
}: {
user_id: number | string;
endpoint: string;
}) {
await BunSQLite.sql({
sql: `DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`,
values: [user_id, endpoint],
});
}
@@ -0,0 +1,32 @@
import type { PushOverview } from "@/src/types";
import describePushDevice from "@/src/utils/describe-push-device";
import grabPushSubscriptions from "./grab-push-subscriptions";
/**
* What the Notifications settings page shows: the public key a device
* subscribes with, and the user's devices without their endpoints (a
* push endpoint is a capability URL).
*/
export default async function grabPushOverview({
user_id,
public_key,
}: {
user_id: number | string;
public_key?: string | null;
}): Promise<PushOverview> {
const subscriptions = await grabPushSubscriptions({ user_id });
return {
public_key: public_key || null,
subscriptions: subscriptions.map((subscription) => ({
id: Number(subscription.id),
device: describePushDevice({
user_agent: subscription.user_agent,
platform: subscription.platform,
}),
created_at: Number(subscription.created_at) || 0,
last_used_at: Number(subscription.last_used_at) || 0,
last_error: subscription.last_error || null,
})),
};
}
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS } from "@/db/types/db";
/**
* A user's subscribed devices, newest first.
*/
export default async function grabPushSubscriptions({
user_id,
}: {
user_id: number | string;
}) {
return (
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS>({
sql: `SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC`,
values: [user_id],
})
).payload || []
);
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_USER_PRESENCE } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
/**
* Whether the app is open for this user right now (any process), based on
* the socket keepalive. Push notifications are skipped while it is.
*/
export default async function isUserPresent({
user_id,
}: {
user_id: number | string;
}) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_USER_PRESENCE>({
sql: `SELECT last_seen_at FROM user_presence WHERE user_id = ? LIMIT 1`,
values: [user_id],
})
).payload?.[0];
if (!row?.last_seen_at) return false;
return Date.now() - Number(row.last_seen_at) <= AppData.PushPresenceWindowMs;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
/**
* Records the result of a send. A subscription the push service says is
* gone is deleted; anything else keeps its error for the settings list.
*/
export default async function markPushSubscription({
endpoint,
error,
}: {
endpoint: string;
error?: string | null;
}) {
if (error) {
await BunSQLite.sql({
sql: `UPDATE push_subscriptions SET last_error = ?, updated_at = ? WHERE endpoint = ?`,
values: [error, Date.now(), endpoint],
});
return;
}
await BunSQLite.sql({
sql: `UPDATE push_subscriptions SET last_used_at = ?, last_error = NULL, updated_at = ? WHERE endpoint = ?`,
values: [Date.now(), Date.now(), endpoint],
});
}
/**
* The push service no longer knows this device (gone, or a new endpoint).
*/
export async function deleteGonePushSubscription({ endpoint }: { endpoint: string }) {
await BunSQLite.sql({
sql: `DELETE FROM push_subscriptions WHERE endpoint = ?`,
values: [endpoint],
});
}
@@ -0,0 +1,60 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
endpoint: string;
p256dh: string;
auth: string;
user_agent?: string | null;
platform?: string | null;
};
/**
* Saves a device's subscription. The endpoint is unique: subscribing again
* (an updated key, or a shared device signing in as someone else) replaces
* the keys and moves it to this user.
*/
export default async function savePushSubscription({
user_id,
endpoint,
p256dh,
auth,
user_agent,
platform,
}: Params): Promise<void> {
const now = Date.now();
const fields = {
user_id: Number(user_id),
endpoint,
p256dh,
auth,
user_agent: user_agent || null,
platform: platform || null,
last_error: null,
updated_at: now,
};
await BunSQLite.sql({
sql: `INSERT INTO push_subscriptions (created_at, updated_at, user_id, endpoint, p256dh, auth, user_agent, platform, last_used_at, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = COALESCE(excluded.user_agent, push_subscriptions.user_agent),
platform = COALESCE(excluded.platform, push_subscriptions.platform),
last_error = NULL,
updated_at = excluded.updated_at`,
values: [
now,
now,
fields.user_id,
fields.endpoint,
fields.p256dh,
fields.auth,
fields.user_agent,
fields.platform,
now,
],
});
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
/**
* At most one write per user every 15s in this process; the keepalive
* arrives every 30s per socket, and a burst of sockets would otherwise
* write repeatedly for no extra accuracy.
*/
const last_written_at = new Map<string, number>();
/**
* Marks a user as having an open app socket. The bots layout keeps its
* socket alive with a raw keepalive, so this is refreshed while the app
* is open. A stale value only means a push may be sent a minute late.
*/
export default async function touchUserPresence({
user_id,
}: {
user_id: number | string;
}) {
const key = String(user_id);
const now = Date.now();
if (now - (last_written_at.get(key) || 0) < 15_000) return;
last_written_at.set(key, now);
await BunSQLite.sql({
sql: `INSERT INTO user_presence (created_at, updated_at, user_id, last_seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at`,
values: [now, now, user_id, now],
});
}
@@ -0,0 +1,53 @@
import path from "path";
import { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
const PUBLIC_DIR = path.resolve(import.meta.dir, "../../../../public");
const PwaFiles: Record<string, string> = {
[PwaServiceWorkerPath]: "sw.js",
[PwaManifestPath]: "manifest.webmanifest",
};
/**
* Serves the service worker and the manifest with headers bunext's public
* handler can't give them: neither may be cached by a browser or a CDN,
* or a deploy waits an hour to reach installed apps. Only these two files
* are special; every other public asset keeps its normal caching.
*/
export default async function handlePwaFile({
pathname,
}: {
pathname: string;
}): Promise<Response | null> {
const file_name = PwaFiles[pathname];
if (!file_name) return null;
const file = Bun.file(path.join(PUBLIC_DIR, file_name));
if (!(await file.exists())) {
return new Response("Not Found", { status: 404 });
}
const headers = new Headers({
"Content-Type":
file_name.endsWith(".webmanifest")
? "application/manifest+json; charset=utf-8"
: "text/javascript; charset=utf-8",
"Cache-Control": "no-store, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
});
if (file_name == "sw.js") {
headers.set("Service-Worker-Allowed", "/");
}
const response = new Response(file, { headers });
/**
* `Bun.file` adds a filename disposition; a manifest is inline data.
*/
response.headers.delete("content-disposition");
return response;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
/**
* Changes after a reason the socket may have died: the browser went back
* online, the page was restored, or a hidden tab became visible again.
* twui's `useWebSocket` gives up after three attempts, so the socket is
* remounted instead of waiting for a reload.
*/
export default function useConnectionEpoch() {
const [epoch, setEpoch] = useState(0);
useEffect(() => {
const bump = () => setEpoch((value) => value + 1);
const onVisible = () => {
if (document.visibilityState == "visible") bump();
};
window.addEventListener("online", bump);
window.addEventListener("pageshow", bump);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("online", bump);
window.removeEventListener("pageshow", bump);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
return epoch;
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
export type InstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
declare global {
interface Window {
/**
* Captured by `PwaInitScript` before React mounts.
*/
__cobbleInstallPrompt?: InstallPromptEvent | null;
}
}
const StandaloneQuery = "(display-mode: standalone)";
function isIosDevice() {
if (typeof navigator == "undefined") return false;
const ua = navigator.userAgent;
return (
/iphone|ipad|ipod/i.test(ua) ||
(navigator.platform == "MacIntel" && navigator.maxTouchPoints > 1)
);
}
/**
* Whether this browser can install the app now, whether it is already
* installed, and the iOS steps when Safari has no install event.
*/
export default function useInstallPrompt() {
const [prompt_event, setPromptEvent] = useState<InstallPromptEvent | null>(
null,
);
const [is_standalone, setIsStandalone] = useState(false);
const [is_ios, setIsIos] = useState(false);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (typeof window == "undefined") return;
const standalone_query = window.matchMedia(StandaloneQuery);
const sync_standalone = () =>
setIsStandalone(
standalone_query.matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone),
);
setPromptEvent(window.__cobbleInstallPrompt || null);
setIsIos(isIosDevice());
sync_standalone();
const sync_prompt = () =>
setPromptEvent(window.__cobbleInstallPrompt || null);
const on_installed = () => {
window.__cobbleInstallPrompt = null;
setPromptEvent(null);
sync_standalone();
};
window.addEventListener("cobble:install-available", sync_prompt);
window.addEventListener("cobble:installed", on_installed);
standalone_query.addEventListener("change", sync_standalone);
return () => {
window.removeEventListener("cobble:install-available", sync_prompt);
window.removeEventListener("cobble:installed", on_installed);
standalone_query.removeEventListener("change", sync_standalone);
};
}, []);
const install = async () => {
if (!prompt_event) return false;
setInstalling(true);
try {
await prompt_event.prompt();
const choice = await prompt_event.userChoice;
window.__cobbleInstallPrompt = null;
setPromptEvent(null);
return choice.outcome == "accepted";
} catch (error) {
return false;
} finally {
setInstalling(false);
}
};
return {
can_install: Boolean(prompt_event) && !is_standalone,
is_standalone,
is_ios,
/**
* Safari has no install event, so it needs the Share menu steps.
*/
needs_ios_steps: is_ios && !is_standalone,
installing,
install,
};
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
/**
* `navigator.onLine` as state. Starts online so server and first client
* render match; the real value arrives on mount.
*/
export default function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import useInstallPrompt from "@/src/hooks/use-install-prompt";
import urlBase64ToUint8Array from "@/src/utils/url-base64-to-uint8-array";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
export type PushDeviceState =
| "loading"
| "dev"
| "unsupported"
| "needs-install"
| "denied"
| "off"
| "on";
/**
* The registrant may still be registering when Settings opens, so give it
* a moment before deciding the browser has no worker.
*/
async function grabRegistration() {
const existing = await navigator.serviceWorker.getRegistration();
if (existing) return existing;
return await Promise.race([
navigator.serviceWorker.ready,
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
]);
}
/**
* One device's Web Push subscription. Permission is only ever asked for
* from an explicit tap: a denied permission is permanent in most browsers
* and iOS requires the gesture. In development the service worker is not
* registered, so this reports "dev" instead of hanging.
*/
export default function usePushNotifications({
public_key,
}: {
public_key?: string | null;
}) {
const { is_ios, is_standalone } = useInstallPrompt();
const [state, setState] = useState<PushDeviceState>("loading");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const read_subscription = useCallback(async () => {
if (process.env.NODE_ENV != "production") {
setState("dev");
return null;
}
if (
typeof navigator == "undefined" ||
!("serviceWorker" in navigator) ||
!("PushManager" in window) ||
!("Notification" in window)
) {
setState("unsupported");
return null;
}
if (is_ios && !is_standalone) {
setState("needs-install");
return null;
}
if (Notification.permission == "denied") {
setState("denied");
return null;
}
const registration = await grabRegistration();
if (!registration) {
setState("unsupported");
return null;
}
const subscription =
await registration.pushManager.getSubscription();
setState(subscription ? "on" : "off");
return subscription;
}, [is_ios, is_standalone]);
useEffect(() => {
read_subscription().catch(() => setState("unsupported"));
}, [read_subscription]);
const enable = async () => {
if (!public_key) {
setError("Notifications aren't set up on this server yet.");
return false;
}
setBusy(true);
setError(undefined);
try {
const permission = await Notification.requestPermission();
if (permission != "granted") {
setState(permission == "denied" ? "denied" : "off");
setError(
permission == "denied"
? "Notifications are blocked for this site. Allow them in the browser's site settings, then try again."
: "Notifications were not allowed.",
);
return false;
}
/**
* The registrant normally registers, but a settings page can
* be the first thing opened.
*/
const registration = await navigator.serviceWorker.register(
PwaServiceWorkerPath,
{ scope: "/", updateViaCache: "none" },
);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
const res = await fetchApi("/api/admin/push/subscribe", {
method: "POST",
body: {
endpoint: subscription.endpoint,
keys: subscription.toJSON().keys,
user_agent: navigator.userAgent,
platform: navigator.platform,
},
});
if (!res.success) {
await subscription.unsubscribe().catch(() => {});
setError(res.msg || "Notifications could not be turned on.");
setState("off");
return false;
}
setState("on");
return true;
} catch (caught: any) {
setError(
caught?.message || "Notifications could not be turned on.",
);
return false;
} finally {
setBusy(false);
}
};
const disable = async () => {
setBusy(true);
setError(undefined);
try {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await fetchApi("/api/admin/push/unsubscribe", {
method: "POST",
body: { endpoint: subscription.endpoint },
});
await subscription.unsubscribe();
}
setState("off");
return true;
} catch (caught: any) {
setError(
caught?.message || "Notifications could not be turned off.",
);
return false;
} finally {
setBusy(false);
}
};
const send_test = async () => {
setBusy(true);
setError(undefined);
try {
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/test",
{ method: "POST", body: {} },
);
if (!res.success) {
setError(res.msg || "The test could not be sent.");
return false;
}
return true;
} catch (caught: any) {
setError(caught?.message || "The test could not be sent.");
return false;
} finally {
setBusy(false);
}
};
/**
* The browser rotated the subscription. Re-subscribing needs the key
* and the session, so the page does it.
*/
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (!("serviceWorker" in navigator)) return;
const onMessage = (event: MessageEvent) => {
if (event.data?.type != "PUSH_RESUBSCRIBE") return;
event.data.oldEndpoint &&
fetchApi("/api/admin/push/unsubscribe", {
method: "POST",
body: { endpoint: event.data.oldEndpoint },
}).catch(() => {});
enable().catch(() => {});
};
navigator.serviceWorker.addEventListener("message", onMessage);
return () =>
navigator.serviceWorker.removeEventListener("message", onMessage);
}, [public_key]);
return {
state,
enabled: state == "on",
busy,
error,
clearError: () => setError(undefined),
enable,
disable,
send_test,
};
}
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
*/ */
export default function AuthLayout({ children }: Props): React.JSX.Element { export default function AuthLayout({ children }: Props): React.JSX.Element {
return ( return (
<Stack className="w-full min-h-screen items-stretch gap-0! bg-light dark:bg-black"> <Stack className="w-full min-h-dvh items-stretch gap-0! bg-light dark:bg-black">
<AuthTopBar /> <AuthTopBar />
<Main className="grow justify-center">{children}</Main> <Main className="grow justify-center">{children}</Main>
<AuthBottomBar /> <AuthBottomBar />
@@ -0,0 +1,17 @@
"use client";
import { createContext } from "react";
export type RailDrawerContextObject = {
open: boolean;
setOpen: (open: boolean) => void;
};
/**
* The rail is a slide-over below `md`, an inline rail above it. The
* pane headers' menu button and the rail itself share this.
*/
export const RailDrawerContext = createContext<RailDrawerContextObject>({
open: false,
setOpen: () => {},
});
+3 -3
View File
@@ -45,14 +45,14 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
aria-current={selected ? "page" : undefined} aria-current={selected ? "page" : undefined}
title={bot.name} title={bot.name}
className={twMerge( className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal", "flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]", "hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper", "focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5", selected && "bg-ink/5 hover:bg-ink/5",
)} )}
> >
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} /> <BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<Stack className="hidden lg:flex grow min-w-0 gap-0!"> <Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> <Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name} {bot.name}
</Span> </Span>
@@ -60,7 +60,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
{snippet} {snippet}
</Span> </Span>
</Stack> </Stack>
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5"> <Stack className="hidden @rail:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
{at ? ( {at ? (
<Span className="text-[11px] leading-4 text-ink-secondary"> <Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(at) })} {relativeTime({ at: Number(at) })}
@@ -0,0 +1,70 @@
"use client";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import { Share, SquarePlus } from "lucide-react";
import { SiteData } from "@/src/data/site-data";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
const Steps = [
{
Icon: Share,
title: "Open the Share menu",
description: "In Safari it is the square with an arrow pointing up, in the toolbar.",
},
{
Icon: SquarePlus,
title: "Choose Add to Home Screen",
description: "Scroll the list if it is not there. Then tap Add.",
},
];
/**
* Safari on iOS has no install event, so installation is the Share menu.
*/
export default function InstallAppModal({ open, setOpen }: Props) {
return (
<Modal
open={open}
setOpen={(next) => setOpen(typeof next == "function" ? next(open) : next)}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
Install {SiteData["SiteName"]}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Installed, it opens in its own window and can send
notifications. Safari installs it from the Share menu.
</Span>
</Stack>
<Stack className="w-full mt-5 gap-4! items-stretch">
{Steps.map((step) => (
<Row
key={step.title}
className="gap-3! flex-nowrap items-start"
>
<Row className="size-9 shrink-0 items-center justify-center rounded-default bg-sunken">
<step.Icon size={17} aria-hidden className="text-ink" />
</Row>
<Stack className="gap-0.5! min-w-0">
<Span className="text-sm font-bold leading-5 text-ink">
{step.title}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{step.description}
</Span>
</Stack>
</Row>
))}
</Stack>
</Modal>
);
}
@@ -0,0 +1,24 @@
"use client";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { WifiOff } from "lucide-react";
/**
* A hairline strip while the browser is offline. Nothing is queued for
* later, so it says messages won't send until the connection is back.
*/
export default function OfflineBanner() {
return (
<Row
role="status"
aria-live="polite"
className="w-full shrink-0 gap-2! flex-nowrap items-center justify-center px-4 py-2 bg-sunken border-0 border-b border-solid border-hairline"
>
<WifiOff size={14} aria-hidden className="shrink-0 text-ink-secondary" />
<Span className="text-[13px] leading-[18px] text-ink-secondary">
You're offline. Messages won't send until you reconnect.
</Span>
</Row>
);
}
@@ -23,7 +23,7 @@ export default function RailAddButton() {
variant="ghost" variant="ghost"
color="text" color="text"
onClick={openAdd} onClick={openAdd}
className="hidden lg:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink" className="hidden @rail:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
> >
<Plus size={18} aria-hidden /> <Plus size={18} aria-hidden />
</Button> </Button>
@@ -21,12 +21,12 @@ export default function RailBackLink({
<Link <Link
href={href} href={href}
title={title} title={title}
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper" className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
> >
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0"> <Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
<ArrowLeft size={18} aria-hidden /> <ArrowLeft size={18} aria-hidden />
</Span> </Span>
<Span className="hidden lg:block min-w-0 truncate text-sm font-bold leading-5 text-ink"> <Span className="hidden @rail:block min-w-0 truncate text-sm font-bold leading-5 text-ink">
{title} {title}
</Span> </Span>
</Link> </Link>
@@ -39,7 +39,7 @@ export default function RailBotsSection({ title, workers, selected_id, hrefFor,
<BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} /> <BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} />
))} ))}
{workers.length ? null : ( {workers.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary"> <Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
{empty} {empty}
</Span> </Span>
)} )}
@@ -0,0 +1,23 @@
"use client";
import { useContext } from "react";
import { Menu } from "lucide-react";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { RailDrawerContext } from "../(data)/rail-drawer-context";
/**
* Opens the rail's drawer below `md`; the inline rail has no need for it.
*/
export default function RailDrawerButton() {
const { setOpen } = useContext(RailDrawerContext);
return (
<span className="inline-flex md:hidden">
<HeaderIconButton
icon={Menu}
title="Open the bots rail"
onClick={() => setOpen(true)}
/>
</span>
);
}
+3 -3
View File
@@ -13,13 +13,13 @@ type Props = {
export default function RailHeader({ show_add = true }: Props) { export default function RailHeader({ show_add = true }: Props) {
return ( return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5"> <Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center @rail:justify-between px-0 @rail:pl-5 @rail:pr-6 pt-5">
<Link <Link
href="/admin/bots" href="/admin/bots"
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper" className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
> >
<Wordmark height={15} className="hidden lg:block" /> <Wordmark height={15} className="hidden @rail:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} /> <LogoIcon icon_size={28} img_props={{ className: "@rail:hidden" }} />
</Link> </Link>
{show_add ? <RailAddButton /> : null} {show_add ? <RailAddButton /> : null}
</Row> </Row>
@@ -22,7 +22,7 @@ export default function RailPagesList({ pathname }: Props) {
return ( return (
<> <>
<RailBackLink /> <RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => ( {RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => (
<RailSettingsLink <RailSettingsLink
key={page.url} key={page.url}
@@ -18,13 +18,13 @@ type Props = {
export default function RailSectionLabel({ title, action }: Props) { export default function RailSectionLabel({ title, action }: Props) {
return ( return (
<> <>
<Row className="hidden lg:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3"> <Row className="hidden @rail:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary"> <Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
{title} {title}
</Span> </Span>
{action} {action}
</Row> </Row>
<Divider className="lg:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="@rail:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
</> </>
); );
} }
@@ -28,15 +28,15 @@ export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
variant="ghost" variant="ghost"
color="text" color="text"
onClick={onOpen} onClick={onOpen}
className="flex shrink-0 h-10 w-full items-center justify-center lg:justify-start gap-3! rounded-default px-1! lg:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink" className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
> >
<Ellipsis size={18} aria-hidden className="lg:hidden" /> <Ellipsis size={18} aria-hidden className="@rail:hidden" />
<Row className="hidden lg:flex shrink-0 gap-1! flex-nowrap items-center"> <Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => ( {hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
<RailSeeAllStone key={bot.id} bot={bot} /> <RailSeeAllStone key={bot.id} bot={bot} />
))} ))}
</Row> </Row>
<Span className="hidden lg:inline text-[13px] font-bold leading-4">{label}</Span> <Span className="hidden @rail:inline text-[13px] font-bold leading-4">{label}</Span>
</Button> </Button>
); );
} }
@@ -139,7 +139,7 @@ export default function RailSettingsAside({ pathname }: Props) {
return ( return (
<> <>
<RailBackLink title={back.title} href={back.href} /> <RailBackLink title={back.title} href={back.href} />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<RailSettingsRecordRow <RailSettingsRecordRow
avatar={avatar} avatar={avatar}
title={title} title={title}
@@ -18,16 +18,16 @@ export default function RailSettingsLink({ title, url, Icon, selected }: Props)
title={title} title={title}
aria-current={selected ? "page" : undefined} aria-current={selected ? "page" : undefined}
className={twMerge( className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink", "flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]", "hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper", "focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5", selected && "bg-ink/5 hover:bg-ink/5",
)} )}
> >
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0"> <Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
<Icon size={18} aria-hidden /> <Icon size={18} aria-hidden />
</Span> </Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink"> <Span className="hidden @rail:inline text-sm font-bold leading-5 text-ink">
{title} {title}
</Span> </Span>
</Link> </Link>
@@ -25,7 +25,7 @@ export default function RailSettingsList({ pathname }: Props) {
return ( return (
<> <>
<RailBackLink /> <RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.filter((page) => {SettingsPages.filter((page) =>
page.can_view page.can_view
? page.can_view({ user_types: pageProps?.user_types }) ? page.can_view({ user_types: pageProps?.user_types })
@@ -21,10 +21,10 @@ export default function RailSettingsRecordRow({
return ( return (
<Row <Row
aria-current="page" aria-current="page"
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5" className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-3 bg-ink/5"
> >
{avatar} {avatar}
<Stack className="hidden lg:flex grow min-w-0 gap-0!"> <Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> <Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title} {title}
</Span> </Span>
@@ -0,0 +1,49 @@
"use client";
import type { LucideIcon } from "lucide-react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
Icon: LucideIcon;
onClick: () => void;
disabled?: boolean;
};
/**
* A popover item that acts rather than navigates, e.g. Install app.
*/
export default function RailUserMenuAction({
title,
Icon,
onClick,
disabled,
}: Props) {
return (
<Row
role="menuitem"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
title={title}
onClick={() => {
if (!disabled) onClick();
}}
onKeyDown={(event) => {
if (disabled) return;
if (event.key == "Enter" || event.key == " ") {
event.preventDefault();
onClick();
}
}}
className={`w-full h-9 gap-2.5! flex-nowrap items-center px-4 ${
disabled
? "text-ink-disabled cursor-not-allowed"
: "text-ink cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
}`}
>
<Icon size={16} aria-hidden />
<Span className="text-sm leading-5">{title}</Span>
</Row>
);
}
+35 -2
View File
@@ -1,14 +1,21 @@
import { useContext } from "react"; "use client";
import { useContext, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper"; import Paper from "@moduletrace/twui/elements/Paper";
import Divider from "@moduletrace/twui/layout/Divider"; import Divider from "@moduletrace/twui/layout/Divider";
import { Download } from "lucide-react";
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict"; import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
import RailUserMenuEntry from "./rail-user-menu-entry"; import RailUserMenuEntry from "./rail-user-menu-entry";
import RailUserMenuAction from "./rail-user-menu-action";
import InstallAppModal from "./install-app-modal";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import useInstallPrompt from "@/src/hooks/use-install-prompt";
/** /**
* 220px popover: the only element in the shell with elevation. Super * 220px popover: the only element in the shell with elevation. Super
* admin items show to super admins only. * admin items show to super admins only. Install app shows while the
* browser can install it; Safari gets the Share menu steps instead.
*/ */
export default function RailUserMenu() { export default function RailUserMenu() {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
@@ -17,6 +24,12 @@ export default function RailUserMenu() {
const items = shown.filter((item) => !item.destructive); const items = shown.filter((item) => !item.destructive);
const destructive = shown.filter((item) => item.destructive); const destructive = shown.filter((item) => item.destructive);
const { can_install, needs_ios_steps, installing, install } =
useInstallPrompt();
const [ios_modal_open, setIosModalOpen] = useState(false);
const show_install = can_install || needs_ios_steps;
return ( return (
<Paper <Paper
role="menu" role="menu"
@@ -25,10 +38,30 @@ export default function RailUserMenu() {
{items.map((item) => ( {items.map((item) => (
<RailUserMenuEntry key={item.title} {...item} /> <RailUserMenuEntry key={item.title} {...item} />
))} ))}
{show_install ? (
<RailUserMenuAction
title={needs_ios_steps ? "How to install" : "Install app"}
Icon={Download}
disabled={installing}
onClick={() => {
if (needs_ios_steps) {
setIosModalOpen(true);
return;
}
install();
}}
/>
) : null}
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" /> <Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
{destructive.map((item) => ( {destructive.map((item) => (
<RailUserMenuEntry key={item.title} {...item} /> <RailUserMenuEntry key={item.title} {...item} />
))} ))}
{needs_ios_steps ? (
<InstallAppModal
open={ios_modal_open}
setOpen={setIosModalOpen}
/>
) : null}
</Paper> </Paper>
); );
} }
@@ -23,7 +23,7 @@ export default function RailUserRow() {
.join(""); .join("");
return ( return (
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline"> <Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 @rail:px-3 mx-0 @rail:mx-3 border-0 border-t border-solid border-hairline">
<Dropdown <Dropdown
position="top-left" position="top-left"
className="w-full" className="w-full"
@@ -34,7 +34,7 @@ export default function RailUserRow() {
tabIndex={0} tabIndex={0}
aria-haspopup="menu" aria-haspopup="menu"
aria-label={`Account menu for ${name}`} aria-label={`Account menu for ${name}`}
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper" className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
> >
<UserAvatar <UserAvatar
src={user?.image_thumbnail || user?.image} src={user?.image_thumbnail || user?.image}
@@ -42,7 +42,7 @@ export default function RailUserRow() {
initials={initials} initials={initials}
size="md" size="md"
/> />
<Stack className="hidden lg:flex grow min-w-0 gap-1"> <Stack className="hidden @rail:flex grow min-w-0 gap-1">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> <Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{name} {name}
</Span> </Span>
@@ -63,7 +63,7 @@ export default function RailUserRow() {
</Stack> </Stack>
<ChevronRight <ChevronRight
size={16} size={16}
className="hidden lg:block shrink-0 text-ink" className="hidden @rail:block shrink-0 text-ink"
aria-hidden aria-hidden
/> />
</Row> </Row>
@@ -37,7 +37,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
title={on_workspace_page ? "All bots" : `Back to ${workspace.name}`} title={on_workspace_page ? "All bots" : `Back to ${workspace.name}`}
href={on_workspace_page ? undefined : base} href={on_workspace_page ? undefined : base}
/> />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} /> <WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
<RailBotsSection <RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />} action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
@@ -32,7 +32,7 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} /> <WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
))} ))}
{workspaces.length ? null : ( {workspaces.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary"> <Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
No workspaces yet. A workspace keeps a project's context, bots and computers together. No workspaces yet. A workspace keeps a project's context, bots and computers together.
</Span> </Span>
)} )}
@@ -21,14 +21,14 @@ export default function WorkspaceRow({ workspace, selected }: Props) {
aria-current={selected ? "page" : undefined} aria-current={selected ? "page" : undefined}
title={workspace.name} title={workspace.name}
className={twMerge( className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink", "flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]", "hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper", "focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5", selected && "bg-ink/5 hover:bg-ink/5",
)} )}
> >
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto lg:mx-0" /> <WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto @rail:mx-0" />
<Stack className="hidden lg:flex grow min-w-0 gap-0!"> <Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span> <Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full"> <Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{workspace.unread ? "New messages" : workspace.description || ""} {workspace.unread ? "New messages" : workspace.description || ""}
+45 -27
View File
@@ -1,9 +1,12 @@
"use client";
import { useContext } from "react"; import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import useLiveBots from "@/src/hooks/use-live-bots"; import useLiveBots from "@/src/hooks/use-live-bots";
import useLiveWorkspaces from "../(hooks)/use-live-workspaces"; import useLiveWorkspaces from "../(hooks)/use-live-workspaces";
import { RailPages } from "@/src/dict/rail-pages-dict"; import { RailPages } from "@/src/dict/rail-pages-dict";
import { RailDrawerContext } from "../(data)/rail-drawer-context";
import RailHeader from "../(partials)/rail-header"; import RailHeader from "../(partials)/rail-header";
import RailPagesList from "../(partials)/rail-pages-list"; import RailPagesList from "../(partials)/rail-pages-list";
import RailCrewList from "../(partials)/rail-crew-list"; import RailCrewList from "../(partials)/rail-crew-list";
@@ -16,10 +19,14 @@ import RailWorkspaceList from "../(partials)/rail-workspace-list";
* the latest bots, the workspaces). Inside a workspace it lists that * the latest bots, the workspaces). Inside a workspace it lists that
* workspace's crew instead; on `/admin/settings/*` a back link and the * workspace's crew instead; on `/admin/settings/*` a back link and the
* settings sub pages, and on routines, activity and help a back link and * settings sub pages, and on routines, activity and help a back link and
* those pages (`RailPages`). Collapses to a 64px icon rail below 1024px. * those pages (`RailPages`). Below `md` it is a slide-over drawer from the
* pane header's menu button; from `md` it is a 64px icon rail, and from
* `lg` the full 260px rail. Its own width, not the viewport's, decides
* whether it shows names (a container query), so the drawer opens full.
*/ */
export default function BotsRail() { export default function BotsRail() {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const { open, setOpen } = useContext(RailDrawerContext);
const pathname = pageProps?.url?.pathname; const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings")); const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
@@ -41,32 +48,43 @@ export default function BotsRail() {
const live_workspace = workspaces.find((w) => w.id == workspace?.id); const live_workspace = workspaces.find((w) => w.id == workspace?.id);
return ( return (
<Stack <>
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline" {open ? (
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"} <Stack
> aria-hidden
<RailHeader show_add={!is_settings_page && !is_rail_page} /> onClick={() => setOpen(false)}
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3"> className="fixed inset-0 z-30 bg-ink/40 md:hidden"
{is_settings_page ? ( />
<RailSettingsAside pathname={pathname} /> ) : null}
) : is_rail_page ? ( <Stack
<RailPagesList pathname={pathname} /> className={`@container fixed inset-y-0 left-0 z-40 w-[280px] max-w-[85vw] shrink-0 h-full gap-0! items-stretch bg-paper border-0 border-r border-solid border-hairline shadow-popover transition-transform duration-[var(--m-base)] pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] md:static md:z-auto md:w-16 md:max-w-none md:shadow-none md:pt-0 md:pb-0 lg:w-[260px] ${
) : in_workspace && live_workspace ? ( open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
<RailWorkspaceList }`}
workspace={live_workspace} aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
bots={bots} >
selected_id={pageProps?.bot?.id || undefined} <RailHeader show_add={!is_settings_page && !is_rail_page} />
on_workspace_page={!pageProps?.bot?.id} <Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 @rail:px-3 pt-1 pb-3">
/> {is_settings_page ? (
) : ( <RailSettingsAside pathname={pathname} />
<RailCrewList ) : is_rail_page ? (
bots={bots} <RailPagesList pathname={pathname} />
workspaces={workspaces} ) : in_workspace && live_workspace ? (
selected_id={pageProps?.bot?.id || undefined} <RailWorkspaceList
/> workspace={live_workspace}
)} bots={bots}
selected_id={pageProps?.bot?.id || undefined}
on_workspace_page={!pageProps?.bot?.id}
/>
) : (
<RailCrewList
bots={bots}
workspaces={workspaces}
selected_id={pageProps?.bot?.id || undefined}
/>
)}
</Stack>
<RailUserRow />
</Stack> </Stack>
<RailUserRow /> </>
</Stack>
); );
} }
@@ -0,0 +1,13 @@
"use client";
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
/**
* The shell's one socket. Remounted with a new key when the connection
* may have been lost, since twui's hook stops retrying after three tries.
*/
export default function LiveSocket() {
useWebSocket({ url: "/ws" });
return null;
}
+54 -13
View File
@@ -1,33 +1,74 @@
import React from "react"; "use client";
import React, { useContext, useEffect, useMemo, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket"; import { AppContext } from "@/src/pages/__root";
import useConnectionEpoch from "@/src/hooks/use-connection-epoch";
import useOnlineStatus from "@/src/hooks/use-online-status";
import { RailDrawerContext } from "./(data)/rail-drawer-context";
import BotsRail from "./(sections)/bots-rail"; import BotsRail from "./(sections)/bots-rail";
import LiveSocket from "./(sections)/live-socket";
import AddFlow from "./(partials)/add-flow"; import AddFlow from "./(partials)/add-flow";
import OfflineBanner from "./(partials)/offline-banner";
/** /**
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations * Full-bleed shell for every `/admin/*` page: the rail of bot conversations
* and workspaces, then the page (a conversation, a workspace, or * and workspaces, then the page (a conversation, a workspace, or
* settings), inside the Add flow both can open. No cards, no shadows; * settings), inside the Add flow both can open. No cards, no shadows;
* regions are separated by 1px hairlines. * regions are separated by 1px hairlines. Below `md` the rail slides over
* the page instead of taking width. Height is `dvh`, so the on-screen
* keyboard doesn't push the composer out of view.
*/ */
export default function BotsLayout({ export default function BotsLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
const [drawer_open, setDrawerOpen] = useState(false);
const epoch = useConnectionEpoch();
const online = useOnlineStatus();
/** /**
* One socket for live bot updates (see `useServerEvent`). * Opening a conversation closes the drawer on a phone.
*/ */
useWebSocket({ url: "/ws" }); useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
if (!drawer_open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key == "Escape") setDrawerOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [drawer_open]);
const drawer = useMemo(
() => ({ open: drawer_open, setOpen: setDrawerOpen }),
[drawer_open],
);
return ( return (
<AddFlow> <RailDrawerContext.Provider value={drawer}>
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink"> <AddFlow>
<BotsRail /> <Stack className="w-full h-dvh gap-0! items-stretch overflow-hidden bg-paper text-ink pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch"> {online ? null : <OfflineBanner />}
{children} <Row className="grow min-h-0 w-full gap-0! flex-nowrap items-stretch overflow-hidden">
</Row> <BotsRail />
</Row> <Row className="grow min-w-0 h-full gap-0! flex-nowrap items-stretch">
</AddFlow> {children}
</Row>
</Row>
</Stack>
<LiveSocket key={epoch} />
</AddFlow>
</RailDrawerContext.Provider>
); );
} }
+17
View File
@@ -17,6 +17,8 @@ import Layout from "../layouts/main";
import AuthLayout from "../layouts/auth"; import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots"; import BotsLayout from "../layouts/bots";
import ThemeInitScript from "../components/general/theme-init-script"; import ThemeInitScript from "../components/general/theme-init-script";
import PwaInitScript from "../components/general/pwa-init-script";
import ServiceWorkerRegistrar from "../components/general/service-worker-registrar";
export const AppContext = React.createContext<AppContextObject>( export const AppContext = React.createContext<AppContextObject>(
{} as AppContextObject, {} as AppContextObject,
@@ -57,6 +59,7 @@ export default function App({
return ( return (
<AppContext.Provider value={appContextObject}> <AppContext.Provider value={appContextObject}>
<BotsLayout>{children}</BotsLayout> <BotsLayout>{children}</BotsLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider> </AppContext.Provider>
); );
} }
@@ -68,6 +71,7 @@ export default function App({
return ( return (
<AppContext.Provider value={appContextObject}> <AppContext.Provider value={appContextObject}>
<AuthLayout>{children}</AuthLayout> <AuthLayout>{children}</AuthLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider> </AppContext.Provider>
); );
} }
@@ -86,6 +90,7 @@ export default function App({
> >
{children} {children}
</Layout> </Layout>
<ServiceWorkerRegistrar />
</AppContext.Provider> </AppContext.Provider>
); );
} }
@@ -94,6 +99,18 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
return ( return (
<> <>
<ThemeInitScript /> <ThemeInitScript />
<PwaInitScript />
<meta name="application-name" content={SiteData["SiteName"]} />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={SiteData["SiteName"]}
/>
<meta
name="apple-mobile-web-app-status-bar-style"
content="default"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link <link
rel="preconnect" rel="preconnect"
@@ -30,7 +30,7 @@ export default function BotProfileModal({ bot }: Props) {
image_media_id={bot.image_media_id} image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null} hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace} face={(bot.face || "idle") as BotFace}
size={40} size={35}
/> />
); );
@@ -65,7 +65,9 @@ export default function BotProfileModal({ bot }: Props) {
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`} : `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
</Span> </Span>
</Stack> </Stack>
{open ? <BotForm bot={bot} onCancel={() => setOpen(false)} /> : null} {open ? (
<BotForm bot={bot} onCancel={() => setOpen(false)} />
) : null}
</Modal> </Modal>
); );
} }
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null; if (!pending.length) return null;
return ( return (
<Row className="w-full max-w-container mx-auto gap-2! px-6 pb-3"> <Row className="w-full max-w-container mx-auto gap-2! px-3 md:px-6 pb-3">
{pending.map((item) => ( {pending.map((item) => (
<ComposerAttachmentChip <ComposerAttachmentChip
key={item.key} key={item.key}
@@ -38,7 +38,7 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
if (!has_tools && !computers.length) return null; if (!has_tools && !computers.length) return null;
return ( return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3"> <Row className="w-full max-w-container mx-auto gap-1.5! items-center px-3 md:px-6 pb-3 -mt-3">
{has_tools ? ( {has_tools ? (
<Span className="text-[12px] leading-4 text-ink-secondary"> <Span className="text-[12px] leading-4 text-ink-secondary">
Tools Tools
@@ -10,6 +10,7 @@ import type {
import sendBotMessage from "../(functions)/send-bot-message"; import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments"; import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft"; import useComposerDraft from "../(hooks)/use-composer-draft";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments"; import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button"; import ComposerSendButton from "./composer-send-button";
@@ -40,11 +41,13 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id }); const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const can_send = const can_send =
Boolean(text.trim() || files.media_ids.length) && Boolean(text.trim() || files.media_ids.length) &&
!sending && !sending &&
!files.uploading; !files.uploading &&
online;
async function send() { async function send() {
if (!can_send || !bot.id) return; if (!can_send || !bot.id) return;
@@ -98,7 +101,7 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
onRemove={files.remove} onRemove={files.remove}
/> />
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline"> <Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto h-[88px] gap-4! flex-nowrap items-center px-6"> <Row className="w-full max-w-container mx-auto h-[88px] gap-3! md:gap-4! flex-nowrap items-center px-3 md:px-6">
<ComposerAttachButton <ComposerAttachButton
disabled={files.full} disabled={files.full}
onFiles={files.add} onFiles={files.add}
@@ -15,6 +15,7 @@ import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal"; import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button"; import NewSessionButton from "./new-session-button";
import HeaderIconButton from "@/src/components/general/header-icon-button"; import HeaderIconButton from "@/src/components/general/header-icon-button";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
import HeaderMetaItem from "./header-meta-item"; import HeaderMetaItem from "./header-meta-item";
type Props = { type Props = {
@@ -50,13 +51,14 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
: canManageCrew({ user_types: pageProps?.user_types }); : canManageCrew({ user_types: pageProps?.user_types });
return ( return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline"> <Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<BotProfileModal bot={bot} /> <BotProfileModal bot={bot} />
<Stack className="min-w-0 gap-0!"> <Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full"> <H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name} {bot.name}
</H1> </H1>
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap"> <Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? ( {workspace?.id ? (
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} /> <HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
) : null} ) : null}
@@ -86,19 +88,23 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
/> />
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} /> <NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} /> <BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
<BotModelsModal <span className="hidden md:inline-flex">
key={bot.id} <BotModelsModal
bot={bot} key={bot.id}
workspace={workspace} bot={bot}
harnesses={harnesses} workspace={workspace}
onSaved={setHarnesses} harnesses={harnesses}
/> onSaved={setHarnesses}
{can_open_settings ? (
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/> />
</span>
{can_open_settings ? (
<span className="hidden md:inline-flex">
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
</span>
) : null} ) : null}
</Row> </Row>
</Row> </Row>
@@ -39,7 +39,7 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
role="log" role="log"
aria-live="polite" aria-live="polite"
aria-label={`Conversation with ${bot.name}`} aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-6 pt-6 pb-6" className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
> >
{groups.map((group) => ( {groups.map((group) => (
<MessageGroupBlock <MessageGroupBlock
@@ -28,14 +28,22 @@ type Props = {
* settings page from the title. Every list uses it, so the row behaves the * settings page from the title. Every list uses it, so the row behaves the
* same, and the open button shows it in the viewer over the page. * same, and the open button shows it in the viewer over the page.
*/ */
export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) { export default function ComputerListRow({
computer,
badge,
subtitle,
children,
linked = true,
}: Props) {
const name = computer.name || `Computer ${computer.id}`; const name = computer.name || `Computer ${computer.id}`;
const ready = computer.status == "running"; const ready = computer.status == "running";
return ( return (
<SettingsListRow <SettingsListRow
avatar={<ComputerIconTile />} avatar={<ComputerIconTile />}
href={linked ? `/admin/settings/computers/${computer.id}` : undefined} href={
linked ? `/admin/settings/computers/${computer.id}` : undefined
}
title={name} title={name}
badge={badge} badge={badge}
subtitle={ subtitle={
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1"; import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
type Props = { type Props = {
title: string; title: string;
@@ -15,7 +16,8 @@ type Props = {
/** /**
* Same 64px header as a conversation, so moving between bots and settings * Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page. * doesn't shift the page. Below `md` it also holds the rail's drawer
* button, since the rail is out of the layout there.
*/ */
export default function SettingsPaneHeader({ export default function SettingsPaneHeader({
title, title,
@@ -23,7 +25,8 @@ export default function SettingsPaneHeader({
action, action,
}: Props) { }: Props) {
return ( return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline"> <Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<Stack className="min-w-0 gap-0!"> <Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate"> <H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{title} {title}
@@ -44,7 +44,7 @@ export default function SettingsShell({
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch"> <Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack <Stack
className={twMerge( className={twMerge(
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16", "w-full max-w-container mx-auto gap-0! items-stretch px-4 md:px-6 lg:px-10 pb-16",
wide && "max-w-[1200px]", wide && "max-w-[1200px]",
)} )}
> >
@@ -0,0 +1,46 @@
"use client";
import { Smartphone } from "lucide-react";
import type { PushDeviceOverview } from "@/src/types";
import IconTile from "@/src/components/general/icon-tile";
import relativeTime from "@/src/utils/relative-time";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
subscription: PushDeviceOverview;
removing: boolean;
onRemove: () => void;
};
/**
* One device in the Notifications list.
*/
export default function NotificationDeviceRow({
subscription,
removing,
onRemove,
}: Props) {
const last_used = subscription.last_used_at
? `Last used ${relativeTime({ at: subscription.last_used_at })} ago`
: "Not used yet";
return (
<SettingsListRow
avatar={<IconTile Icon={Smartphone} />}
title={subscription.device}
subtitle={
subscription.last_error
? `Last send failed: ${subscription.last_error}`
: `Added ${relativeTime({ at: subscription.created_at })} ago · ${last_used}`
}
>
<SettingsRowAction
title={`Remove ${subscription.device}`}
label={removing ? "Removing" : "Remove"}
disabled={removing}
onClick={onRemove}
/>
</SettingsListRow>
);
}
@@ -0,0 +1,99 @@
"use client";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { useState } from "react";
import { BellRing, Send } from "lucide-react";
import type usePushNotifications from "@/src/hooks/use-push-notifications";
type Props = ReturnType<typeof usePushNotifications> & {
onChanged?: () => void;
};
const StateCopy: Record<string, string> = {
loading: "Checking this device.",
dev: "Notifications need the installed app. They work in a production build over https, or on localhost.",
unsupported:
"This browser can't receive notifications. On iPhone and iPad, add the app to the Home Screen first, then open it from there.",
"needs-install":
"Add the app to the Home Screen first. iPhone and iPad only allow notifications for installed apps.",
denied:
"Notifications are blocked for this site. Allow them in the browser's site settings, then reload this page.",
};
/**
* The switch for the device the settings are open on, and a test send.
*/
export default function NotificationsDeviceGroup({
state,
enabled,
busy,
error,
enable,
disable,
send_test,
onChanged,
}: Props) {
const [test_status, setTestStatus] = useState<string>();
const can_switch = state == "on" || state == "off";
const copy = error || StateCopy[state];
return (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-3! flex-nowrap items-start">
<Checkbox
checked={enabled}
readOnly={!can_switch || busy}
label="Notifications on this device"
clickDispatchHandler={async (on) => {
if (!can_switch || busy) return;
const ok = on ? await enable() : await disable();
if (ok) onChanged?.();
}}
/>
</Row>
{copy ? (
<Span
role={error ? "alert" : "status"}
className="text-[13px] leading-[18px] text-ink-secondary"
>
{copy}
</Span>
) : null}
<Row className="gap-2! flex-nowrap">
<Button
title="Send a test notification to this user's devices"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={!enabled || busy}
loading={busy}
beforeIcon={
enabled ? <Send size={15} /> : <BellRing size={15} />
}
onClick={async () => {
setTestStatus(undefined);
const ok = await send_test();
if (ok) setTestStatus("Test sent. It may take a few seconds to arrive.");
}}
>
Send a test
</Button>
</Row>
{test_status ? (
<Span
role="status"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{test_status}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { PushDeviceOverview } from "@/src/types";
import SettingsList from "../../(partials)/settings-list";
import NotificationDeviceRow from "./notification-device-row";
type Props = {
subscriptions: PushDeviceOverview[];
status?: string;
onRemoved?: (msg?: string) => void;
};
/**
* Every device this user gets notifications on.
*/
export default function NotificationsDevicesGroup({
subscriptions,
status,
onRemoved,
}: Props) {
const [removing_id, setRemovingId] = useState<number>();
const remove = async (id: number) => {
setRemovingId(id);
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/remove-device",
{ method: "POST", body: { id } },
);
setRemovingId(undefined);
if (res.success) onRemoved?.("Device removed.");
};
return (
<>
<SettingsList
is_empty={!subscriptions.length}
empty="No device yet. Turn notifications on above."
>
{subscriptions.map((subscription) => (
<NotificationDeviceRow
key={subscription.id}
subscription={subscription}
removing={removing_id == subscription.id}
onRemove={() => remove(subscription.id)}
/>
))}
</SettingsList>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import SettingsGroup from "../../(partials)/settings-group";
import NotificationsDeviceGroup from "../(partials)/notifications-device-group";
import NotificationsDevicesGroup from "../(partials)/notifications-devices-group";
import { AppContext } from "@/src/pages/__root";
import usePushNotifications from "@/src/hooks/use-push-notifications";
import type { PushOverview } from "@/src/types";
/**
* Notifications settings: switch this device on or off, send a test, and
* see the other devices that receive notifications.
*/
export default function NotificationsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState<PushOverview>({
public_key: pageProps?.push_overview?.public_key || null,
subscriptions: pageProps?.push_overview?.subscriptions || [],
});
const [devices_status, setDevicesStatus] = useState<string>();
const push = usePushNotifications({ public_key: overview.public_key });
const refresh = async () => {
const res = await fetchApi<{}, APIResponseObject<PushOverview>>(
"/api/admin/push/overview",
{ method: "GET" },
);
if (res.success && res.singleRes) {
setOverview(res.singleRes);
}
};
return (
<>
<SettingsGroup
title="This device"
description="A notification arrives when a bot replies or needs you and the app is closed. While the app is open, nothing is sent."
>
<NotificationsDeviceGroup {...push} onChanged={refresh} />
</SettingsGroup>
<SettingsGroup
title="Devices"
description="Every device that receives notifications. Removing one stops it here."
>
<NotificationsDevicesGroup
subscriptions={overview.subscriptions}
status={devices_status}
onRemoved={(msg) => {
setDevicesStatus(msg);
refresh();
}}
/>
</SettingsGroup>
</>
);
}
@@ -0,0 +1,27 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* The Notifications page: this user's devices and the public key a new
* device subscribes with.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const keys = await ensureVapidKeys().catch(() => null);
return {
props: {
...settings.props,
push_overview: await grabPushOverview({
user_id: settings.props.person.id,
public_key: keys?.public_key || null,
}),
},
};
};
@@ -0,0 +1,16 @@
import NotificationsSection from "./(sections)/notifications-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsNotificationsPage() {
return (
<SettingsShell
title="Notifications"
description="When a bot needs you, and which devices hear about it."
>
<NotificationsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Notifications" });
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import type { PushOverview } from "@/src/types";
/**
* The Notifications page refreshes this user's devices after a change
* without a reload.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<PushOverview>
> = async ({ req }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
return {
success: true,
singleRes: await grabPushOverview({
user_id: user.id,
public_key: (await ensureVapidKeys().catch(() => null))
?.public_key,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscriptionById from "@/src/functions/backend/db/push/delete-push-subscription-by-id";
import { PushSubscriptionIdRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Removes one of the session user's devices from the settings list. Its
* browser keeps its own subscription until it turns notifications off
* there, which also removes it again on the next subscribe/unsubscribe.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { id } = parseWithSchema({
schema: PushSubscriptionIdRequestSchema,
data: body,
});
await deletePushSubscriptionById({ user_id: user.id, id });
return { success: true, msg: "Device removed." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+49
View File
@@ -0,0 +1,49 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import savePushSubscription from "@/src/functions/backend/db/push/save-push-subscription";
import { SavePushSubscriptionSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Saves this device's push subscription for the session user. The
* endpoint is unique, so subscribing again only refreshes the keys.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const data = parseWithSchema({
schema: SavePushSubscriptionSchema,
data: body,
});
await savePushSubscription({
user_id: user.id,
endpoint: data.endpoint,
p256dh: data.keys.p256dh,
auth: data.keys.auth,
user_agent: data.user_agent,
platform: data.platform,
});
return { success: true, msg: "Notifications are on for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+54
View File
@@ -0,0 +1,54 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import deliverToUser from "@/src/push/deliver-to-user";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { SiteData } from "@/src/data/site-data";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Sends one notification to every device this user has subscribed, so
* they can check that notifications arrive without waiting for a bot.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const subscriptions = await grabPushSubscriptions({
user_id: user.id,
});
if (!subscriptions.length) {
throw new AppError(
`Turn notifications on for this device first, then try again.`,
);
}
await deliverToUser({
user_id: user.id,
payload: {
title: `${SiteData["SiteName"]} notifications are on`,
body: "This is what a bot's message will look like.",
url: "/admin/settings/notifications",
tag: "cobble-test",
},
});
return { success: true, msg: "Test sent." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+42
View File
@@ -0,0 +1,42 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscription from "@/src/functions/backend/db/push/delete-push-subscription";
import { PushEndpointRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Forgets this device. The browser also drops its own subscription first,
* so the two never disagree for long.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { endpoint } = parseWithSchema({
schema: PushEndpointRequestSchema,
data: body,
});
await deletePushSubscription({ user_id: user.id, endpoint });
return { success: true, msg: "Notifications are off for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+1 -1
View File
@@ -21,7 +21,7 @@ export default function LogoutPage() {
return ( return (
<Section <Section
className={twMerge( className={twMerge(
"h-screen w-screen px-0 items-center justify-center", "h-dvh w-screen px-0 items-center justify-center",
)} )}
> >
<Center> <Center>
+23
View File
@@ -0,0 +1,23 @@
import type { PushPayload } from "@/src/dict/push-dict";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import sendWebPush from "./send-web-push";
/**
* Sends to every device the user subscribed. Best effort: one failed
* device does not stop the others.
*/
export default async function deliverToUser({
user_id,
payload,
}: {
user_id: number | string;
payload: PushPayload;
}) {
const subscriptions = await grabPushSubscriptions({ user_id });
await Promise.all(
subscriptions.map((subscription) =>
sendWebPush({ subscription, payload }),
),
);
}
+65
View File
@@ -0,0 +1,65 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import { logError } from "@/src/functions/backend/logger";
import { pushNeedsYouTag, truncatePushBody } from "@/src/dict/push-dict";
import isUserPresent from "@/src/functions/backend/db/push/is-user-present";
import deliverToUser from "./deliver-to-user";
type Row = {
bot_name: string | null;
workspace_id: number | null;
session_id: number | null;
};
/**
* A bot changing to needs-you without posting a message (a task that
* finished running into something only the user can solve). Messages that
* carry needs-you already notify from `notifyBotMessage`.
*/
export default async function notifyBotFace({
bot,
}: {
bot: BotFaceChange;
}) {
try {
if (bot.face != "needs-you") return;
if (await isUserPresent({ user_id: bot.user_id })) return;
const row = (
await BunSQLite.sql<Row>({
sql: `SELECT b.name AS bot_name, s.workspace_id, s.id AS session_id
FROM bots b
LEFT JOIN bot_sessions s ON s.bot_id = b.id AND s.user_id = ? AND s.status = 'active'
WHERE b.id = ?
ORDER BY s.last_activity_at DESC
LIMIT 1`,
values: [bot.user_id, bot.id],
})
).payload?.[0];
const bot_name = row?.bot_name || "A bot";
const url = row?.workspace_id
? `/admin/workspaces/${row.workspace_id}/bots/${bot.id}`
: `/admin/bots/${bot.id}`;
await deliverToUser({
user_id: bot.user_id,
payload: {
title: `${bot_name} needs you`,
body: truncatePushBody(
bot.status_line || "Open the conversation to carry on.",
),
url,
tag: pushNeedsYouTag({ bot_id: bot.id }),
requireInteraction: true,
},
});
} catch (error) {
await logError({
title: "Could not send a needs-you notification",
error,
metadata: { bot_id: bot.id, user_id: bot.user_id },
}).catch(() => {});
}
}
+81
View File
@@ -0,0 +1,81 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { logError } from "@/src/functions/backend/logger";
import { pushMessageTag, truncatePushBody } from "@/src/dict/push-dict";
import messagePreview from "@/src/utils/message-preview";
import isUserPresent from "@/src/functions/backend/db/push/is-user-present";
import deliverToUser from "./deliver-to-user";
type Row = {
user_id: number;
workspace_id: number | null;
workspace_name: string | null;
bot_name: string | null;
};
/**
* A bot's message in one of the user's conversations. Only the bot's own
* messages notify; the user's are already on their screen. Skipped while
* the app is open anywhere.
*/
export default async function notifyBotMessage({
message,
}: {
message: BUN_SQLITE_COBBLE_BOT_MESSAGES;
}) {
try {
const bot_id = Number(message.bot_id);
const session_id = Number(message.session_id);
if (message.author != "bot" || !bot_id || !session_id) return;
const row = (
await BunSQLite.sql<Row>({
sql: `SELECT s.user_id, s.workspace_id, w.name AS workspace_name, b.name AS bot_name
FROM bot_sessions s
LEFT JOIN workspaces w ON w.id = s.workspace_id
LEFT JOIN bots b ON b.id = ?
WHERE s.id = ? LIMIT 1`,
values: [bot_id, session_id],
})
).payload?.[0];
if (!row?.user_id) return;
if (await isUserPresent({ user_id: row.user_id })) return;
const preview = messagePreview({ text: message.text || "" });
const body = preview
? truncatePushBody(preview)
: "Sent you an attachment.";
const bot_name = row.bot_name || "A bot";
const title = row.workspace_name
? `${bot_name} in ${row.workspace_name}`
: bot_name;
const url = row.workspace_id
? `/admin/workspaces/${row.workspace_id}/bots/${bot_id}`
: `/admin/bots/${bot_id}`;
await deliverToUser({
user_id: row.user_id,
payload: {
title,
body,
url,
tag: pushMessageTag({
bot_id,
session_id,
}),
requireInteraction: Boolean(message.needs_you),
},
});
} catch (error) {
await logError({
title: "Could not send a message notification",
error,
metadata: { message_id: message.id },
}).catch(() => {});
}
}
+68
View File
@@ -0,0 +1,68 @@
import webpush from "web-push";
import type { BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS } from "@/db/types/db";
import type { PushPayload } from "@/src/dict/push-dict";
import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import markPushSubscription, {
deleteGonePushSubscription,
} from "@/src/functions/backend/db/push/mark-push-subscription";
import { configureWebPush } from "./vapid";
type Params = {
subscription: BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS;
payload: PushPayload;
};
/**
* Sends one notification and records how it went. A device the push
* service no longer knows is removed. Never throws: a notification is
* never worth failing the work that produced it.
*/
export default async function sendWebPush({ subscription, payload }: Params) {
try {
const keys = await configureWebPush();
if (!keys) return false;
await webpush.sendNotification(
{
endpoint: subscription.endpoint || "",
keys: {
p256dh: subscription.p256dh || "",
auth: subscription.auth || "",
},
},
JSON.stringify(payload),
{
TTL: AppData.PushTtlSeconds,
urgency: payload.requireInteraction ? "high" : "normal",
},
);
await markPushSubscription({ endpoint: subscription.endpoint || "" });
return true;
} catch (error: any) {
const status = Number(error?.statusCode);
if (status == 404 || status == 410) {
await deleteGonePushSubscription({
endpoint: subscription.endpoint || "",
}).catch(() => {});
return false;
}
await markPushSubscription({
endpoint: subscription.endpoint || "",
error: error?.message || "Send failed",
}).catch(() => {});
await logError({
title: "Web Push send failed",
error,
metadata: { endpoint: subscription.endpoint?.slice(0, 120) },
}).catch(() => {});
return false;
}
}
+102
View File
@@ -0,0 +1,102 @@
import webpush from "web-push";
import { SiteData } from "@/src/data/site-data";
import grabAllEnvs from "@/src/functions/backend/db/envs/grab-all-envs";
import grabEnv from "@/src/functions/backend/db/envs/grab-env";
import saveEnv from "@/src/functions/backend/db/envs/save-env";
import deleteEnv from "@/src/functions/backend/db/envs/delete-env";
import { logError } from "@/src/functions/backend/logger";
export type VapidKeys = {
public_key: string;
private_key: string;
};
let configured_for: string | undefined;
/**
* The install's Web Push key pair, from the Environment settings. The
* first server that starts generates it, so push works without setup;
* a super admin can replace it in Settings.
*
* A pair must match, so when either half is missing both are generated
* and the stray half is replaced. Not part of a request path's
* correctness: a failure to read or save only means no notifications
* until it is fixed.
*/
export async function ensureVapidKeys(): Promise<VapidKeys | null> {
const public_key = await grabEnv({ key: "VAPID_PUBLIC_KEY" });
const private_key = await grabEnv({ key: "VAPID_PRIVATE_KEY" });
if (public_key && private_key) {
return { public_key, private_key };
}
const generated = webpush.generateVAPIDKeys();
/**
* A saved half would clash with the new one (`saveEnv` allows one row
* per key), and a mismatched pair can't sign.
*/
const saved = await grabAllEnvs();
const stale = saved.filter(
(env) =>
env.paradigm == "general" &&
(env.key == "VAPID_PUBLIC_KEY" || env.key == "VAPID_PRIVATE_KEY"),
);
for (const env of stale) {
await deleteEnv({ env_id: env.id });
}
await saveEnv({
user_id: 0,
key: "VAPID_PUBLIC_KEY",
value: generated.publicKey,
paradigm: "general",
secret: false,
expose_to_computers: false,
});
await saveEnv({
user_id: 0,
key: "VAPID_PRIVATE_KEY",
value: generated.privateKey,
paradigm: "general",
secret: true,
expose_to_computers: false,
});
const saved_public = await grabEnv({ key: "VAPID_PUBLIC_KEY" });
const saved_private = await grabEnv({ key: "VAPID_PRIVATE_KEY" });
if (!saved_public || !saved_private) return null;
return { public_key: saved_public, private_key: saved_private };
}
/**
* `web-push` keeps the details globally; set them when the pair changes.
*/
export async function configureWebPush(): Promise<VapidKeys | null> {
const keys = await ensureVapidKeys().catch(async (error) => {
await logError({ title: "Could not load Web Push keys", error }).catch(
() => {},
);
return null;
});
if (!keys) return null;
const fingerprint = `${keys.public_key}:${keys.private_key}`;
if (configured_for != fingerprint) {
webpush.setVapidDetails(
SiteData["SiteURL"],
keys.public_key,
keys.private_key,
);
configured_for = fingerprint;
}
return keys;
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
const PushEndpointField = z
.string()
.trim()
.min(1, "Missing the device's push endpoint")
.max(2048, "That push endpoint is too long")
.refine(
(value) => value.startsWith("https://"),
"A push endpoint must use https",
);
export const SavePushSubscriptionSchema = z.object({
endpoint: PushEndpointField,
keys: z.object({
p256dh: z.string().trim().min(1).max(200),
auth: z.string().trim().min(1).max(100),
}),
/**
* Sent by the browser, only to label the device in Settings.
*/
user_agent: z.string().max(400).optional(),
platform: z.string().max(100).optional(),
});
export type SavePushSubscriptionRequest = z.infer<
typeof SavePushSubscriptionSchema
>;
export const PushEndpointRequestSchema = z.object({
endpoint: PushEndpointField,
});
export const PushSubscriptionIdRequestSchema = z.object({
id: z.coerce.number().int().positive("Pick a device"),
});
+300 -9
View File
@@ -1,14 +1,38 @@
import { existsSync, mkdirSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, writeFileSync } from "fs";
import path from "path"; import path from "path";
import sharp from "sharp"; import sharp from "sharp";
import { SiteData } from "../data/site-data";
import {
PwaIcons,
PwaManifestName,
PwaManifestPath,
PwaOfflinePath,
PwaScope,
PwaShortName,
PwaShortcuts,
PwaStartPath,
} from "../data/pwa-data";
const ROOT = path.resolve(import.meta.dir, "../.."); const ROOT = path.resolve(import.meta.dir, "../..");
const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg"); const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg");
const PUBLIC_DIR = path.join(ROOT, "public"); const PUBLIC_DIR = path.join(ROOT, "public");
const ICONS_DIR = path.join(PUBLIC_DIR, "icons");
const MAIN_CSS = path.join(ROOT, "src/styles/main.css");
const ICO_SIZES = [16, 24, 32, 48] as const; const ICO_SIZES = [16, 24, 32, 48] as const;
const PNG_SIZES = [...ICO_SIZES, 180, 512] as const; const PNG_SIZES = [...ICO_SIZES, 180, 512] as const;
/**
* The app icon is the stone on paper, with room around it for the
* launcher's mask. The favicons keep the transparent stone.
*/
const AppIconMarkScale = 0.8;
/**
* A maskable icon is cropped to a circle of 80% of the canvas, so the
* stone stays inside it with room to spare.
*/
const MaskableMarkScale = 0.52;
function buildIco(pngs: Record<number, Buffer>): Buffer { function buildIco(pngs: Record<number, Buffer>): Buffer {
const header = Buffer.alloc(6); const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); header.writeUInt16LE(0, 0);
@@ -38,16 +62,245 @@ function buildIco(pngs: Record<number, Buffer>): Buffer {
return Buffer.concat([header, ...entries, ...blobs]); return Buffer.concat([header, ...entries, ...blobs]);
} }
function escapeHtml(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
type ThemeColors = {
paper: string;
ink: string;
ink_secondary: string;
hairline: string;
dark_paper: string;
dark_ink: string;
dark_ink_secondary: string;
dark_hairline: string;
};
/**
* The theme's colors come from `src/styles/main.css`, the only place they
* are defined, so the manifest and the offline page never drift from it.
*/
async function grabThemeColors(): Promise<ThemeColors> {
const css = await Bun.file(MAIN_CSS).text();
const root_block = css.match(/:root\s*\{([^}]*)\}/s)?.[1] || "";
const dark_block = css.match(/\.dark\s*\{([^}]*)\}/s)?.[1] || "";
const token = (block: string, name: string) =>
block.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1]?.trim();
const paper = token(root_block, "c-paper");
const ink = token(root_block, "c-ink");
const ink_secondary = token(root_block, "c-ink-secondary");
const hairline = token(root_block, "c-border");
const dark_paper = token(dark_block, "c-paper");
const dark_ink = token(dark_block, "c-ink");
const dark_ink_secondary = token(dark_block, "c-ink-secondary");
const dark_hairline = token(dark_block, "c-border");
if (
!paper ||
!ink ||
!ink_secondary ||
!hairline ||
!dark_paper ||
!dark_ink ||
!dark_ink_secondary ||
!dark_hairline
) {
throw new Error("Cobble color tokens not found in src/styles/main.css");
}
return {
paper,
ink,
ink_secondary,
hairline,
dark_paper,
dark_ink,
dark_ink_secondary,
dark_hairline,
};
}
async function renderAppIcon({
svg,
size,
scale,
background,
}: {
svg: Buffer;
size: number;
scale: number;
background: string;
}) {
const mark_size = Math.max(1, Math.round(size * scale));
const mark = await sharp(svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background,
},
})
.composite([{ input: mark, gravity: "center" }])
.png()
.toBuffer();
}
function buildManifest({ colors }: { colors: ThemeColors }) {
return {
id: "/",
name: PwaManifestName,
short_name: PwaShortName,
description: "Your crew of small AI worker bots.",
lang: "en",
dir: "ltr",
start_url: PwaStartPath,
scope: PwaScope,
display: "standalone",
display_override: ["standalone", "minimal-ui"],
orientation: "any",
background_color: colors.paper,
theme_color: colors.paper,
categories: ["productivity", "utilities"],
prefer_related_applications: false,
icons: PwaIcons,
shortcuts: PwaShortcuts,
};
}
function buildOfflinePage({
colors,
logo,
}: {
colors: ThemeColors;
logo: string;
}) {
const name = escapeHtml(SiteData["SiteName"]);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="${colors.paper}" />
<title>Offline | ${name}</title>
<link rel="manifest" href="${PwaManifestPath}" />
<style>
:root {
--paper: ${colors.paper};
--ink: ${colors.ink};
--ink-secondary: ${colors.ink_secondary};
--hairline: ${colors.hairline};
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: ${colors.dark_paper};
--ink: ${colors.dark_ink};
--ink-secondary: ${colors.dark_ink_secondary};
--hairline: ${colors.dark_hairline};
}
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
background: var(--paper);
color: var(--ink);
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
main {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
h1 {
margin: 8px 0 0;
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
margin: 0;
font-size: 15px;
line-height: 1.5;
color: var(--ink-secondary);
}
button {
margin-top: 12px;
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: var(--ink);
color: var(--paper);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
</style>
</head>
<body>
<main>
${logo}
<h1>${name}</h1>
<p>You're offline.</p>
<p>Reconnect to keep working with your bots. Anything you sent before you lost the connection is still on its way.</p>
<button type="button" onclick="location.reload()">Try again</button>
</main>
</body>
</html>
`;
}
/**
* The logo inline, so the offline page has no request to make while
* offline. Its own width and height give way to ours.
*/
function inlineLogo(raw: string) {
const cleaned = raw.replace(/<\?xml[^>]*\?>/, "").replace(/<!DOCTYPE[^>]*>/, "");
return cleaned.replace(/<svg([^>]*)>/, (match, attrs: string) => {
const trimmed = attrs
.replace(/\swidth="[^"]*"/, "")
.replace(/\sheight="[^"]*"/, "");
return `<svg width="48" height="48" role="img" aria-label="${escapeHtml(
SiteData["SiteName"],
)}"${trimmed}>`;
});
}
async function main() { async function main() {
if (!existsSync(LOGO_SVG)) { if (!existsSync(LOGO_SVG)) {
throw new Error(`Logo SVG not found at ${LOGO_SVG}`); throw new Error(`Logo SVG not found at ${LOGO_SVG}`);
} }
const svg = await Bun.file(LOGO_SVG).arrayBuffer(); const svg = Buffer.from(await Bun.file(LOGO_SVG).arrayBuffer());
const colors = await grabThemeColors();
const pngs: Record<number, Buffer> = {}; const pngs: Record<number, Buffer> = {};
for (const size of PNG_SIZES) { for (const size of PNG_SIZES) {
pngs[size] = await sharp(Buffer.from(svg)) pngs[size] = await sharp(svg)
.resize(size, size) .resize(size, size)
.png() .png()
.toBuffer(); .toBuffer();
@@ -57,16 +310,54 @@ async function main() {
writeFileSync(path.join(PUBLIC_DIR, "favicon.ico"), buildIco(pngs)); writeFileSync(path.join(PUBLIC_DIR, "favicon.ico"), buildIco(pngs));
writeFileSync( writeFileSync(
path.join(PUBLIC_DIR, "apple-touch-icon.png"), path.join(PUBLIC_DIR, "apple-touch-icon.png"),
pngs[180]!, await renderAppIcon({
svg,
size: 180,
scale: AppIconMarkScale,
background: colors.paper,
}),
); );
mkdirSync(path.join(PUBLIC_DIR, "logos"), { recursive: true }); mkdirSync(path.join(PUBLIC_DIR, "logos"), { recursive: true });
writeFileSync(path.join(PUBLIC_DIR, "logos/logo.png"), pngs[512]!); writeFileSync(path.join(PUBLIC_DIR, "logos/logo.png"), pngs[512]!);
console.log("Generated brand assets from public/logos/logo.svg:"); mkdirSync(ICONS_DIR, { recursive: true });
console.log(" public/favicon.ico (16/24/32/48 multi-size)");
console.log(" public/favicon.png (32x32)"); for (const icon of PwaIcons) {
console.log(" public/apple-touch-icon.png (180x180)"); const size = Number(icon.sizes.split("x")[0]);
console.log(" public/logos/logo.png (512x512)"); const maskable = icon.purpose == "maskable";
writeFileSync(
path.join(PUBLIC_DIR, icon.src.replace(/^\//, "")),
await renderAppIcon({
svg,
size,
scale: maskable ? MaskableMarkScale : AppIconMarkScale,
background: colors.paper,
}),
);
}
writeFileSync(
path.join(PUBLIC_DIR, "manifest.webmanifest"),
`${JSON.stringify(buildManifest({ colors }), null, 4)}\n`,
);
writeFileSync(
path.join(PUBLIC_DIR, "offline.html"),
buildOfflinePage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }),
);
console.log("Generated brand and PWA assets from public/logos/logo.svg:");
console.log(" public/favicon.ico (16/24/32/48 multi-size)");
console.log(" public/favicon.png (32x32)");
console.log(" public/apple-touch-icon.png (180x180, opaque paper)");
console.log(" public/logos/logo.png (512x512)");
console.log(" public/icons/icon-192.png (app icon)");
console.log(" public/icons/icon-512.png (app icon)");
console.log(" public/icons/maskable-192.png (maskable, safe zone)");
console.log(" public/icons/maskable-512.png (maskable, safe zone)");
console.log(" public/manifest.webmanifest (name, colors and icons)");
console.log(" public/offline.html (self-contained offline page)");
} }
main() main()
@@ -74,4 +365,4 @@ main()
.catch((error) => { .catch((error) => {
console.error("Brand asset generation failed:", error); console.error("Brand asset generation failed:", error);
process.exit(1); process.exit(1);
}); });
+18
View File
@@ -9,8 +9,10 @@ import botsQueue from "./bots-queue";
import liveRelay from "./live-relay"; import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import { ComputerProxyPrefix } from "./dict/computer-dict"; import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network"; import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
import { ensureVapidKeys } from "./push/vapid";
declare global {} declare global {}
@@ -22,6 +24,18 @@ const port = process.env.PORT || 7227;
await enableForeignKeys(); await enableForeignKeys();
await bunext.bunextInit(); await bunext.bunextInit();
/**
* Web Push needs a key pair; the first start of the main install creates
* it (in the Environment settings) so notifications work without setup.
*/
if (process.env.NODE_ENV != "production" || process.env.MAIN_SERVER) {
await ensureVapidKeys().catch((error) =>
logError({ title: "Could not prepare Web Push keys", error }).catch(
() => {},
),
);
}
/** /**
* Computers are docker containers on this network, so the app doesn't * Computers are docker containers on this network, so the app doesn't
* start without docker. * start without docker.
@@ -39,6 +53,10 @@ const server = Bun.serve({
try { try {
const url = new URL(req.url); const url = new URL(req.url);
const pwa_file = await handlePwaFile({ pathname: url.pathname });
if (pwa_file) return pwa_file;
if (url.pathname.startsWith("/media")) { if (url.pathname.startsWith("/media")) {
try { try {
return await handleMediaServer({ req }); return await handleMediaServer({ req });
+3
View File
@@ -153,6 +153,9 @@
--radius-default-xl: 14px; --radius-default-xl: 14px;
--container-container: 870px; --container-container: 870px;
/* The rail's full (named) width: below it the rail shows icons only.
A container query, so the mobile drawer opens full too. */
--container-rail: 220px;
} }
body { body {
+21
View File
@@ -652,6 +652,7 @@ export type PagePropsType = {
harness_overview?: HarnessOverviewPayload | null; harness_overview?: HarnessOverviewPayload | null;
bot_harness_overview?: BotHarnessOverviewPayload | null; bot_harness_overview?: BotHarnessOverviewPayload | null;
envs_overview?: EnvsOverviewPayload | null; envs_overview?: EnvsOverviewPayload | null;
push_overview?: PushOverview | null;
mcps_overview?: McpServersOverviewPayload | null; mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null; tools_overview?: CustomToolsOverviewPayload | null;
bot_extensions?: BotExtensionsPayload | null; bot_extensions?: BotExtensionsPayload | null;
@@ -1086,3 +1087,23 @@ export type FeatureItem = {
title: string; title: string;
description: string; description: string;
}; };
/**
* One device with push notifications on, as Settings lists it. Never the
* endpoint: that is a capability URL held by the browser.
*/
export type PushDeviceOverview = {
id: number;
device: string;
created_at: number;
last_used_at: number;
last_error: string | null;
};
export type PushOverview = {
/**
* The install's VAPID public key, which a subscribing browser needs.
*/
public_key: string | null;
subscriptions: PushDeviceOverview[];
};
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import describePushDevice from "./describe-push-device";
describe("describePushDevice", () => {
test("names the browser and the device", () => {
expect(
describePushDevice({
user_agent:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
platform: "Linux armv8l",
}),
).toBe("Chrome on Android");
});
test("knows iOS browsers are WebKit but still Chrome", () => {
expect(
describePushDevice({
user_agent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.0.0 Mobile/15E148 Safari/604.1",
}),
).toBe("Chrome on iPhone");
});
test("falls back to the browser, then the platform, then a generic device", () => {
expect(describePushDevice({ user_agent: "curl/8.0" })).toBe(
"A browser",
);
expect(
describePushDevice({ user_agent: "something", platform: "linux" }),
).toBe("Linux");
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* A subscription's user agent as a short device label for the settings
* list, e.g. "Chrome on Android". Unknown parts are left out.
*/
export default function describePushDevice({
user_agent,
platform,
}: {
user_agent?: string | null;
platform?: string | null;
}): string {
const ua = user_agent || "";
const browser = /Edg\//.test(ua)
? "Edge"
: /OPR\/|Opera/.test(ua)
? "Opera"
: /Firefox\/|FxiOS/.test(ua)
? "Firefox"
: /CriOS/.test(ua)
? "Chrome"
: /Chrome\//.test(ua)
? "Chrome"
: /Safari\//.test(ua)
? "Safari"
: "";
const device = /iPhone/.test(ua)
? "iPhone"
: /iPad/.test(ua)
? "iPad"
: /Android/.test(ua)
? "Android"
: /Windows/.test(ua)
? "Windows"
: /Macintosh|Mac OS X/.test(ua)
? "Mac"
: /Linux/.test(ua)
? "Linux"
: platform
? platform.charAt(0).toUpperCase() + platform.slice(1)
: "";
if (browser && device) return `${browser} on ${device}`;
if (browser) return browser;
if (device) return device;
return "A browser";
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import urlBase64ToUint8Array from "./url-base64-to-uint8-array";
describe("urlBase64ToUint8Array", () => {
test("decodes base64url without padding", () => {
expect([...urlBase64ToUint8Array("AQID")]).toEqual([1, 2, 3]);
});
test("handles the URL-safe characters and missing padding", () => {
const bytes = urlBase64ToUint8Array("-_8");
expect([...bytes]).toEqual([0xfb, 0xff]);
});
test("returns a fresh typed array", () => {
const bytes = urlBase64ToUint8Array("AQID");
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.byteLength).toBe(3);
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* A base64url VAPID public key as the bytes `PushManager.subscribe`
* expects.
*/
export default function urlBase64ToUint8Array(base64_string: string) {
const padding = "=".repeat((4 - (base64_string.length % 4)) % 4);
const base64 = (base64_string + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let index = 0; index < raw.length; index += 1) {
bytes[index] = raw.charCodeAt(index);
}
return bytes;
}
+13
View File
@@ -11,6 +11,7 @@ import { wsSendData } from "./(functions)/ws-send-data";
import checkSessionAccess from "./(functions)/check-session-access"; import checkSessionAccess from "./(functions)/check-session-access";
import logEvent from "@/src/functions/backend/logger"; import logEvent from "@/src/functions/backend/logger";
import { computerProxySocket } from "@/src/functions/server/computers/computer-proxy-socket"; import { computerProxySocket } from "@/src/functions/server/computers/computer-proxy-socket";
import touchUserPresence from "@/src/functions/backend/db/push/touch-user-presence";
/** /**
* The twui `useWebSocket` hook sends a raw keepalive string to keep the * The twui `useWebSocket` hook sends a raw keepalive string to keep the
@@ -34,6 +35,10 @@ export const websocket = {
joinRoom(ws, "public"); joinRoom(ws, "public");
if (user.id) {
void touchUserPresence({ user_id: user.id }).catch(() => {});
}
wsSendData({ wsSendData({
ws, ws,
data: { data: {
@@ -59,6 +64,14 @@ export const websocket = {
) { ) {
if (ws.data.proxy) return computerProxySocket.message(ws, message); if (ws.data.proxy) return computerProxySocket.message(ws, message);
/**
* Every keepalive counts, including twui's raw ping string, so
* presence stays fresh while the app is open.
*/
if (ws.data.user?.id) {
void touchUserPresence({ user_id: ws.data.user.id }).catch(() => {});
}
const data = parseMessage(message); const data = parseMessage(message);
if (!data) return; if (!data) return;