Files
neko/client/README.md
T
Miroslav Šedivý 3191dbb058 Remove legacy mode.
- 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.
2026-10-16 20:23:56 +02:00

20 KiB

neko client

The web client for 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:

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):

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.

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:

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:

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.