27 Commits

Author SHA1 Message Date
SamAcctX 6ad3ad046b client: React client on the v3 API, replacing the Vue 2 client (#713)
* client-react: framework-free client core for the v3 API

A new client is built next to the Vue 2 one (client/ keeps shipping until
it is replaced at the end of this series). This first part is the core
library in client-react/src/core: no UI framework, no runtime
dependencies, written against master's protocol (server/pkg/types,
webrtc payloads, openapi.yaml).

- client.ts: NekoClient: login/logout/whoami (token or cookie, ?token=),
  websocket with backoff reconnect and stale-socket detection (the server
  heartbeats every ~10s), WebRTC as answerer (server candidates that
  arrive before signal/provide are buffered), the binary big-endian input
  data channel, control/screen/clipboard/broadcast/unicast messages,
  letterboxed video mount, microphone tracks via renegotiation.
- overlay.ts: input layer over the video: mouse (throttled, last
  position always sent), wheel steps with sensitivity, keyboard through
  the vendored Apache Guacamole keyboard (also used by the Vue 2 client)
  with the macOS key remapping, native touch or touch-as-mouse, file
  drop, and the host's cursor drawn for everyone not in control.
- store.ts: tiny proxy store (subscribe/watch/version) + typed emitter.
- api.ts + api.gen.ts: fetch wrapper; types generated from
  server/openapi.yaml (npm run gen:api), no generated runtime code.
- types.ts: client state and wire types mirroring server/pkg/types.

The root .gitignore ignores "core" (core dumps); client-react/.gitignore
re-includes src/core.

