Compare commits
53
Commits
fe0012635a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 |
@@ -63,3 +63,10 @@ scripts/setup/officer-setup/.setup-progress
|
|||||||
# the repository has no ecosystem file at all any more, and the next machine
|
# the repository has no ecosystem file at all any more, and the next machine
|
||||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||||
ecosystem.config.cjs
|
ecosystem.config.cjs
|
||||||
|
|
||||||
|
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||||
|
# rewritten on every install. See servers/plugins/generate.ts.
|
||||||
|
build/
|
||||||
|
build.next/
|
||||||
|
src/apps/officer-web/Plugins.gen.tsx
|
||||||
|
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||||
standing between a Member and a shell.
|
standing between a Member and a shell.
|
||||||
|
|
||||||
|
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||||
|
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||||
|
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||||
|
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||||
|
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||||
|
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||||
|
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||||
|
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||||
|
which is why nobody noticed. Found 2026-08-14.
|
||||||
|
|
||||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||||
broken panel or an endless spinner rather than a clean refusal.
|
broken panel or an endless spinner rather than a clean refusal.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
|||||||
coverage = true
|
coverage = true
|
||||||
coverageDir = "coverage"
|
coverageDir = "coverage"
|
||||||
preload = ["./test-setup.ts"]
|
preload = ["./test-setup.ts"]
|
||||||
root = "./src"
|
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||||
|
#
|
||||||
|
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||||
|
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||||
|
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||||
|
# extraction would have taken its tests out of the suite the same way.
|
||||||
|
#
|
||||||
|
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||||
|
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||||
|
root = "."
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Extracting a feature into a plugin
|
||||||
|
|
||||||
|
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||||
|
|
||||||
|
**Read first, in this order:**
|
||||||
|
|
||||||
|
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||||
|
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||||
|
3. `plugins/offscale/` — the worked example, all four parts
|
||||||
|
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||||
|
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||||
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The rules. These are not preferences
|
||||||
|
|
||||||
|
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||||
|
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||||
|
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||||
|
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||||
|
|
||||||
|
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||||
|
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||||
|
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||||
|
|
||||||
|
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||||
|
|
||||||
|
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||||
|
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||||
|
structural is convention — presence is the declaration:
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts required
|
||||||
|
api/router.ts a backend router, mounted at mountPrefix()
|
||||||
|
db/schema.ts tables, prefixed <app-name>_
|
||||||
|
sidecar/index.ts a process (.mjs instead means node)
|
||||||
|
web/panels.ts panels — REQUIRED with web/
|
||||||
|
web/layout.ts layout — REQUIRED with web/
|
||||||
|
```
|
||||||
|
|
||||||
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||||
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||||
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||||
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||||
|
|
||||||
|
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||||
|
|
||||||
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||||
|
anywhere else means two systems, and only one gets tested.
|
||||||
|
|
||||||
|
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||||
|
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||||
|
on uninstall. Only deleting a plugin's source can lose its data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The order that worked
|
||||||
|
|
||||||
|
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||||
|
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||||
|
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||||
|
`plugins/<name>/db/`.
|
||||||
|
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||||
|
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||||
|
reference a plugin may make.
|
||||||
|
4. **Write `manifest.ts`.**
|
||||||
|
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||||
|
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||||
|
and `helpers/clipboard` stay as they are.
|
||||||
|
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||||
|
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||||
|
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||||
|
**both** database barrels (`index.ts` and `schema.ts`).
|
||||||
|
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||||
|
8. **Verify on the live server** — see below.
|
||||||
|
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification — run all of it
|
||||||
|
|
||||||
|
```
|
||||||
|
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||||
|
|
||||||
|
| Step | Expect |
|
||||||
|
| ------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||||
|
| the plugin's API | answers |
|
||||||
|
| the plugin's screen | renders as a Workspace |
|
||||||
|
| dock | tile appears |
|
||||||
|
| permissions page | its permission is listed, read/write/none |
|
||||||
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||||
|
| enable | comes back |
|
||||||
|
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||||
|
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||||
|
| install again | identical to the first install |
|
||||||
|
|
||||||
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Traps, all of which cost real time once
|
||||||
|
|
||||||
|
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||||
|
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||||
|
`install.ts`; do not reorder it.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||||
|
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||||
|
pending for the whole protocol.
|
||||||
|
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||||
|
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||||
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||||
|
contract change. Regenerate rather than hand-edit.
|
||||||
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
|
||||||
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||||
|
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
|
||||||
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||||
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||||
|
`catalogue.ts` is the one to read.
|
||||||
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||||
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||||
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||||
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||||
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||||
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||||
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||||
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||||
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||||
|
it. Find those before you plan the split; they decide it for you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Music is done. What it changed about this runbook
|
||||||
|
|
||||||
|
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||||
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||||
|
|
||||||
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||||
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||||
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||||
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||||
|
none, so it came out whole and made the job look cleaner than it is.
|
||||||
|
|
||||||
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||||
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||||
|
not tidy them into a plugin as a warm-up.
|
||||||
|
|
||||||
|
### The global-overlay question is answered, and the answer is no
|
||||||
|
|
||||||
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||||
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||||
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||||
|
gated on its plugin's permission so it switches itself off with the plugin.
|
||||||
|
|
||||||
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which one next
|
||||||
|
|
||||||
|
No decision has been made. What the tree says, for whoever picks it:
|
||||||
|
|
||||||
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||||
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||||
|
is exactly what its extraction needs.
|
||||||
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||||
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||||
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||||
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||||
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||||
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||||
|
|
||||||
|
## Still open, platform-wide. Do not rediscover these
|
||||||
|
|
||||||
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||||
|
music would have been the first and cliamp being out of scope is what let it pass.
|
||||||
|
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||||
|
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||||
|
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||||
|
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
|
||||||
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||||
|
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||||
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||||
|
owner's. Its own job, not the platform's.
|
||||||
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||||
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||||
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRouter } from '@@/create-router';
|
||||||
|
|
||||||
|
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||||
|
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||||
|
//
|
||||||
|
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||||
|
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||||
|
export const router = createRouter();
|
||||||
|
|
||||||
|
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||||
|
// the smallest thing that is still a real one: a manifest and one route.
|
||||||
|
//
|
||||||
|
// Everything structural is convention, so this directory IS the documentation:
|
||||||
|
//
|
||||||
|
// manifest.ts you are here — only what a directory listing cannot say
|
||||||
|
// api/router.ts exports `router`; mounted at /api/example
|
||||||
|
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||||
|
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||||
|
// web/Router.tsx a frontend, if it had one
|
||||||
|
//
|
||||||
|
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||||
|
// code sits.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Example',
|
||||||
|
summary: 'The reference plugin — one route, nothing else',
|
||||||
|
icon: 'Puzzle',
|
||||||
|
color: '#94a3b8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||||
|
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'example',
|
||||||
|
label: 'Example',
|
||||||
|
description: 'The reference plugin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The reference sidecar: a long-lived process PM2 supervises.
|
||||||
|
//
|
||||||
|
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||||
|
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||||
|
//
|
||||||
|
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||||
|
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||||
|
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||||
|
// installer, and adding a socket here would test Bun rather than that.
|
||||||
|
|
||||||
|
const name = 'officer-example';
|
||||||
|
console.log(`[${name}] started (pid ${process.pid})`);
|
||||||
|
|
||||||
|
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||||
|
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||||
|
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
console.log(`[${name}] ${signal} — exiting`);
|
||||||
|
clearInterval(beat);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
// The second panel, reading the URL rather than being told by its sibling.
|
||||||
|
//
|
||||||
|
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||||
|
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||||
|
// independently; nothing is passed between them, so they cannot disagree.
|
||||||
|
export const ExampleDetail = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-duck-dark/40">
|
||||||
|
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||||
|
the panel beside it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||||
|
//
|
||||||
|
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||||
|
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||||
|
export const ExampleOverview = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['example', 'ping'],
|
||||||
|
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||||
|
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||||
|
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||||
|
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||||
|
//
|
||||||
|
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||||
|
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'example-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||||
|
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Puzzle, ListTree } from 'lucide-react';
|
||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { ExampleOverview } from './ExampleOverview';
|
||||||
|
import { ExampleDetail } from './ExampleDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||||
|
//
|
||||||
|
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||||
|
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||||
|
// a rule someone has to remember.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||||
|
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -29,12 +29,12 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
|||||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||||
|
|
||||||
| Case | Status | Headers |
|
| Case | Status | Headers |
|
||||||
|---|---|---|
|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||||
|
|
||||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||||
VBR MP3s. No need to scan the file.
|
VBR MP3s. No need to scan the file.
|
||||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||||
|
|
||||||
@@ -51,13 +51,14 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
|||||||
this instead of walking + ID3-parsing the library itself.
|
this instead of walking + ID3-parsing the library itself.
|
||||||
|
|
||||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||||
|
|
||||||
### 2.1 Manifest — one call, whole library
|
### 2.1 Manifest — one call, whole library
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/music/manifest
|
GET /api/music/manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -66,11 +67,12 @@ GET /api/music/manifest
|
|||||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||||
@@ -82,7 +84,9 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
|||||||
```
|
```
|
||||||
GET /api/music/meta?path=<rel>
|
GET /api/music/meta?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
|||||||
"track": "1",
|
"track": "1",
|
||||||
"year": "1980",
|
"year": "1980",
|
||||||
"durationSec": 312,
|
"durationSec": 312,
|
||||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
],
|
],
|
||||||
"videos": [ // present only for folders that contain video files
|
"videos": [
|
||||||
|
// present only for folders that contain video files
|
||||||
{
|
{
|
||||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
"durationSec": 8130,
|
"durationSec": 8130,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||||
omitted entirely when the folder has none.
|
omitted entirely when the folder has none.
|
||||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
|||||||
```
|
```
|
||||||
GET /api/music/cover?path=<rel>
|
GET /api/music/cover?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
|||||||
```
|
```
|
||||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
|||||||
```
|
```
|
||||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
|||||||
```
|
```
|
||||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"artist": "Anthrax",
|
"artist": "Anthrax",
|
||||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
|||||||
"[1984] Fistful Of Metal": "Studio",
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
"[1985] Armed And Dangerous": "EP",
|
"[1985] Armed And Dangerous": "EP",
|
||||||
"[1994] The Island Years": "Live",
|
"[1994] The Island Years": "Live",
|
||||||
"[1991] Attack Of The Killer B's": "Compilation"
|
"[1991] Attack Of The Killer B's": "Compilation",
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexStatus`:
|
`IndexStatus`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"running": true,
|
"running": true,
|
||||||
"startedAt": 1785034701973, "finishedAt": null,
|
"startedAt": 1785034701973,
|
||||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
"finishedAt": null,
|
||||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
"foldersScanned": 45,
|
||||||
|
"albumsBuilt": 12,
|
||||||
|
"albumsSkipped": 3,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"videosIndexed": 4,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"postersSaved": 4,
|
||||||
|
"lyricsIndexed": 45,
|
||||||
|
"discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
GET /api/music/reindex/stream
|
GET /api/music/reindex/stream
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||||
`IndexReport`) and **closes** the stream.
|
`IndexReport`) and **closes** the stream.
|
||||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexReport` (the `done` payload):
|
`IndexReport` (the `done` payload):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
{
|
||||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
"albums": 15,
|
||||||
|
"built": 12,
|
||||||
|
"skipped": 3,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"discographies": 3,
|
||||||
|
"elapsedSec": 37.2,
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
@@ -257,7 +289,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
|
|||||||
supplies; the server never interprets them:
|
supplies; the server never interprets them:
|
||||||
|
|
||||||
| kind | key |
|
| kind | key |
|
||||||
|---|---|
|
| -------- | --------------------------------------------------------------------- |
|
||||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||||
| `artist` | music-rel — `Albums/AC-DC` |
|
| `artist` | music-rel — `Albums/AC-DC` |
|
||||||
@@ -266,7 +298,11 @@ supplies; the server never interprets them:
|
|||||||
|
|
||||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||||
```json
|
```json
|
||||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
{
|
||||||
|
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||||
|
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||||
|
"artists": ["Albums/AC-DC"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||||
(a repeat add is a no-op).
|
(a repeat add is a no-op).
|
||||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
|||||||
|
|
||||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||||
```json
|
```json
|
||||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
{
|
||||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"durationSec": 312.5,
|
||||||
|
"positionSec": 140,
|
||||||
|
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||||
@@ -300,7 +343,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
|||||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||||
|
|
||||||
| method | path | body | returns |
|
| method | path | body | returns |
|
||||||
|---|---|---|---|
|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
|||||||
|
|
||||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||||
audio files* is a separate, later feature.)
|
audio files_ is a separate, later feature.)
|
||||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Music — the second plugin
|
||||||
|
|
||||||
|
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||||
|
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||||
|
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||||
|
|
||||||
|
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||||
|
worked second case, and it is interesting mainly for being the messy one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What music is
|
||||||
|
|
||||||
|
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||||
|
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||||
|
free to change.
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts identity, one permission
|
||||||
|
api/router.ts re-exports the platform's proxy — see below
|
||||||
|
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||||
|
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||||
|
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||||
|
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||||
|
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
web/ two panels and a layout; the shell renders the Workspace
|
||||||
|
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three things that stayed, and why
|
||||||
|
|
||||||
|
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||||
|
means each one is written down with what would close it.
|
||||||
|
|
||||||
|
### 1. cliamp — out of scope by decision
|
||||||
|
|
||||||
|
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||||
|
terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least
|
||||||
|
important part of music and not worth blocking the extraction on.
|
||||||
|
|
||||||
|
It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and
|
||||||
|
upgrade into `handlers` entries that are commented out. So:
|
||||||
|
|
||||||
|
- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
|
||||||
|
`cliamp-ws.test.ts`. **Untouched.**
|
||||||
|
- This plugin's sidecar still serves those sockets, so it imports both modules from
|
||||||
|
`@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be.
|
||||||
|
- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive.
|
||||||
|
|
||||||
|
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
|
||||||
|
|
||||||
|
`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this
|
||||||
|
plugin's `api/router.ts` **re-exports it** rather than building a second one.
|
||||||
|
|
||||||
|
That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and
|
||||||
|
subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the
|
||||||
|
first reconnect where only one of them happened to be listening — the same class of failure as the
|
||||||
|
install-order bug offscale found, and just as invisible from reading.
|
||||||
|
|
||||||
|
### 3. The player — the one open judgement call, and it is decided
|
||||||
|
|
||||||
|
**`officerdev/src/MusicPlayer/` stays in the platform.** The runbook left this open with either answer
|
||||||
|
acceptable. What decided it was not the overlay but the state:
|
||||||
|
|
||||||
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
|
||||||
|
> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ out of scope and stays.
|
||||||
|
> **The platform cannot import from a plugin.** So the player state stays here whatever is decided about
|
||||||
|
> the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
|
||||||
|
|
||||||
|
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
|
||||||
|
left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
|
||||||
|
component on **every route** — which is exactly the escape hatch this system deleted on purpose. "There
|
||||||
|
is no way to export a component" is what makes "every plugin route is a Workspace" a property of the
|
||||||
|
shape rather than a rule someone has to remember, and reopening it for one plugin is a bad trade.
|
||||||
|
|
||||||
|
The seam is inert without the plugin: `MusicPlayerHost` gates on `can('music')`, and `music` is now the
|
||||||
|
plugin's permission — registered at install, gone at uninstall.
|
||||||
|
|
||||||
|
What stayed with it, and why each: `gapless-engine` (the engine the state drives), `player-time` (the
|
||||||
|
module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
|
||||||
|
`useMusicFavorites` (the bar renders a heart), and `shared.ts` — the library vocabulary, which the host
|
||||||
|
needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
|
||||||
|
of the boundary drifting apart; `plugins/music/web/shared.ts` re-exports it from the package's declared
|
||||||
|
`officerdev/MusicPlayer/shared` subpath.
|
||||||
|
|
||||||
|
**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
|
||||||
|
should stay shut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||||
|
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||||
|
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
|
installed, enabled and healthy.
|
||||||
|
|
||||||
|
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||||
|
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||||
|
it cost minutes instead of an evening. Entry removed.
|
||||||
|
|
||||||
|
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||||
|
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||||
|
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||||
|
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||||
|
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||||
|
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||||
|
for the overlay. Renaming it would have been a silent data change.
|
||||||
|
|
||||||
|
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||||
|
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||||
|
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||||
|
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||||
|
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||||
|
|
||||||
|
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||||
|
|
||||||
|
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||||
|
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||||
|
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||||
|
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||||
|
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||||
|
change inside `db/queries.ts`, not a flag on the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host dependencies — the field music created
|
||||||
|
|
||||||
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||||
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||||
|
that. That was the wrong answer, because of HOW music fails without them.
|
||||||
|
|
||||||
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||||
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||||
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||||
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||||
|
nobody reads. A refusal wearing the costume of a normal result.
|
||||||
|
|
||||||
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
osDependencies: [
|
||||||
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||||
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||||
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||||
|
a single `ffmpeg` before anything reaches a command line.
|
||||||
|
|
||||||
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||||
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||||
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||||
|
recorded why that indirection was rejected.
|
||||||
|
|
||||||
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||||
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||||
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||||
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||||
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||||
|
named — nothing had happened, so there was nothing to undo.
|
||||||
|
|
||||||
|
`~/Music` still does not exist, so there is no library to index.
|
||||||
|
|
||||||
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified on the live server, 2026-08-15
|
||||||
|
|
||||||
|
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||||
|
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||||
|
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||||
|
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||||
|
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
|
||||||
|
| permissions page | `music` listed among the grantable |
|
||||||
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
|
| enable | 200 again, sidecar online, favourites still there |
|
||||||
|
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||||
|
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||||
|
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||||
|
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||||
|
|
||||||
|
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||||
|
exist before.
|
||||||
|
|
||||||
|
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||||
|
|
||||||
|
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||||
|
|
||||||
|
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||||
|
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** capability, and `files` is
|
||||||
|
**`confined`** — so:
|
||||||
|
|
||||||
|
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||||
|
library**, because every listing 403s;
|
||||||
|
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||||
|
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||||
|
|
||||||
|
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||||
|
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||||
|
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||||
|
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||||
|
|
||||||
|
Three possible shapes, none chosen:
|
||||||
|
|
||||||
|
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||||
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||||
|
Most self-contained, and the most work.
|
||||||
|
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||||
|
but it makes one plugin's grant conditional on another capability, which is new machinery.
|
||||||
|
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||||
|
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||||
|
|
||||||
|
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||||
|
Not tonight's call.
|
||||||
|
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||||
|
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||||
|
wire — noted rather than fixed.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||||
|
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||||
|
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||||
|
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
|
||||||
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { musicRouter } from '@@/api/music/router';
|
||||||
|
|
||||||
|
// /api/music/* — auth, then forward to officer-music.
|
||||||
|
//
|
||||||
|
// ── Why this re-exports the platform's proxy instead of creating its own ──
|
||||||
|
//
|
||||||
|
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
|
||||||
|
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
|
||||||
|
// deliberately out of scope — it is a second playback path that the platform still owns.
|
||||||
|
//
|
||||||
|
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
|
||||||
|
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
|
||||||
|
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
|
||||||
|
// proxy, one subscription, mounted by whoever needs it.
|
||||||
|
//
|
||||||
|
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
|
||||||
|
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
|
||||||
|
export const router = musicRouter;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||||
|
//
|
||||||
|
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||||
|
// a clean cut: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
|
||||||
|
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
|
||||||
|
//
|
||||||
|
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
|
||||||
|
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||||
|
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
// web/ the library panels; the shell renders the Workspace
|
||||||
|
//
|
||||||
|
// ── What stayed in the platform, and why ──
|
||||||
|
//
|
||||||
|
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
|
||||||
|
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
|
||||||
|
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
|
||||||
|
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
|
||||||
|
// imports both modules from `@@/sidecar/music/`.
|
||||||
|
//
|
||||||
|
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
|
||||||
|
// the mechanism was not worth inventing for one.
|
||||||
|
//
|
||||||
|
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
|
||||||
|
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
|
||||||
|
// the second is the one that settles it.
|
||||||
|
//
|
||||||
|
// - Moving it needs a shell slot that renders a plugin-provided component on every route. That is
|
||||||
|
// exactly the escape hatch this system deleted on purpose — "there is no way to export a component"
|
||||||
|
// is what makes "every plugin route is a Workspace" a property of the shape rather than a rule
|
||||||
|
// someone has to remember. Reopening it for one plugin is a bad trade.
|
||||||
|
// - It would not even work. The widget above imports `useMusicPlayer` and `PlayerTrack` from
|
||||||
|
// `officerdev`, and the platform cannot import from a plugin — so the player STATE stays whatever
|
||||||
|
// is decided about the UI. Splitting the engine from the state it drives would leave the same seam
|
||||||
|
// in a worse place, and two copies of that state would mean two engines.
|
||||||
|
//
|
||||||
|
// The overlay gates on `can('music')`, which resolves against the permission below — registered at
|
||||||
|
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
|
||||||
|
// that knows why.
|
||||||
|
//
|
||||||
|
// ── Host dependencies ──
|
||||||
|
//
|
||||||
|
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||||
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Music',
|
||||||
|
summary: 'The music library — browse, play, favourites and playlists',
|
||||||
|
icon: 'Music',
|
||||||
|
color: '#22c55e',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||||
|
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
|
||||||
|
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||||
|
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||||
|
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||||
|
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||||
|
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
description: 'The music library, playback, and your own favourites and playlists',
|
||||||
|
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||||
|
//
|
||||||
|
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||||
|
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||||
|
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||||
|
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||||
|
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||||
|
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||||
|
//
|
||||||
|
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||||
|
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||||
|
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||||
|
//
|
||||||
|
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||||
|
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||||
|
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||||
|
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||||
|
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'ffprobe',
|
||||||
|
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'ffmpeg',
|
||||||
|
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
|
||||||
import { ensurePulseAudio } from './pulse-audio';
|
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
@@ -37,10 +37,9 @@ import {
|
|||||||
addPlaylistItems,
|
addPlaylistItems,
|
||||||
setPlaylistItems,
|
setPlaylistItems,
|
||||||
type FavoriteKind,
|
type FavoriteKind,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
|
|
||||||
// ── Per-user state validation ──
|
// ── Per-user state validation ──
|
||||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||||
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
|
|||||||
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
// ── Audio-streaming HTTP server ──
|
// ── Audio-streaming HTTP server ──
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|||||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||||
// phone's resync diff (fetch only changed `v`s).
|
// phone's resync diff (fetch only changed `v`s).
|
||||||
|
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||||
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
||||||
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||||
if (prev.version !== next.version) {
|
if (prev.version !== next.version) {
|
||||||
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
|
console.log(
|
||||||
|
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { added, removed, changed } = diffManifest(prev, next);
|
const { added, removed, changed } = diffManifest(prev, next);
|
||||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
|
console.log(
|
||||||
|
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
|
||||||
|
);
|
||||||
const sample = (label: string, rels: string[]) => {
|
const sample = (label: string, rels: string[]) => {
|
||||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
||||||
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||||
+3
-1
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
|||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||||
const at = new Date(Date.now() + ms);
|
const at = new Date(Date.now() + ms);
|
||||||
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
|
console.log(
|
||||||
|
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||||
|
);
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
console.log('[music] nightly full reindex starting');
|
console.log('[music] nightly full reindex starting');
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
|||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration',
|
||||||
|
'-of',
|
||||||
|
'default=noprint_wrappers=1:nokey=1',
|
||||||
|
absPath,
|
||||||
|
],
|
||||||
{ stdout: 'pipe', stderr: 'ignore' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = (await new Response(proc.stdout).text()).trim();
|
const out = (await new Response(proc.stdout).text()).trim();
|
||||||
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
|
|||||||
}
|
}
|
||||||
return new Response(file.slice(start, end + 1), {
|
return new Response(file.slice(start, end + 1), {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||||
|
'Content-Length': String(end - start + 1),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-6
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from 'officerdev';
|
||||||
import { useMusicFavorites } from './useMusicFavorites';
|
import { useMusicFavorites } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
|||||||
const albumRel = toRel(albumHome);
|
const albumRel = toRel(albumHome);
|
||||||
try {
|
try {
|
||||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(
|
||||||
|
q,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
player.playQueue([{ albumRel, file }], 0);
|
player.playQueue([{ albumRel, file }], 0);
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
|||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
<Heart size={44} className="text-muted-foreground/30" />
|
<Heart size={44} className="text-muted-foreground/30" />
|
||||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No favorites yet. Click the heart on any artist, album or track.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { Loader2, Music4 } from 'lucide-react';
|
import { Loader2, Music4 } from 'lucide-react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from 'officerdev';
|
||||||
import { useActiveLyricIndex } from './useLyrics';
|
import { useActiveLyricIndex } from './useLyrics';
|
||||||
|
|
||||||
type LyricsPaneProps = {
|
type LyricsPaneProps = {
|
||||||
+2
-2
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { MicVocal } from 'lucide-react';
|
import { MicVocal } from 'lucide-react';
|
||||||
import { LyricsPane } from './LyricsPane';
|
import { LyricsPane } from './LyricsPane';
|
||||||
import { useLyrics } from './useLyrics';
|
import { useLyrics } from './useLyrics';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||||
+8
-8
@@ -4,15 +4,15 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from '../../components/Workspace';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from 'officerdev';
|
||||||
import { FavoritesView } from './FavoritesView';
|
import { FavoritesView } from './FavoritesView';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from 'officerdev';
|
||||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
import { coverUrl, fmtClock } from './shared';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from 'officerdev';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from 'officerdev';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from 'officerdev';
|
||||||
import { usePlayerClock } from './usePlayerClock';
|
import { usePlayerClock } from './usePlayerClock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { Music, ListMusic } from 'lucide-react';
|
||||||
|
import { MusicBrowser } from './MusicBrowser';
|
||||||
|
import { MusicDetail } from './MusicDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||||
|
// `layout.ts` — a plugin never renders the screen.
|
||||||
|
//
|
||||||
|
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||||
|
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||||
|
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||||
|
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// The library vocabulary, re-exported from the host.
|
||||||
|
//
|
||||||
|
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
|
||||||
|
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
|
||||||
|
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
|
||||||
|
//
|
||||||
|
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
|
||||||
|
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
|
||||||
|
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
|
||||||
|
//
|
||||||
|
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
|
||||||
|
// different specifier in each of them.
|
||||||
|
export * from 'officerdev/MusicPlayer/shared';
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||||
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||||
|
|
||||||
export type UseLyrics = {
|
export type UseLyrics = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
|
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position + duration, straight off the engine's per-frame feed.
|
* Position + duration, straight off the engine's per-frame feed.
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
# Offscale — the first real plugin
|
||||||
|
|
||||||
|
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||||
|
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||||
|
|
||||||
|
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||||
|
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||||
|
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||||
|
history of something it no longer knows exists.
|
||||||
|
|
||||||
|
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||||
|
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
|
||||||
|
|
||||||
|
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||||
|
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||||
|
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||||
|
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||||
|
|
||||||
|
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||||
|
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The reframe
|
||||||
|
|
||||||
|
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||||
|
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||||
|
intuition is that it is one plugin requiring two sidecars.
|
||||||
|
|
||||||
|
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||||
|
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||||
|
written on runs five.
|
||||||
|
|
||||||
|
### Two words, because "core" was doing two jobs
|
||||||
|
|
||||||
|
- **baseline** — what a fresh install actually runs
|
||||||
|
- **first-party** — what Officer Dev publishes
|
||||||
|
|
||||||
|
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||||
|
"is X core?" a question with two answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a plugin is made of
|
||||||
|
|
||||||
|
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||||
|
|
||||||
|
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||||
|
- an **ID** (see below)
|
||||||
|
- a **sidecar**
|
||||||
|
- a **backend router** and its routes
|
||||||
|
- a **db schema**
|
||||||
|
- **default permissions per user group**
|
||||||
|
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||||
|
- a **frontend router**, its routes, and the frontend code
|
||||||
|
- how it **mounts into the file browser context menu**
|
||||||
|
- a set of **capabilities added to officer-items**
|
||||||
|
- **plugin settings page** definitions
|
||||||
|
- an accompanying **mobile app**
|
||||||
|
|
||||||
|
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||||
|
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||||
|
|
||||||
|
### What offscale needs
|
||||||
|
|
||||||
|
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||||
|
|
||||||
|
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity and routing
|
||||||
|
|
||||||
|
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||||
|
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||||
|
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||||
|
|
||||||
|
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||||
|
|
||||||
|
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||||
|
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||||
|
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||||
|
Needed because a private plugin never asks the marketplace anything.
|
||||||
|
|
||||||
|
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||||
|
restrictions; publishing is what invites review.
|
||||||
|
|
||||||
|
### Mount prefixes
|
||||||
|
|
||||||
|
```
|
||||||
|
first-party /api/<app-name> e.g. /api/offscale
|
||||||
|
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||||
|
```
|
||||||
|
|
||||||
|
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||||
|
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||||
|
|
||||||
|
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||||
|
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||||
|
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||||
|
become two systems, and only one of them gets tested.
|
||||||
|
|
||||||
|
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||||
|
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||||
|
adding core routes forever without breaking installs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The database
|
||||||
|
|
||||||
|
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||||
|
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||||
|
|
||||||
|
### A Postgres schema per plugin was tested and rejected
|
||||||
|
|
||||||
|
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||||
|
earns. Recorded so nobody re-runs the experiment:
|
||||||
|
|
||||||
|
| Property | Result |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------- |
|
||||||
|
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||||
|
| Cross-schema FK to `public.users` | works |
|
||||||
|
| Partial unique index preserved | works |
|
||||||
|
| Push is idempotent, no spurious re-creation | works |
|
||||||
|
| Cascade delete across the schema boundary | works |
|
||||||
|
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||||
|
|
||||||
|
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||||
|
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||||
|
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||||
|
naming the schema in `schemaFilter` made the identical push work.
|
||||||
|
|
||||||
|
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||||
|
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||||
|
costume of a normal result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mounting — rebuild and swap, at runtime
|
||||||
|
|
||||||
|
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||||
|
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||||
|
|
||||||
|
### What was actually tested
|
||||||
|
|
||||||
|
| Router | `app.route()` after serving has begun |
|
||||||
|
| -------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||||
|
| `RegExpRouter` | **throws**, same reason |
|
||||||
|
| `TrieRouter` | works |
|
||||||
|
| `PatternRouter` | works |
|
||||||
|
|
||||||
|
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||||
|
remove a route**, which uninstall needs.
|
||||||
|
|
||||||
|
### The approach that solves both
|
||||||
|
|
||||||
|
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||||
|
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||||
|
|
||||||
|
// install: app = buildApp([...installed, 'offscale'])
|
||||||
|
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||||
|
|
||||||
|
```
|
||||||
|
no plugins /offscale/x -> 404 | /core -> 200
|
||||||
|
installed /offscale/x -> 200 | /core -> 200
|
||||||
|
uninstalled /offscale/x -> 404 | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||||
|
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||||
|
|
||||||
|
### The one line that has to change
|
||||||
|
|
||||||
|
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||||
|
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||||
|
This is the whole mechanical cost.
|
||||||
|
|
||||||
|
### Websockets are a separate table, and they reload
|
||||||
|
|
||||||
|
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||||
|
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||||
|
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||||
|
|
||||||
|
```
|
||||||
|
before reload /api/offscale/ws -> refused | /core -> 200
|
||||||
|
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||||
|
after remove /api/offscale/ws -> refused | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||||
|
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||||
|
|
||||||
|
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||||
|
before a plugin install can interrupt somebody's terminal.
|
||||||
|
|
||||||
|
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||||
|
|
||||||
|
### What this means for `assertCapabilityTotality`
|
||||||
|
|
||||||
|
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||||
|
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||||
|
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||||
|
where mounting actually happens instead of once at start-up.
|
||||||
|
|
||||||
|
Two things it must survive, both live today:
|
||||||
|
|
||||||
|
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||||
|
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||||
|
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||||
|
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||||
|
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||||
|
still needs pointing at reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
|
||||||
|
|
||||||
|
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||||
|
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||||
|
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||||
|
platform's to assign.
|
||||||
|
|
||||||
|
### The platform grants read or write. Everything richer is the plugin's own job
|
||||||
|
|
||||||
|
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||||
|
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
|
||||||
|
the surface; `write` permits everything.
|
||||||
|
|
||||||
|
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||||
|
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||||
|
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||||
|
means; the platform only knows whether this account got through the door.
|
||||||
|
|
||||||
|
### Offscale v1 uses that model exactly, with nothing added
|
||||||
|
|
||||||
|
One shared resource, role-gated:
|
||||||
|
|
||||||
|
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||||
|
- **write** — can change them, including deleting a server the owner registered
|
||||||
|
|
||||||
|
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||||
|
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||||
|
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||||
|
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||||
|
|
||||||
|
Two implementation consequences, both inside the plugin:
|
||||||
|
|
||||||
|
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||||
|
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||||
|
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||||
|
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||||
|
|
||||||
|
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||||
|
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||||
|
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||||
|
|
||||||
|
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||||
|
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||||
|
delete, policy `PUT` — is a genuine write.
|
||||||
|
|
||||||
|
### Music is where the richer model gets designed
|
||||||
|
|
||||||
|
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||||
|
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||||
|
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||||
|
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||||
|
where offscale's is just "the owner's".
|
||||||
|
|
||||||
|
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||||
|
|
||||||
|
### Three different things are called "capability" here
|
||||||
|
|
||||||
|
A manifest needs three names, not one:
|
||||||
|
|
||||||
|
1. `capabilities/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||||
|
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||||
|
3. `sidecar-registry` `capabilities: ['music']` — **routing keys** for `sendCommand`
|
||||||
|
|
||||||
|
Offscale needs (1) and (3), and not (2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||||
|
|
||||||
|
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||||
|
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||||
|
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||||
|
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||||
|
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||||
|
parties touch it.
|
||||||
|
|
||||||
|
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||||
|
the host:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from '../db'; // the connection
|
||||||
|
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||||
|
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||||
|
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||||
|
the pilot rather than being invented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `/api/vpn` is being deleted
|
||||||
|
|
||||||
|
Officer had two headscale surfaces:
|
||||||
|
|
||||||
|
| | `/api/vpn` | `/api/headscale` |
|
||||||
|
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||||
|
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||||
|
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||||
|
| surface | one route, `POST /enroll` | the whole admin API |
|
||||||
|
|
||||||
|
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||||
|
anywhere.** Verified against the mobile monorepo:
|
||||||
|
|
||||||
|
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||||
|
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||||
|
3. `embedded` is optional and defaults to `false`
|
||||||
|
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||||
|
|
||||||
|
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||||
|
does the Officer web app. The live database holds no `vpn` grants.
|
||||||
|
|
||||||
|
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||||
|
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||||
|
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||||
|
|
||||||
|
### Everything collapses to one namespace
|
||||||
|
|
||||||
|
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||||
|
contract has no counterparty.
|
||||||
|
|
||||||
|
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||||
|
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||||
|
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||||
|
|
||||||
|
- **phone → Companion** — untouched by anything here
|
||||||
|
- **web admin → Officer → sidecar** — ours to rename freely
|
||||||
|
|
||||||
|
### There are THREE components, not two
|
||||||
|
|
||||||
|
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||||
|
|
||||||
|
| Component | Repo | Enrolment surface |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||||
|
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||||
|
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||||
|
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||||
|
|
||||||
|
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||||
|
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||||
|
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||||
|
|
||||||
|
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||||
|
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||||
|
in the invite (`https://<domain>/officer-api`).
|
||||||
|
|
||||||
|
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||||
|
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||||
|
`POST /_officer/enroll` handler below it is dead.
|
||||||
|
|
||||||
|
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||||
|
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||||
|
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||||
|
|
||||||
|
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||||
|
is correct. The invite flow supersedes it completely:
|
||||||
|
|
||||||
|
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||||
|
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||||
|
3. the Companion turns that into the redirect the phone app claims
|
||||||
|
4. the device joins
|
||||||
|
|
||||||
|
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||||
|
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
|
||||||
|
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What headscale actually is — the inventory
|
||||||
|
|
||||||
|
Read end to end on 2026-08-14. This is what has to move.
|
||||||
|
|
||||||
|
### Backend — 2,406 lines
|
||||||
|
|
||||||
|
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||||
|
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||||
|
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||||
|
|
||||||
|
Three things worth knowing before touching it:
|
||||||
|
|
||||||
|
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||||
|
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||||
|
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||||
|
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||||
|
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||||
|
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||||
|
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||||
|
undo.
|
||||||
|
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||||
|
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||||
|
branches on whether the body parses.
|
||||||
|
|
||||||
|
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||||
|
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||||
|
|
||||||
|
### Frontend — 29 files, 27 endpoints
|
||||||
|
|
||||||
|
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||||
|
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||||
|
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||||
|
Diagnostics · Console.
|
||||||
|
|
||||||
|
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||||
|
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||||
|
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||||
|
|
||||||
|
The whole frontend↔host coupling, which becomes the plugin API:
|
||||||
|
|
||||||
|
| Import | Why it matters |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||||
|
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||||
|
| `AppRegistryMeta` | the panel-contribution contract |
|
||||||
|
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||||
|
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
|
||||||
|
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||||
|
|
||||||
|
### `assist.ts` travels, but stays unwired
|
||||||
|
|
||||||
|
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||||
|
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||||
|
later. Do not tidy it away as unused code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The manifest — proposal
|
||||||
|
|
||||||
|
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||||
|
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||||
|
is the floor, not the ceiling.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plugins/offscale/manifest.ts
|
||||||
|
export const manifest = {
|
||||||
|
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||||
|
publisher: 'officerdev',
|
||||||
|
/** The plugin's own semver. Updates compare against this. */
|
||||||
|
version: '1.0.0',
|
||||||
|
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||||
|
platform: '>=1.0.0 <2.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
|
||||||
|
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||||
|
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes and ACLs',
|
||||||
|
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||||
|
ownerOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||||
|
|
||||||
|
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||||
|
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||||
|
|
||||||
|
```
|
||||||
|
web/panels.ts exports appRegistryMetas — at least one panel
|
||||||
|
web/layout.ts exports defaultLayout — how they are arranged
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||||
|
name and with the reason:
|
||||||
|
|
||||||
|
```
|
||||||
|
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||||
|
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||||
|
```
|
||||||
|
|
||||||
|
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||||
|
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||||
|
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||||
|
is nowhere to put a screen.
|
||||||
|
|
||||||
|
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||||
|
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||||
|
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||||
|
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||||
|
than rendering another plugin's panel inside this one.
|
||||||
|
|
||||||
|
### Everything the tree can say, the tree says
|
||||||
|
|
||||||
|
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||||
|
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||||
|
|
||||||
|
| Path | Means |
|
||||||
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||||
|
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||||
|
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||||
|
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||||
|
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||||
|
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||||
|
|
||||||
|
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||||
|
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||||
|
duplication that could only ever drift.
|
||||||
|
|
||||||
|
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||||
|
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||||
|
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||||
|
field that can contradict the file it describes.
|
||||||
|
|
||||||
|
### Install asks nothing, and that is the default
|
||||||
|
|
||||||
|
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||||
|
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||||
|
and no external service to point at.
|
||||||
|
|
||||||
|
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||||
|
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||||
|
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||||
|
how it works today.
|
||||||
|
|
||||||
|
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||||
|
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||||
|
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||||
|
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||||
|
|
||||||
|
### Dropped from the first draft
|
||||||
|
|
||||||
|
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||||
|
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||||
|
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||||
|
when something consumes it.
|
||||||
|
- **`kind`** — see below.
|
||||||
|
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||||
|
|
||||||
|
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||||
|
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||||
|
|
||||||
|
### `admin` has to be allowed, and the pilot proved it immediately
|
||||||
|
|
||||||
|
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||||
|
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||||
|
|
||||||
|
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||||
|
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||||
|
nothing is gained by forbidding it.
|
||||||
|
|
||||||
|
Corrected rule:
|
||||||
|
|
||||||
|
| Kind | May a plugin declare it? | Why |
|
||||||
|
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `app` | yes | the ordinary grantable surface |
|
||||||
|
| `admin` | yes | self-restriction, never an escalation |
|
||||||
|
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||||
|
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||||
|
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||||
|
|
||||||
|
### One function decides the prefix
|
||||||
|
|
||||||
|
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const mountPrefix = (m: Manifest) =>
|
||||||
|
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||||
|
|
||||||
|
### Notes on the fields
|
||||||
|
|
||||||
|
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||||
|
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||||
|
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||||
|
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||||
|
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||||
|
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||||
|
degrade. This is for the human reading the store.
|
||||||
|
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||||
|
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||||
|
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is built — complete, as of 2026-08-15
|
||||||
|
|
||||||
|
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||||
|
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||||
|
time.
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
| --------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||||
|
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||||
|
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||||
|
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||||
|
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||||
|
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||||
|
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||||
|
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||||
|
| Capability registration | `capabilities/registry.ts` → `setPluginCapabilities` |
|
||||||
|
| Install state | `plugin_installs` |
|
||||||
|
| The screen | `/plugins`, two panels, SSE log |
|
||||||
|
| The reference plugin | `plugins/example/` |
|
||||||
|
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||||
|
|
||||||
|
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||||
|
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
|
||||||
|
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||||
|
|
||||||
|
### Three bugs the extraction found
|
||||||
|
|
||||||
|
Worth recording because none were visible from reading:
|
||||||
|
|
||||||
|
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||||
|
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||||
|
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||||
|
has no listener to announce. Install and enable now mount first.
|
||||||
|
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||||
|
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||||
|
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||||
|
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||||
|
|
||||||
|
### Still open
|
||||||
|
|
||||||
|
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||||
|
still the hardcoded providers. No plugin owns a socket yet.
|
||||||
|
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||||
|
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||||
|
`buildHonoApp`, which is now the single place routes are mounted.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||||
|
plugin system. One when the app store is rebuilt on this.
|
||||||
|
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||||
|
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The state of the app store, as found
|
||||||
|
|
||||||
|
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||||
|
|
||||||
|
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||||
|
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||||
|
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||||
|
|
||||||
|
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||||
|
> and it is not built."_
|
||||||
|
|
||||||
|
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||||
|
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||||
|
|
||||||
|
Also found:
|
||||||
|
|
||||||
|
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||||
|
`bun db:push`.
|
||||||
|
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||||
|
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||||
|
that the directory exists.
|
||||||
|
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||||
|
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||||
|
means editing two files and rebuilding.
|
||||||
|
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||||
|
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||||
|
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||||
|
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||||
|
renders against it. Not to be confused with any of the above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the code lives
|
||||||
|
|
||||||
|
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||||
|
|
||||||
|
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||||
|
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||||
|
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||||
|
`extensions/` may become plugins later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||||
|
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||||
|
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||||
|
platform checkout, so dev-time and build-time are the same mechanism.
|
||||||
|
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||||
|
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||||
|
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||||
|
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||||
|
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||||
|
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||||
|
future, not before.
|
||||||
|
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||||
|
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||||
|
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||||
|
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||||
|
Console section does not travel with the plugin.
|
||||||
|
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||||
|
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||||
|
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||||
|
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||||
|
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
|
||||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||||
// this file must never grow app logic.
|
// this file must never grow app logic.
|
||||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|||||||
|
|
||||||
const proxy = createSidecarProxy({
|
const proxy = createSidecarProxy({
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
prefix: '/api/headscale',
|
prefix: '/api/offscale',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headscaleRouter = proxy.router;
|
export const router = proxy.router;
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { headscaleServers } from './schema';
|
import { headscaleServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
|
||||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||||
|
//
|
||||||
|
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||||
|
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||||
|
// distinct product rather than a badge on someone else's.
|
||||||
|
//
|
||||||
|
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||||
|
//
|
||||||
|
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||||
|
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||||
|
// db/ offscale_servers, and the only table this plugin owns
|
||||||
|
// web/ panels and a layout; the shell renders the Workspace
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||||
|
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||||
|
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||||
|
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||||
|
// That is a change inside these queries, not a flag on the manifest.
|
||||||
|
//
|
||||||
|
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||||
|
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||||
|
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||||
|
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||||
|
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||||
|
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||||
|
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
|
|
||||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
|
|
||||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
// wire-level quirks are handled once:
|
// wire-level quirks are handled once:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||||
|
|
||||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import type { OfficerUser } from './normalize';
|
import type { OfficerUser } from './normalize';
|
||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
|||||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||||
//
|
//
|
||||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||||
|
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { handleOfficerRoute } from './routes';
|
import { handleOfficerRoute } from './routes';
|
||||||
import { MIN_VERSION_LABEL } from './version';
|
import { MIN_VERSION_LABEL } from './version';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// DELETE /_officer/keys/:id delete outright
|
// DELETE /_officer/keys/:id delete outright
|
||||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||||
// for a joining device. userId is only required when the server
|
// for a joining device. userId is only required when the server
|
||||||
// has more than one user; reached via /api/vpn/enroll.
|
// has more than one user.
|
||||||
|
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||||
|
// which is deleted. Kept because it is the handler a route under
|
||||||
|
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||||
|
// IS live — dispatches through the same function.
|
||||||
// anything else 404
|
// anything else 404
|
||||||
//
|
//
|
||||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteHeadscaleServer,
|
deleteHeadscaleServer,
|
||||||
getHeadscaleCredentials,
|
getHeadscaleCredentials,
|
||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { createClient, HeadscaleError } from './client';
|
import { createClient, HeadscaleError } from './client';
|
||||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
|||||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||||
import { headscaleSectionPath } from './shared';
|
import { headscaleSectionPath } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||||
import { TerminalView } from '../Terminal/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { Button } from './Cards';
|
import { Button } from './Cards';
|
||||||
|
|
||||||
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
|||||||
|
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'headscale-root',
|
id: 'offscale-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||||
import { HeadscaleNav } from './HeadscaleNav';
|
import { HeadscaleNav } from './HeadscaleNav';
|
||||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
|||||||
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
||||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||||
|
|
||||||
const BASE = '/headscale/_officer/companion';
|
const BASE = '/offscale/_officer/companion';
|
||||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+15
-18
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: NODES_KEY,
|
queryKey: NODES_KEY,
|
||||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
|
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
||||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||||
refetchInterval: 20_000,
|
refetchInterval: 20_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setTags = useMutation({
|
const setTags = useMutation({
|
||||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
|
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
|
||||||
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||||
const moveToUser = useMutation({
|
const moveToUser = useMutation({
|
||||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
|
|||||||
// because Headscale's approve_routes replaces the whole set.
|
// because Headscale's approve_routes replaces the whole set.
|
||||||
const toggleRoute = useMutation({
|
const toggleRoute = useMutation({
|
||||||
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
||||||
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
|
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: USERS_KEY,
|
queryKey: USERS_KEY,
|
||||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
|
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
||||||
post('/headscale/_officer/users', input),
|
post('/offscale/_officer/users', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/users/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: KEYS_KEY,
|
queryKey: KEYS_KEY,
|
||||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
|
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
|
|||||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: CreateKeyInput) =>
|
mutationFn: (input: CreateKeyInput) =>
|
||||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
|
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
|||||||
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
||||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||||
|
|
||||||
const BASE = '/headscale/_officer/enroll/invites';
|
const BASE = '/offscale/_officer/enroll/invites';
|
||||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
const INVITES_KEY = ['headscale', 'invites'] as const;
|
||||||
|
|
||||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
|||||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||||
|
|
||||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
const POLICY_KEY = ['headscale', 'policy'] as const;
|
||||||
const PATH = '/headscale/_officer/policy';
|
const PATH = '/offscale/_officer/policy';
|
||||||
|
|
||||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||||
+2
-2
@@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
|||||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||||
const EMPTY: HeadscaleServer[] = [];
|
const EMPTY: HeadscaleServer[] = [];
|
||||||
|
|
||||||
const BASE = '/headscale/_officer/servers';
|
const BASE = '/offscale/_officer/servers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||||
@@ -89,7 +89,7 @@ export function useHeadscaleServers() {
|
|||||||
export function useHeadscaleSshTest() {
|
export function useHeadscaleSshTest() {
|
||||||
const { post } = useClient();
|
const { post } = useClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
|
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,23 +96,24 @@ pkgs_core() {
|
|||||||
# separate decision from removing the tool that wanted it.
|
# separate decision from removing the tool that wanted it.
|
||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
apt-transport-https lsb-release software-properties-common \
|
apt-transport-https lsb-release software-properties-common \
|
||||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
fail2ban unattended-upgrades
|
fail2ban unattended-upgrades
|
||||||
;;
|
;;
|
||||||
pacman)
|
pacman)
|
||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||||
fail2ban
|
fail2ban
|
||||||
;;
|
;;
|
||||||
dnf)
|
dnf)
|
||||||
echo curl ca-certificates gnupg2 git jq unzip \
|
echo curl ca-certificates gnupg2 git jq unzip \
|
||||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
fail2ban
|
fail2ban
|
||||||
;;
|
;;
|
||||||
brew)
|
brew)
|
||||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||||
echo gnupg git jq wget btop htop tree ripgrep fd eza
|
# brotli is here because macOS ships the library but not the CLI.
|
||||||
|
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2311,6 +2311,41 @@ EOF
|
|||||||
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Keys ──
|
||||||
|
#
|
||||||
|
# This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
|
||||||
|
# PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
|
||||||
|
# the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
|
||||||
|
# history-prefix search, no Home/End. Confirmed on this machine before writing it, with
|
||||||
|
# `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
|
||||||
|
#
|
||||||
|
# The two files are still separate — one is a template the platform copies, the other is an
|
||||||
|
# idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
|
||||||
|
# web terminal and neither should have to learn which account they are on.
|
||||||
|
if append_once "$ZSHRC" keybindings <<'EOF'
|
||||||
|
bindkey -e
|
||||||
|
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
|
||||||
|
zle -N up-line-or-beginning-search
|
||||||
|
zle -N down-line-or-beginning-search
|
||||||
|
bindkey '^[[A' up-line-or-beginning-search
|
||||||
|
bindkey '^[[B' down-line-or-beginning-search
|
||||||
|
bindkey '^[[1;5C' forward-word
|
||||||
|
bindkey '^[[1;5D' backward-word
|
||||||
|
bindkey '^[[1;3C' forward-word
|
||||||
|
bindkey '^[[1;3D' backward-word
|
||||||
|
bindkey '^[[3~' delete-char
|
||||||
|
bindkey '^[[H' beginning-of-line
|
||||||
|
bindkey '^[[F' end-of-line
|
||||||
|
bindkey '^[[1~' beginning-of-line
|
||||||
|
bindkey '^[[4~' end-of-line
|
||||||
|
bindkey '^H' backward-kill-word
|
||||||
|
bindkey '^[^?' backward-kill-word
|
||||||
|
bindkey '^[[3;5~' kill-word
|
||||||
|
EOF
|
||||||
|
then
|
||||||
|
ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
|
||||||
|
fi
|
||||||
|
|
||||||
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
||||||
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
||||||
# failed to install leaves the owner with no working `ls` at all, in every new
|
# failed to install leaves the owner with no working `ls` at all, in every new
|
||||||
|
|||||||
@@ -446,6 +446,11 @@ if ! skip; then
|
|||||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The client goes on the HOST, before any of the container work, because it is the half
|
||||||
|
# that is not in the container. A member has their own Postgres role and no access to the
|
||||||
|
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||||
|
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||||
|
|
||||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||||
|
|||||||
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
|
|||||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||||
PG_PORT="${PG_PORT:-5432}"
|
PG_PORT="${PG_PORT:-5432}"
|
||||||
|
|
||||||
|
# ── The CLIENT, on the host, matching the server in the container ──
|
||||||
|
#
|
||||||
|
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||||
|
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||||
|
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||||
|
# owner's Docker socket.
|
||||||
|
#
|
||||||
|
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||||
|
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||||
|
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
|
||||||
|
# archive package is not merely old, it is unusable for dumps. That is also why this lives
|
||||||
|
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||||
|
# one place to bump.
|
||||||
|
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||||
|
|
||||||
|
pg_client_installed() {
|
||||||
|
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||||
|
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||||
|
# just a more annoying one to operate.
|
||||||
|
install_pg_client() {
|
||||||
|
local major codename
|
||||||
|
major="$(pg_client_major)"
|
||||||
|
[[ -n "$major" ]] || {
|
||||||
|
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if pg_client_installed; then
|
||||||
|
ok "psql ${major} already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||||
|
[[ -n "$codename" ]] || {
|
||||||
|
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||||
|
warn "could not fetch the PostgreSQL signing key"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||||
|
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||||
|
>/etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||||
|
warn "postgresql-client-${major} did not install"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||||
|
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||||
|
# succeed while holding an older client back.
|
||||||
|
pg_client_installed || {
|
||||||
|
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||||
|
}
|
||||||
|
|
||||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||||
ensure_docker_network() {
|
ensure_docker_network() {
|
||||||
docker_network_exists && return 1
|
docker_network_exists && return 1
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ CORE_PROCESSES=(
|
|||||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||||
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
write_ecosystem() {
|
write_ecosystem() {
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ bind - split-window -v
|
|||||||
unbind r
|
unbind r
|
||||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||||
|
|
||||||
|
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||||
|
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||||
|
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||||
|
# in one TCP frame, which over a websocket relay it always does.
|
||||||
|
set -sg escape-time 10
|
||||||
|
|
||||||
# switch panes using Alt-arrow without prefix
|
# switch panes using Alt-arrow without prefix
|
||||||
bind -n M-Left select-pane -L
|
bind -n M-Left select-pane -L
|
||||||
bind -n M-Right select-pane -R
|
bind -n M-Right select-pane -R
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
import { useServerSettings } from 'state/useServerSettings';
|
import { useServerSettings } from 'state/useServerSettings';
|
||||||
import { useServerEnvironment } from 'state/useServerEnvironment';
|
import { useServerEnvironment } from 'state/useServerEnvironment';
|
||||||
import { useInitialData } from '@/state/useInitialData';
|
import { useInitialData } from '@/state/useInitialData';
|
||||||
|
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||||
|
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||||
|
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||||
|
import { plugins as installedPlugins } from './Plugins.gen';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { isLoading, isAuthenticated } = useAuth();
|
const { isLoading, isAuthenticated } = useAuth();
|
||||||
@@ -56,14 +60,26 @@ export function App() {
|
|||||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
|
||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
|
||||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
|
||||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||||
|
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||||
|
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||||
|
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||||
|
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||||
|
{installedPlugins.flatMap((plugin) => [
|
||||||
|
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||||
|
// The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
|
||||||
|
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||||
|
// passed between them.
|
||||||
|
<Route
|
||||||
|
key={`${plugin.appName}-section`}
|
||||||
|
path={`${plugin.route}/:section`}
|
||||||
|
element={<Dashboard.PluginScreen {...plugin} />}
|
||||||
|
/>,
|
||||||
|
])}
|
||||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ResetPassword() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||||
<div className="text-duck-dark/60">Enter your new password</div>
|
<div className="text-duck-dark/60">Enter your new password</div>
|
||||||
|
|||||||
@@ -105,4 +105,3 @@ const validateForm = (state: Partial<LoginFormState>) => {
|
|||||||
if (!email || !password) return false;
|
if (!email || !password) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
|||||||
<section className="relative h-dvh snap-start overflow-hidden">
|
<section className="relative h-dvh snap-start overflow-hidden">
|
||||||
<Background />
|
<Background />
|
||||||
<div className="absolute inset-0 z-20">
|
<div className="absolute inset-0 z-20">
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DuckAvatar } from "./DuckAvatar";
|
import { DuckAvatar } from './DuckAvatar';
|
||||||
import { PixelGrid } from "@/components/PixelGrid";
|
import { PixelGrid } from '@/components/PixelGrid';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
const landscapebg = '/landscape1.webp';
|
const landscapebg = '/landscape1.webp';
|
||||||
|
|
||||||
@@ -19,4 +19,4 @@ export function Background() {
|
|||||||
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
|
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -14,4 +14,3 @@ export const SignoutScreen = () => {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
|
|||||||
// Poll the registry (harness task files + announced detached jobs).
|
// Poll the registry (harness task files + announced detached jobs).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
|
const tick = () =>
|
||||||
|
get<Registry>('/activity/tasks')
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) {
|
||||||
|
setReg(r);
|
||||||
|
setRegLoaded(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
tick();
|
tick();
|
||||||
const iv = setInterval(tick, POLL_MS);
|
const iv = setInterval(tick, POLL_MS);
|
||||||
return () => { alive = false; clearInterval(iv); };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(iv);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The row backing the open id, and the stream query it implies. A string rather than the row object,
|
// The row backing the open id, and the stream query it implies. A string rather than the row object,
|
||||||
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
|
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
|
||||||
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
|
const row = selectedId
|
||||||
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
|
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
|
||||||
|
: undefined;
|
||||||
|
const query = !row
|
||||||
|
? null
|
||||||
|
: row.source === 'harness'
|
||||||
|
? `task=${encodeURIComponent(row.id)}`
|
||||||
|
: `path=${encodeURIComponent(row.path)}`;
|
||||||
|
|
||||||
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
|
|||||||
try {
|
try {
|
||||||
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
|
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
|
||||||
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
|
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
|
||||||
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
else if (d.kind === 'line' && typeof d.text === 'string')
|
||||||
} catch { /* ignore */ }
|
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, [query, token]);
|
}, [query, token]);
|
||||||
|
|
||||||
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
const rowCls = (active: boolean) =>
|
const rowCls = (active: boolean) =>
|
||||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
|
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
|
||||||
@@ -68,20 +90,36 @@ export const ActivityScreen = () => {
|
|||||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
|
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Background tasks
|
||||||
|
</div>
|
||||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||||
{reg.tasks.map((t) => (
|
{reg.tasks.map((t) => (
|
||||||
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
|
<Link
|
||||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
|
key={t.id}
|
||||||
|
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||||
|
className={rowCls(selectedId === t.id)}
|
||||||
|
title={t.cwd}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||||
|
/>
|
||||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{reg.detached.length > 0 && (
|
{reg.detached.length > 0 && (
|
||||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
|
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Detached
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{reg.detached.map((d) => (
|
{reg.detached.map((d) => (
|
||||||
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
|
<Link
|
||||||
|
key={d.id}
|
||||||
|
to={`/activity/${encodeURIComponent(d.id)}`}
|
||||||
|
className={rowCls(selectedId === d.id)}
|
||||||
|
title={d.path}
|
||||||
|
>
|
||||||
<FileText size={13} className="shrink-0" />
|
<FileText size={13} className="shrink-0" />
|
||||||
<span className="truncate">{d.id}</span>
|
<span className="truncate">{d.id}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
|
|||||||
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
|
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
|
||||||
{progress.status ? ` (${progress.status})` : ''}
|
{progress.status ? ` (${progress.status})` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
|
<span className="shrink-0 pl-2">
|
||||||
|
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{typeof progress.pct === 'number' && (
|
{typeof progress.pct === 'number' && (
|
||||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
|
||||||
|
>
|
||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{query ? 'waiting for output…' : regLoaded ? (
|
{query ? (
|
||||||
|
'waiting for output…'
|
||||||
|
) : regLoaded ? (
|
||||||
<>
|
<>
|
||||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or it never started.{' '}
|
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||||
<Link to="/activity" className="underline">Back to the list</Link>
|
it never started.{' '}
|
||||||
|
<Link to="/activity" className="underline">
|
||||||
|
Back to the list
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : 'loading…'}
|
) : (
|
||||||
|
'loading…'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
|
lines.map((l, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap break-words">
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Select a task to follow its live output
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ export const TabPreview = () => {
|
|||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{/* URL bar */}
|
{/* URL bar */}
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 shrink-0"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
<form
|
<form
|
||||||
@@ -104,7 +110,13 @@ export const TabPreview = () => {
|
|||||||
placeholder="Navigate to URL..."
|
placeholder="Navigate to URL..."
|
||||||
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 shrink-0"
|
||||||
|
type="submit"
|
||||||
|
disabled={isNavigating || !navUrl.trim()}
|
||||||
|
>
|
||||||
<Send className="h-3.5 w-3.5" />
|
<Send className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -143,7 +155,13 @@ export const TabPreview = () => {
|
|||||||
placeholder="Evaluate JavaScript..."
|
placeholder="Evaluate JavaScript..."
|
||||||
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 shrink-0"
|
||||||
|
type="submit"
|
||||||
|
disabled={isEvaluating || !evalExpr.trim()}
|
||||||
|
>
|
||||||
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
|
|||||||
type Contact = { address: string; name: string };
|
type Contact = { address: string; name: string };
|
||||||
|
|
||||||
// A recipient field with contact autocomplete on the last comma-separated segment.
|
// A recipient field with contact autocomplete on the last comma-separated segment.
|
||||||
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
|
const RecipientInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
|
client
|
||||||
|
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
|
||||||
|
.then(setSuggestions)
|
||||||
|
.catch(() => {});
|
||||||
}, 180);
|
}, 180);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [seg]);
|
}, [seg]);
|
||||||
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
|
|||||||
inlineMap.current.clear();
|
inlineMap.current.clear();
|
||||||
nextImgId.current = 0;
|
nextImgId.current = 0;
|
||||||
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
||||||
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
if (editorRef.current)
|
||||||
|
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||||
refreshEmpty();
|
refreshEmpty();
|
||||||
}
|
}
|
||||||
if (!draft) seeded.current = null;
|
if (!draft) seeded.current = null;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
// Clipboard images often come nameless — give them a sensible filename.
|
// Clipboard images often come nameless — give them a sensible filename.
|
||||||
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
|
const named = (f: File) =>
|
||||||
|
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
|
||||||
|
|
||||||
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
||||||
const addAttachments = (incoming: FileList | File[]) => {
|
const addAttachments = (incoming: FileList | File[]) => {
|
||||||
@@ -248,7 +263,8 @@ export const ComposeModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
const fmtSize = (n: number) =>
|
||||||
|
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
|
||||||
if (!draft) return null;
|
if (!draft) return null;
|
||||||
|
|
||||||
@@ -315,7 +331,9 @@ export const ComposeModal = () => {
|
|||||||
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
||||||
/>
|
/>
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>}
|
{bodyEmpty && (
|
||||||
|
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
contentEditable
|
contentEditable
|
||||||
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
|
|||||||
>
|
>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -382,12 +403,21 @@ export const ComposeModal = () => {
|
|||||||
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
||||||
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
||||||
// is a follow-up: store the Message-Id header on ingest.)
|
// is a follow-up: store the Message-Id header on ingest.)
|
||||||
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
|
export const replyDraft = (m: {
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
date: string;
|
||||||
|
text?: string;
|
||||||
|
snippet?: string;
|
||||||
|
}): ComposeDraft => {
|
||||||
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
||||||
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
||||||
const original = (m.text || m.snippet || '').trim();
|
const original = (m.text || m.snippet || '').trim();
|
||||||
const quoted = original
|
const quoted = original
|
||||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
|
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => `> ${l}`)
|
||||||
|
.join('\n')}`
|
||||||
: '';
|
: '';
|
||||||
return { to: addr, subject, body: quoted };
|
return { to: addr, subject, body: quoted };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,8 +63,13 @@ type MessagePanelProps = {
|
|||||||
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
|
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
|
<button
|
||||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
|
||||||
|
{senderName(message.from)}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
|
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
|
||||||
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
|
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
|
||||||
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
|
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
|
||||||
@@ -114,7 +119,11 @@ const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: Me
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
|
{message.html ? (
|
||||||
|
<HtmlBody html={message.html} />
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -209,7 +218,11 @@ export const EmailReader = () => {
|
|||||||
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
||||||
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
||||||
{openAttachment && (
|
{openAttachment && (
|
||||||
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
|
<FileViewerProvider
|
||||||
|
filePath={openAttachment.filePath}
|
||||||
|
fileName={openAttachment.fileName}
|
||||||
|
root={openAttachment.root}
|
||||||
|
>
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
||||||
<FileViewerHeader />
|
<FileViewerHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user