mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 00:11:48 -06:00
3191dbb058
- Deleted the legacy WebSocket client implementation (`wstoclient.go`) and its test file (`wstoclient_test.go`). - Removed legacy handling from the HTTP manager, including legacy proxy setup and routing. - Cleaned up session management by removing legacy host checks and related functions. - Eliminated the legacy WebRTC handler and its associated logic. - Updated session interface to remove legacy methods. - Removed legacy API documentation and configuration options from migration guide and help files.
288 lines
20 KiB
Markdown
288 lines
20 KiB
Markdown
# neko client
|
|
|
|
The web client for [neko](https://github.com/m1k1o/neko): React 19 + TypeScript, built with Vite.
|
|
It speaks only the v3 API (`/api/ws` + REST).
|
|
|
|
```
|
|
client/
|
|
core/ @m1k1o/neko: the framework-free client library, its own npm package (core/README.md)
|
|
src/
|
|
main.tsx start-up: the strings, the instance (createNekoApp), the plugins, the connection, the render under <NekoProvider>
|
|
app/ the shell: App, ErrorBoundary, the #neko grid
|
|
layout/ Header, Side (the tab bar), RoomBar (members, room menu, controls, emotes)
|
|
features/ one folder per feature: video, members, controls, room-menu, emotes, settings, connect, about
|
|
plugins/ the registry (index.ts: the list, the slots, event dispatch), the contract (types.ts), chat/, filetransfer/
|
|
components/ shared pieces: IconButton, Avatar, Dialog, EachHook; ui/ holds the shadcn/ui kit (dialog, menus, popover, tabs, button, sonner)
|
|
state/ the instance (neko.ts: createNekoApp, app.ts: the GUI store and NekoApp), the provider and hooks, actions, settings, dialogs, event wiring
|
|
i18n/ i18next: initI18n(), setLang(), the loader of the locale files
|
|
lib/ cn(): class names with Tailwind conflicts resolved (clsx + tailwind-merge)
|
|
locales/ the strings, one folder per language: common.json, chat.json, files.json
|
|
assets/ images
|
|
index.css Tailwind v4: the design tokens (@theme), the global rules, the layout's breakpoint variants
|
|
```
|
|
|
|
## Layering
|
|
|
|
Dependencies point one way: `app → layout → features → components → design`, with `state`, `i18n`
|
|
and `lib` below everything but `design`. In practice:
|
|
|
|
- A feature is a folder with its component(s), styled with Tailwind classes in the markup. Other
|
|
folders import a feature by its main file with `@/` (`import { Stage } from '@/features/video/Stage'`);
|
|
only a folder's own files are imported relatively.
|
|
- Shared components know nothing about features or plugins. Nothing imports `app/`.
|
|
- Plugins import neither each other nor `features/`; the app shell and the layout reach plugins only
|
|
through the registry `@/plugins`, and features get plugin contributions passed in (they see only
|
|
the contract in `@/plugins/types`).
|
|
- The core is imported only from its entry point, `@m1k1o/neko`, never from a path into `core/`.
|
|
- State is zustand: the core's `client.store`, the GUI's `app` store (`state/app.ts`) and each plugin's
|
|
`store.ts`, all per instance (see Composition). A component gets the instance from the context
|
|
(`useClient()`, `useApp()`, `useNeko()`), selects what it renders,
|
|
`useStore(useClient().store, (s) => s.video.playing)` (`useShallow` for several fields; the core's
|
|
`selectControlling`, `selectIsAdmin`, `selectSession` for the computed values), and re-renders only
|
|
when that value changes. Everything outside React takes the instance as its first argument, reads
|
|
`store.getState()` and writes `store.setState()` with a new object, never in place.
|
|
- Where two layers must talk without importing each other, there is a store or a signal in `state/`:
|
|
the room's event lines ("bob took the controls") are emitted on `state/bus.ts` and shown by the chat
|
|
plugin; `logout` is a bus signal every store with per-session data listens to.
|
|
|
|
`npm run check` enforces this: `eslint.config.js` holds one `no-restricted-imports` rule set per
|
|
layer, and `tools/cycles.mjs` fails on an import cycle (type-only imports excepted).
|
|
|
|
## Composition
|
|
|
|
There is no global client. `createNekoApp()` (`src/state/neko.ts`) makes one instance of the GUI, a
|
|
`NekoApp` (`src/state/app.ts`): the `NekoClient` (WebRTC transport, `autologin`, `autoconnect`,
|
|
`autoplay` from the viewer settings), its input `Overlay`, the GUI store `app` and the `bus`, with the
|
|
event wiring (server events to event lines and toasts, `state/events.ts`) and the settings (stored
|
|
and from the URL, `state/settings.ts`) applied. `main.tsx` composes the page in the order the pieces
|
|
need:
|
|
|
|
```tsx
|
|
const ready = initI18n(plugins.map((p) => p.ns)) // the strings of the active language
|
|
const neko = createNekoApp() // the client, its wiring, the settings
|
|
initPlugins(neko) // the slots, the event dispatch, each plugin's init
|
|
ready.then(() => {
|
|
neko.client.setUrl(location.href) // the connection: resume a saved session and connect
|
|
createRoot(root).render(
|
|
<NekoProvider neko={neko}>
|
|
<App />
|
|
</NekoProvider>,
|
|
)
|
|
})
|
|
```
|
|
|
|
`<NekoProvider>` (`src/state/provider.tsx`) puts the instance in a React context; a component reads it
|
|
with `useNeko()` (the whole `NekoApp`), `useClient()`, `useApp()` (the GUI store) and `useActions()`
|
|
(`state/actions.ts`, the room actions bound to the instance), and the rest, `setSetting(neko, ...)`,
|
|
`ask(app, ...)`, `api(neko, ...)`, a plugin's `store(neko)`, takes it as the first argument. Two
|
|
instances on one page share nothing but the page (the strings, the URL parameters, `localStorage`).
|
|
|
|
To embed the GUI in another React app, compose the same pieces and render `<App />` under a provider
|
|
(`?embed=1` and `?cast=1` on the page's URL still give the video-only layouts, for an iframe or a kiosk):
|
|
|
|
```tsx
|
|
import { initI18n } from '@/i18n'
|
|
import { plugins, initPlugins } from '@/plugins'
|
|
import { createNekoApp } from '@/state/neko'
|
|
import { NekoProvider } from '@/state/provider'
|
|
import { App } from '@/app/App'
|
|
|
|
await initI18n(plugins.map((p) => p.ns))
|
|
const neko = createNekoApp()
|
|
initPlugins(neko)
|
|
neko.client.setUrl('https://neko.example.com/')
|
|
|
|
export const Room = () => (
|
|
<NekoProvider neko={neko}>
|
|
<App />
|
|
</NekoProvider>
|
|
)
|
|
```
|
|
|
|
## Design
|
|
|
|
Styling is Tailwind v4 (`tailwindcss`, `@tailwindcss/vite`): the classes are in the markup, there is no
|
|
stylesheet per component. `src/index.css` is the whole of the CSS:
|
|
|
|
- **Tokens**: an `@theme` block with the values of the previous SCSS variables under the same names, so
|
|
`$background-primary` is `--color-background-primary` and the utility `bg-background-primary`;
|
|
`$text-normal` is `text-text-normal`, `$style-primary` is `bg-style-primary` / `text-style-primary`,
|
|
`$elevation-high` is `shadow-elevation-high`, `$side-width` / `$menu-height` / `$controls-height` are
|
|
`w-side` / `h-menu` / `h-controls`, `$text-size` is `text-ui`. The font is Tailwind's system stack
|
|
(`--font-sans`). Sizes are written in px where the legacy layout had px (`p-[5px]`, `h-7.5`).
|
|
- **Global rules** the preflight does not have: the page's size and overflow, 14px text with line-height 1
|
|
on `body` (so `rem`, the base of Tailwind's spacing scale, stays 16px), no native video controls.
|
|
- **Breakpoints** as variants: `tablet:` (up to 1024px: the page scrolls and the side panel goes under the
|
|
video, with Tailwind's `portrait:` / `landscape:` for the split), `phone:` (up to 768px: no room bar).
|
|
- The animations (`animate-shake`, `animate-loader`, `animate-badge`) and the `slider` utility (the range
|
|
inputs).
|
|
|
|
`cn()` (`src/lib/utils.ts`, `clsx` + `tailwind-merge`) joins class names and lets the last Tailwind class
|
|
win, so a caller's `className` overrides a component's; `class-variance-authority` gives the variants.
|
|
|
|
The component kit under `src/components/ui/` is shadcn/ui on Radix, restyled with the tokens: `dialog.tsx`
|
|
(`@radix-ui/react-dialog`, the overlay and the content composed by the caller), `dropdown-menu.tsx` (a
|
|
menu from a button: the resolution list, the emote picker), `popover.tsx` (the clipboard textarea, the
|
|
emoji picker), `tabs.tsx` (the side panel), `context-menu.tsx` (the member menu: a menu opened at a
|
|
point, on `@radix-ui/react-menu`, since it is opened from the member list, a chat author and Shift+F10),
|
|
`button.tsx` (the text buttons: `primary`, `outline`, `confirm`, `cancel`) and `sonner.tsx` (the toasts,
|
|
`toast()` in `src/state/dialogs.ts`). They bring focus trapping, roving focus, Escape, outside click and
|
|
ARIA. `components.json` configures `npx shadcn add <component>` (the radix base); a component added
|
|
that way comes with shadcn's theme classes and is rewritten with the tokens, like the ones here.
|
|
|
|
Every icon control is `IconButton` (`src/components/IconButton.tsx`): a `<button type="button">` named
|
|
by its `label` (the accessible name and the tooltip), with the icon as its child and the variants `plain`
|
|
(the room bar, the chat, the files), `header` (a 30px box) and `video` (the translucent box over the
|
|
video). Icons are `@phosphor-icons/react` components, tree-shaken: `import { MouseIcon } from
|
|
'@phosphor-icons/react'` and `<MouseIcon className="size-4" />` (`size-3.5` next to 14px text, `size-6` in
|
|
the room bar), all filled: `IconContext` in `App.tsx` sets the weight once; a plugin tab's `icon` is one.
|
|
Emoji are text: the character of each name from `public/emoji.json` (`src/plugins/chat/Emoji.tsx`).
|
|
|
|
To add a component: Tailwind classes and the tokens in the markup, repeated class strings as constants
|
|
next to the component, `Button` / `IconButton` for buttons, the kit for a dialog, a menu or a popover. To
|
|
add an icon: import it from `@phosphor-icons/react`.
|
|
|
|
## Plugins
|
|
|
|
A plugin is a folder under `src/plugins/` with its components, styles, store and actions, described
|
|
by one object (`src/plugins/types.ts`); its strings are a namespace of their own under `src/locales/`:
|
|
|
|
| field | |
|
|
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `id` | the prefix of its server events: `message` events named `<id>/...` reach `onEvent` |
|
|
| `ns` | the namespace of its strings, `src/locales/<lang>/<ns>.json` in every language, used as `t('<ns>:key')` |
|
|
| `tab` | a side-panel tab: `id` (the remembered tab), `icon`, `label` (the key of its name, `chat:tab`), `component`, the hooks `useVisible()` and `useBadge()` (unread count) |
|
|
| `topBar` | items for the header menu; each renders its own `<li>` or nothing |
|
|
| `memberMenu` | entries of another member's context menu: the hook `useVisible(member)`, `label(member)`, `onClick(member)` |
|
|
| `onEvent` | `(neko, ...[event, payload])` for its server events, typed by `PluginEvents` (event name to payload, `src/plugins/types.ts`): `if (event === 'chat/init')` narrows `payload` to its shape |
|
|
| `init` | `(neko)`, called once per instance at start-up, before the connection: subscribe to `neko.client.events`, `neko.bus`, ... |
|
|
|
|
Every callback gets the instance (`neko: NekoApp`, see Composition) as its first argument, the member
|
|
menu's `label(neko, member)` and `onClick(neko, member)` included; the hooks (`useVisible`, `useBadge`)
|
|
and the components read it from the context (`useNeko()`, `useClient()`). A plugin's store is one per
|
|
instance: `export const store = scoped(() => createStore(...))` (`scoped`, `src/state/app.ts`) and
|
|
`store(neko)` is the instance's.
|
|
|
|
`src/plugins/index.ts` lists the plugins of the build in side-panel order, and `initPlugins(neko)`
|
|
(called from `main.tsx`) wires them in that order: it puts each `tab`, `topBar` item and
|
|
`memberMenu` entry into its slot (`registerSlot('side.tab' | 'header.item' | 'member.menu', item)`,
|
|
once: the slots are the build's, not the instance's), builds the event dispatch (a map from the `id`
|
|
prefix to the plugin, one lookup per message) on the instance's `client.events` and runs each
|
|
`init(neko)`. The side panel, the header and the app shell read the slots with `useSlot(name)`
|
|
(`useVisible` and `useBadge` are hooks over the plugin's store, so each runs in a small component of
|
|
its own and re-renders only that).
|
|
|
|
To add a plugin:
|
|
|
|
1. Create `src/plugins/<name>/` with an `index.ts` exporting the plugin object, its components (Tailwind
|
|
classes, the tokens of `src/index.css`), and a `store.ts` made with `scoped(() => createStore(...))`
|
|
(components select from it with `useStore(store(useNeko()), (s) => s.field)`, the rest reads
|
|
`store(neko).getState()` and writes `store(neko).setState()`).
|
|
2. Add its strings as `src/locales/<lang>/<ns>.json` in every language (see i18n).
|
|
3. Add the import and the entry to `src/plugins/index.ts`.
|
|
|
|
Removing the entry removes the tab, the header items, the menu entries and the event handling;
|
|
nothing else changes (its locale files are then unused).
|
|
|
|
## i18n
|
|
|
|
Strings live in `src/locales/<lang>/<ns>.json`: one folder per language, one file per namespace,
|
|
`common.json` (the GUI), `chat.json` and `files.json` (the plugins). The runtime is `i18next` with
|
|
`react-i18next` (`src/i18n/index.ts`): components call `useTranslation()` and render
|
|
`t('side.settings')` or `t('chat:tab')` (`common` is the default namespace, a plugin's is prefixed);
|
|
code outside components imports `t` from `@/i18n`. Values interpolate with `{{name}}`.
|
|
|
|
Only the active language is downloaded: the files are lazy chunks (`import.meta.glob`), and the
|
|
loader hands i18next the `<lang>/<ns>` it asks for at start-up (`initI18n`, before the first render)
|
|
and on a pick in the room menu (`setLang`, i18next's `changeLanguage`). `en` is the fallback for a
|
|
key a language lacks, so it is loaded next to the active language; the other languages never are
|
|
(`tools/dist.check.mjs`, run by `npm run build`, checks the main chunk carries none). The language is
|
|
kept in `localStorage` (`lang`, the short code) and set by `?lang=`; `<html lang>` gets the BCP 47
|
|
tag (`cn` is `zh-CN`). In development, a key no language has is logged (`i18n: missing ...`).
|
|
|
|
- Adding a key: add it to `en` and to the other fourteen files. `src/i18n/i18n.test.ts` lists the
|
|
keys each language lacks today and fails on a new one.
|
|
- Adding a language: a folder with the three files, and its code in `langs` (`src/i18n/index.ts`,
|
|
the picker order).
|
|
- Not yet: plural forms (`key_one`, `key_other`); the strings with a count keep one form, as before.
|
|
|
|
## core
|
|
|
|
`core/` is `@m1k1o/neko`: `NekoClient` (auth, websocket with reconnect and stale detection, WebRTC,
|
|
the binary input channel, control, clipboard, broadcast), the input `Overlay`, the zustand store with
|
|
its selectors, `Emitter`, `NekoApi` and the wire types. The GUI consumes it from source through the `@m1k1o/neko` alias in
|
|
`tsconfig.json` and `vite.config.ts`; `npm run build:core` builds the package (`core/dist/`). The API
|
|
reference is `core/README.md`.
|
|
|
|
```ts
|
|
import { NekoClient } from '@m1k1o/neko'
|
|
|
|
const client = new NekoClient({ autologin: true, autoconnect: true, autoplay: true })
|
|
client.store.subscribe((s) => s.sessions, render) // state: connection, sessions, control, screen, ...
|
|
client.events.on('message', (event, payload) => {}) // plugin events (chat/*, filetransfer/*, ...)
|
|
client.setUrl('https://neko.example.com/')
|
|
client.mount(document.getElementById('video')!)
|
|
```
|
|
|
|
## Development
|
|
|
|
Backend from `server/dev`, then:
|
|
|
|
```sh
|
|
cd client/dev && ./serve # http://localhost:3001, /api proxied to the backend on :3000
|
|
# or without docker:
|
|
NEKO_URL=http://localhost:3000 npm run dev
|
|
```
|
|
|
|
| script | |
|
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `npm run build` | type-check + production build into `dist/`, then `tools/dist.check.mjs` (no language in the main chunk) |
|
|
| `npm run build:core` | build the `@m1k1o/neko` package into `core/dist/` |
|
|
| `npm run check` | `check:types`, `check:format`, `check:lint` (hooks, layering), `check:cycles` and `test` in parallel (`npm-run-all2`, one label per script), then `build:core` and `check:dist` (the built core package); each `check:*` script also runs on its own |
|
|
| `npm test` | unit tests (vitest): the core's connection state machine and store, the chat's markdown (rendered with react-dom/server), the i18n runtime and locale files, the plugin registry, an instance and two of them, the provider; no browser |
|
|
| `npm run format` | prettier |
|
|
| `npm run build:emoji` | regenerate the emoji data `public/emoji.json` (names, characters, groups, keywords; `tools/emoji.ts`) |
|
|
| `npm run test:e2e` | two-user browser test against a running server, see below |
|
|
|
|
### e2e
|
|
|
|
`e2e.mjs` drives two (or more) headless Chromium users through login, video, chat, control, files,
|
|
admin actions, reconnects and URL parameters. It needs a disposable server (it takes control, types
|
|
into the desktop and kicks users) with the multiuser provider:
|
|
|
|
```sh
|
|
docker run -d -p 8080:8080 -p 52000:52000/udp \
|
|
-e NEKO_MEMBER_PROVIDER=multiuser -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
|
|
-e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=admin -e NEKO_FILETRANSFER_ENABLED=true \
|
|
-e NEKO_WEBRTC_UDPMUX=52000 \
|
|
-e NEKO_WEBRTC_NAT1TO1=127.0.0.1 ghcr.io/m1k1o/neko/kde
|
|
npx playwright install chromium
|
|
NEKO_URL=http://localhost:8080/ npm run test:e2e
|
|
```
|
|
|
|
The typing step expects a launcher on Alt+F2 (KRunner on KDE, the app finder on Xfce). To run the
|
|
steps in a real Chromium-based browser instead of Playwright's, start it with
|
|
`--headless --remote-debugging-port=9222 --user-data-dir=<empty dir>
|
|
--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream
|
|
--use-fake-device-for-media-stream` and set `NEKO_CDP=http://127.0.0.1:9222`.
|
|
|
|
## Behaviour notes
|
|
|
|
- **Kick** removes the session (the user may log in again), see the comment at `actions.kick` in
|
|
`src/state/actions.ts`.
|
|
- Settings use the same `localStorage` keys and format as the previous client, so preferences carry
|
|
over. Scroll sensitivity now uses v3 steps (-5..5, key `scroll_sensitivity`); `?scroll=` is mapped.
|
|
- Chat markdown (`src/plugins/chat/markdown.ts`) is rendered by `react-markdown` with `remark-gfm`
|
|
(`~~strike~~`, tables, bare links) and `remark-breaks` (a newline is a line break) to React elements,
|
|
no HTML strings; four small rehype plugins of ours add the chat's dialect: `:name:` emoji, plain-text
|
|
`||spoiler||` (click to reveal; no formatting inside), `__underline__`, and links to anything but
|
|
http(s)/mailto, images and raw HTML shown as the text they were typed as (nothing in a message loads
|
|
anything). Compared with the hand-written parser before: the Discord `>>>` block quote is gone
|
|
(`> ` quotes stay, and continue until a blank line), and CommonMark's headings, lists, nested
|
|
quotes and tables are in. Messages over 512 characters (the server's default `chat.max_length`), or one the renderer fails on, are shown
|
|
as plain text.
|
|
- Short-lived list items (uploads, emote animations) are keyed by 64 random bits from `crypto.getRandomValues()`, which
|
|
every origin has (`crypto.randomUUID()` needs a secure context, and plain http on a LAN is not one).
|
|
- File downloads are plain links so the browser streams them; the session token is added to the
|
|
URL only when the server runs without cookies.
|