Check: npm run check (types, prettier, store self-check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client-react: React GUI with the look and features of the Vue 2 client

React 19 + Vite GUI on top of the core (client-react/src/app). The Vue 2
client's SCSS is reused with the same class names, so it looks the same.

Features: login (display name + password, ?usr=/?pwd= invite links,
OAuth button), video with fullscreen + keyboard lock, picture-in-picture,
resolution menu, clipboard sync and fallback, play/unmute overlays,
members with admin menu, control request/release/give/take, local
controls lock, microphone, volume, emotes, chat, file transfer
(streamed downloads, uploads with progress, multi-select delete),
settings (scroll, autoplay, emotes, chat sound, links in app, keyboard
layout, broadcast), locks for login/controls/file transfer, toasts and
SweetAlert-style dialogs (native <dialog>), all 15 translations,
URL params (embed, cast, volume, scroll, lang, show_side, mute_chat).

Behaviour notes:
- Kick/ban follow common chat-app semantics, documented at actions.kick
  in neko.ts: kick removes the session (may log in again); ban sets
  can_login:false and is only offered when the auth provider stores
  accounts (GET /api/members), with an unban list in Settings. Stale
  "IP ban / restart server" translations are dropped (English fallback).
- Settings keep the Vue 2 localStorage keys and format, so preferences
  carry over; scroll sensitivity uses the v3 steps (new key), ?scroll=
  is mapped onto them.
- Chat markdown (markdown.ts) parses to React elements: no HTML strings,
  links limited to http(s)/mailto. Emoji use the same emoji.json and
  twitter sprite sheet as before.
- Icon controls are labelled buttons with keyboard activation.

Check: npm run check (adds markdown self-check), npm run build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client-react: two-user end-to-end test

e2e.mjs drives headless Chromium users (Playwright) through a running
server with the legacy API off and the multiuser provider: login + video,
members, chat (incl. markdown rendered safely and the emoji picker),
control hand-off and admin force-release, emotes, file upload/download/
delete, language switch, locks, mute, ban hidden without stored
accounts, keyboard activation, resolution change, keyboard layouts,
about, implicit hosting, clipboard sync, microphone (fake device),
reconnect after a network drop, typing into the desktop, kick, and URL
params (usr/pwd/lang/show_side, embed). With NEKO_FILE_URL pointing at a
file-provider server it also covers ban/unban.

Failing steps save screenshots of both users and log their member lists.

  NEKO_URL=http://host:port/ npm run test:e2e

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client-react: Dockerfile, dev scripts, emoji tool, README

- Dockerfile (node 24 LTS, npm ci) with the same contract as the Vue 2
  client stage: npm run build -> /src/dist, used by Dockerfile.tmpl.
- dev/ docker wrappers like the Vue 2 client: serve runs vite on :3001
  with /api (incl. websocket) proxied to the backend on :3000 (override
  with NEKO_URL), plus npm/build/exec.
- tools/emoji.ts: the Vue 2 emoji generator ported to ESM (runs on node
  directly); regenerates _emoji.scss byte-identically (npm run
  build:emoji).
- README: core API, development, checks, e2e, behaviour notes.

Check: npm run check; docker build of this directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: replace the Vue 2 client with the React client

client-react/ becomes client/. The client uses only the v3 API (it makes
no requests to the legacy compatibility layer, which stays in the server
for other V2 clients), so images built from this tree also work with
NEKO_LEGACY=false. The image build is unchanged: Dockerfile.tmpl still
builds ./client and copies /src/dist to /var/www.

- CI: node 24 LTS (React 19 / Vite 7 need >= 20.19), `npm run check`
  before the build, no browser download for the e2e tool.
- Docs: developer guide (build, development, repository structure) and
  release notes.

Tested on an image built with ./build (xfce, default config, legacy on):
client e2e (28 steps), ban/unban on a file-provider server, no requests
to /ws, /stats, /file or /screenshot.jpg and no V2 data channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: back off and give up when WebRTC keeps failing

When the peer connection failed (no media route: firewall, wrong
NAT1TO1, missing TURN, or a browser that cannot reach the server), the
client immediately asked for a new peer, which failed again: ~9 peer
setups per second on the server (551 in 60s in a test with Firefox).
Retries now back off (1.5s up to 6s) and stop after 10 consecutive
failures with "video connection failed (WebRTC)"; a successful
connection resets the count.

Tested: Firefox (ICE fails in this environment, for the Vue 2 client
too) now makes 11 attempts in 75s and shows the Disconnected dialog;
Chromium e2e 28/28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: tell the user when the browser cannot play the stream's codec

If the browser has no video codec in common with the server (e.g. an
H264 stream and a Firefox without the OpenH264 plugin), it rejects the
video section of the offer (m=video 0). The server then fails with
"unable to start track, codec is not supported by remote" and never
answers ICE, so the client spun and retried until it gave up with a
generic WebRTC error. It now stops right away with "this browser cannot
play H264 video".

Tested with stock Firefox 157 (no OpenH264): H264 server -> message
after 0.3s; VP8 server -> video plays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: build on Debian trixie like the rest of the repo

Upstream moved its images to Debian 13 (trixie) in #581; the client
Dockerfile and dev scripts still used node:24-bookworm-slim.

Tested: client/Dockerfile builds on node:24-trixie-slim (Node 24.21)
and produces dist/; dev/build builds dist/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: ask for a new peer when the server closes it; mic survives peer changes

When the media path fails, the server notices within about 5 s, destroys
its peer and sends signal/close. The client only closed its own peer: a
locally closed RTCPeerConnection never reports "failed", so the backoff
from 5c9418fb never ran, and the websocket's heartbeats kept the stale
check quiet. Result: a frozen frame with status "connected" until the
page was reloaded. Reproduced by dropping the media port for 12 s with
the websocket up: no signal/request followed, and video.currentTime
stayed frozen for 60 s after the port was open again.

signal/close now takes the same path as a failure the browser noticed:
close, back off, request again. With this the client re-requested 1.5 s
after signal/close and video resumed about 5 s after the port opened.

Recovering exposed the microphone: its RTCRtpSender belonged to the old
peer connection, so removeTrack() on the new one threw InvalidAccessError
before the track was stopped (mic stuck on; releasing control then threw
inside an effect and the error boundary replaced the page). removeTrack()
now ignores senders of a previous connection, and the mic is switched off
whenever the connection is re-established, as the Vue client did on every
disconnect.

The core now runs under plain Node (imports with extensions, no parameter
properties, erasableSyntaxOnly), which makes src/core/client.check.ts
possible: a state-machine check with stub WebSocket/RTCPeerConnection that
fails on the old signal/close handling. It is part of `npm run check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: encode session ids in REST paths

Session ids on the multiuser and noauth providers start with the name the
user typed at login, and the client interpolated them into URLs as they
were. The browser resolves "..", "?" and "#" before a request leaves, so
a member could choose what an admin's action against them does:

  display name            admin clicks  request actually sent
  x/../../logout?         Mute          POST /api/logout        (admin logged out)
  <someone else's id>#    Kick          DELETE /api/sessions/<someone else>

Both were reproduced against a test server. Every id that is part of a
path now goes through encodeURIComponent; with that the first name no
longer leaves /api/members/ and the second gets the right session kicked.

The e2e test logs in a member named "evil#?x" and checks that the admin's
mute lands on that member and the admin keeps the session. Names with "/"
need the server to build ids from a sanitised name; that is a separate
server change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: chat stays fast past the line limit

Once the chat held CHAT_LIMIT lines, every new line replaced the list
with `chat.slice(-CHAT_LIMIT)`. The elements of that slice are the proxies
the store had handed out, the setter unwraps only the array itself, and
the next read wrapped every element again: one more proxy layer on each
of the 1,000 kept lines per incoming message. Measured in the UI, the
main thread blocked 78 ms per line at the limit and 751 ms eighty lines
later; a read overflowed the stack about 3,000 lines in and the error
boundary replaced the page. Event lines count toward the limit, so a busy
room reached it in an evening.

Store.wrap() now returns a value that is already one of its proxies, and
the chat is trimmed in place. Lines carry an increasing `seq` used as the
React key and by the scroll-to-newest effect, so a trim no longer shifts
spoiler state between messages, re-parses every message, or stops the
scrolling (the length no longer changed once at the limit).

store.check.ts asserts that an element put back into the store keeps its
identity, and that 3,000 such round trips still read in constant depth;
the old store fails both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: keep one identity per login, keep the session through an outage

Three session-flow defects, all reproduced against a test server:

- An invite link (?usr=&pwd=) opened in a browser that still had a valid
  session raced the autologin: the resumed session opened the websocket,
  the invite's login replaced the token, and connect() was a no-op
  because a connection was already wanted. The websocket then ran as the
  old session and every REST call as the new one (3 of 3 runs). login()
  now closes whatever was resumed before storing the new token.

- After the core gave up reconnecting (about 80 s offline) the app asked
  /whoami and treated any failure as "session gone", including a network
  error, so logout() removed the saved token and the user landed on the
  login form although the session was still valid. The screen written
  for this case (logged in but disconnected, with a Connect button) was
  unreachable. Only a 401 logs out now.

- A wrong ?pwd= was a dead end: the invite was cleared only on success,
  the password field stayed hidden and the URL was already cleaned. The
  invite is now used for the first attempt only, as in the Vue client.

The disconnect dialog also names the common case where a second tab or
window took over the session, instead of the raw "connection replaced".

e2e: a second invite link in a profile with a live session must leave
exactly the new session connected; a long outage must end at the Connect
button, not the login form.

The websocket is closed with status 1000 when the client closes it on
purpose (login, logout, giving up). Without a status the server assumed a
reconnect was coming and kept the session connected, and host, for 5 s;
with it the old session drops at once, which the invite test relies on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: chat parser cannot be stalled by a message

The chat markdown parser runs during render for every message from
every member. Three inputs made that expensive or fatal:

- A code fence with no closing fence followed by newlines: the fence
  regex backtracked for 4.3 s on a 512-character message that fits the
  normal chat box (the same expression the Vue client used).
- Long runs of backticks: the inline-code regex with a backreference was
  quadratic per position.
- Deep nesting or very long messages: 24,000 asterisks overflowed the
  stack inside render, which ends at the error boundary for everyone.

Fences and inline code are now found with indexOf, the line-start and
previous-word facts a rule needs are computed once per position instead
of by regex over the whole prefix, and the UI parses through parseSafe():
messages above 10,000 characters are shown as plain text, and any parser
failure falls back to plain text as well. The self-check covers the
unclosed fence (must parse in milliseconds), 40,000 asterisks (must not
throw) and the length cap, and keeps the earlier formatting cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: muting the sound keeps the desktop usable

The "unmute" overlay was shown whenever the video played muted, so a
user who muted on purpose got a click-catching layer over the desktop:
the first click unmuted instead of reaching the remote, and the only way
to work with the sound off was the volume slider at zero. The Vue client
showed that overlay only until the first unmute.

The core now remembers when it was autoplay that muted the video, and the
overlay appears only in that case. e2e: muting from the controls must
leave no overlay over the player.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: member menu works on narrow screens

The member menu was rendered inside the members bar, which the layout
hides at 768 px and below. Right-clicking a chat author there set the
menu state but nothing appeared, so ignore, mute, kick and give-control
were unreachable on phones. The Vue chat had its own menu.

The menu is position: fixed anyway; it is now rendered by App, outside
the hidden container. e2e: at 700 px wide, the menu opens from a chat
author.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the video takes the keyboard only while input can flow

Mouse-enter focused the hidden input layer for everyone, so a viewer
typing in the chat lost the focus by brushing the video and the rest of
the sentence went nowhere. The Vue client focused the overlay only while
hosting and unlocked.

The overlay now focuses on mouse-enter only when input is active, and
also when control arrives while the pointer is already over the video.
e2e: a viewer types half a sentence, crosses the video, and the chat box
has the whole sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: emote repeat stops with the bar

Holding an emote sends it every 350 ms; the interval was cleared only by
mouse-up or mouse-leave on the bar. When an admin muted the member the
bar was removed first, so neither event could arrive and the member kept
sending that emote until reload (11 more in the 4 s after releasing the
mouse, then on). The interval now dies with the component, and a muted
member's sendEmote is a no-op, as the Vue client's action was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: master's keyboard file; typed text as keys; Ctrl+V keeps both clipboards right

The vendored Guacamole keyboard was the 2022 copy from the reference
branch, not the one upstream master ships. It lacked two fixes from
September 2025: typing of indirectly entered text (IME, on-screen
keyboards) as key events (#577) and the GUACAMOLE-1823 Caps Lock fix
for macOS Chrome; it also sent right Alt as AltGr on every platform and
had wrong or missing Japanese keys. The file is now master's, byte for
byte.

With it, IME and phone text is typed as key events instead of going
through control/paste, which overwrote the remote clipboard (and over
https the local one) on every keystroke and did nothing in programs
where Ctrl+V is not paste.

Ctrl+V keeps using the browser's paste event, the reason the reference
client introduced the textarea in 2022: only Chromium over https can
read the clipboard programmatically, a paste event hands the text over
in every browser. Two defects in that path are fixed:

- Ctrl was released to let the paste through and the server's paste
  reset all keys, so with Ctrl still held the next key arrived as a
  plain letter ("xyzxyza"). Ctrl is no longer touched; the remote gets
  its paste keystroke from the overlay, which completes the chord with
  its own Ctrl if the user has let go by then.
- The local text always won, so after copying inside the remote a paste
  on a plain-http site (no clipboard sync) pasted stale local text. Now
  the side that copied most recently wins: the server reports every
  remote change, and if the remote changed since the local clipboard was
  last seen, only the keystroke goes out; otherwise the local text is
  set first and the keystroke follows once the server confirms it.

Verified in a browser on https and on plain http: a fresh session pastes
local text; copy-then-paste inside the remote yields the remote text on
both origins; Ctrl held across V and A selects all instead of typing a.
client.check.ts covers the decision; e2e now asserts what the typing
step delivered and that Ctrl+V inside the remote doubles it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: e2e asserts what its step names promise

- Page errors and console errors now fail the run; they were only
  printed, so a crash to the error boundary passed.
- The typing step checks what arrived in the remote (added with the
  keyboard change) and the resolution step waits for the revert to take
  effect instead of leaving later steps to run mid-resize.
- "cannot log in" for a banned account requires a login error with the
  form still shown, not any open dialog.
- The upload step is named for what it checks (the uploader's own list;
  the documented setup gives plain users no Files tab).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: core fixes from the review's minor list

- A member who may not watch never receives an offer and was left on a
  full-screen spinner for good, chat included. Eight seconds without an
  offer now shows the room without video. e2e: can_watch off, reload,
  the chat is reachable.
- The reconnect lookup (/whoami) is bounded to 5 s; on a black-holed
  network a fetch can otherwise hang for minutes. A close()+connect()
  while that lookup was pending could open a second socket; the decision
  now belongs to one connection generation.
- A closed peer took no retry timer with it, so a websocket reconnect
  could trigger a second signal/request.
- play() treated any rejection as a refused autoplay and muted the
  video; only NotAllowedError does now.
- Upload errors showed xhr.statusText, which is empty under HTTP/2; the
  server's JSON message is used.
- keyboard/map was re-sent, and setxkbmap run on the server, on every
  viewer setting change while hosting; the watcher now follows the
  layout, not the object.
- A click waiting for control under implicit hosting was replayed
  whenever control arrived, even minutes later by an admin giving it;
  only a press from the last two seconds is replayed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: small behaviours the Vue client had

- The side panel, the selected tab and the volume are remembered again,
  under the Vue client's keys (side, tab, volume 0..100). ?show_side=
  and ?volume= still win for the visit they are in; ?volume= is applied
  once, not on every reconnect.
- The autoplay setting is read when a track arrives, so changing it no
  longer needs a reload.
- Control events say who took the controls from whom and who released
  whose, with the strings every locale already had.
- Logging out clears the chat, the ignore list, uploads and the
  broadcast URL for whoever uses the browser next, as the Vue client's
  reset did. The login name and password the Vue client stored in
  localStorage are removed on load; nothing reads them.
- <html lang> gets zh-CN and zh-TW for the two Chinese locales instead of
  the invalid cn and tw.
- ?token= is removed from the address bar after login, like ?pwd=.

e2e opens the side panel instead of toggling it blindly, since the state
now survives a reload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: small UI defects from the review

- The resolution menu was pinned to the window and sat on the side panel
  when that was open; it is now inside the player. It also no longer
  shares the member menu's class, so the member menu's row styles stop
  leaking into it.
- The file icon lost its class when the list was ported, so the gap
  before the name was gone.
- Choosing the same file twice in a row uploaded nothing: the file input
  is reset after each pick.
- The banned list reloads after a ban or unban from anywhere.
- A slow "can this member be banned" answer could land on another
  member's menu.
- The clipboard box follows what is copied on the remote while it is
  open. Fullscreen and picture-in-picture rejections are no longer
  unhandled. The video's control button toggles through the same
  action as the one in the room bar, with its toast.
- Emote names from other clients are checked against the known list
  before they become class names; link text in chat is not linked
  again, so [https://a](https://b) is one anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: keyboard access to menus and pickers

- Escape closes the member menu, the emote and emoji pickers, the
  resolution and clipboard menus and the About panel; before, only a
  click anywhere did.
- The member menu moves focus to its first item when it opens, and the
  emote picker opened from the keyboard appears at the icon instead of
  the top-left corner.
- The 1,800 emoji were individual tab stops; they are reachable through
  the search box now, where Enter takes the first match.
- The broadcast buttons, the setting switches and the control lock have
  accessible names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: remove what nothing uses

- The generated OpenAPI types (2,492 lines, a devDependency and a
  script) served two type names in three places; those two shapes are
  now declared in types.ts next to the other hand-written wire types.
  The generated schemas were all-optional and already lacked two
  settings fields, so nothing is lost.
- 47 emote PNGs that no stylesheet referenced (the SVGs are used).
- Styles ported from Vue components that have no element any more: the
  About page's markdown loader and bounce animation (connect.scss has
  the same keyframes), `body > p`, the event line's `<i>`.
- A re-export and a helper that nothing imported, and three locale keys
  nothing reads (one of them added by this branch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs and build: release notes, Node version, dead build settings

- Release notes cover the behaviour changes of the fix series: media
  recovery, paste semantics and IME typing, the microphone on
  reconnect, the second tab, sanitised session ids.
- The build page said Node 20.19 works; `npm run check` runs TypeScript
  files with Node itself, which needs 22.18 or newer.
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD did nothing (Playwright 1.63 has no
  install script); removed from the Dockerfile, the workflow and the dev
  script.
- Three comments used a private convention; reworded as plain notes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: lint the two React hook rules

ESLint with only rules-of-hooks and exhaustive-deps (typescript-eslint
for the parser), run by `npm run check` and therefore in CI. The five
findings were effects that meant to run once but read a hook value:
they now read the stable store instead, or list the dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: e2e can drive a real Chromium-based browser

NEKO_CDP=http://host:port connects the suite to a browser started with
--remote-debugging-port (Brave, Chrome, Edge) instead of launching
Playwright's Chromium, so the same steps run against a browser people
actually use. README: how to start it, the check script also lints, the
typing step works with Xfce's launcher too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the on-screen keyboard toggle closes the keyboard

Tapping the keyboard button while the keyboard was open closed and
reopened it: the button is focusable, so the tap took the focus from
the hidden input first (its blur handler cleared the flag), and the
toggle then saw "closed" and focused again. Checked with a Pixel 7
emulation: focus went overlay, overlay, overlay. The button no longer
takes the focus on press, and the toggle decides from its own flag
before the blur handler changes it. Now: overlay, none, overlay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* server: default video pipeline without the mouse pointer

The client draws the pointer itself from the data channel (cursor image +
position, sent to every peer when its channel opens and on every host
move): CSS cursor for the host, canvas overlay for everyone else. With
the pointer also drawn into the stream, a second pointer trailed the
real one by the stream latency, for the host and for watchers.

Before #691 the default main pipeline had no pointer (the "legacy"
stream carried one for V2 clients, and the V2-config path still forces
show-pointer=false on main). #691 made the default visible; this goes
back to the original default for the main stream. Broadcast and
screencast keep following capture.video.show_pointer (default true),
the legacy stream keeps its pointer, and capture.video.show_pointer=true
puts it back on all video pipelines. Docs: custom pipeline examples use
show-pointer=false, with a note why.

Tested (xfce image built with ./build, default config, compat layer on):
- stream: pointer moved into an empty region changes received frames
  6/6 before (diff ~41k), 0/6 after (diff 0)
- watcher (not in control) sees the host's pointer drawn at the right
  spot 5/5, and still sees it after the host releases control
- client e2e passes

Known gap (from the code): pointer moves that do not come from neko
input (apps warping the pointer, touch-only hosts) are not broadcast, so
watchers see the last position until the next mouse move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: an invite link logs in even when a saved session resumes first

Opening a `?usr=&pwd=` link in a browser that still holds a session did
the invite login only if the saved session had not finished resuming by
the time the connect screen mounted. That is a race between the whoami
request and React's first effect; WebKit loses it every time (the page
stayed logged in as the old user, the server never saw the new one) and
Chromium happens to win it. The link now always logs in; login() already
drops a websocket the resumed session may have opened meanwhile.

Found by running e2e.mjs in WebKit (url params step).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: no "connected" without video while the server is silent

When a request for media gets no offer within 8 s, the room is shown
without video (the server declines, e.g. can_watch is off). After a lost
network the same timer ran for the wrong reason: the media path fails
within seconds, the client asks for a new peer into a socket that is
dead but not yet known to be (the stale check needs 25 s), and 8 s later
the connect screen gave way to a frozen picture labelled connected, until
the stale check reconnected. The fallback now applies only while the
server has spoken within the last 16 s (it heartbeats every 10 s);
otherwise it re-arms and leaves the outage to the stale check.

client.check.ts drives the timers by hand and fails on the old code.
Observed in WebKit with the server container paused for 40 s: before,
the connect screen disappeared at +25 s while the server was still
frozen; after, it stays until the real reconnect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: e2e steps hold in WebKit and Firefox too

The second-invite step gave the server 1.5 s to drop the first session.
Chromium closes the old page's socket with a close code; Firefox closes
it abruptly (1006), and the server then keeps the session for its 5 s
reconnect grace. The step now polls for up to 8 s.

Playwright's offline emulation freezes an open websocket only in
Chromium; WebKit and Firefox keep delivering heartbeats to it (measured:
one frame in 15 s offline), so the two outage steps would wait for
nothing there. They say so and skip outside Chromium; the recovery path
itself was verified in WebKit by pausing the server container.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: @/ path alias in tsconfig; design layer for tokens, reset and fonts

First step of the layout requested in the #713 review: `src/design/` holds the
SCSS tokens (`_variables`), the reset, the Whitney font faces and the global page
styles that `assets/styles/main.scss` used to collect. The `@/` alias that only
Vite knew is now also in `tsconfig.json` (`paths`), so cross-layer imports can
use it everywhere.

The emote and emoji sprite sheets are still under `assets/styles/vendor/` and are
imported from `main.tsx` for now; they move next to their owners (emotes feature,
chat plugin) in the following commits. No CSS rule changes (built CSS compared
rule by rule).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: split neko.ts into state, i18n and a11y modules

`src/app/neko.ts` held the stores, settings, actions, dialogs, chat log, event wiring
and i18n in one module. It is now split by concern:

  state/storage.ts   localStorage helpers (same keys and '1'/'0' booleans as before)
  state/stores.ts    createStore(): every GUI store registers here so one hook can
                     subscribe to all of them (plugins will add their own)
  state/app.ts       the app store and the viewer-settings defaults
  state/client.ts    the NekoClient instance; name(), isMuted(), hostId(), isLocked()
  state/settings.ts  setSetting(), applySettings(), initSettings() (URL parameters)
  state/actions.ts   room actions (login, control, locks, kick/ban, broadcast)
  state/dialogs.ts   ask(), tell(), toast()
  state/api.ts       REST call with a toast on failure
  state/chat.ts      chat log, sendChat(), mute(), openInApp()
  state/files.ts     file transfer actions and types
  state/emoji.ts     emoji data for the picker
  state/emotes.ts    EMOTES, showEmote(), sendEmote()
  state/events.ts    server events -> chat lines and toasts
  state/hooks.ts     useNeko()
  i18n/              t(), setLang(), the locale table (moved from app/locale)
  components/a11y.ts a11y(), closeOn() and the Enter/Space activation listener
  app/boot.ts        start-up order: event wiring, settings, then the connection

Components still live in `app/` and now import from these modules with `@/`.
No behaviour change; built CSS identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: GUI split into app, layout, features and components; styles beside their components

The four GUI files (`App.tsx`, `Room.tsx`, `Side.tsx`, `Video.tsx`) and the
`app/styles/` folder become one folder per unit, each with its component and its
`.scss` next to it, imported by the component:

  app/         App (shell), ErrorBoundary, boot, app.scss (the #neko grid)
  layout/      Header, Side, RoomBar (the bar under the video)
  features/    video (+ Resolution, Clipboard), members (+ MemberMenu), controls,
               room-menu, emotes (+ Emote animation, sprite sheet), settings (+ Banned),
               connect, about
  components/  Avatar, Dialog, Toasts, Logo, ContextMenu (the `.context` menu used by
               the member menu and the emote picker), a11y
  plugins/     chat (Chat, Markdown, EmojiPicker, parser, emoji sprite sheet) and
               filetransfer (Files): components and styles only for now, still wired
               statically; the manifest and registry follow in the next commit

Units are imported through their `index.ts` with `@/`. `assets/` keeps only images.
`openMenu()` moved to `state/actions.ts` so the member list and the chat open the
same menu without importing each other. `tools/emoji.ts` and `build:emoji` write
the sprite sheet to its new place. No markup, class-name or behaviour change; built
CSS identical rule for rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: plugin manifest and registry; chat and file transfer become plugins

`src/plugins/types.ts` describes what a plugin contributes (sidebar tab, top-bar
items, member-menu items, locale strings, server event handler, init), and
`src/plugins/index.ts` lists the plugins of the build in order: `[chat, filetransfer]`.
The side panel, the header and the member menu render what the registry returns,
and `message` events starting with `<id>/` go to that plugin's `onEvent`. Adding a
plugin is its folder plus one entry in the registry; removing the entry removes its
tab, header item and event handling, and the app still compiles (checked with each
plugin and with both removed).

Each plugin owns its store (`createStore` registers it so useNeko() re-renders on
its changes), its actions, its locale strings (`locale.ts`, merged into the i18n
table at start-up; the merged table is identical to before) and its components:

  plugins/chat          Chat, Markdown, EmojiPicker, emoji data, the mute member-menu
                        item, `chat/init` and `chat/message`, the muted/unmuted lines
  plugins/filetransfer  Files, the header lock (via the shared LockButton), the
                        `filetransfer/update` event, the lock event line, uploads

`chat`, `texts`, `files` and `uploads` left the app store. Room events that the chat
shows as lines ("bob took the controls") go through a small bus in `state/bus.ts`,
so features and plugins never import each other; `logout` is a bus signal too.
`openInApp`, `ignored` and `chat_sound` stay in the app store and settings: they are
read by features (Settings, the member menu, emotes), not only by the chat.

The file drop on the video is unchanged: it is handled in the core (room/upload/drop,
the server's drop into the remote desktop) and the video feature never imported file
transfer. No markup, class-name or behaviour change; built CSS identical rule for rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: publish the core as the @m1k1o/neko package

`client/src/core` becomes `client/core`, its own npm package:

  core/package.json   @m1k1o/neko 3.0.0, ESM (`type: module`), `exports` with types,
                      `files: dist + README`, no runtime dependencies, `typescript` as
                      the only dev dependency (own lockfile, so `npm ci && npm run build`
                      works on its own)
  core/tsconfig.json  declaration build into dist/ with `rewriteRelativeImportExtensions`
                      (the sources import `./x.ts` so the self-checks run under plain Node)
  core/build.mjs      after tsc: `.ts` -> `.js` in the .d.ts imports (tsc only rewrites
                      the JavaScript), copies the vendored Guacamole keyboard
  core/src/index.ts   the public entry point: NekoClient, Overlay, Store, Emitter,
                      NekoApi, ApiError and the wire types
  core/README.md      the API reference with usage examples

The GUI imports only `@m1k1o/neko`: for development and the Vite build the name is
aliased to `core/src/index.ts` in tsconfig.json (`paths`) and vite.config.ts, so there
is no workspace and `client/Dockerfile` is unchanged. `npm run build:core` builds the
package; `npm run check` keeps running the core's `*.check.ts` and lints both trees.
`client/.gitignore` re-includes `core/` (the root .gitignore ignores core dumps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: lint the layering rules and fail on import cycles

`eslint.config.js` gets one `no-restricted-imports` rule set per layer: between folders
only `@/` imports (a folder's own files relatively), features through their `index.ts`,
plugins only through the registry `@/plugins` (and the contract `@/plugins/types`), the
core only from `@m1k1o/neko`, and no layer imports one above it (app -> layout ->
features -> components -> design; state and i18n below all but design). `tools/cycles.mjs`
walks the import graph of `src/` (type-only imports excepted) and fails on a cycle. Both
run in `npm run check`.

Two imports did not fit the rules: the plugin entries used `../types` (now
`@/plugins/types`), and the About dialog read the version from `../../../package.json`;
Vite now defines `__APP_VERSION__` from it, same text in the dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: build the @m1k1o/neko package in the client workflow

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: client layering, plugin contract and the core package

`client/README.md` documents the layout and its dependency rules, the plugin contract
with the steps to add one, the core package and the scripts. `client/core/README.md` is
the API reference of `@m1k1o/neko` (added in the package commit). The repository
structure page and the release notes name `client/core` and the package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* core: unit tests for the connection state machine

node:test suites for @m1k1o/neko, run with `npm test` (from client/ or
core/) and as part of `npm run check`. client.test.ts drives NekoClient
headless against fake sockets, peers, fetch and a virtual clock: the
handshake, signal/close and peer failures with backoff and the give-up
after RECONNECT_MAX, the offer timeout, the stale socket check, socket
loss and reconnect, disconnect(), heartbeats, the codec mismatch,
system/disconnect, session/control/screen/clipboard/settings events,
preparePaste precedence, login/authenticate/logout, send() and
sendData(). store.test.ts ports store.check.ts. The *.check.ts
self-checks stay; *.test.ts is excluded from the tsc builds like
*.check.ts, so dist/ and the published package are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: vitest replaces node:test and the check scripts

One test runner: `npm test` is `vitest run` (core/package.json delegates to
it for core/src). client.test.ts and store.test.ts import the runner from
vitest and keep node:assert; the custom clock in client.test.ts is now
vi.useFakeTimers (setImmediate stays real to settle promises between timers).
The three *.check.ts scripts are folded in: store.check.ts was already
covered by store.test.ts, client.check.ts's stale-sender removeTrack case
moved into client.test.ts, markdown.check.ts became markdown.test.ts. The
tsconfigs, tools/cycles.mjs, dist.check.mjs and the READMEs no longer
mention them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: Video.tsx -> Stage, Player and three hooks

Pure split of the 156-line Video component, no markup or behaviour
change (the view harness shows identical #root HTML for every view):
Stage.tsx keeps the layout, the two popover menus and the play/unmute
overlays; Player.tsx is the mount point the client puts the stream and
the input overlay into; useFullscreen (with the keyboard lock),
useClipboardSync (focus listener + the hover sync, and the
canReadClipboard capability) and usePip (the capability and the
request) carry the browser-API bits out of the component. The feature
index exports Stage; App.tsx renders it in place of Video.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* core: StreamTransport seam, WebRTCTransport, input channels

NekoClient no longer owns the RTCPeerConnection, the data channel or
the <video>: it keeps auth, the websocket, the room state, the stale
check, reconnects and heartbeats, the overlay and the letterboxing,
and hands the stream to a StreamTransport (transport.ts). Modelled on
guacamole-common-js (Client over a Tunnel).

transport.ts: StreamTransport (kind, element, input?, connect(session),
suspend(), close(), attach/detach, setPlaying/setVolume/setMuted,
addTrack?/removeTrack?, on('state'|'stats'|'error'|'cursor.position'|
'cursor.image')), SessionInfo (url, token, the system/init payload,
send/on over the main socket, lastMessage, autoplay) and InputChannel
(send(op, ...fields)). suspend() is what the socket loss did to the
peer (drop it, keep the picture and the backoff count); close() is the
full stop; status 'unavailable' is today's "no offer while the server
keeps talking", shown as connected without resetting the socket
attempt counter, as before.

transport/webrtc.ts: everything peer-related, unchanged in behaviour:
signal/request, provide/offer/answer/restart/candidate/close, trickle
ICE buffering, onnegotiationneeded for the microphone, the offer
timeout, peer-failure backoff with the same constants and messages,
the codec-mismatch check, ontrack -> its own <video> (same element,
attributes and style as before), the autoplay-muted fallback and the
mirrored video state, cursor frames from the data channel as events.

input/datachannel.ts: DataChannelInput, the big-endian encoding that
was sendData() plus the incoming cursor frames. input/websocket.ts:
WebSocketInput, the same opcodes as the server's control/* events
(delta_x/delta_y/control_key, keysym, code, touch_id/x/y/pressure),
moves coalesced on a 16 ms timer and flushed before any other input.
The client uses transport.input ?? new WebSocketInput(client); the
overlay only calls client.input. Not used by default (WebRTC has the
data channel), so nothing changes on the wire.

NekoClient: the `transport` option (instance or factory, default
WebRTCTransport); signal/* is forwarded to the transport and not
handled; connection.status follows socket + transport state as the
joint machine did; play/pause/mute/unmute/setVolume/addTrack/
removeTrack delegate; mount/unmount attach the transport and the
overlay; sendData and the `video` field are gone (transport.element).
App.tsx checks WebRTCTransport.supported() instead of
typeof RTCPeerConnection; the hooks read client.transport.element.

Tests: the 18 state-machine tests run unchanged against the real
WebRTCTransport (sendData moved to input/datachannel.test.ts); the
fake browser moved to test/browser.ts and grew elements, a data
channel and a richer peer; new client.transport.test.ts (the seam with
a fake transport), transport/webrtc.test.ts, input/websocket.test.ts.
build.mjs and tsconfig: the dist now has subfolders, the harness is
excluded from the builds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* core: document the transport seam, check the new exports

README: Connection and Video and audio follow the transport, Control
and input describes client.input and the two channels, a Transports
section with the interface, SessionInfo, the state it reports and how
a second transport plugs in; the options, state and types tables and
the test notes updated. dist.check.mjs: the new exports, the default
transport and input, a custom transport falling back to WebSocketInput,
and the .d.ts specifier check walks the subfolders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* core: typed input channel, shareMedia, smaller transport surface

InputChannel is now five typed methods (move, scroll, button, key, touch)
instead of send(op, [bytes, value]...): DataChannelInput encodes them into
the same frames as before (same opcodes, widths, order, big-endian; the OP
table and the field tuple are private to input/datachannel.ts) and
WebSocketInput sends the same control/* events and payloads, with the 16 ms
move coalescing and the flush before a click kept. The overlay's call sites
use the typed methods. The wire format and the control/* payloads are
unchanged.

StreamTransport carries no WebRTC types any more: addTrack/removeTrack
become shareMedia(stream), which returns the function that stops sharing;
NekoClient.shareMedia delegates (and throws "transport cannot send media"
without it); the GUI's microphone holds that function.

The transport surface shrinks to the maintainer's sketch: suspend() is gone
(a socket loss calls close(), a reconnect connect() again; close() drops the
media path, timers and counters, pauses the element and reports it not
playable but keeps the last picture), attach() returns the detach function,
SessionInfo is url/token/init/send/on (no lastMessage, no autoplay),
TransportKind drops 'webtransport'. Autoplay is the client's: setPlaying(true)
when the stream turns playable and the option is on; playback that was
running when the socket was lost is resumed by the client the same way. The
no-offer timeout reports 'unavailable' after OFFER_TIMEOUT_MS on its own;
the stale-socket check stays the guard for a dead socket. A redundant
clearTimeout in the signal/provide handler is dropped (closePeer clears it).

Behaviour changes, both accepted: the peer-failure backoff counter starts
over after a socket reconnect, and 'unavailable' no longer waits for the
server to be heard from. Everything else is unchanged.

Tests: every method's bytes and control/* payload, shareMedia, close and
reconnect, autoplay and resume, mount/unmount (the fake browser gained the
globals the overlay needs); README and dist.check.mjs follow the API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* core: overlay is a separate control device, Player takes the transport

The keyboard/mouse/touch overlay leaves NekoClient. `Overlay` (already
exported) is now built by the consumer, `new Overlay(client)`, and attached
next to the transport's element with `attach(box)`, which returns the detach
function; it letterboxes the box it is given inside its parent (what the
client's mount() used to do for its container), subscribes to the transport's
cursor events, the store and the DOM itself, and takes everything down on
detach. `mobileKeyboardToggle()` moves onto it. client.ts no longer imports
overlay.ts or the Guacamole keyboard, which stays vendored and used by the
overlay only; dist.check.mjs walks the built client.js import graph to assert
it. `client.mount()/unmount()` and `canvasSize` are gone; `mount(client, el)`
in core/src/mount.ts is the plain-page convenience (the transport, then an
overlay, in a letterboxed box). The GUI builds its one Overlay next to its one
client (src/state/client.ts); `<Player transport={client.transport} />`
attaches the transport and then the overlay into a box it renders, Stage
passes `client.transport`.

onMessage no longer skips `signal/*`: every message already reaches the
transport's subscribers first, and the client acts on none of the signal
events, so they fall through to the `message` event like any other event it
does not handle (the plugin registry ignores unknown prefixes).

Close and outage rule: `WebRTCTransport.close()` is a true close - the peer,
timers, counters go and the element is emptied (`srcObject = null`, paused,
playable false), so a kick, system/disconnect, a give-up or disconnect() show
black behind the Connect screen again. A socket loss leaves the transport
alone: its peer may keep streaming while the socket reconnects, and a peer
that fails meanwhile re-requests itself through session.send, which drops
until the socket is back; the reconnect's system/init calls `connect()` again,
which now replaces whatever the previous session left (peer, retry, failure
count) while the last picture stays until the new stream shows. The
transport's status reports count only while the socket is up (a new
`introduced` flag in the client), so an 'unavailable' verdict during an outage
cannot read as connected.

Markup, CSS and the wire format are unchanged: the views harness shows 27/27
identical DOM dumps, the CSS rule diff is 0, the data-channel probe sends the
same frames.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* e2e: KDE paste check collapses the selection with Home, not End

The step types into KRunner, selects all and copies inside the remote, then
pressed End and Ctrl+V expecting the text twice; the paste never arrived, on
every build, and xdotool inside the container reproduces it without the client.
KRunner's results view (milou ResultsView.qml, navigationKeyHandler) takes End
when the query field's caret is already at the end of the text - which it is
right after Ctrl+A - focuses the results list and jumps to the last result; the
following Ctrl+V reaches the list, whose handler drops control characters, so
nothing is pasted. Home goes to the field (caret not at 0), collapses the
selection and the paste lands at the start, which doubles the text the same way.

The client's paste path is correct: the browser paste event carries the remote
text, no clipboard/set is sent, and the Control_L/v frames arrive in order
(verified in headless Chromium and in Brave over CDP, identical frames).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* e2e: type into KRunner once it has the keyboard, not after a fixed sleep

On a cold container the full suite dropped one character mid-text now and then
("react gu works"). Traced on a fresh container with the server's per-frame
trace and xev attached to KRunner's window: the client sent and the server
injected all 15 characters in order, the KRunner window received the KeyPress
for the missing one - at the same X timestamp as the window's FocusIn. KRunner
is started by the Alt+F2 and takes the keyboard focus only once it is up; under
the suite's load that is later than the 1200 ms the step slept, and the key
that lands on the focus change is lost by the freshly activated window. The
step alone never reproduced it (5/5 cold), the suite did (1 of 2, plus the
coordinator's run).

The sleep is replaced by a readiness probe: type a character, select all and
copy inside the remote, read the remote clipboard, until the character comes
back; then the text is typed over the still selected probe. 15 s deadline, no
retry of the measured typing, assertion unchanged. 5/5 cold full-suite runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* e2e: seed the remote clipboard before the hover-sync poll

The first poll of GET /api/room/clipboard ran before the hover sync had set
anything, so xclip found no owner and the server answered 500, which the
browser logged as an error and the suite counted as a page error (exit 1
with every step passing). The step is about the hover sync, so give the X
clipboard an owner first; the assertion still proves the sync replaced it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: replace the proxy store with zustand and selector hooks

The core's hand-written deep-observable Store (proxies, microtask batching, watch/version)
is gone: `client.store` is a zustand vanilla store with subscribeWithSelector and
`client.state` a read-only getter of its current state. Every write is one immutable
setState (what one server message changes is one update, so subscribers never see it
half-applied); the former watch sites are selector subscriptions; selectSession,
selectControlling and selectIsAdmin are exported for UIs. Emitter moved to emitter.ts.
zustand is the package's one runtime dependency.

GUI: the app, chat and filetransfer stores are zustand stores too; useNeko() (subscribe to
everything, return the state whole) is replaced by useStore(store, selector) in every
component, useShallow for several fields, getState/setState outside React. The plugin
contract's visible()/badge() become the hooks useVisible()/useBadge() (member menu:
useVisible(member)), each run in a component of its own (EachHook). A chat message now
re-renders the chat and the badge, not the stage.

Behaviour, markup and CSS are unchanged: same localStorage keys, URL parameters and events;
the DOM views, the e2e (34/34), the input probe and the CSS rules match the previous build.
READMEs, dist.check.mjs and the tests follow (66 tests: the store tests are rewritten for
zustand semantics, every former watch site has a subscription test, and the transport test's
status sequence no longer hides the first 'connecting' in a batched flush).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: locales as JSON per language and namespace, i18next with lazy loading

The strings move from src/i18n/locale/*.ts and the two plugin tables to
src/locales/<lang>/{common,chat,files}.json (15 languages, generated once
from the tables: the same strings, `{x}` is now `{{x}}`). The runtime is
i18next with react-i18next: components use useTranslation(), code outside
components `t` from @/i18n. The files are lazy chunks (import.meta.glob)
that a small backend hands to i18next as it asks for <lang>/<ns>, so only
the active language is downloaded, with en as the fallback; before, all 15
shipped in the main chunk (379 kB -> 372 kB, plus 45 chunks of 0.4-4 kB).

A plugin registers a namespace (`ns`) instead of handing over a language
map; its keys that sat in shared groups (side.chat, context.mute,
notifications.muted, locks.file_transfer.*, files.*, send_a_message) are
`chat:` and `files:` keys now, and a tab carries its own label key. The
language stays the short code in localStorage `lang` and ?lang=, and
<html lang> keeps the zh-CN/zh-TW mapping. A key no language has is logged
in development. src/i18n/i18n.test.ts checks the runtime, that every key
the sources use exists in en, and that each language lacks exactly the
known keys versus en (so a key added to en alone fails); tools/dist.check.mjs
(npm run build) checks the main chunk carries no language. No plural forms
yet (README).

Behaviour and markup unchanged: the same strings in every language, the
same storage keys and URL parameters; a language pick applies once its
chunk has arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: plugin contract: typed events, named slots

Server events are typed through PluginEvents (event name to payload, after
server/internal/plugins/*/types.go): onEvent(...[event, payload]) narrows
the payload on the event, no `any` in the contract. The registry dispatches
through a map from the id prefix to the plugin, built once at initPlugins,
instead of a find per message; an event without a slash reaches nobody, as
before.

