Compare commits
2
Commits
8bfcd40bd2
...
1e79b4effd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e79b4effd | ||
|
|
a9bf51407e |
@@ -3,7 +3,7 @@
|
||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||
|
||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
||||
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||
|
||||
+49
-17
@@ -34,30 +34,62 @@ scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||
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
|
||||
### 1. cliamp — parked in the plugin, not left in the platform
|
||||
|
||||
`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.
|
||||
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||
|
||||
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:
|
||||
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||
|
||||
- `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.
|
||||
```
|
||||
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||
api/cliamp/relay.ts → cliamp/relay.ts
|
||||
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||
```
|
||||
|
||||
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
|
||||
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||
|
||||
`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.
|
||||
Two things went with it that were live rather than inert:
|
||||
|
||||
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.
|
||||
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||
its two menu entries are removed; the components are parked here.
|
||||
- **The two socket routes.** They now 404. Verified live.
|
||||
|
||||
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||
|
||||
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||
and never called. A platform gap, not a music one.
|
||||
|
||||
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||
|
||||
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||
`getMusicServerWsUrl` from it, so it could not move.
|
||||
|
||||
That reason was three layers of nothing:
|
||||
|
||||
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||
|
||||
A dead import held a music-named file in the platform. The proxy is now built in
|
||||
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||
|
||||
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||
to know about provenance. It now calls `mountPrefix`.
|
||||
|
||||
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||
|
||||
### 3. The player — the one open judgement call, and it is decided
|
||||
|
||||
|
||||
+41
-13
@@ -1,18 +1,46 @@
|
||||
import { musicRouter } from '@@/api/music/router';
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { manifest } from '../manifest';
|
||||
|
||||
// /api/music/* — auth, then forward to officer-music.
|
||||
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||
//
|
||||
// ── Why this re-exports the platform's proxy instead of creating its own ──
|
||||
// ── This used to live in the platform, and that was the bug ──
|
||||
//
|
||||
// `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.
|
||||
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||
//
|
||||
// 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.
|
||||
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||
//
|
||||
// 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;
|
||||
// ── The prefix is DERIVED, not written ──
|
||||
//
|
||||
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||
//
|
||||
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||
// become two systems with only one of them tested.
|
||||
//
|
||||
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||
// factory the installer calls instead. Recorded rather than worked around.
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'music',
|
||||
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const router = proxy.router;
|
||||
|
||||
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||
|
||||
+19
-4
@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
ctxRef.current = audioCtx;
|
||||
|
||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||
if (disposed) { audioCtx.close(); return; }
|
||||
if (disposed) {
|
||||
audioCtx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||
outputChannelCount: [CHANNELS],
|
||||
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try { wsRef.current?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
|
||||
try {
|
||||
nodeRef.current?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
nodeRef.current = null;
|
||||
try { audioCtx?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
audioCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ctxRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Music } from 'lucide-react';
|
||||
import { TerminalView } from '../Terminal/Terminal';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||
|
||||
export const CliampPanelHeader = () => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { getMusicServerWsUrl } from '../music/router';
|
||||
import { getMusicServerWsUrl } from '../api/router';
|
||||
|
||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||
|
||||
@@ -3,21 +3,21 @@ 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.
|
||||
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||
//
|
||||
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
|
||||
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||
// 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 ──
|
||||
// ── What stayed in the platform, and why ── (one item, down from three)
|
||||
//
|
||||
// 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/`.
|
||||
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||
//
|
||||
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
|
||||
// the mechanism was not worth inventing for one.
|
||||
|
||||
@@ -3,8 +3,8 @@ import { join, basename } from 'node:path';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { streamAudioFile } from './stream-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
|
||||
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||
import {
|
||||
reindexNow,
|
||||
|
||||
+8
-21
@@ -17,7 +17,6 @@ import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
||||
import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay';
|
||||
// import { desktopWebsocket } from './servers/api/desktop/websocket';
|
||||
// import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket';
|
||||
import officerWeb from './apps/officer-web/index.gen.html';
|
||||
@@ -38,16 +37,7 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
provider:
|
||||
| 'terminal'
|
||||
| 'chat'
|
||||
| 'task-runner'
|
||||
| 'pipeline'
|
||||
| 'cliamp'
|
||||
| 'cliamp-audio'
|
||||
| 'desktop'
|
||||
| 'vault'
|
||||
| 'sidecar';
|
||||
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'vault' | 'sidecar';
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
command?: string;
|
||||
@@ -137,8 +127,6 @@ const handlers: Record<string, any> = {
|
||||
chat: chatWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
// cliamp: cliampWebsocket,
|
||||
// 'cliamp-audio': cliampAudioWebsocket,
|
||||
// desktop: desktopWebsocket,
|
||||
// vault: vaultWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
@@ -264,15 +252,15 @@ console.log(
|
||||
async function upgradeWs(
|
||||
req: Request,
|
||||
server: any,
|
||||
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop',
|
||||
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop',
|
||||
) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
// Same resolver as the two HTTP doors, so a key that works against /api works here too — a music app
|
||||
// holding one needs cliamp and cliamp-audio, and a socket that only understood JWTs would have made
|
||||
// "signed in" and "can play audio" two different questions. `jti` is absent on a key, so the
|
||||
// Same resolver as the two HTTP doors, so a key that works against /api works here too — a mobile app
|
||||
// holding one gets the same doors as a browser session, and a socket that only understood JWTs would
|
||||
// have made "signed in" and "can open this" two different questions. `jti` is absent on a key, so the
|
||||
// blacklist below simply does not apply to one; its revocation is a column, checked in the lookup.
|
||||
const user = await resolveAuthToken(token);
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
@@ -393,8 +381,6 @@ const server = serve({
|
||||
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
||||
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
||||
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
|
||||
// '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
|
||||
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
||||
@@ -514,7 +500,8 @@ void (async () => {
|
||||
})();
|
||||
|
||||
// PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no
|
||||
// audio responsibilities. They belong to the music sidecar, which owns both cliamp halves now
|
||||
// (sidecar/music/pulse-audio.ts).
|
||||
// audio responsibilities. Every trace of cliamp left on 2026-08-15 — the relay, the two socket routes and
|
||||
// the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it
|
||||
// back means a plugin owning a websocket, which is a platform gap, not a music one.
|
||||
|
||||
// Pi check/install is handled by bootstrap.ts (imported above)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge:
|
||||
// this file must never grow app logic.
|
||||
//
|
||||
// The sidecar owns the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites,
|
||||
// now-playing, playlists) backed by Postgres. The full HTTP contract is documented at the top of the
|
||||
// sidecar's fetch handler — src/servers/sidecar/music/index.ts.
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'music',
|
||||
prefix: '/api/music',
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const musicRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getMusicServerUrl = proxy.getHttpUrl;
|
||||
|
||||
/** The same server as a `ws://` base — the cliamp relay pipes the player socket to it. */
|
||||
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||
@@ -154,10 +154,12 @@ export async function isApiRequestAllowed(
|
||||
/**
|
||||
* May this account open this WebSocket provider?
|
||||
*
|
||||
* There is no method to reason about, so a socket needs the capability at any level. That is deliberate
|
||||
* for the two that reach here — cliamp and cliamp-audio are the music app's playback transport, which is
|
||||
* the whole point of a music account. Every other provider belongs to an `execution` capability and is
|
||||
* refused above, structurally, rather than by being left off a list.
|
||||
* There is no method to reason about, so a socket needs the capability at any level.
|
||||
*
|
||||
* That rule was written for cliamp and cliamp-audio — the music app's playback transport, and the only
|
||||
* grantable sockets there have ever been. Both left on 2026-08-15 with `plugins/music/cliamp/`, so every
|
||||
* provider reaching here today belongs to an `execution` capability and is refused above, structurally,
|
||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||
*/
|
||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||
|
||||
@@ -14,17 +14,9 @@ import { assertCapabilityTotality, isExemptApiPath } from './totality';
|
||||
// The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure
|
||||
// half, which is where the rules actually live. Everything here runs without a database.
|
||||
|
||||
const REAL_WS = [
|
||||
'terminal',
|
||||
'chat',
|
||||
'task-runner',
|
||||
'pipeline',
|
||||
'cliamp',
|
||||
'cliamp-audio',
|
||||
'desktop',
|
||||
'vault',
|
||||
'sidecar',
|
||||
];
|
||||
// Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching.
|
||||
// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below.
|
||||
const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar'];
|
||||
const realApi = () => [...new Set(CAPABILITIES.flatMap((c) => c.api))];
|
||||
const surface = () => ({
|
||||
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'],
|
||||
@@ -106,16 +98,19 @@ describe('path → capability', () => {
|
||||
expect(capabilityForWsProvider('nope')).toBeNull();
|
||||
});
|
||||
|
||||
// `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because
|
||||
// music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on
|
||||
// 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the
|
||||
// boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table.
|
||||
// This pinned a real hole for one day: `/api/cliamp/ws` and `/api/cliamp/audio/ws` were SERVED in
|
||||
// server.tsx's route table while no capability claimed them and their `handlers` entries were commented
|
||||
// out — so connecting crashed on a non-null assertion, and the boot check could not see any of it
|
||||
// because it reads `Object.keys(handlers)` rather than the route table.
|
||||
//
|
||||
// Pinned here so the hole is a documented fact with a test on it rather than something to rediscover.
|
||||
// Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's.
|
||||
test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => {
|
||||
// Closed on 2026-08-15 by cliamp leaving for `plugins/music/cliamp/`: the routes, the relay and the
|
||||
// provider names all went with it, and the two lists agree again. Kept as an assertion rather than
|
||||
// deleted, because "the route table serves nothing the handlers map lacks" is the invariant the
|
||||
// original incident was about, and this is the cheapest place to notice it breaking again.
|
||||
test('no cliamp socket is served or claimed — the route table and handlers agree', () => {
|
||||
expect(capabilityForWsProvider('cliamp')).toBeNull();
|
||||
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
|
||||
expect(REAL_WS).not.toContain('cliamp');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
setRenamingName,
|
||||
handleReadAloud,
|
||||
handleExtract,
|
||||
handlePlay,
|
||||
getMatchingTaskGroups,
|
||||
getMatchingAgentGroups,
|
||||
handleRunTask,
|
||||
@@ -244,7 +243,6 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onRenamingChange={setRenamingName}
|
||||
onReadAloud={handleReadAloud}
|
||||
onExtract={handleExtract}
|
||||
onPlay={handlePlay}
|
||||
taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
|
||||
onRunTask={handleRunTask}
|
||||
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
|
||||
|
||||
+2
-23
@@ -14,7 +14,6 @@ import {
|
||||
Volume2,
|
||||
FolderArchive,
|
||||
ClipboardCopy,
|
||||
Music,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
@@ -64,7 +63,6 @@ export type FileItemProps = {
|
||||
onRenamingChange: (name: string | null) => void;
|
||||
onReadAloud: (entry: DirEntry) => void;
|
||||
onExtract: (entry: DirEntry) => void;
|
||||
onPlay: (entry: DirEntry) => void;
|
||||
taskGroups: TaskGroup[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
agentGroups: AgentGroup[];
|
||||
@@ -96,7 +94,6 @@ type MenuItemsProps = {
|
||||
onDownload: (e: DirEntry) => void;
|
||||
onReadAloud: (e: DirEntry) => void;
|
||||
onExtract: (e: DirEntry) => void;
|
||||
onPlay: (e: DirEntry) => void;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
taskGroups: TaskGroup[];
|
||||
@@ -116,7 +113,6 @@ const DropdownMenuItems = ({
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onExtract,
|
||||
onPlay,
|
||||
onCut,
|
||||
onCopy,
|
||||
taskGroups,
|
||||
@@ -128,11 +124,10 @@ const DropdownMenuItems = ({
|
||||
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
||||
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
||||
const showExtract = fileType === 'archive';
|
||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
||||
|
||||
const hasTasks = taskGroups.length > 0;
|
||||
const hasAgents = agentGroups.length > 0;
|
||||
const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
// Only nest when more than one category matched — a file usually matches a single category, and
|
||||
// Run Task > Video > Convert would just add a hop.
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
@@ -142,12 +137,6 @@ const DropdownMenuItems = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlay && (
|
||||
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
@@ -285,7 +274,6 @@ const ContextMenuItems = ({
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onExtract,
|
||||
onPlay,
|
||||
onCut,
|
||||
onCopy,
|
||||
taskGroups,
|
||||
@@ -297,11 +285,10 @@ const ContextMenuItems = ({
|
||||
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
||||
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
||||
const showExtract = fileType === 'archive';
|
||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
||||
|
||||
const hasTasks = taskGroups.length > 0;
|
||||
const hasAgents = agentGroups.length > 0;
|
||||
const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
// Only nest when more than one category matched — a file usually matches a single category, and
|
||||
// Run Task > Video > Convert would just add a hop.
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
@@ -311,12 +298,6 @@ const ContextMenuItems = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlay && (
|
||||
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
@@ -563,7 +544,6 @@ export const FileItem = ({
|
||||
onRenamingChange,
|
||||
onReadAloud,
|
||||
onExtract,
|
||||
onPlay,
|
||||
taskGroups,
|
||||
onRunTask,
|
||||
agentGroups,
|
||||
@@ -645,7 +625,6 @@ export const FileItem = ({
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onExtract,
|
||||
onPlay,
|
||||
onCut,
|
||||
onCopy,
|
||||
taskGroups,
|
||||
|
||||
@@ -470,11 +470,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
setViewerParams({ play: filePath });
|
||||
};
|
||||
|
||||
const handleExtract = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Extracting archive...');
|
||||
@@ -737,7 +732,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
handleCreateDashboardHere,
|
||||
handleReadAloud,
|
||||
handleExtract,
|
||||
handlePlay,
|
||||
handleGitClone,
|
||||
handleCut,
|
||||
handleCopy,
|
||||
|
||||
@@ -6,12 +6,6 @@ export const singleViewerLayout: LayoutNode = {
|
||||
appType: null,
|
||||
};
|
||||
|
||||
export const singleCliampLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'files-cliamp',
|
||||
appType: null,
|
||||
};
|
||||
|
||||
export const viewerWithEphemeralLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'files-viewer-group',
|
||||
|
||||
@@ -2,11 +2,24 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { EphemeralPanels } from '../../components/Workspace';
|
||||
import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer';
|
||||
import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts';
|
||||
import {
|
||||
singleViewerLayout,
|
||||
viewerWithEphemeralLayout,
|
||||
viewerWithEphemeralSplitLayout,
|
||||
singleChatLayout,
|
||||
} from './layouts';
|
||||
import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers';
|
||||
import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel';
|
||||
|
||||
const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play'];
|
||||
const EPHEMERAL_KEYS = [
|
||||
'view',
|
||||
'ephemeral',
|
||||
'ephemeralRoot',
|
||||
'ephemeral2',
|
||||
'ephemeral2Root',
|
||||
'ephemeral2Auto',
|
||||
'chatContext',
|
||||
'chatType',
|
||||
];
|
||||
|
||||
export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -29,17 +42,14 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
const ephemeralPath = searchParams.get('ephemeral');
|
||||
const ephemeral2Path = searchParams.get('ephemeral2');
|
||||
const chatContext = searchParams.get('chatContext');
|
||||
const playPath = searchParams.get('play');
|
||||
|
||||
const layout = playPath
|
||||
? singleCliampLayout
|
||||
: chatContext
|
||||
? singleChatLayout
|
||||
: viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
: viewPath && ephemeralPath
|
||||
? viewerWithEphemeralLayout
|
||||
: singleViewerLayout;
|
||||
const layout = chatContext
|
||||
? singleChatLayout
|
||||
: viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
: viewPath && ephemeralPath
|
||||
? viewerWithEphemeralLayout
|
||||
: singleViewerLayout;
|
||||
|
||||
const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]);
|
||||
|
||||
@@ -80,23 +90,8 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const onClosePlay = useCallback(
|
||||
() =>
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('play');
|
||||
return next;
|
||||
}),
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
'files-cliamp': {
|
||||
header: CliampPanelHeader,
|
||||
component: CliampPanelBody,
|
||||
onClose: onClosePlay,
|
||||
},
|
||||
'files-viewer': {
|
||||
provider: ViewerProvider,
|
||||
header: FileViewerHeader,
|
||||
@@ -120,11 +115,11 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
onClose: onCloseChat,
|
||||
},
|
||||
}),
|
||||
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay],
|
||||
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat],
|
||||
);
|
||||
|
||||
if (!viewPath && !chatContext && !playPath) return null;
|
||||
const onClose = playPath ? onClosePlay : onCloseViewer;
|
||||
if (!viewPath && !chatContext) return null;
|
||||
const onClose = onCloseViewer;
|
||||
return { layout, components, defaultBaseSize: 40, onClose };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user