tab, topBar and memberMenu go into named slots: initPlugins registers them
in registry order (registerSlot 'side.tab' | 'header.item' | 'member.menu'),
and Side, Header and App read useSlot(name). The manifest keeps the three
fields, so adding a plugin is still a folder and one registry line, and
removing the line removes its slot entries and its event handling
(src/plugins/registry.test.ts).

Behaviour, markup and the bundle unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: e2e selects by test id, not by class or icon name

The e2e used 66 class selectors (15 of them Font Awesome icon classes), which the
styling pass is about to delete. The elements it drives now carry `data-testid`
(member, side, tabs, chat-*, control-request, mic, mute, lock-<resource>, dialog,
emoji-*, emotes-*, file*, resolution-*, about-*, ...) and their state as data
attributes (`data-self`, `data-host`, `data-locked`, `data-on`, `data-active`,
`data-shown`, `data-status`); `e2e.mjs` selects those. `LockButton` takes the id
of its resource for its test id. No style or behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: Tailwind v4 with the design tokens, cn(), shadcn config

Adds `tailwindcss` 4.3.3 and `@tailwindcss/vite` (the Vite plugin), `src/index.css`
with the theme and the utilities and an `@theme` block holding the 27 tokens of
`src/design/_variables.scss` under their names and values (`--color-text-normal`,
`--color-background-primary`, `--shadow-elevation-high`, `--spacing-side`, ...),
`cn()` in `src/lib/utils.ts` (`clsx` + `tailwind-merge`, tested) and
`components.json` for shadcn/ui (radix base, lucide icons). `lib/` is a bottom
layer like `state/` (eslint, README). The preflight is not imported yet and no
component is migrated: the 27 views render identically (0.00 % pixels differ).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: app shell and layout on Tailwind, room bar without its wrapper divs

`src/index.css` now imports the whole of Tailwind (the preflight replaces
`_reset.scss`), keeps the global rules the preflight does not have (the page's
size and overflow, the 14px/line-height 1 text on body, the focus ring of the icon
controls) and defines the layout's breakpoints as variants (`tablet:` under
1024px, `phone:` under 768px, stacked with Tailwind's `portrait:`/`landscape:`
for the split of the small screen). App, ErrorBoundary, Header, Side and RoomBar
carry their classes; the Logo is written out where it is used (its sizes differ
per place). The room bar's three wrapper divs are gone: the row is a 3-column
grid and the room menu, the controls and the emotes are its cells.

Deleted: src/design/_reset.scss (432), src/app/app.scss (115),
src/app/unsupported.scss (52), src/layout/Header/header.scss (101),
src/layout/Side/side.scss (51). The 27 views: 0.6 % of pixels differ on
average (max 1.8 %, the parts still on legacy scss shifted by the preflight's
border-box, migrated next).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: features on Tailwind

video (Stage, Player, Resolution, Clipboard), controls, members and the member
menu, room menu, emotes, settings, connect and about carry Tailwind classes; so do
Avatar and ContextMenu, whose rules lived in the features' scss. The glyphs the
scss drew with Font Awesome pseudo-elements (the member badges, the lock on the
control switch) are icon elements in the markup. The emote sprites are plain CSS
in Tailwind's components layer (sprites.css, same 47 rules). The shake, loader
and badge animations and the range slider are theme keyframes and an @utility in
src/index.css; Font Awesome's stylesheet is imported there into a layer below the
utilities. The Logo component is written out at its last two call sites.

Deleted: video.scss (100), clipboard.scss (28), resolution.scss (79),
controls.scss (183), members.scss (126), menu.scss (33), emotes.scss (65),
emote.scss (17), sprites.scss (150), settings.scss (232), connect.scss (126),
about.scss (86), avatar.scss (16), context.scss (79), components/Logo (12 + 1).
The 27 views: 0.33 % of pixels differ on average (max 1.5 %, the chat and files
panels still on scss, and the text of form controls, which now inherit the page
font instead of the browser's Arial).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: plugins on Tailwind

The chat (history, messages, markdown, the send box) with its emoji picker and
the file transfer tab carry Tailwind classes. The picker's search glyph and its
group tabs, drawn by Font Awesome pseudo-elements, are icon elements in the
markup. The transfer progress bar is styled (the preflight's border reset drops
Chromium's native theme). The generated emoji sprite sheet stays until the
emoji change.

Deleted: chat.scss (299), emoji.scss (227), files.scss (240). The 27 views: 0.3 %
of pixels differ on average (max 2.2 % in the emoji picker, where the form
controls inherit the page font).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: Dialog and Toasts on Tailwind

The modal dialog (the look of the legacy SweetAlert theme) and the toasts (the
legacy vue-notification palette) carry Tailwind classes; the last component scss
is gone. Named text sizes are written in px throughout (Tailwind's `text-xs` and
friends also set a line height, which the legacy layout did not).

Deleted: dialog.scss (72), toasts.scss (40). What remains of scss: the Whitney
font and the generated emoji sprite sheet. The 27 views: 0.2 % of pixels differ on
average (max 0.9 %, the files panel: the download icon takes the text colour
instead of the browser's link blue, the progress bar is styled).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: dialogs, menus and toasts on Radix (shadcn/ui) and sonner

The kit under src/components/ui/: dialog, dropdown-menu and popover in shadcn's
shape on @radix-ui/react-dialog, -dropdown-menu and -popover, restyled with the
tokens; context-menu on @radix-ui/react-menu (what shadcn's context menu wraps),
anchored to a point, since the member menu is one menu opened from the member
list, a chat author and Shift+F10; sonner's Toaster in the look of the legacy
toasts. They bring focus trapping, roving focus, Escape, outside click and ARIA.

- the modal dialog (ask()/tell(), unchanged promise contract, tested) and About
  are Radix dialogs; a click outside dismisses them too
- the member menu is the context menu; the emote picker and the resolution list
  are dropdown menus from their button; the clipboard textarea and the emoji
  picker are popovers from theirs, so each sits next to the control that opened
  it instead of at the pointer
- toast() maps its kind to sonner (tested); "clear on connected" is dismiss()

Deleted: closeOn() (the setTimeout that avoided the opening click), the toasts
state and the Toasts component, the ContextMenu component. The e2e passes 34/34
(the dialog test id is the mounted content now).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: lucide icons, real buttons, IconButton; a11y.ts and Font Awesome gone

Every icon control is a <button type="button"> through IconButton
(src/components/IconButton.tsx: cva variants plain / header / video, the label as
aria-label and title, tested), so Enter, Space and focus come from the browser;
the text buttons are the kit's Button (variants primary, outline, confirm, cancel);
the side panel's tabs are Radix tabs (@radix-ui/react-tabs, shadcn tabs.tsx);
the files actions are buttons. Icons are lucide-react components (40 of them for
the 40 Font Awesome names, the member badges and the switch knob included; the
GitHub mark is an inline SVG); a plugin tab's `icon` is a lucide component.

Deleted: src/components/a11y.ts (the a11y() spread and the global keydown
listener), LockButton (written out in Header and FileLock),
@fortawesome/fontawesome-free (the dist loses its four webfonts and its CSS).
The e2e passes 34/34; the input probe types and pastes (wsControl=0); the
keyboard and mobile checks (a11y-check.mjs) pass: Tab order, Enter/Space,
Escape on the dialog and the member menu, the touch keyboard toggle, the
clipboard fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: emoji as text, the sprite sheet gone

The chat renders an emoji as its Unicode character (src/plugins/chat/Emoji.tsx,
a 22px box, the browser's emoji font), the custom `neko` as its image.
tools/emoji.ts emits the character of each name into public/emoji.json
(`chars`, from emoji-datasource's unified code points) and no longer writes a
stylesheet; every datasource emoji is listed (eye-in-speech-bubble, which had no
Twitter image, included; the modifier components are not). A test checks the
generated data against emoji-datasource, which is a devDependency now.

Deleted: src/plugins/chat/emoji-sprites.scss (5,426 generated lines) and the
4.2 MB Twitter sheet it embedded in the build (dist: 6.7 MB -> 1.6 MB). The e2e
passes 34/34.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the last scss, sass, the Whitney font and the one-line barrels gone

- src/design/ is deleted (_variables.scss: the tokens live in src/index.css;
  _font-whitney.scss and the five woff files: the font is Tailwind's system
  stack); the scss block of vite.config.ts and the `sass` devDependency with it
- the 13 one-line index.ts barrels are deleted: a feature is imported by its main
  file (`@/features/video/Stage`), the eslint rule that forced the barrel is
  dropped (the layer rules stay); the single-file folders Avatar, Dialog, Header,
  Side and RoomBar are single files, so their imports do not change
- README: the tree and the layering bullets follow

The dist has no font files left (1.2 MB, from 6.7 MB). The e2e passes 34/34,
the input probe types and pastes, the keyboard and mobile checks pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: README: the design layer

Tailwind v4 and src/index.css (the tokens by their old names, the global rules,
the breakpoint variants, the animations and the slider utility), cn() and cva,
the shadcn/ui kit under src/components/ui/ and what each piece is used for,
IconButton and its variants, lucide icons and their sizes, emoji as text, and
how to add a component or an icon. The scripts table names what build:emoji
writes now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: tailwindcss and its Vite plugin are build-time dependencies

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the client through <NekoProvider> and hooks, the singleton gone

src/state/client.ts (the module-level `client` and `overlay`) and src/app/boot.ts
(the import-time start-up) are deleted. createNekoApp() (src/state/neko.ts) makes
one instance of the GUI, a NekoApp (src/state/app.ts): the NekoClient with its
WebRTC transport, the input Overlay, the GUI store and the bus, with the event
wiring (state/events.ts, now wireEvents(neko)) and the settings (initSettings(neko),
the URL parameters included; the ?cast= chat-sound rule moved there from App.tsx)
applied. main.tsx composes the page in the same order as before: the strings,
the instance, the plugins (initPlugins(neko)), then the connection and the first
render under <NekoProvider neko={neko}> (src/state/provider.tsx).

Components read the instance from the context: useNeko(), useClient(), useApp()
(the GUI store) and useActions() (the room actions bound to the instance, one set
per instance through scoped()); everything outside React takes it as its first
argument (setSetting, ask/tell, api, event/line, the emotes). The plugin contract
passes it to every callback (init(neko), onEvent(neko, ...), the member menu's
label(neko, m) and onClick(neko, m)); the chat and file-transfer stores and the
chat's mutedByMe are one per instance (scoped()), the slots stay the build's and
are filled once. The emoji data is still downloaded once per page, each chat's
store gets emojiReady from it.

Behaviour, markup and CSS are unchanged: same localStorage keys, URL parameters,
events and start-up order. Tests: an instance and two of them share nothing
(stores, bus, event wiring, settings, actions, plugin stores and wiring), the
provider's hooks (react-dom/server), the browser faked in src/test/browser.ts;
the three tests that used the singleton or a module store now make their own.
README: Composition (createNekoApp -> NekoProvider -> hooks, embedding) and the
plugin contract's instance argument.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: npm run check runs its scripts in parallel with npm-run-all2

The chain in `check` (tsc, prettier, eslint, tools/cycles.mjs, vitest, the core
build, core/dist.check.mjs) is split into scripts of their own: check:types,
check:format, check:lint, check:cycles (and the existing test), run in parallel
by `run-p` with one label per script and the output of each kept together
(--aggregate-output); the first failure stops the rest and names its script.
build:core and check:dist still follow in sequence (`run-s`), the latter reads
the former's output. `format:check` is now `check:format`. `npm run check`,
`npm run build` and `npm run build:core` keep their names, so CI
(.github/workflows/client_build.yml) is unchanged.

On this machine `npm run check` takes 3.2 s instead of 6.1 s (three runs each).
core/package.json's `check` is left as it is: build -> test -> dist.check is a
dependency chain with nothing to run in parallel. npm-run-all2 is the one new
(dev) dependency. README: the scripts table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: crypto.randomUUID() for upload ids and emote keys, nextId() gone

The counter nextId() in src/state/dialogs.ts (used for the ids of the upload
list) and the Math.random().toString(36) key of a shown emote are replaced by
the browser's crypto.randomUUID(). Upload.id is a string now (it was a number);
nothing else read it but the React key and the progress updates. Toasts never
had ids of their own (sonner keeps them), so nothing changes there.

Tests: the upload list gives each file a UUID of its own and tracks progress
by it (the core's upload call stubbed); each shown emote gets a UUID key and
hideEmote drops it. README: the behaviour note, with the one constraint of
crypto.randomUUID(): a secure context (https or localhost).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: chat markdown rendered by react-markdown, the hand-written parser gone

Chat messages are other members' text, relayed by the server as they are, so
the 160-line parser of src/plugins/chat/markdown.ts is replaced by react-markdown
(CommonMark to React elements, no HTML string ever reaches the DOM) with
remark-gfm (~~strike~~, tables, bare links; singleTilde off) and remark-breaks
(a newline is a line break, as before). What remains ours in markdown.ts are
four small rehype plugins on the hast tree, 15 lines each at most:

- rehypeSafe: a link to anything but http(s)/mailto ([x](javascript:...),
  <data:...>, a relative path) and an image are replaced by the text they were
  typed as (the node's position in the source); a generated link without a
  position (a GFM footnote's) keeps its text only.
- rehypeUnderline: __x__, which CommonMark reads as strong, is <u> when the
  marker in the source is an underscore.
- rehypeSpoiler: ||x|| in a text node becomes a span Markdown.tsx renders as
  the click-to-reveal Spoiler. Plain text only: formatting inside a spoiler is
  split by the parser first and stays as typed (documented).
- rehypeEmoji: :name: of a name the emoji data knows becomes a span rendered by
  Emoji.tsx (the character, or the image of a custom one). Code is left alone.

Markdown.tsx passes urlTransform: safeUrl (the same whitelist, normalising the
href, a second line behind rehypeSafe), disallowedElements: ['img'] with
unwrapDisallowed (a third, should an image ever get through), and components
for a (target=_blank, rel, open-in-app and its icon as before), the plugins'
spans, code and pre; the other elements are styled from the message's wrapper
(strong, em, blockquote as before; p+p one line apart; lists, headings, table
cells minimal). Raw HTML is text by react-markdown's default. FORMAT_LIMIT
(10,000 characters) is kept, and react-markdown's Markdown, a plain function,
is called in a try/catch so a renderer error (5,000 nested quotes overflow the
hast conversion's stack) shows the text as it is. The component is memo'd: a
new line does not parse the others again.

Dialect: bold, em, underline, strike, code, fences, spoilers, emoji, links,
escapes and line breaks as before. Gone: the Discord `>>>` block quote (no
CommonMark equivalent; `> ` quotes stay, and continue until a blank line as in
CommonMark). New, by CommonMark/GFM: headings, lists, nested quotes, tables,
www. and e-mail autolinks. Images are never rendered.

Tests (src/plugins/chat/markdown.test.tsx, react-dom/server under a
<NekoProvider>): every assertion of the old parser's tests has a successor on
the rendered HTML, plus images, relative and reference links, raw HTML, the
CommonMark extras, open-in-app, and hostile input of every marker at the
10,000-character limit under a time bound. Bundle: +158 kB minified (+48 kB
gzip) in the main chunk. README: the behaviour note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: list keys from crypto.getRandomValues, which plain http also has

crypto.randomUUID() is undefined outside a secure context, and a neko reached
over plain http on a LAN is not one, so the first upload or emote would have
thrown there. uid() takes 64 random bits from crypto.getRandomValues(), which
every origin has; the two call sites and their tests follow, the README note too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: vendor chunks, locale chunks named by language

manualChunks splits node_modules into react, radix, markdown, i18n, lucide and vendor;
chunkFileNames names the lazily loaded locale files assets/locales/<lang>-<ns>-<hash>.js.
Before: index 730.17 kB (gzip 232.95) + 45 locale chunks common/chat/files-<hash>.js; total JS 800863 B.
After: react 222.29 (69.21), markdown 158.51 (48.21), index 119.85 (39.78), radix 96.71 (32.24), vendor 65.62 (19.69), i18n 48.97 (16.20), lucide 17.29 (6.23) + 45 locales/<lang>-<ns>; total JS 799935 B, no chunk-size warning, no module in two chunks.
Nothing in src/ changed; tools/dist.check.mjs now also checks the locale chunk names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the keyboard stays in the chat box after an emoji pick

Radix returns focus to the popover trigger when it closes, after the
component had focused the textarea, so the first key typed after picking
an emoji went to the Emoji button (found in a real browser; the old
picker kept the textarea). The picker now hands focus back to the message
box on close; the e2e emoji step types after the pick and checks it lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: a banned user goes back to the login screen; no mic button where getUserMedia is missing

A closed connection asked /whoami to decide between the login screen and the Connect button, and
only a 401 meant the login screen. A ban changes the profile first: the server disconnects the
session with "profile changed" and /whoami then answers 403 "login is disabled for this session"
(server/internal/api/router.go), which left the banned user on a Connect button that could never
work. 403 now goes back to the login screen too. The two file-provider e2e steps pass.

The microphone button called navigator.mediaDevices.getUserMedia without checking for
mediaDevices, which a plain-http origin does not have, so clicking it raised a raw TypeError. The
button is hidden there, the way the clipboard already picks its fallback by feature.

Found by a real-world pass (file-provider ban, LAN origin), both pre-existing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: chat formats messages up to the server's default length, hostile input stays cheap on CI

The hostile-input test failed on the CI runner: 5000 nested quotes took 1.9 s there (420 ms here),
micromark's cost for nested block quotes growing with the depth squared. The formatting limit was
10,000 characters, a number of our own. It is now 512, the server's default chat.max_length, so a
message only exceeds it on a server configured for longer ones, and the worst hostile case takes
23 ms here. The test gains the bare `>` form, which is the dense one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the hostile-markdown test has no wall-clock assertions; its timeout is the bound

The second CI run failed on the other bound of the same test: the first render, which pays the
pipeline's warm-up, took 57 ms on the runner against a 50 ms limit. A shared runner is about four
times slower than a workstation and uneven, so any wall-clock assertion there is a flaky test. The
test now asserts what is deterministic (never throws, the fallback to plain text above the limit)
and is bounded by its own 3 s timeout, which a blow-up would hit; all the cases take about 60 ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: release notes and repository structure match the client as it now is

The core is packaged as @m1k1o/neko, not published to npm. The release notes gain the chat dialect
(CommonMark/GFM, no `>>>`, plain text above 512 characters) and the system font and native emoji.
The repository structure drops the deleted design/ folder and names locales/ and lib/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* client: filled icons, phosphor instead of lucide

The v2 client's icons were all filled (FontAwesome solid); lucide is
stroke-only. @phosphor-icons/react has a fill weight, set once through
IconContext in App.tsx, with the same tree-shaken import style, so the
icon components are renamed and nothing else changes. The GitHub mark in
About is phosphor's GithubLogo instead of an inline svg.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: themed focus ring on text fields

The old reset removed outlines globally and this client had no rule for
inputs, so they showed the browser's default ring; now text fields get
the ring the buttons use, hidden where a container is the visual field
(the chat and clipboard textareas).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: remove ban, kick stays

The v2 ban was an IP ban in the legacy layer with no v3 counterpart, so
the ban/unban actions, the Banned list in Settings and their strings go;
member removal belongs with a members tab in a follow-up. The e2e suite
is 33 steps now, the optional NEKO_FILE_URL file-provider run is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: add npm publish workflow.

* client: phosphor icons by their *Icon names

phosphor 2.1 marks every bare export (`Shield`) deprecated in favour of `ShieldIcon`. `@typescript-eslint/no-deprecated` (type-aware, src and core/src without the tests) now fails the lint on deprecated APIs; it also caught `FormEvent` and `navigator.platform`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: the core library is published as @m1k1o/neko

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* client: the on-screen clipboard keeps the focus while open

The overlay takes the keyboard focus whenever the host's mouse enters the video; the clipboard popover
(the textarea fallback for browsers that cannot read the local clipboard) closed on that, before
anything could be copied from it. The popover is modal now, so the video gets no pointer events while
it is open. The e2e gets a step for it, with readText removed before the app loads to show the
fallback; the step fails on the previous code ("the clipboard popover closed").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-10-15 23:45:48 +02:00
SamAcctX c75a2f60b5 server: sanitised session ids, chat length limit, websocket read limit, leaky frame queues (#712)
* server: generated video pipelines keep at most one raw frame queued

GetPipeline used plain `queue` elements, which buffer up to 1s / 200
buffers. When the encoder cannot keep up, frames pile up there and the
stream (and every reaction to input) falls behind by up to that much.
The queues now hold only the newest frame (leaky=downstream): a slow
encoder drops frames instead of adding latency.

Measured input-to-display latency (Alt+F2/Escape opens/closes a dialog,
time to the first changed received frame; xfce image built with ./build,
1920x1080, default VP8 pipeline, 30 trials per run, runs interleaved):

                  before p50/p90 ms     after p50/p90 ms
  no CPU limit    268/303, 188/225      183/217, 157/226
  --cpus=1        270/422, 362/673      155/237, 200/428

fps unchanged without a limit (25), slightly higher with one (22.5 ->
23.5-24). Pipelines given as raw gst strings are unchanged.

Tests: TestVideoConfigGetPipelineQueues; client e2e on the built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* server: session ids no longer carry the login name verbatim

The multiuser and noauth providers built the session id as
"<username>-<token>". The v3 API addresses sessions and members by id
in the URL path (DELETE /api/sessions/{id}, POST /api/members/{id},
POST /api/room/control/give/{id}), so a login name like
"x/../../logout?" produced an id that sent an admin's unencoded
request to another endpoint, and even when the client encodes it, an
id with "/" never matches the chi route: such a member could not be
kicked, muted or banned.

utils.SafeID maps a name to letters and digits of any script plus
".", "_" and "-": every run of other characters becomes one "-",
leading and trailing "-" and "." are trimmed, the result is cut to 64
characters, and an empty result becomes "user". Nothing with a meaning
in a URL path survives; letters outside ASCII are kept so names such as
"Müller" stay recognisable (clients percent-encode ids in paths). Both
providers now use SafeID(username) + "-" + token; the profile name
keeps the name as typed, so nothing changes on screen.

The file and object providers use the username itself as the member
id. Members there are created explicitly by an admin, so
POST /api/members now rejects a name SafeID would change with a 400
instead of silently altering it. The oauth provider derives ids from
the IdP subject ("oauth:" + sub) and is unchanged.

Tests: TestSafeID (pkg/utils).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* server: chat plugin limits message length (chat.max_length)

The chat plugin accepted text of any size, over the websocket
(chat/message) and over REST (POST /api/chat), and broadcast it to
every session. The web client caps its input box at 512 characters,
but any client can send more, and one large message stalls every
viewer's renderer.

New option chat.max_length (default 512, counted in characters with
utf8.RuneCountInString, not bytes; 0 disables the limit). Both paths
go through one check: REST answers 400 with the length and the limit,
the websocket handler logs at warn level and drops the message, as it
already does for a sender without permission. The default never
rejects what the web client can type: its maxlength counts UTF-16
units, which is never fewer than runes.

Docs: plugins.md and release notes. help.txt/help.json list no plugin
options (no chat.*, filetransfer.*, openinapp.* entries), so nothing
was added there.

Tests: TestCheckLength (internal/plugins/chat).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* server: limit the size of websocket frames a client may send

There was no read limit on the websocket, so a client could push frames
of any size; they were read and decoded in full before a handler (such
as the chat length check) could reject them. Frames above 1 MiB now end
the connection, as gorilla's SetReadLimit does. The largest legitimate
messages, an SDP answer and pasted clipboard text, stay far below that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* server: video RTP timestamps follow the time between captured frames

ximagesrc gives a frame it captures late a buffer duration of only the
time left until the next frame slot ("Frame duration is from now until
the next expected capture time"), and frames dropped before the encoder
leave gaps. pion advances the RTP clock by Sample.Duration, so whenever
the encoder falls behind, the stream's clock runs slower than real
time. Browsers grow their jitter buffer to make up for it and lip sync
drifts. This happens with any queue in front of the encoder.

Each video sample's duration is now the gap since the previous sample's
PTS. The gap is known only when the next frame arrives, so it is
applied one frame late; the clock itself stays right. The sink also
counts frames in neko_capture_streamsink_frames: a rate below the
configured fps means the encoder falls behind.

Measured on the firefox image built with ./build, 1080p25 VP8, the
container limited to 0.5 CPU: RTP clock 0.2-0.9x real time before,
1.00x after; browser video jitter buffer 0.24-1.9 s before, 60-220 ms
after; audio up to 0.4 s (default queue) or 0.74 s (one-frame leaky
queue) ahead of the video before, within -70..+150 ms after.

Tests: TestSampleDuration; go test -race ./internal/capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:09:43 +02:00
Miroslav Šedivý 6bc8350213 feat: add email scope to OAuth configuration and update documentation 2026-09-24 00:18:02 +02:00
Miroslav Šedivý 3f3ae10fb9 feat: implement user email restrictions for OAuth login 2026-09-24 00:17:58 +02:00
Miroslav Šedivý d991fec3c7 refactor(oauth): improve readability of newOAuthHandler and OAuth methods. 2026-09-24 00:17:34 +02:00
@刘羡鱼 dae23a90b5 feat(auth): add OIDC-only member authentication (#686)
* feat(auth): add OIDC member authentication

* docs(auth): document OIDC member login

* fix(auth): address OAuth review feedback

* refactor(auth): move OAuth flow into member provider
2026-09-24 00:15:46 +02:00
Abdellatif Anaflous 73b9d71b0d fix(capture): accept a bare pipeline string in video pipelines entries (#701)
* fix(capture): accept a bare pipeline string in video pipelines entries

NEKO_CAPTURE_VIDEO_PIPELINES expects every entry to be a full pipeline config
object, but the singular capture.video.pipeline option trains people to write
the whole pipeline as a plain string, so a dict like {"high": "ximagesrc ...
! appsink"} failed to unmarshal, the error got reduced to a warn and neko fell
back to the default vp8 pipeline silently, also overriding a directly set
video codec

the json string hook decodes the env value into a generic map, so the shorthand
is restored in the mapstructure chain: a string value for an entry now becomes
VideoConfig{GstPipeline: ...}, the same shape the single pipeline option
produces, object entries are untouched

the new config test drives the production env flow (Init + Set) and fails on
master with the same two warnings the reporter saw

Fixes #689

* feat(capture): add shorthand support for pipeline entries as plain strings.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-09-10 13:45:30 +02:00
ycsi b0f01cedea feat(capture): toggle mouse pointer via capture.video.show_pointer (#691)
* feat(capture): toggle mouse pointer via capture.video.show_pointer

- config: register capture.video.show_pointer config option
- config: default pointer to visible (ShowPointer: true) on the
hardcoded fallback pipeline, override applies only when the flag is
explicitly set, so per-pipeline YAML overrides stay untouched
- compose: document NEKO_CAPTURE_VIDEO_SHOW_POINTER example

* fix(capture): apply show_pointer to all pipelines

- capture: thread capture.video.show_pointer into broadcast and
  screencast gstreamer pipelines, previously hardcoded show-pointer=true
  and silently ignored the flag
- config: resolve capture.video.show_pointer once into
  Capture.VideoShowPointer so all pipeline builders share one value
- docs: document capture.video.show_pointer in configuration/capture.md,
  including its reach into broadcast and screencast, and regenerate
  help.txt/help.json
- compose: drop stray commented-out example, default compose file is
  not meant to list every option
2026-08-29 13:47:04 +02:00
Miroslav Šedivý baf21c1d97 feat: remove default configuration to prevend accidental override.
we used to have file neko.yaml with default configuration, but if we would add neko.yml the default configuration would still be read. so instead, default configuartion was moved to the sever itself and no file is distributed with it by defautl.
2026-08-15 15:36:06 +02:00
Paul Troeger cc014571cc feat: add openinapp plugin to open chat links in shared neko instance (#682)
* feat: add openinapp plugin to open chat links in shared neko instance

* lint.

* feat(openinapp): implement open link handler and route for opening links.

* feat(openinapp): switched to http backend and removed example in docker-compose.yaml

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-08-09 22:53:39 +02:00
Abdellatif Anaflous d6a4200b36 feat(filetransfer): allow file deletion and add user delete config option (#683)
* feat(filetransfer): allow file deletion and add user delete config option

* fix(client): make user_delete required boolean in payload

* lint.

* feat(filetransfer): add confirmation dialog for file deletion.

* feat(filetransfer): add delete file confirmation messages in multiple languages

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-08-07 18:12:02 +02:00
Abdellatif Anaflous 613647c72c fix(filetransfer): allow non-admin file uploads and downloads (#679)
* fix(filetransfer): remove auth.AdminsOnly middleware wrapper from routes

* fix(filetransfer): add config option to allow non-admin users to download and upload files.

* fix(filetransfer): implement user permissions for file download and upload in client.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-08-05 22:33:17 +02:00
Budovi dfc41f73d6 Add support for H265 encoded streams (#648)
* Add H265 support

Adds H265 as a selectable video codec with software (x265enc), VAAPI
(vah265enc), and NVENC (nvh265enc) pipeline paths in both the legacy
pipeline builder and the RTPCodec definition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Replace legacy vaapih264enc with vah264enc

The gstreamer-vaapi plugin suite (vaapih264enc) is superseded by the va
plugin (vah264enc) in gst-plugins-bad. Switch to it and update the
parameters accordingly: CBR instead of VBR for lower-latency streaming,
key-int-max instead of keyframe-period, and target-usage instead of
quality-level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Unify keyframe interval to 60 frames for NVENC H264/H265 encoder paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 15:16:33 +02:00
Miroslav Šedivý e6239d6a51 feat: update video encoding to use nvautogpuh264enc for NVIDIA driver 590+ support. (#667) 2026-07-31 14:28:19 +02:00
4g0tt3nSou1 c11e7f35b9 Clarify authentication module implementation status (#617)
* Clarify authentication module implementation status

Added a note regarding the implementation status of LDAP, OIDC, and other subsystems.

* use warning box.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2026-01-24 10:47:42 +01:00
Miroslav Šedivý 7b7c876775 update nvidia docs. 2025-09-16 19:59:55 +02:00
Miroslav Šedivý 5b9225f401 add hostname to broadcast variables. (#576) 2025-09-10 22:53:52 +02:00
Miroslav Šedivý 04c0d5ea15 fix docs #545. 2025-06-17 10:05:27 +02:00
Miroslav Šedivý 2b565bc762 fix incorrect docs, #545. 2025-06-17 09:58:42 +02:00
Miroslav Šedivý c1ccae4ac4 docs: info about supporting only one provider at a time. #505 2025-04-07 22:45:42 +02:00
Miroslav Šedivý b783a9adbe docs: add overview of available encoders. 2025-04-05 22:37:56 +02:00
Miroslav Šedivý 81c259cdc9 docs: add nvidis gpu examples. #502 2025-04-05 21:45:19 +02:00
Miroslav Šedivý 3a8a5c30ef docs: use ConfigurationTab that allows switching between yaml, env and cmd. 2025-04-05 18:25:04 +02:00
Miroslav Šedivý 0e3bcedcd4 update config options naming, move scripts to own folder. 2025-04-05 18:24:23 +02:00
Miroslav Šedivý e3a1929f7f reword implicit hosting option #501. 2025-04-05 11:01:39 +02:00
Miroslav Šedivý 9f480f5581 update docs with more deeplinking. 2025-03-26 22:46:11 +01:00
Miroslav Šedivý 5e04c31222 fix links on webpage. 2025-03-22 23:02:57 +01:00