Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e35dd525 | |||
| 23d8a2718e | |||
| 92f99168b8 | |||
| 97d737683f | |||
| 71216a30ca | |||
| f399ac8df5 | |||
| 020def0305 | |||
| 8100f0f46b | |||
| 5f40eb9388 | |||
| 21ad64423f | |||
| 3191dbb058 | |||
| ff6dd395c9 | |||
| 92420ca8e4 | |||
| 2d2e11502d | |||
| 55df495389 | |||
| 6ad3ad046b | |||
| c75a2f60b5 | |||
| a2cb38dd10 | |||
| bdd0428a60 | |||
| 1725bfa50b | |||
| 4cca11475d | |||
| 65fba485de | |||
| 3f4f94087a | |||
| f4d632df2c | |||
| 6bc8350213 | |||
| 3f3ae10fb9 | |||
| d991fec3c7 | |||
| 11b6c8d8f6 | |||
| 7573d43c95 | |||
| dae23a90b5 | |||
| ddf2787c5e | |||
| ba098db663 | |||
| 73b9d71b0d | |||
| 371f5c2c7b | |||
| 347418c3e7 | |||
| b0f01cedea | |||
| a7a217e607 | |||
| 393177f574 | |||
| 4832522228 | |||
| 44ccf64257 | |||
| baf21c1d97 | |||
| 6f31613d3b | |||
| cc014571cc | |||
| 365944d1ea | |||
| d6a4200b36 | |||
| 30d8e1c595 | |||
| 613647c72c | |||
| e843961c37 |
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
@@ -33,10 +33,18 @@ jobs:
|
||||
working-directory: ./client
|
||||
run: npm ci
|
||||
|
||||
- name: Check (types, formatting, self-checks)
|
||||
working-directory: ./client
|
||||
run: npm run check
|
||||
|
||||
- name: Build client
|
||||
working-directory: ./client
|
||||
run: npm run build
|
||||
|
||||
- name: Build core package (@m1k1o/neko)
|
||||
working-directory: ./client
|
||||
run: npm run build:core
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ inputs.with-artifact }}
|
||||
|
||||
@@ -6,10 +6,20 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- client/**
|
||||
- utils/release.sh
|
||||
- .github/workflows/client_build.yml
|
||||
- .github/workflows/client_test.yml
|
||||
- .github/workflows/npm_publish.yml
|
||||
|
||||
jobs:
|
||||
check-release-helper:
|
||||
name: Check Release Helper
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check Bash syntax
|
||||
run: bash -n utils/release.sh
|
||||
|
||||
test-client:
|
||||
name: Test Client
|
||||
uses: ./.github/workflows/client_build.yml
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: chromium
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: google-chrome
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: ungoogled-chromium
|
||||
platforms: linux/amd64
|
||||
- name: microsoft-edge
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Publish npm Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-checks:
|
||||
name: Client Checks
|
||||
uses: ./.github/workflows/client_build.yml
|
||||
with:
|
||||
with-artifact: false
|
||||
|
||||
publish:
|
||||
name: Publish @m1k1o/neko
|
||||
needs: client-checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: npm install --global 'npm@^11.5.1'
|
||||
|
||||
- name: Verify tag matches package version
|
||||
working-directory: client/core
|
||||
run: |
|
||||
node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
const { version } = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const expected = `v${version}`;
|
||||
if (process.env.GITHUB_REF_NAME !== expected) {
|
||||
throw new Error(`Tag ${process.env.GITHUB_REF_NAME} does not match package version ${version}`);
|
||||
}
|
||||
'
|
||||
|
||||
- name: Install package dependencies
|
||||
working-directory: client/core
|
||||
run: npm ci
|
||||
|
||||
- name: Build package
|
||||
working-directory: client/core
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: client/core
|
||||
run: npm publish
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: webpage/package-lock.json
|
||||
|
||||
|
||||
@@ -13,5 +13,3 @@ COPY --from=server /src/bin/neko /usr/bin/neko
|
||||
COPY --from=client /src/dist/ /var/www
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
COPY config.yml /etc/neko/neko.yaml
|
||||
|
||||
@@ -40,7 +40,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# create a profile directory
|
||||
mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions; \
|
||||
chown -R neko:neko /home/neko/.mozilla/firefox/profile.default; \
|
||||
chown -R neko:neko /home/neko/.mozilla; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# create a profile directory
|
||||
mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions; \
|
||||
chown -R neko:neko /home/neko/.mozilla/firefox/profile.default; \
|
||||
chown -R neko:neko /home/neko/.mozilla; \
|
||||
#
|
||||
# clean up
|
||||
apt-get --purge autoremove -y xz-utils; \
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
ARG BASE_IMAGE=ghcr.io/m1k1o/neko/base:latest
|
||||
FROM $BASE_IMAGE
|
||||
|
||||
ARG SRC_URL="https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb"
|
||||
# Set this to a complete .deb URL to pin a specific Chrome version. By default,
|
||||
# download the package matching the image architecture.
|
||||
ARG SRC_URL=""
|
||||
|
||||
#
|
||||
# install google chrome
|
||||
RUN set -eux; apt-get update; \
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
amd64|arm64) ;; \
|
||||
*) echo "Google Chrome is not available for ${ARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
SRC_URL="${SRC_URL:-https://dl.google.com/linux/direct/google-chrome-stable_current_${ARCH}.deb}"; \
|
||||
apt-get update; \
|
||||
wget -O /tmp/google-chrome.deb "${SRC_URL}"; \
|
||||
apt-get install -y --no-install-recommends openbox /tmp/google-chrome.deb; \
|
||||
# Install the ARM64 CDM prepared by the runtime where Chrome discovers it.
|
||||
if [ "${ARCH}" = "arm64" ]; then \
|
||||
mkdir -p /opt/google/chrome/WidevineCdm; \
|
||||
find /var/lib/widevine/WidevineCdm/ ! -name "WidevineCdm" -exec cp -RL {} /opt/google/chrome/WidevineCdm/ \; ; \
|
||||
fi; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
|
||||
@@ -7,9 +7,9 @@ RUN set -eux; apt-get update; \
|
||||
apt-get install -y --no-install-recommends openbox curl xz-utils file libgtk-3-0 libdbus-glib-1-2; \
|
||||
#
|
||||
# download TOR browser
|
||||
DOWNLOAD_URI="$(curl -s -N https://www.torproject.org/download/ | grep -Po -m 1 '(?=(dist/torbrowser)).*(?<=.tar.xz)')"; \
|
||||
DOWNLOAD_URI="$(curl -s -N https://aus1.torproject.org/torbrowser/update_3/release/download-linux-x86_64.json | grep -Po '"binary"\s*:\s*"\K[^"]+')"; \
|
||||
echo "Downloading $DOWNLOAD_URI"; \
|
||||
curl -sSL -o /tmp/tor.tar.xz "https://www.torproject.org/$DOWNLOAD_URI"; \
|
||||
curl -sSL -o /tmp/tor.tar.xz "$DOWNLOAD_URI"; \
|
||||
tar -xvJf /tmp/tor.tar.xz -C /opt; \
|
||||
mv /opt/tor-browser* /opt/tor-browser_en-US; \
|
||||
chown -R neko:neko /opt/tor-browser_en-US/; \
|
||||
|
||||
@@ -17,7 +17,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# create a profile directory
|
||||
mkdir -p /home/neko/.waterfox/profile.default/extensions; \
|
||||
chown -R neko:neko /home/neko/.waterfox/profile.default; \
|
||||
chown -R neko:neko /home/neko/.waterfox; \
|
||||
#
|
||||
# clean up
|
||||
apt-get --purge autoremove -y xz-utils bzip2; \
|
||||
|
||||
@@ -323,6 +323,6 @@ docker run --rm -i \
|
||||
-e "RUNTIME_DOCKERFILE=$RUNTIME_DOCKERFILE" \
|
||||
--workdir /src \
|
||||
--entrypoint go \
|
||||
golang:1.25-trixie \
|
||||
golang:1.26-trixie \
|
||||
run utils/docker/main.go \
|
||||
-i Dockerfile.tmpl -client "$CLIENT_DIST" | build_image $BASE_IMAGE -f - .
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"presets": ["@vue/cli-plugin-babel/preset"]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
e2e-out
|
||||
core/node_modules
|
||||
core/dist
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"],
|
||||
"parserOptions": {
|
||||
"parser": "@typescript-eslint/parser"
|
||||
},
|
||||
"rules": {
|
||||
"vue/valid-v-for": "off",
|
||||
"no-case-declarations": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-console": "off",
|
||||
"no-empty": "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
e2e-out
|
||||
|
||||
# the root .gitignore ignores "core" (core dumps); this directory is the @m1k1o/neko package
|
||||
!/core/
|
||||
/core/dist
|
||||
@@ -0,0 +1,5 @@
|
||||
core/src/keyboard/guacamole.js
|
||||
core/dist/
|
||||
src/locales/
|
||||
public/
|
||||
dist/
|
||||
@@ -3,6 +3,5 @@
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"vueIndentScriptAndStyle": true
|
||||
"tabWidth": 2
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false,
|
||||
"files.encoding": "utf8",
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "./node_modules/typescript/lib",
|
||||
"todo-tree.filtering.excludeGlobs": ["**/node_modules/**"],
|
||||
"eslint.validate": [
|
||||
"vue",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
],
|
||||
"vetur.validation.template": true,
|
||||
"vetur.useWorkspaceDependencies": true,
|
||||
"remote.extensionKind": {
|
||||
"ms-azuretools.vscode-docker": "ui"
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"remote.containers.defaultExtensions": [
|
||||
"octref.vetur",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-vscode-remote.vscode-remote-extensionpack",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"editorconfig.editorconfig",
|
||||
"gruntfuggly.todo-tree",
|
||||
"eamodio.gitlens",
|
||||
"swyphcosmo.spellchecker"
|
||||
],
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BASE_IMAGE=node:18-bullseye-slim
|
||||
ARG BASE_IMAGE=node:24-trixie-slim
|
||||
FROM $BASE_IMAGE AS client
|
||||
|
||||
WORKDIR /src
|
||||
@@ -6,7 +6,7 @@ WORKDIR /src
|
||||
#
|
||||
# install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
#
|
||||
# build client
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# neko client
|
||||
|
||||
The web client for [neko](https://github.com/m1k1o/neko): React 19 + TypeScript, built with Vite.
|
||||
It speaks only the v3 API (`/api/ws` + REST).
|
||||
|
||||
```
|
||||
client/
|
||||
core/ @m1k1o/neko: the framework-free client library, its own npm package (core/README.md)
|
||||
src/
|
||||
main.tsx start-up: the strings, the instance (createNekoApp), the plugins, the connection, the render under <NekoProvider>
|
||||
app/ the shell: App, ErrorBoundary, the #neko grid
|
||||
layout/ Header, Side (the tab bar), RoomBar (members, room menu, controls, emotes)
|
||||
features/ one folder per feature: video, members, controls, room-menu, emotes, settings, connect, about
|
||||
plugins/ the registry (index.ts: the list, the slots, event dispatch), the contract (types.ts), chat/, filetransfer/
|
||||
components/ shared pieces: IconButton, Avatar, Dialog, EachHook; ui/ holds the shadcn/ui kit (dialog, menus, popover, tabs, button, sonner)
|
||||
state/ the instance (neko.ts: createNekoApp, app.ts: the GUI store and NekoApp), the provider and hooks, actions, settings, dialogs, event wiring
|
||||
i18n/ i18next: initI18n(), setLang(), the loader of the locale files
|
||||
lib/ cn(): class names with Tailwind conflicts resolved (clsx + tailwind-merge)
|
||||
locales/ the strings, one folder per language: common.json, chat.json, files.json
|
||||
assets/ images
|
||||
index.css Tailwind v4: the design tokens (@theme), the global rules, the layout's breakpoint variants
|
||||
```
|
||||
|
||||
## Layering
|
||||
|
||||
Dependencies point one way: `app → layout → features → components → design`, with `state`, `i18n`
|
||||
and `lib` below everything but `design`. In practice:
|
||||
|
||||
- A feature is a folder with its component(s), styled with Tailwind classes in the markup. Other
|
||||
folders import a feature by its main file with `@/` (`import { Stage } from '@/features/video/Stage'`);
|
||||
only a folder's own files are imported relatively.
|
||||
- Shared components know nothing about features or plugins. Nothing imports `app/`.
|
||||
- Plugins import neither each other nor `features/`; the app shell and the layout reach plugins only
|
||||
through the registry `@/plugins`, and features get plugin contributions passed in (they see only
|
||||
the contract in `@/plugins/types`).
|
||||
- The core is imported only from its entry point, `@m1k1o/neko`, never from a path into `core/`.
|
||||
- State is zustand: the core's `client.store`, the GUI's `app` store (`state/app.ts`) and each plugin's
|
||||
`store.ts`, all per instance (see Composition). A component gets the instance from the context
|
||||
(`useClient()`, `useApp()`, `useNeko()`), selects what it renders,
|
||||
`useStore(useClient().store, (s) => s.video.playing)` (`useShallow` for several fields; the core's
|
||||
`selectControlling`, `selectIsAdmin`, `selectSession` for the computed values), and re-renders only
|
||||
when that value changes. Everything outside React takes the instance as its first argument, reads
|
||||
`store.getState()` and writes `store.setState()` with a new object, never in place.
|
||||
- Where two layers must talk without importing each other, there is a store or a signal in `state/`:
|
||||
the room's event lines ("bob took the controls") are emitted on `state/bus.ts` and shown by the chat
|
||||
plugin; `logout` is a bus signal every store with per-session data listens to.
|
||||
|
||||
`npm run check` enforces this: `eslint.config.js` holds one `no-restricted-imports` rule set per
|
||||
layer, and `tools/cycles.mjs` fails on an import cycle (type-only imports excepted).
|
||||
|
||||
## Composition
|
||||
|
||||
There is no global client. `createNekoApp()` (`src/state/neko.ts`) makes one instance of the GUI, a
|
||||
`NekoApp` (`src/state/app.ts`): the `NekoClient` (WebRTC transport, `autologin`, `autoconnect`,
|
||||
`autoplay` from the viewer settings), its input `Overlay`, the GUI store `app` and the `bus`, with the
|
||||
event wiring (server events to event lines and toasts, `state/events.ts`) and the settings (stored
|
||||
and from the URL, `state/settings.ts`) applied. `main.tsx` composes the page in the order the pieces
|
||||
need:
|
||||
|
||||
```tsx
|
||||
const ready = initI18n(plugins.map((p) => p.ns)) // the strings of the active language
|
||||
const neko = createNekoApp() // the client, its wiring, the settings
|
||||
initPlugins(neko) // the slots, the event dispatch, each plugin's init
|
||||
ready.then(() => {
|
||||
neko.client.setUrl(location.href) // the connection: resume a saved session and connect
|
||||
createRoot(root).render(
|
||||
<NekoProvider neko={neko}>
|
||||
<App />
|
||||
</NekoProvider>,
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`<NekoProvider>` (`src/state/provider.tsx`) puts the instance in a React context; a component reads it
|
||||
with `useNeko()` (the whole `NekoApp`), `useClient()`, `useApp()` (the GUI store) and `useActions()`
|
||||
(`state/actions.ts`, the room actions bound to the instance), and the rest, `setSetting(neko, ...)`,
|
||||
`ask(app, ...)`, `api(neko, ...)`, a plugin's `store(neko)`, takes it as the first argument. Two
|
||||
instances on one page share nothing but the page (the strings, the URL parameters, `localStorage`).
|
||||
|
||||
To embed the GUI in another React app, compose the same pieces and render `<App />` under a provider
|
||||
(`?embed=1` and `?cast=1` on the page's URL still give the video-only layouts, for an iframe or a kiosk):
|
||||
|
||||
```tsx
|
||||
import { initI18n } from '@/i18n'
|
||||
import { plugins, initPlugins } from '@/plugins'
|
||||
import { createNekoApp } from '@/state/neko'
|
||||
import { NekoProvider } from '@/state/provider'
|
||||
import { App } from '@/app/App'
|
||||
|
||||
await initI18n(plugins.map((p) => p.ns))
|
||||
const neko = createNekoApp()
|
||||
initPlugins(neko)
|
||||
neko.client.setUrl('https://neko.example.com/')
|
||||
|
||||
export const Room = () => (
|
||||
<NekoProvider neko={neko}>
|
||||
<App />
|
||||
</NekoProvider>
|
||||
)
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
Styling is Tailwind v4 (`tailwindcss`, `@tailwindcss/vite`): the classes are in the markup, there is no
|
||||
stylesheet per component. `src/index.css` is the whole of the CSS:
|
||||
|
||||
- **Tokens**: an `@theme` block with the values of the previous SCSS variables under the same names, so
|
||||
`$background-primary` is `--color-background-primary` and the utility `bg-background-primary`;
|
||||
`$text-normal` is `text-text-normal`, `$style-primary` is `bg-style-primary` / `text-style-primary`,
|
||||
`$elevation-high` is `shadow-elevation-high`, `$side-width` / `$menu-height` / `$controls-height` are
|
||||
`w-side` / `h-menu` / `h-controls`, `$text-size` is `text-ui`. The font is Tailwind's system stack
|
||||
(`--font-sans`). Sizes are written in px where the legacy layout had px (`p-[5px]`, `h-7.5`).
|
||||
- **Global rules** the preflight does not have: the page's size and overflow, 14px text with line-height 1
|
||||
on `body` (so `rem`, the base of Tailwind's spacing scale, stays 16px), no native video controls.
|
||||
- **Breakpoints** as variants: `tablet:` (up to 1024px: the page scrolls and the side panel goes under the
|
||||
video, with Tailwind's `portrait:` / `landscape:` for the split), `phone:` (up to 768px: no room bar).
|
||||
- The animations (`animate-shake`, `animate-loader`, `animate-badge`) and the `slider` utility (the range
|
||||
inputs).
|
||||
|
||||
`cn()` (`src/lib/utils.ts`, `clsx` + `tailwind-merge`) joins class names and lets the last Tailwind class
|
||||
win, so a caller's `className` overrides a component's; `class-variance-authority` gives the variants.
|
||||
|
||||
The component kit under `src/components/ui/` is shadcn/ui on Radix, restyled with the tokens: `dialog.tsx`
|
||||
(`@radix-ui/react-dialog`, the overlay and the content composed by the caller), `dropdown-menu.tsx` (a
|
||||
menu from a button: the resolution list, the emote picker), `popover.tsx` (the clipboard textarea, the
|
||||
emoji picker), `tabs.tsx` (the side panel), `context-menu.tsx` (the member menu: a menu opened at a
|
||||
point, on `@radix-ui/react-menu`, since it is opened from the member list, a chat author and Shift+F10),
|
||||
`button.tsx` (the text buttons: `primary`, `outline`, `confirm`, `cancel`) and `sonner.tsx` (the toasts,
|
||||
`toast()` in `src/state/dialogs.ts`). They bring focus trapping, roving focus, Escape, outside click and
|
||||
ARIA. `components.json` configures `npx shadcn add <component>` (the radix base); a component added
|
||||
that way comes with shadcn's theme classes and is rewritten with the tokens, like the ones here.
|
||||
|
||||
Every icon control is `IconButton` (`src/components/IconButton.tsx`): a `<button type="button">` named
|
||||
by its `label` (the accessible name and the tooltip), with the icon as its child and the variants `plain`
|
||||
(the room bar, the chat, the files), `header` (a 30px box) and `video` (the translucent box over the
|
||||
video). Icons are `@phosphor-icons/react` components, tree-shaken: `import { MouseIcon } from
|
||||
'@phosphor-icons/react'` and `<MouseIcon className="size-4" />` (`size-3.5` next to 14px text, `size-6` in
|
||||
the room bar), all filled: `IconContext` in `App.tsx` sets the weight once; a plugin tab's `icon` is one.
|
||||
Emoji are text: the character of each name from `public/emoji.json` (`src/plugins/chat/Emoji.tsx`).
|
||||
|
||||
To add a component: Tailwind classes and the tokens in the markup, repeated class strings as constants
|
||||
next to the component, `Button` / `IconButton` for buttons, the kit for a dialog, a menu or a popover. To
|
||||
add an icon: import it from `@phosphor-icons/react`.
|
||||
|
||||
## Plugins
|
||||
|
||||
A plugin is a folder under `src/plugins/` with its components, styles, store and actions, described
|
||||
by one object (`src/plugins/types.ts`); its strings are a namespace of their own under `src/locales/`:
|
||||
|
||||
| field | |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | the prefix of its server events: `message` events named `<id>/...` reach `onEvent` |
|
||||
| `ns` | the namespace of its strings, `src/locales/<lang>/<ns>.json` in every language, used as `t('<ns>:key')` |
|
||||
| `tab` | a side-panel tab: `id` (the remembered tab), `icon`, `label` (the key of its name, `chat:tab`), `component`, the hooks `useVisible()` and `useBadge()` (unread count) |
|
||||
| `topBar` | items for the header menu; each renders its own `<li>` or nothing |
|
||||
| `memberMenu` | entries of another member's context menu: the hook `useVisible(member)`, `label(member)`, `onClick(member)` |
|
||||
| `onEvent` | `(neko, ...[event, payload])` for its server events, typed by `PluginEvents` (event name to payload, `src/plugins/types.ts`): `if (event === 'chat/init')` narrows `payload` to its shape |
|
||||
| `init` | `(neko)`, called once per instance at start-up, before the connection: subscribe to `neko.client.events`, `neko.bus`, ... |
|
||||
|
||||
Every callback gets the instance (`neko: NekoApp`, see Composition) as its first argument, the member
|
||||
menu's `label(neko, member)` and `onClick(neko, member)` included; the hooks (`useVisible`, `useBadge`)
|
||||
and the components read it from the context (`useNeko()`, `useClient()`). A plugin's store is one per
|
||||
instance: `export const store = scoped(() => createStore(...))` (`scoped`, `src/state/app.ts`) and
|
||||
`store(neko)` is the instance's.
|
||||
|
||||
`src/plugins/index.ts` lists the plugins of the build in side-panel order, and `initPlugins(neko)`
|
||||
(called from `main.tsx`) wires them in that order: it puts each `tab`, `topBar` item and
|
||||
`memberMenu` entry into its slot (`registerSlot('side.tab' | 'header.item' | 'member.menu', item)`,
|
||||
once: the slots are the build's, not the instance's), builds the event dispatch (a map from the `id`
|
||||
prefix to the plugin, one lookup per message) on the instance's `client.events` and runs each
|
||||
`init(neko)`. The side panel, the header and the app shell read the slots with `useSlot(name)`
|
||||
(`useVisible` and `useBadge` are hooks over the plugin's store, so each runs in a small component of
|
||||
its own and re-renders only that).
|
||||
|
||||
To add a plugin:
|
||||
|
||||
1. Create `src/plugins/<name>/` with an `index.ts` exporting the plugin object, its components (Tailwind
|
||||
classes, the tokens of `src/index.css`), and a `store.ts` made with `scoped(() => createStore(...))`
|
||||
(components select from it with `useStore(store(useNeko()), (s) => s.field)`, the rest reads
|
||||
`store(neko).getState()` and writes `store(neko).setState()`).
|
||||
2. Add its strings as `src/locales/<lang>/<ns>.json` in every language (see i18n).
|
||||
3. Add the import and the entry to `src/plugins/index.ts`.
|
||||
|
||||
Removing the entry removes the tab, the header items, the menu entries and the event handling;
|
||||
nothing else changes (its locale files are then unused).
|
||||
|
||||
## i18n
|
||||
|
||||
Strings live in `src/locales/<lang>/<ns>.json`: one folder per language, one file per namespace,
|
||||
`common.json` (the GUI), `chat.json` and `files.json` (the plugins). The runtime is `i18next` with
|
||||
`react-i18next` (`src/i18n/index.ts`): components call `useTranslation()` and render
|
||||
`t('side.settings')` or `t('chat:tab')` (`common` is the default namespace, a plugin's is prefixed);
|
||||
code outside components imports `t` from `@/i18n`. Values interpolate with `{{name}}`.
|
||||
|
||||
Only the active language is downloaded: the files are lazy chunks (`import.meta.glob`), and the
|
||||
loader hands i18next the `<lang>/<ns>` it asks for at start-up (`initI18n`, before the first render)
|
||||
and on a pick in the room menu (`setLang`, i18next's `changeLanguage`). `en` is the fallback for a
|
||||
key a language lacks, so it is loaded next to the active language; the other languages never are
|
||||
(`tools/dist.check.mjs`, run by `npm run build`, checks the main chunk carries none). The language is
|
||||
kept in `localStorage` (`lang`, the short code) and set by `?lang=`; `<html lang>` gets the BCP 47
|
||||
tag (`cn` is `zh-CN`). In development, a key no language has is logged (`i18n: missing ...`).
|
||||
|
||||
- Adding a key: add it to `en` and to the other fourteen files. `src/i18n/i18n.test.ts` lists the
|
||||
keys each language lacks today and fails on a new one.
|
||||
- Adding a language: a folder with the three files, and its code in `langs` (`src/i18n/index.ts`,
|
||||
the picker order).
|
||||
- Not yet: plural forms (`key_one`, `key_other`); the strings with a count keep one form, as before.
|
||||
|
||||
## core
|
||||
|
||||
`core/` is `@m1k1o/neko`: `NekoClient` (auth, websocket with reconnect and stale detection, WebRTC,
|
||||
the binary input channel, control, clipboard, broadcast), the input `Overlay`, the zustand store with
|
||||
its selectors, `Emitter`, `NekoApi` and the wire types. The GUI consumes it from source through the `@m1k1o/neko` alias in
|
||||
`tsconfig.json` and `vite.config.ts`; `npm run build:core` builds the package (`core/dist/`). The API
|
||||
reference is `core/README.md`.
|
||||
|
||||
```ts
|
||||
import { NekoClient } from '@m1k1o/neko'
|
||||
|
||||
const client = new NekoClient({ autologin: true, autoconnect: true, autoplay: true })
|
||||
client.store.subscribe((s) => s.sessions, render) // state: connection, sessions, control, screen, ...
|
||||
client.events.on('message', (event, payload) => {}) // plugin events (chat/*, filetransfer/*, ...)
|
||||
client.setUrl('https://neko.example.com/')
|
||||
client.mount(document.getElementById('video')!)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Backend from `server/dev`, then:
|
||||
|
||||
```sh
|
||||
cd client/dev && ./serve # http://localhost:3001, /api proxied to the backend on :3000
|
||||
# or without docker:
|
||||
NEKO_URL=http://localhost:3000 npm run dev
|
||||
```
|
||||
|
||||
| script | |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm run build` | type-check + production build into `dist/`, then `tools/dist.check.mjs` (no language in the main chunk) |
|
||||
| `npm run build:core` | build the `@m1k1o/neko` package into `core/dist/` |
|
||||
| `npm run check` | `check:types`, `check:format`, `check:lint` (hooks, layering), `check:cycles` and `test` in parallel (`npm-run-all2`, one label per script), then `build:core` and `check:dist` (the built core package); each `check:*` script also runs on its own |
|
||||
| `npm test` | unit tests (vitest): the core's connection state machine and store, the chat's markdown (rendered with react-dom/server), the i18n runtime and locale files, the plugin registry, an instance and two of them, the provider; no browser |
|
||||
| `npm run format` | prettier |
|
||||
| `npm run build:emoji` | regenerate the emoji data `public/emoji.json` (names, characters, groups, keywords; `tools/emoji.ts`) |
|
||||
| `npm run test:e2e` | two-user browser test against a running server, see below |
|
||||
|
||||
### e2e
|
||||
|
||||
`e2e.mjs` drives two (or more) headless Chromium users through login, video, chat, control, files,
|
||||
admin actions, reconnects and URL parameters. It needs a disposable server (it takes control, types
|
||||
into the desktop and kicks users) with the multiuser provider:
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 -p 52000:52000/udp \
|
||||
-e NEKO_MEMBER_PROVIDER=multiuser -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
|
||||
-e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=admin -e NEKO_FILETRANSFER_ENABLED=true \
|
||||
-e NEKO_WEBRTC_UDPMUX=52000 \
|
||||
-e NEKO_WEBRTC_NAT1TO1=127.0.0.1 ghcr.io/m1k1o/neko/kde
|
||||
npx playwright install chromium
|
||||
NEKO_URL=http://localhost:8080/ npm run test:e2e
|
||||
```
|
||||
|
||||
The typing step expects a launcher on Alt+F2 (KRunner on KDE, the app finder on Xfce). To run the
|
||||
steps in a real Chromium-based browser instead of Playwright's, start it with
|
||||
`--headless --remote-debugging-port=9222 --user-data-dir=<empty dir>
|
||||
--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream
|
||||
--use-fake-device-for-media-stream` and set `NEKO_CDP=http://127.0.0.1:9222`.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- **Kick** removes the session (the user may log in again), see the comment at `actions.kick` in
|
||||
`src/state/actions.ts`.
|
||||
- Settings use the same `localStorage` keys and format as the previous client, so preferences carry
|
||||
over. Scroll sensitivity now uses v3 steps (-5..5, key `scroll_sensitivity`); `?scroll=` is mapped.
|
||||
- Chat markdown (`src/plugins/chat/markdown.ts`) is rendered by `react-markdown` with `remark-gfm`
|
||||
(`~~strike~~`, tables, bare links) and `remark-breaks` (a newline is a line break) to React elements,
|
||||
no HTML strings; four small rehype plugins of ours add the chat's dialect: `:name:` emoji, plain-text
|
||||
`||spoiler||` (click to reveal; no formatting inside), `__underline__`, and links to anything but
|
||||
http(s)/mailto, images and raw HTML shown as the text they were typed as (nothing in a message loads
|
||||
anything). Compared with the hand-written parser before: the Discord `>>>` block quote is gone
|
||||
(`> ` quotes stay, and continue until a blank line), and CommonMark's headings, lists, nested
|
||||
quotes and tables are in. Messages over 512 characters (the server's default `chat.max_length`), or one the renderer fails on, are shown
|
||||
as plain text.
|
||||
- Short-lived list items (uploads, emote animations) are keyed by 64 random bits from `crypto.getRandomValues()`, which
|
||||
every origin has (`crypto.randomUUID()` needs a secure context, and plain http on a LAN is not one).
|
||||
- File downloads are plain links so the browser streams them; the session token is added to the
|
||||
URL only when the server runs without cookies.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
# @m1k1o/neko
|
||||
|
||||
Framework-free client library for the [neko](https://github.com/m1k1o/neko) v3 API: REST, the
|
||||
`/api/ws` event protocol, the stream through a pluggable transport (WebRTC with its binary input
|
||||
channel, see [Transports](#transports)), and a pluggable control device, with the keyboard/mouse/touch
|
||||
overlay (wheel, file drop, the host's cursor) included (see [Control and input](#control-and-input)).
|
||||
It is what the React GUI in `../src` is built on, and it can drive a neko server from any framework
|
||||
or from a plain page.
|
||||
|
||||
ESM, TypeScript declarations included; the one runtime dependency is [zustand](https://github.com/pmndrs/zustand)
|
||||
(its vanilla store, ~1 KB), which holds the state.
|
||||
|
||||
```sh
|
||||
npm install @m1k1o/neko
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { NekoClient, mount } from '@m1k1o/neko'
|
||||
|
||||
const client = new NekoClient({ autologin: true, autoconnect: true, autoplay: true })
|
||||
|
||||
// state: connection, video, control, screen, sessions, settings (a zustand store: select what you need)
|
||||
client.store.subscribe(
|
||||
(s) => s.connection.status,
|
||||
(status) => render(client.state),
|
||||
)
|
||||
// one-off happenings, including plugin events (chat/*, filetransfer/*, ...)
|
||||
client.events.on('message', (event, payload) => console.log(event, payload))
|
||||
|
||||
// point at a server: resumes a saved session (autologin) or logs in with ?token= in the URL
|
||||
client.setUrl('https://neko.example.com/')
|
||||
// or log in by hand
|
||||
await client.login('name', 'password')
|
||||
client.connect()
|
||||
|
||||
// the video, with the keyboard/mouse overlay on top (mount() does the two attaches below)
|
||||
const unmount = mount(client, document.getElementById('video')!)
|
||||
```
|
||||
|
||||
The two pieces separately — the stream alone, or with the overlay as a second step:
|
||||
|
||||
```ts
|
||||
import { Overlay } from '@m1k1o/neko'
|
||||
|
||||
const box = document.getElementById('box')! // position: relative, inside the area the video fills
|
||||
const detach = client.transport.attach(box) // the stream: a <video> that fills the box
|
||||
const overlay = new Overlay(client)
|
||||
const detachOverlay = overlay.attach(box) // keyboard, mouse, touch and the host's cursor over it
|
||||
```
|
||||
|
||||
## `NekoClient`
|
||||
|
||||
### Options
|
||||
|
||||
| option | default | |
|
||||
| ------------- | -------- | ----------------------------------------------------------------------------------- |
|
||||
| `autologin` | `false` | remember the session token in `localStorage` (`neko_session`) and resume it |
|
||||
| `autoconnect` | `false` | connect as soon as `setUrl()` finds a valid session |
|
||||
| `autoplay` | `false` | start playback when the stream becomes playable (read each time, so a getter works) |
|
||||
| `inputMode` | `'auto'` | `'touch'` or `'mouse'` instead of detecting it (`isTouchDevice`) |
|
||||
| `transport` | WebRTC | a `StreamTransport` (or a factory for one), see [Transports](#transports) |
|
||||
|
||||
### Authentication
|
||||
|
||||
| method | |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `setUrl(url = location.href)` | the server (defaults to the page itself); `?token=` logs in with that token; with `autoconnect` connects |
|
||||
| `authenticate(token?)` | validate a token (or the saved one with `autologin`) with `GET /api/whoami` |
|
||||
| `login(username, password)` | `POST /api/login`; keeps the token when the server runs without cookies |
|
||||
| `logout()` | `POST /api/logout`, forgets the token |
|
||||
|
||||
### Connection
|
||||
|
||||
`connect()` opens the websocket (`/api/ws`) and, once the server has introduced the session
|
||||
(`system/init`), starts the transport, which brings up the stream (WebRTC: asks for an offer and
|
||||
answers it; the server's ICE candidates are buffered until the offer is applied). The connection
|
||||
is kept alive by itself: a socket that goes silent is replaced and the transport started over on the
|
||||
new one, a peer the server drops is requested again, and after repeated failures the client backs
|
||||
off and finally gives up (`connection.closed` with an error). While the socket is away the stream is
|
||||
left alone, so the last picture stays (and keeps streaming while it can) until the reconnect
|
||||
replaces it. `disconnect()` closes everything, the stream included (the element goes black); the
|
||||
session stays valid.
|
||||
|
||||
`connection.status` follows both: `'connecting'` while the socket is being (re)opened or the
|
||||
transport has no stream yet, `'connected'` once the transport reports `'connected'` (or
|
||||
`'unavailable'`: the server will not stream to this session, the room is shown without video).
|
||||
|
||||
| event / state | |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- |
|
||||
| `state.connection.status` | `'disconnected'`, `'connecting'`, `'connected'` |
|
||||
| `events: 'connection.status'` | the status changed |
|
||||
| `events: 'connection.closed'` | closed; with an `Error` when not asked for |
|
||||
| `connected`, `session`, `isAdmin`, `implicitControl` | computed from the state (see [State](#state)) |
|
||||
| `transport` | the `StreamTransport` in use |
|
||||
|
||||
### State
|
||||
|
||||
`client.store` is a [zustand](https://github.com/pmndrs/zustand) vanilla store (`createStore` with
|
||||
`subscribeWithSelector`), and `client.state` is its current state, `client.store.getState()`: a plain
|
||||
object that is replaced, never mutated, on every change (the changed slice is a new object, the rest
|
||||
keeps its identity). Read it anywhere; subscribe with a selector to be called when that value changes:
|
||||
|
||||
```ts
|
||||
const off = client.store.subscribe(
|
||||
(s) => s.control.host_id,
|
||||
(hostId, before) => {},
|
||||
) // when the selected value changes (Object.is; options: { equalityFn, fireImmediately })
|
||||
client.store.subscribe((state, before) => {}) // after every change
|
||||
useStore(client.store, (s) => s.video.playing) // React: zustand's hook, re-renders on that value only
|
||||
```
|
||||
|
||||
`selectSession`, `selectControlling` and `selectIsAdmin` compute what the getters `session`,
|
||||
`controlling` and `isAdmin` report, from a state: `client.store.subscribe(selectControlling, (on) => {})`,
|
||||
`useStore(client.store, selectIsAdmin)`. The client owns the state: `client.store.setState()` exists
|
||||
(zustand, and the tests use it) but nothing a consumer writes is sent to the server.
|
||||
|
||||
| field | |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `authenticated` | a valid session is known |
|
||||
| `connection.{url, token, status}` | |
|
||||
| `video.{playable, playing, volume, muted}` | as the transport reports it; plus `mutedByAutoplay` when the browser refused sound |
|
||||
| `control.host_id` | who has control (`controlling` compares it with `session_id`) |
|
||||
| `control.locked` | local lock: keep control, send no input |
|
||||
| `control.clipboard` | the remote clipboard, as the server reports it |
|
||||
| `control.{scroll, keyboard, touch}` | input settings and whether the server takes native touch events |
|
||||
| `screen.{size, configurations}` | the current and the available screen sizes |
|
||||
| `session_id`, `sessions` | this session and all sessions (`Session`: `id`, `profile`, `state`) |
|
||||
| `settings` | room settings (`Settings`: locks, implicit hosting, `plugins`, ...) |
|
||||
| `mobile_keyboard_open` | the on-screen keyboard is up (touch devices) |
|
||||
|
||||
### Events
|
||||
|
||||
`client.events` is a typed `Emitter` (`on`, `off`, `once`; `on` returns the unsubscribe):
|
||||
|
||||
| event | arguments |
|
||||
| ------------------------ | ---------------------------------------------------------------------- |
|
||||
| `session.created` | `id` |
|
||||
| `session.updated` | `id` (profile or state changed) |
|
||||
| `session.deleted` | `id` |
|
||||
| `room.control.host` | `hasHost, hostId, by` |
|
||||
| `room.control.request` | `id` of the member asking for control (hosts only) |
|
||||
| `room.screen.updated` | `width, height, rate, by` |
|
||||
| `room.settings.updated` | `settings, by` |
|
||||
| `room.clipboard.updated` | `text` |
|
||||
| `room.broadcast.status` | `active, url` |
|
||||
| `receive.broadcast` | `sender, subject, body` (`sendBroadcast` from another client) |
|
||||
| `receive.unicast` | `sender, subject, body` |
|
||||
| `upload.drop.progress` | `{ loaded, total }` of a file dropped on the video |
|
||||
| `upload.drop.finished` | `error?` |
|
||||
| `overlay.click` | a click on the video (the GUI hints at the control button with it) |
|
||||
| `message` | `event, payload`: everything else, i.e. plugin events (`chat/message`) |
|
||||
|
||||
### Video and audio
|
||||
|
||||
The stream is the transport's: `transport.attach(box)` creates its media element inside `box` (a
|
||||
`<video>` filling it, keeping the aspect ratio by itself) and returns what removes it again; the
|
||||
media calls below go through the transport. `mount(client, el)` (a plain function, not a method)
|
||||
attaches the transport and an `Overlay` into a box it letterboxes inside `el`, and returns the
|
||||
function that takes them out again.
|
||||
|
||||
| method / field | |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `transport.attach(box)` | the media element into `box`; returns the detach function |
|
||||
| `transport.element` | the transport's media element (a `<video>` for WebRTC), once attached |
|
||||
| `play()`, `pause()` | when the browser refuses sound, `play()` starts muted (`mutedByAutoplay`) and unmutes on the first click |
|
||||
| `mute()`, `unmute()`, `setVolume(v)` | `v` in 0..1 |
|
||||
| `shareMedia(stream)` | send local media (microphone) on a transport that can; returns the function that stops sharing it |
|
||||
|
||||
### Control and input
|
||||
|
||||
Input reaches the remote desktop through `client.input`, an `InputChannel` in the server's terms:
|
||||
the transport's own channel when it has one (WebRTC: the data channel, `DataChannelInput`, binary
|
||||
frames), otherwise `WebSocketInput`, which sends the same input as the server's `control/*`
|
||||
websocket events. The client does not care what produces the input: a control device is anything
|
||||
that calls these methods, and the one included is the `Overlay`.
|
||||
|
||||
```ts
|
||||
interface InputChannel {
|
||||
move(x: number, y: number): void // remote screen coordinates
|
||||
scroll(deltaX: number, deltaY: number, controlKey: boolean): void
|
||||
button(code: number, down: boolean): void // X11 button code
|
||||
key(keysym: number, down: boolean): void // X11 keysym
|
||||
touch(phase: 'begin' | 'update' | 'end', id: number, x: number, y: number, pressure: number): void
|
||||
}
|
||||
```
|
||||
|
||||
`Overlay` is the keyboard/mouse/touch device: a transparent layer over the stream that turns DOM
|
||||
mouse, wheel, keyboard (Apache Guacamole's keyboard, vendored in `keyboard/`, so keysyms match the
|
||||
server's layouts), touch, drop and paste events into `client.input` calls while this member is host
|
||||
and not locked, asks for control on a click under implicit hosting, and draws the host's cursor for
|
||||
everyone else from the transport's `cursor.*` events. `new Overlay(client)` builds it (it reads the
|
||||
client's state and talks to it); `overlay.attach(box)` puts it into the box that already holds the
|
||||
transport's element (so the stream is below) and keeps that box letterboxed inside its parent to the
|
||||
remote screen's aspect ratio, so that its canvas maps onto the picture; the returned function
|
||||
detaches it (a second attach replaces the first). `overlay.mobileKeyboardToggle()` opens / closes
|
||||
the on-screen keyboard on touch devices. A stream without input is just `transport.attach(box)`: the
|
||||
client never loads the overlay or the keyboard unless they are imported.
|
||||
|
||||
Another device (a gamepad, a VR controller, a test driver) does what the overlay does: watch
|
||||
`client.controlling` / `client.state.control.locked` to know when input is accepted, call
|
||||
`client.request()` to become host, and send through `client.input`:
|
||||
|
||||
```ts
|
||||
const off = client.store.subscribe(
|
||||
(s) => selectControlling(s) && !s.control.locked,
|
||||
(on) => (on ? gamepad.start() : gamepad.stop()),
|
||||
)
|
||||
gamepad.onStick = (x, y) => client.input.move(x, y) // in remote screen pixels
|
||||
gamepad.onButton = (n, down) => client.input.button(n + 1, down)
|
||||
```
|
||||
|
||||
| method | |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| `request()`, `release()` | ask for / give up control |
|
||||
| `lock()`, `unlock()` | local lock (`control.locked`) |
|
||||
| `setScrollInverse(v)`, `setScrollSensitivity(n)` | wheel direction and v3 scroll steps (-5..5) |
|
||||
| `setKeyboard(layout, variant?)` | the remote keyboard layout, sent while in control |
|
||||
| `setScreenSize(width, height, rate)` | admins: change the screen |
|
||||
| `paste(text)` | type text remotely through the server's clipboard |
|
||||
| `preparePaste(text)` | before a paste keystroke: the side that copied most recently wins (see the source comment) |
|
||||
| `uploadDrop({ x, y, files })` | drop files into the remote desktop at a position (the overlay does this on drop) |
|
||||
| `input.move(x, y)`, `input.key(keysym, down)`, … | input straight to the remote desktop (the `InputChannel` above) |
|
||||
|
||||
### Messages
|
||||
|
||||
| method | |
|
||||
| -------------------------------------- | ---------------------------------------------------------- |
|
||||
| `send(event, payload?)` | a websocket message, e.g. `send('chat/message', { text })` |
|
||||
| `sendBroadcast(subject, body)` | to every other client (`receive.broadcast` there) |
|
||||
| `sendUnicast(receiver, subject, body)` | to one session |
|
||||
|
||||
### REST
|
||||
|
||||
`client.api` (`NekoApi`) carries the server URL and the token:
|
||||
|
||||
```ts
|
||||
const sessions = await client.api.req<Session[]>('GET', '/sessions')
|
||||
await client.api.req('POST', `/members/${encodeURIComponent(id)}`, { can_login: false })
|
||||
const blob = await client.api.blob('/room/screen/shot.jpg') // admins
|
||||
await client.api.upload('/filetransfer', formData, ({ loaded, total }) => {})
|
||||
```
|
||||
|
||||
Failures throw `ApiError` with `status` and the server's `message`. Session ids can come from
|
||||
login names on some member providers, so they go through `encodeURIComponent` in paths.
|
||||
|
||||
## Transports
|
||||
|
||||
`NekoClient` owns the session (auth, the websocket, the room state) and leaves the stream to a
|
||||
`StreamTransport` (`transport.ts`), the way Guacamole's client runs over a tunnel it does not care
|
||||
about. WebRTC is the one implemented (`WebRTCTransport`, `transport/webrtc.ts`); others
|
||||
(WebCodecs over a websocket, WebTransport, MSE, HLS) plug in the same way:
|
||||
|
||||
```ts
|
||||
interface StreamTransport {
|
||||
readonly kind: 'webrtc' | 'webcodecs-ws' | 'ws-mse' | 'hls'
|
||||
readonly element: HTMLElement | null // its media element, created by attach()
|
||||
readonly input?: InputChannel // its own input path, if any (the data channel)
|
||||
|
||||
connect(session: SessionInfo): Promise<void> // start streaming; again on every reconnect, replacing what runs
|
||||
close(): void // the stream is over: peer, timers, counters and the picture on the element go
|
||||
|
||||
attach(container: HTMLElement): () => void // create the media element inside; returns what removes it
|
||||
|
||||
setPlaying(on: boolean): Promise<void>
|
||||
setVolume(volume: number): void
|
||||
setMuted(on: boolean): void
|
||||
shareMedia?(stream: MediaStream): () => void // local media (microphone); returns what stops sharing it
|
||||
|
||||
on(event: 'state' | 'stats' | 'error' | 'cursor.position' | 'cursor.image', cb): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`SessionInfo` is what the client hands over on `system/init`: the server `url` and `token`, the
|
||||
`init` payload, and `send(event, payload)` / `on(event, cb)` for the transport's own messages over
|
||||
the main socket (every message reaches these subscribers; `signal/*` mean nothing to the client
|
||||
itself, which passes them on as `message` events like any event it does not handle). A socket loss
|
||||
does not touch the transport: `send` drops until the socket is back, and the reconnect's
|
||||
`system/init` calls `connect()` again, which replaces whatever the previous session left (its peer,
|
||||
retries and failure count) while the last picture stays until the new stream shows; `close()` is
|
||||
called only when the connection ends for good (`disconnect()`, a kick, giving up) and empties the element.
|
||||
|
||||
A transport reports `'state'` (`{ status, size, video }`: its `status` — `'connecting'`,
|
||||
`'connected'`, `'unavailable'` when the server will not stream, `'disconnected'` — the stream's
|
||||
size and the media element's `playable`/`playing`/`volume`/`muted`/`mutedByAutoplay`, mirrored
|
||||
into `state.video`; its status counts only while the socket is up), `'error'` (the stream is lost
|
||||
for good; the client closes the connection with it), the host's `'cursor.position'`/`'cursor.image'`
|
||||
when its server feeds them back (the `Overlay` draws them), and `'stats'` (reserved). A transport
|
||||
does not start playback by itself: the client calls `setPlaying(true)` when `playable` turns true
|
||||
and `autoplay` is on, and a transport keeps playing across its own reconnects. A second transport
|
||||
needs: its own connection from the `SessionInfo`, an element it draws into, play/volume/mute, and
|
||||
either an `input` channel or nothing (the client then sends input over the websocket). Pass it as
|
||||
`new NekoClient({ transport })`; the GUI checks `WebRTCTransport.supported()` before rendering.
|
||||
|
||||
## The store and `Emitter`
|
||||
|
||||
The state is a zustand store (`createStore` from `zustand/vanilla` with `subscribeWithSelector`
|
||||
from `zustand/middleware`; zustand is the package's one runtime dependency): every change is one
|
||||
immutable `setState`, listeners run synchronously in it, and a selector subscription fires only when
|
||||
its value changes (see [State](#state)). A GUI makes its own stores the same way and selects from all
|
||||
of them with one hook (`useStore` from `zustand`). `Emitter<Events>` is a small typed event emitter
|
||||
(`on` returns the unsubscribe, `once`, `off`, `emit`), exported for the same reason.
|
||||
|
||||
## Types
|
||||
|
||||
The wire types mirror `server/pkg/types`: `Session`, `MemberProfile`, `SessionState`, `Settings`,
|
||||
`ScreenSize`, `InitPayload`, `LoginResponse`, `MemberData`, `CursorImage`, the client `State` and
|
||||
the `NekoEvents` map; the transport seam is `StreamTransport`, `SessionInfo`, `InputChannel`,
|
||||
`TransportKind`, `TransportStatus`, `TransportState` and `TransportEvents`.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm run build # tsc -> dist/ (ESM + .d.ts), plus the vendored keyboard library
|
||||
npm test # the unit tests in src/**/*.test.ts (vitest, run from ../ where it is installed): the connection
|
||||
# state machine (handshake, reconnects, timeouts, events, clipboard, auth), the transport seam,
|
||||
# the WebRTC transport, the input channels, the overlay and the store, against the fake browser
|
||||
# in src/test/browser.ts (sockets, peers, elements, fake timers)
|
||||
npm run check # build, the tests, then node dist.check.mjs against the built package: exports, the store
|
||||
# (selector subscriptions, immutable updates, zustand resolvable), Emitter, setUrl, .d.ts
|
||||
# specifiers, and that client.js never imports the overlay or the keyboard
|
||||
```
|
||||
|
||||
The tests and dist.check.mjs run under Node with a few browser globals stubbed, so the connection state
|
||||
machine and the store are tested without a browser. The GUI in `../src` consumes this package from source
|
||||
through the `@m1k1o/neko` alias in `../tsconfig.json` and `../vite.config.ts`; consumers of the npm
|
||||
package get `dist/`.
|
||||
@@ -0,0 +1,14 @@
|
||||
// After `tsc`: declaration files keep the `.ts` import extensions the sources use (tsc only
|
||||
// rewrites them in the JavaScript output), and the vendored keyboard library is plain JS.
|
||||
import { readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'
|
||||
|
||||
for (const f of readdirSync('dist', { recursive: true }).filter((f) => f.endsWith('.d.ts'))) {
|
||||
const src = readFileSync(`dist/${f}`, 'utf8')
|
||||
writeFileSync(
|
||||
`dist/${f}`,
|
||||
src.replace(/(from '\.\.?\/[^']+)\.ts'/g, "$1.js'").replace(/(import\("\.\.?\/[^"]+)\.ts"\)/g, '$1.js")'),
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync('dist/keyboard', { recursive: true })
|
||||
for (const f of ['guacamole.js', 'guacamole.d.ts']) copyFileSync(`src/keyboard/${f}`, `dist/keyboard/${f}`)
|
||||
@@ -0,0 +1,135 @@
|
||||
// Runnable self-check of the built package in dist/ (after `npm run build`): `node dist.check.mjs`
|
||||
import assert from 'node:assert/strict'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// just enough of the browser for the core to load and construct (as in src/client.test.ts)
|
||||
const g = globalThis
|
||||
g.window = g
|
||||
g.location = { href: 'http://neko.test/' }
|
||||
Object.defineProperty(g, 'navigator', {
|
||||
value: { platform: 'Linux', userAgent: 'check', maxTouchPoints: 0 },
|
||||
configurable: true,
|
||||
})
|
||||
g.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }
|
||||
g.matchMedia = () => ({ matches: false })
|
||||
|
||||
const dist = join(import.meta.dirname, 'dist')
|
||||
const pkg = await import('./dist/index.js')
|
||||
const { WebRTCTransport } = pkg
|
||||
|
||||
// the public surface
|
||||
for (const name of [
|
||||
'NekoClient',
|
||||
'Overlay',
|
||||
'mount',
|
||||
'Emitter',
|
||||
'selectSession',
|
||||
'selectControlling',
|
||||
'selectIsAdmin',
|
||||
'NekoApi',
|
||||
'ApiError',
|
||||
'WebRTCTransport',
|
||||
'WebSocketInput',
|
||||
'DataChannelInput',
|
||||
])
|
||||
assert.equal(typeof pkg[name], 'function', name)
|
||||
|
||||
// the client is free of the keyboard/mouse overlay and the Guacamole keyboard: a stream-only
|
||||
// consumer that imports NekoClient (and tree-shakes the rest, sideEffects: false) never loads them
|
||||
const imports = (file, seen = new Set()) => {
|
||||
if (seen.has(file)) return seen
|
||||
seen.add(file)
|
||||
for (const [, spec] of readFileSync(join(dist, file), 'utf8').matchAll(/(?:from|import) ["'](\.[^"']+)["']/g))
|
||||
imports(join(file, '..', spec), seen)
|
||||
return seen
|
||||
}
|
||||
const clientGraph = [...imports('client.js')]
|
||||
assert.ok(
|
||||
!clientGraph.some((f) => /overlay|keyboard/.test(f)),
|
||||
'client.js must not reach the overlay or the keyboard: ' + clientGraph.join(', '),
|
||||
)
|
||||
assert.ok(
|
||||
[...imports('overlay.js')].some((f) => f.startsWith('keyboard/')),
|
||||
'the keyboard is still used by the overlay',
|
||||
)
|
||||
assert.equal(WebRTCTransport.supported(), typeof RTCPeerConnection !== 'undefined')
|
||||
assert.ok(!('OP' in pkg), 'the data channel opcodes are not part of the API')
|
||||
|
||||
// the store: zustand, the one runtime dependency, resolvable from the built package (client.js keeps
|
||||
// the bare specifiers); a selector subscription fires on its value only, updates are immutable, and
|
||||
// the proxy store's API (Store, watch, version) is gone
|
||||
assert.ok(!('Store' in pkg), 'the proxy Store is gone')
|
||||
await import('zustand/vanilla')
|
||||
assert.ok(readFileSync(join(dist, 'client.js'), 'utf8').includes("from 'zustand/vanilla'"))
|
||||
const probe = new pkg.NekoClient({ autologin: false, autoconnect: false })
|
||||
assert.equal(probe.state, probe.store.getState())
|
||||
assert.ok(!('watch' in probe.store) && !('version' in probe.store))
|
||||
const seen = []
|
||||
probe.store.subscribe(
|
||||
(s) => s.control.host_id,
|
||||
(v, old) => seen.push([v, old]),
|
||||
)
|
||||
probe.store.setState({ mobile_keyboard_open: true })
|
||||
assert.deepEqual(seen, [])
|
||||
const before = probe.state
|
||||
probe.store.setState({ session_id: 'h', control: { ...before.control, host_id: 'h' } })
|
||||
assert.deepEqual(seen, [['h', null]])
|
||||
assert.notEqual(probe.state, before)
|
||||
assert.equal(probe.state.video, before.video, 'untouched slices keep their identity')
|
||||
assert.equal(pkg.selectControlling(probe.state), true)
|
||||
assert.equal(probe.controlling, true)
|
||||
|
||||
// Emitter: on() returns the unsubscribe
|
||||
const e = new pkg.Emitter()
|
||||
const got = []
|
||||
const off = e.on('x', (v) => got.push(v))
|
||||
e.emit('x', 1)
|
||||
off()
|
||||
e.emit('x', 2)
|
||||
assert.deepEqual(got, [1])
|
||||
|
||||
// NekoClient: constructs without a connection, setUrl normalises the server URL, and the
|
||||
// transport seam: WebRTC by default (its data channel as the input), or the one given
|
||||
const client = new pkg.NekoClient({ autologin: false, autoconnect: false })
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
client.setUrl('http://neko.test/some/path/')
|
||||
assert.equal(client.api.url, 'http://neko.test/some/path')
|
||||
assert.ok(client.transport instanceof pkg.WebRTCTransport)
|
||||
assert.equal(client.transport.kind, 'webrtc')
|
||||
assert.ok(client.input instanceof pkg.DataChannelInput)
|
||||
const noop = () => {}
|
||||
const custom = {
|
||||
kind: 'hls',
|
||||
element: null,
|
||||
connect: async () => {},
|
||||
close: noop,
|
||||
attach: () => noop,
|
||||
setPlaying: async () => {},
|
||||
setVolume: noop,
|
||||
setMuted: noop,
|
||||
on: () => noop,
|
||||
}
|
||||
const other = new pkg.NekoClient({ transport: custom })
|
||||
assert.equal(other.transport, custom)
|
||||
assert.ok(other.input instanceof pkg.WebSocketInput, 'no input channel on the transport: the websocket')
|
||||
for (const m of ['move', 'scroll', 'button', 'key', 'touch']) assert.equal(typeof other.input[m], 'function', m)
|
||||
assert.throws(() => other.shareMedia({}), /transport cannot send media/)
|
||||
|
||||
// a stream-only consumer: the transport's element in a box, no overlay (no input, no cursor), and
|
||||
// input from a device of its own straight through client.input
|
||||
const box = { children: [], append: (el) => box.children.push(el) }
|
||||
custom.attach = (el) => (el.append('media'), () => el.children.pop())
|
||||
const detach = other.transport.attach(box)
|
||||
assert.deepEqual(box.children, ['media'])
|
||||
detach()
|
||||
assert.deepEqual(box.children, [])
|
||||
other.input.key(0xff0d, true) // a gamepad mapped to Return, say; dropped here: no socket
|
||||
|
||||
// build.mjs rewrote the `.ts` specifiers the declarations inherit from the sources
|
||||
for (const f of readdirSync(dist, { recursive: true }).filter((f) => f.endsWith('.d.ts'))) {
|
||||
const src = readFileSync(join(dist, f), 'utf8')
|
||||
assert.ok(!/from '[^']*\.ts'/.test(src) && !/import\("[^"]*\.ts"\)/.test(src), `${f} still imports a .ts path`)
|
||||
}
|
||||
|
||||
console.log('dist ok')
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@m1k1o/neko",
|
||||
"version": "3.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@m1k1o/neko",
|
||||
"version": "3.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@m1k1o/neko",
|
||||
"version": "3.2.0",
|
||||
"description": "Framework-free client library for the neko v3 API: REST, websocket events, WebRTC with the binary input channel, and the input overlay.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/m1k1o/neko.git",
|
||||
"directory": "client/core"
|
||||
},
|
||||
"homepage": "https://neko.m1k1o.net/",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && node build.mjs",
|
||||
"test": "npm --prefix .. test -- core/src",
|
||||
"check": "npm run build && npm test && node dist.check.mjs"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"zustand": "^5.0.15"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Thin fetch wrapper over the v3 REST API (server/openapi.yaml). The few response shapes the
|
||||
// client reads are declared in types.ts next to the other wire types.
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export class NekoApi {
|
||||
url = location.href.replace(/[?#].*$/, '').replace(/\/+$/, '')
|
||||
token = ''
|
||||
|
||||
private headers(json: boolean): Record<string, string> {
|
||||
const h: Record<string, string> = {}
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (this.token) h['Authorization'] = 'Bearer ' + this.token
|
||||
return h
|
||||
}
|
||||
|
||||
async req<T = void>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(this.url + '/api' + path, {
|
||||
...init,
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: this.headers(body !== undefined),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null)
|
||||
throw new ApiError(res.status, err?.message || res.statusText)
|
||||
}
|
||||
const text = await res.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
|
||||
async blob(path: string): Promise<Blob> {
|
||||
const res = await fetch(this.url + '/api' + path, { credentials: 'include', headers: this.headers(false) })
|
||||
if (!res.ok) throw new ApiError(res.status, res.statusText)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
// XHR because fetch cannot report upload progress
|
||||
upload(path: string, form: FormData, onProgress?: (p: { loaded: number; total: number }) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', this.url + '/api' + path)
|
||||
xhr.withCredentials = true
|
||||
for (const [k, v] of Object.entries(this.headers(false))) xhr.setRequestHeader(k, v)
|
||||
if (onProgress) xhr.upload.onprogress = (e) => onProgress({ loaded: e.loaded, total: e.total })
|
||||
xhr.onload = () => {
|
||||
if (xhr.status < 300) return resolve()
|
||||
let message = xhr.statusText // empty under HTTP/2
|
||||
try {
|
||||
message = JSON.parse(xhr.responseText).message || message
|
||||
} catch {}
|
||||
reject(new ApiError(xhr.status, message || 'upload failed'))
|
||||
}
|
||||
xhr.onerror = () => reject(new ApiError(0, 'network error'))
|
||||
xhr.send(form)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
// Unit tests of the connection state machine (with the real WebRTC transport): `npm test` (vitest).
|
||||
// The browser is faked just far enough for the core to load and connect (test/browser.ts), and the
|
||||
// clock is vitest's: `tick(ms)` fires the timers that fall due, in order, so nothing waits.
|
||||
import { test, vi } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
tick,
|
||||
settle,
|
||||
reset,
|
||||
sent,
|
||||
sentPayloads,
|
||||
sockets,
|
||||
fetches,
|
||||
net,
|
||||
storage,
|
||||
FakePeer,
|
||||
FakeElement,
|
||||
OFFER,
|
||||
profile,
|
||||
session,
|
||||
settings,
|
||||
init,
|
||||
receive,
|
||||
} from './test/browser.ts'
|
||||
const { NekoClient, ApiError } = await import('./index.ts')
|
||||
type Client = InstanceType<typeof NekoClient>
|
||||
type Options = ConstructorParameters<typeof NekoClient>[0]
|
||||
|
||||
const setup = (opts?: Options) => {
|
||||
reset()
|
||||
const client = new NekoClient(opts)
|
||||
const closed: (Error | undefined)[] = [] // every connection.closed, with its error
|
||||
client.events.on('connection.closed', (e) => closed.push(e))
|
||||
return { client, closed }
|
||||
}
|
||||
// a client through the handshake, with `sent` cleared
|
||||
const connected = async (opts?: Options) => {
|
||||
const r = setup(opts)
|
||||
r.client.store.setState({ authenticated: true })
|
||||
r.client.connect()
|
||||
receive('system/init', init())
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
FakePeer.all[0].transition('connected')
|
||||
await settle()
|
||||
assert.equal(r.client.state.connection.status, 'connected')
|
||||
sent.length = 0
|
||||
return r
|
||||
}
|
||||
const messages = (closed: (Error | undefined)[]) => closed.map((e) => e?.message)
|
||||
|
||||
test('handshake: init -> signal/request -> provide -> signal/answer -> peer connected', async () => {
|
||||
const { client } = setup()
|
||||
client.setUrl('http://neko.test/') // as the GUI does at boot
|
||||
assert.throws(() => client.connect(), /not authenticated/)
|
||||
client.store.setState({ authenticated: true, connection: { ...client.state.connection, token: 'tok' } })
|
||||
const statuses: string[] = []
|
||||
client.events.on('connection.status', (s) => statuses.push(s))
|
||||
client.connect()
|
||||
client.connect() // already wanted: not a second socket
|
||||
assert.equal(sockets.length, 1)
|
||||
assert.equal(sockets[0].url, 'ws://neko.test/api/ws?token=tok')
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
|
||||
receive('system/init', init())
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
assert.equal(client.state.session_id, 's1')
|
||||
assert.deepEqual(client.state.screen.size, { width: 1920, height: 1080, rate: 60 })
|
||||
assert.deepEqual(Object.keys(client.state.sessions), ['s1', 's2'])
|
||||
assert.equal(client.session?.profile.name, 'Alice')
|
||||
assert.equal(client.state.control.host_id, 's2')
|
||||
assert.equal(client.state.control.touch, true)
|
||||
assert.equal(client.implicitControl, true)
|
||||
|
||||
receive('signal/candidate', { candidate: 'early' }) // trickled before the offer: held back
|
||||
receive('signal/provide', { sdp: OFFER, iceservers: [] })
|
||||
await settle()
|
||||
assert.deepEqual(sent, ['signal/request', 'signal/answer'])
|
||||
const pc = FakePeer.all[0]
|
||||
assert.deepEqual(pc.remoteDescription, { type: 'offer', sdp: OFFER })
|
||||
assert.deepEqual(pc.candidates, [{ candidate: 'early' }])
|
||||
receive('signal/candidate', { candidate: 'late' })
|
||||
assert.deepEqual(pc.candidates, [{ candidate: 'early' }, { candidate: 'late' }])
|
||||
assert.equal(client.state.connection.status, 'connecting', 'not connected before the media path is')
|
||||
|
||||
pc.transition('connected')
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
assert.equal(client.connected, true)
|
||||
assert.deepEqual(statuses, ['connecting', 'connected'])
|
||||
})
|
||||
|
||||
test('signal/close: the peer is dropped and a new one requested after the backoff', async () => {
|
||||
const { client } = await connected()
|
||||
const pc = FakePeer.all[0]
|
||||
const stopMic = client.shareMedia({ getTracks: () => [{ kind: 'audio' }] } as unknown as MediaStream)
|
||||
receive('signal/close')
|
||||
assert.ok(pc.closed)
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
receive('signal/close') // no peer any more: nothing to drop, no second request
|
||||
await tick(1499)
|
||||
assert.deepEqual(sent, [], 'waits RECONNECT_BACKOFF_MS before asking again')
|
||||
await tick(1)
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.length, 2)
|
||||
assert.deepEqual(sent, ['signal/request', 'signal/answer'])
|
||||
assert.doesNotThrow(stopMic, 'the media shared on the dropped peer is gone with it')
|
||||
FakePeer.all[1].transition('connected')
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
})
|
||||
|
||||
test('peer failed: re-requested with growing backoff, given up after RECONNECT_MAX', async () => {
|
||||
const { client, closed } = await connected()
|
||||
FakePeer.all[0].transition('disconnected') // ICE may still recover
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
assert.equal(FakePeer.all[0].closed, false)
|
||||
for (let n = 1; n <= 10; n++) {
|
||||
sent.length = 0
|
||||
FakePeer.all.at(-1)!.transition('failed')
|
||||
assert.ok(FakePeer.all.at(-1)!.closed)
|
||||
const backoff = 1500 * Math.min(n, 4)
|
||||
await tick(backoff - 1)
|
||||
assert.deepEqual(sent, [], `failure ${n}: waits ${backoff} ms`)
|
||||
await tick(1)
|
||||
assert.deepEqual(sent, ['signal/request'], `failure ${n}`)
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
}
|
||||
assert.deepEqual(closed, [])
|
||||
FakePeer.all.at(-1)!.transition('failed')
|
||||
await settle()
|
||||
assert.deepEqual(messages(closed), ['video connection failed (WebRTC)'])
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
assert.equal(sockets[0].code, 1000)
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 1, 'no reconnect after giving up')
|
||||
})
|
||||
|
||||
test('peer connected: the failure count starts over', async () => {
|
||||
await connected()
|
||||
for (const backoff of [1500, 3000]) {
|
||||
FakePeer.all.at(-1)!.transition('failed')
|
||||
await tick(backoff)
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
}
|
||||
FakePeer.all.at(-1)!.transition('connected')
|
||||
await settle()
|
||||
sent.length = 0
|
||||
FakePeer.all.at(-1)!.transition('failed')
|
||||
await tick(1500)
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
})
|
||||
|
||||
test('no offer after OFFER_TIMEOUT_MS: shown without video; a dead socket is the stale check’s business', async () => {
|
||||
const { client } = await connected()
|
||||
FakePeer.all[0].transition('failed')
|
||||
await tick(1500) // -> signal/request
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
await tick(7999)
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
await tick(1) // OFFER_TIMEOUT_MS: the server will not stream (can_watch is off)
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
assert.equal(FakePeer.all.length, 1)
|
||||
await tick(25_000) // and the silent socket is still replaced by the stale check
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
assert.ok(sockets[0].closed)
|
||||
})
|
||||
|
||||
test('stale socket: silent for STALE_MS it is replaced; a message keeps it', async () => {
|
||||
const { client } = await connected()
|
||||
await tick(20_000)
|
||||
receive('system/heartbeat')
|
||||
await tick(20_000)
|
||||
assert.equal(sockets[0].closed, false, '20 s since the last message is not stale')
|
||||
assert.equal(sockets.length, 1)
|
||||
await tick(10_000) // 30 s since
|
||||
const old = sockets[0]
|
||||
assert.ok(old.closed)
|
||||
assert.equal(FakePeer.all[0].closed, false, 'the stream outlives the socket')
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
assert.equal(fetches.at(-1)?.url, 'http://neko.test/api/whoami')
|
||||
await tick(1500)
|
||||
assert.equal(sockets.length, 2)
|
||||
receive('system/init', init()) // the handshake starts over on the new socket, with a new peer
|
||||
assert.deepEqual(sent, ['client/heartbeat', 'signal/request'])
|
||||
assert.ok(FakePeer.all[0].closed)
|
||||
old.onclose!() // the browser reports the closure of the replaced socket: not a second loss
|
||||
await tick(20_000)
|
||||
assert.deepEqual([sockets.length, fetches.length], [2, 1])
|
||||
})
|
||||
|
||||
test('socket closed: reconnects after the backoff once the session is confirmed', async () => {
|
||||
const { client } = await connected()
|
||||
sockets[0].onclose!()
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
assert.equal(FakePeer.all[0].closed, false)
|
||||
await settle()
|
||||
assert.equal(fetches.at(-1)?.url, 'http://neko.test/api/whoami')
|
||||
await tick(1499)
|
||||
assert.equal(sockets.length, 1)
|
||||
await tick(1)
|
||||
assert.equal(sockets.length, 2)
|
||||
sockets[1].onclose!() // a second loss in a row waits longer
|
||||
await tick(2999)
|
||||
assert.equal(sockets.length, 2)
|
||||
await tick(1)
|
||||
assert.equal(sockets.length, 3)
|
||||
net.respond = () => {
|
||||
throw new TypeError('network') // a lookup that fails for any other reason is still worth a retry
|
||||
}
|
||||
sockets[2].onclose!()
|
||||
await tick(4500)
|
||||
assert.equal(sockets.length, 4)
|
||||
net.respond = () => ({ status: 200 })
|
||||
receive('system/init', init())
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
FakePeer.all.at(-1)!.transition('connected') // the attempt count starts over
|
||||
await settle()
|
||||
sockets[3].onclose!()
|
||||
await tick(1500)
|
||||
assert.equal(sockets.length, 5)
|
||||
})
|
||||
|
||||
test('socket lost: the stream outlives the socket; the reconnect replaces its peer; a final close empties the element', async () => {
|
||||
const { client, closed } = await connected()
|
||||
const box = new FakeElement('div')
|
||||
client.transport.attach(box as any)
|
||||
const video = box.children[0]
|
||||
const stream = { id: 'stream' }
|
||||
FakePeer.all[0].ontrack!({ track: { kind: 'video' }, streams: [stream], receiver: {} })
|
||||
await client.play()
|
||||
video.dispatch('canplaythrough')
|
||||
assert.deepEqual([client.state.video.playable, client.state.video.playing], [true, true])
|
||||
|
||||
sockets[0].onclose!()
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
assert.equal(FakePeer.all[0].closed, false, 'the peer keeps streaming while the socket reconnects')
|
||||
assert.deepEqual([video.srcObject, video.paused], [stream, false], 'the picture stays')
|
||||
FakePeer.all[0].transition('connected') // a report from the stream while the socket is down counts for nothing
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
|
||||
await tick(1500)
|
||||
receive('system/init', init(), sockets[1])
|
||||
assert.ok(FakePeer.all[0].closed, 'the reconnect starts the transport over: the old peer goes')
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
assert.equal(video.srcObject, stream, 'the last picture until the new stream shows')
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.filter((p) => !p.closed).length, 1, 'exactly one live peer')
|
||||
FakePeer.all[1].ontrack!({ track: { kind: 'video' }, streams: [{ id: 'new' }], receiver: {} })
|
||||
await settle()
|
||||
assert.deepEqual([video.srcObject, video.plays], [{ id: 'new' }, 2], 'kept playing')
|
||||
FakePeer.all[1].transition('connected')
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
|
||||
receive('system/disconnect', { message: 'kicked' }) // a final close: black behind the Connect screen
|
||||
assert.deepEqual(messages(closed), ['kicked'])
|
||||
assert.ok(FakePeer.all[1].closed)
|
||||
assert.deepEqual([video.srcObject, video.paused, client.state.video.playable], [null, true, false])
|
||||
})
|
||||
|
||||
test('socket lost while the peer fails: it asks again by itself, which waits for the socket; neither that nor its verdict moves the status', async () => {
|
||||
const { client } = await connected()
|
||||
sockets[0].onclose!()
|
||||
FakePeer.all[0].transition('failed')
|
||||
await tick(1500) // the transport's own retry: signal/request goes to session.send, which has no socket
|
||||
assert.deepEqual(sent, [])
|
||||
assert.equal(sockets.length, 2, 'the socket reconnect is on its own clock')
|
||||
await tick(8000) // no offer: the transport calls it unavailable, which would otherwise read as connected
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
receive('system/init', init(), sockets[1])
|
||||
assert.deepEqual(sent, ['signal/request'], 'one request, from the new session')
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.length, 2)
|
||||
FakePeer.all[1].transition('connected')
|
||||
await settle()
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
sent.length = 0
|
||||
FakePeer.all[1].transition('failed') // the count started over with the new session
|
||||
await tick(1499)
|
||||
assert.deepEqual(sent, [])
|
||||
await tick(1)
|
||||
assert.deepEqual(sent, ['signal/request'])
|
||||
})
|
||||
|
||||
test('socket closed: a deleted session (401) is not retried', async () => {
|
||||
const { client, closed } = await connected()
|
||||
net.respond = () => ({ status: 401, body: { message: 'session not found' } })
|
||||
sockets[0].onclose!()
|
||||
await settle()
|
||||
assert.deepEqual(messages(closed), ['session expired'])
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 1)
|
||||
})
|
||||
|
||||
test('socket closed: given up after RECONNECT_MAX attempts', async () => {
|
||||
const { client, closed } = await connected()
|
||||
for (let n = 1; n <= 10; n++) {
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(6000)
|
||||
assert.equal(sockets.length, n + 1, `attempt ${n}`)
|
||||
}
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 11)
|
||||
assert.deepEqual(messages(closed), ['connection lost'])
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
})
|
||||
|
||||
test('disconnect: closes socket and peer, clears the room state, cancels every timer', async () => {
|
||||
const { client, closed } = await connected()
|
||||
client.disconnect()
|
||||
assert.equal(sockets[0].code, 1000, 'a normal closure, or the server keeps the session for 5 s')
|
||||
assert.ok(FakePeer.all[0].closed)
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
assert.equal(client.state.session_id, null)
|
||||
assert.deepEqual(client.state.sessions, {})
|
||||
assert.equal(client.state.control.host_id, null)
|
||||
assert.deepEqual(closed, [undefined])
|
||||
assert.equal(vi.getTimerCount(), 0)
|
||||
sockets[0].onclose!() // the server's side of the closure
|
||||
await tick(60_000)
|
||||
assert.deepEqual([sockets.length, fetches.length], [1, 0])
|
||||
|
||||
client.connect() // and connecting again works
|
||||
assert.equal(sockets.length, 2)
|
||||
sockets[1].onclose!()
|
||||
await settle() // the session lookup came back: a reconnect is scheduled
|
||||
client.disconnect()
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 2, 'a disconnect cancels the pending reconnect')
|
||||
|
||||
client.connect()
|
||||
sockets[2].onclose!()
|
||||
client.disconnect() // while the lookup is still running
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 3)
|
||||
|
||||
client.connect()
|
||||
sockets[3].onclose!()
|
||||
client.disconnect()
|
||||
client.connect() // reconnected by hand while the old lookup is still running
|
||||
assert.equal(sockets.length, 5)
|
||||
await tick(10_000) // the old lookup comes back and would reconnect after 1.5 s
|
||||
assert.equal(sockets.length, 5, 'the old lookup must not replace the new connection')
|
||||
assert.equal(sockets[4].closed, false)
|
||||
assert.deepEqual(closed, [undefined, undefined, undefined, undefined])
|
||||
})
|
||||
|
||||
test('system/heartbeat is answered with client/heartbeat', async () => {
|
||||
await connected()
|
||||
receive('system/heartbeat')
|
||||
assert.deepEqual(sent, ['client/heartbeat'])
|
||||
})
|
||||
|
||||
test('codec mismatch: the offer is refused naming the codec, no retry', async () => {
|
||||
const { client, closed } = setup()
|
||||
client.store.setState({ authenticated: true })
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
FakePeer.answerSdp = 'v=0\r\nm=audio 9 RTP/AVP 0\r\nm=video 0 RTP/AVP 96\r\n' // the browser rejected the video section
|
||||
receive('signal/provide', { sdp: OFFER.replace('VP8', 'H264') })
|
||||
await settle()
|
||||
assert.deepEqual(sent, ['signal/request'], 'no answer')
|
||||
assert.deepEqual(messages(closed), ['this browser cannot play H264 video'])
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
assert.equal(sockets[0].code, 1000)
|
||||
await tick(60_000)
|
||||
assert.deepEqual([sockets.length, FakePeer.all.length], [1, 1])
|
||||
})
|
||||
|
||||
test('system/disconnect (the session opened in another tab): closed with the message, no reconnect', async () => {
|
||||
const { client, closed } = await connected()
|
||||
receive('system/disconnect', { message: 'connection replaced' })
|
||||
assert.deepEqual(messages(closed), ['connection replaced'])
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
assert.equal(client.state.session_id, null)
|
||||
assert.equal(sockets[0].code, 1000)
|
||||
assert.ok(FakePeer.all[0].closed)
|
||||
sockets[0].onclose!() // the server closes the socket right after
|
||||
await tick(60_000)
|
||||
assert.deepEqual([sockets.length, fetches.length], [1, 0])
|
||||
})
|
||||
|
||||
test('session, control, screen, clipboard and settings events update the state', async () => {
|
||||
const { client } = await connected()
|
||||
const got: string[] = []
|
||||
const names = [
|
||||
'session.created',
|
||||
'session.updated',
|
||||
'session.deleted',
|
||||
'room.control.host',
|
||||
'room.screen.updated',
|
||||
'room.clipboard.updated',
|
||||
'room.settings.updated',
|
||||
'room.broadcast.status',
|
||||
'receive.unicast',
|
||||
'receive.broadcast',
|
||||
'message',
|
||||
] as const
|
||||
for (const n of names) client.events.on(n, (...a: unknown[]) => got.push(n + ' ' + JSON.stringify(a)))
|
||||
|
||||
receive('session/created', session('s3', { name: 'Carol' }))
|
||||
assert.equal(client.state.sessions.s3.profile.name, 'Carol')
|
||||
receive('session/profile', { id: 's3', ...profile({ name: 'Caroline' }) })
|
||||
assert.equal(client.state.sessions.s3.profile.name, 'Caroline')
|
||||
receive('session/state', { id: 's3', is_connected: true, is_watching: true })
|
||||
assert.equal(client.state.sessions.s3.state.is_watching, true)
|
||||
receive('session/profile', { id: 'nobody', ...profile() }) // unknown session: ignored
|
||||
receive('session/state', { id: 'nobody', is_connected: true, is_watching: true })
|
||||
assert.equal('nobody' in client.state.sessions, false)
|
||||
receive('session/deleted', { id: 's3' })
|
||||
assert.equal('s3' in client.state.sessions, false)
|
||||
receive('session/profile', { id: 's1', ...profile({ is_admin: true }) }) // our own profile
|
||||
assert.equal(client.isAdmin, true)
|
||||
|
||||
assert.equal(client.controlling, false)
|
||||
receive('control/host', { id: 's1', has_host: true, host_id: 's1' })
|
||||
assert.equal(client.state.control.host_id, 's1')
|
||||
assert.equal(client.controlling, true)
|
||||
await settle()
|
||||
assert.deepEqual(sent, ['keyboard/map'], 'the keyboard layout is sent on taking control')
|
||||
receive('control/host', { id: 's1', has_host: false })
|
||||
assert.equal(client.state.control.host_id, null)
|
||||
receive('control/request', { id: 's2' })
|
||||
|
||||
receive('screen/updated', { id: 's2', width: 1280, height: 720, rate: 30 })
|
||||
assert.deepEqual(client.state.screen.size, { width: 1280, height: 720, rate: 30 })
|
||||
receive('clipboard/updated', { text: 'hi' })
|
||||
assert.deepEqual(client.state.control.clipboard, { text: 'hi' })
|
||||
receive('system/settings', { id: 's2', ...settings({ private_mode: true }) })
|
||||
assert.equal(client.state.settings.private_mode, true)
|
||||
assert.equal(client.privateModeEnabled, false, 'admins see through private mode')
|
||||
receive('system/admin', {
|
||||
screen_sizes_list: [
|
||||
{ width: 1280, height: 720, rate: 30 },
|
||||
{ width: 1920, height: 1080, rate: 30 },
|
||||
{ width: 1920, height: 1080, rate: 60 },
|
||||
],
|
||||
broadcast_status: { is_active: true, url: 'rtmp://x' },
|
||||
})
|
||||
assert.deepEqual(
|
||||
client.state.screen.configurations.map((c) => `${c.width}x${c.height}@${c.rate}`),
|
||||
['1920x1080@60', '1920x1080@30', '1280x720@30'],
|
||||
)
|
||||
receive('send/unicast', { sender: 's2', subject: 'hey', body: 1 })
|
||||
receive('send/broadcast', { sender: 's2', subject: 'all', body: [2] })
|
||||
receive('chat/message', { text: 'x' }) // a plugin event
|
||||
assert.deepEqual(got, [
|
||||
'session.created ["s3"]',
|
||||
'session.updated ["s3"]',
|
||||
'session.updated ["s3"]',
|
||||
'session.deleted ["s3"]',
|
||||
'session.updated ["s1"]',
|
||||
'room.control.host [true,"s1","s1"]',
|
||||
'room.control.host [false,null,"s1"]',
|
||||
'room.screen.updated [1280,720,30,"s2"]',
|
||||
'room.clipboard.updated ["hi"]',
|
||||
`room.settings.updated [${JSON.stringify(settings({ private_mode: true }))},"s2"]`,
|
||||
'room.broadcast.status [true,"rtmp://x"]',
|
||||
'receive.unicast ["s2","hey",1]',
|
||||
'receive.broadcast ["s2","all",[2]]',
|
||||
'message ["chat/message",{"text":"x"}]',
|
||||
])
|
||||
})
|
||||
|
||||
test('preparePaste: the side that copied most recently wins', async () => {
|
||||
const { client } = await connected()
|
||||
receive('clipboard/updated', { text: 'remote' })
|
||||
for (const text of ['remote', '']) {
|
||||
const p = client.preparePaste(text) // the remote clipboard's own text, or nothing: nothing to do
|
||||
await settle()
|
||||
assert.deepEqual(sent, [], `"${text}" is not sent`)
|
||||
await p
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const p = client.preparePaste('local').then(() => (settled = true)) // newer than anything the remote reported
|
||||
await settle()
|
||||
assert.deepEqual([sent, settled], [['clipboard/set'], false])
|
||||
receive('clipboard/updated', { text: 'local' }) // resolves once the server confirms
|
||||
await p
|
||||
|
||||
await tick(1)
|
||||
receive('clipboard/updated', { text: 'remote2' }) // the remote copied since: the local text is older
|
||||
sent.length = 0
|
||||
const older = client.preparePaste('local')
|
||||
await settle()
|
||||
assert.deepEqual(sent, [])
|
||||
await older
|
||||
const same = client.preparePaste('remote2') // on https the remote text is in the local clipboard too: nothing to send
|
||||
await settle()
|
||||
assert.deepEqual(sent, [])
|
||||
await same
|
||||
|
||||
settled = false
|
||||
client.preparePaste('other').then(() => (settled = true)) // nothing changed remotely since that look
|
||||
await tick(499)
|
||||
assert.deepEqual([sent, settled], [['clipboard/set'], false])
|
||||
await tick(1) // no confirmation: the wait ends after 500 ms
|
||||
assert.equal(settled, true)
|
||||
})
|
||||
|
||||
test('login, authenticate, logout: the token and the session', async () => {
|
||||
const { client } = setup({ autologin: true })
|
||||
client.setUrl('http://neko.test/')
|
||||
net.respond = (url, init) =>
|
||||
url.endsWith('/api/login') && JSON.parse(init.body as string).password === 'pw'
|
||||
? { status: 200, body: { ...session('s1'), token: 'tok' } }
|
||||
: { status: 401, body: { message: 'invalid password' } }
|
||||
await assert.rejects(
|
||||
client.login('admin', 'wrong'),
|
||||
(e: unknown) => e instanceof ApiError && e.status === 401 && e.message === 'invalid password',
|
||||
)
|
||||
assert.equal(client.state.authenticated, false)
|
||||
await client.login('admin', 'pw')
|
||||
assert.equal(fetches.at(-1)?.url, 'http://neko.test/api/login')
|
||||
assert.equal(client.state.authenticated, true)
|
||||
assert.equal(client.api.token, 'tok')
|
||||
assert.equal(storage.get('neko_session'), 'tok')
|
||||
|
||||
net.respond = () => ({ status: 200, body: session('s1') })
|
||||
await client.authenticate() // whoami carries the token
|
||||
assert.equal(fetches.at(-1)?.url, 'http://neko.test/api/whoami')
|
||||
assert.equal((fetches.at(-1)?.init.headers as Record<string, string>).Authorization, 'Bearer tok')
|
||||
client.connect()
|
||||
assert.equal(sockets[0].url, 'ws://neko.test/api/ws?token=tok')
|
||||
|
||||
await client.logout()
|
||||
assert.equal(fetches.at(-1)?.url, 'http://neko.test/api/logout')
|
||||
assert.equal(client.state.authenticated, false)
|
||||
assert.equal(client.api.token, '')
|
||||
assert.equal(storage.has('neko_session'), false)
|
||||
assert.equal(sockets[0].code, 1000, 'logging out closes the connection')
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
})
|
||||
|
||||
test('authenticate: resumes the saved session, or fails with the ApiError', async () => {
|
||||
const { client } = setup({ autologin: true, autoconnect: true })
|
||||
storage.set('neko_session', 'saved')
|
||||
client.setUrl('http://neko.test/some/path/')
|
||||
await settle()
|
||||
assert.equal(fetches[0].url, 'http://neko.test/some/path/api/whoami')
|
||||
assert.equal((fetches[0].init.headers as Record<string, string>).Authorization, 'Bearer saved')
|
||||
assert.equal(client.state.authenticated, true)
|
||||
assert.equal(sockets[0]?.url, 'ws://neko.test/some/path/api/ws?token=saved', 'autoconnect')
|
||||
|
||||
const expired = setup({ autologin: true }).client
|
||||
storage.set('neko_session', 'expired')
|
||||
net.respond = () => ({ status: 401, body: { message: 'unauthorized' } })
|
||||
await assert.rejects(expired.authenticate(), (e: unknown) => e instanceof ApiError && e.status === 401)
|
||||
assert.equal(expired.state.authenticated, false)
|
||||
assert.throws(() => expired.connect(), /not authenticated/)
|
||||
})
|
||||
|
||||
test('send: dropped while the socket is not open', async () => {
|
||||
const { client } = setup()
|
||||
client.send('chat/message', { text: 'x' }) // no socket at all
|
||||
client.store.setState({ authenticated: true })
|
||||
client.connect()
|
||||
sockets[0].readyState = 0 // still connecting
|
||||
client.send('chat/message', { text: 'x' })
|
||||
assert.deepEqual(sent, [])
|
||||
sockets[0].readyState = 1
|
||||
client.send('chat/message', { text: 'x' })
|
||||
assert.deepEqual(sent, ['chat/message'])
|
||||
client.disconnect()
|
||||
client.send('chat/message', { text: 'x' })
|
||||
assert.deepEqual(sent, ['chat/message'])
|
||||
})
|
||||
|
||||
test('subscriptions: the keyboard map goes out when control or the layout changes, once per change, never for the rest', async () => {
|
||||
const { client } = await connected() // Bob (s2) is host
|
||||
sentPayloads.length = 0
|
||||
const statuses: string[] = []
|
||||
client.events.on('connection.status', (s) => statuses.push(s))
|
||||
client.setKeyboard('de')
|
||||
assert.deepEqual(sent, [], 'not host: nothing')
|
||||
receive('control/host', { id: 's1', has_host: true, host_id: 's1' })
|
||||
assert.deepEqual(sent, ['keyboard/map'])
|
||||
assert.deepEqual(sentPayloads.at(-1)?.payload, { layout: 'de', variant: '' })
|
||||
client.setKeyboard('de') // the same layout again: not resent
|
||||
receive('screen/updated', { id: 's2', width: 1280, height: 720, rate: 30 }) // unrelated changes
|
||||
receive('clipboard/updated', { text: 'x' })
|
||||
receive('session/profile', { id: 's1', ...profile({ name: 'Alicia' }) })
|
||||
client.lock()
|
||||
assert.deepEqual(sent, ['keyboard/map'])
|
||||
client.setKeyboard('de', 'nodeadkeys')
|
||||
assert.deepEqual(
|
||||
sentPayloads.map((m) => m.payload),
|
||||
[
|
||||
{ layout: 'de', variant: '' },
|
||||
{ layout: 'de', variant: 'nodeadkeys' },
|
||||
],
|
||||
)
|
||||
receive('control/host', { id: 's1', has_host: false })
|
||||
assert.equal(sent.length, 2, 'losing control sends nothing')
|
||||
assert.deepEqual(statuses, [], 'none of it is a status change')
|
||||
|
||||
// what a member list selects: the sessions object is replaced by a session event, by nothing else
|
||||
let lists = 0
|
||||
client.store.subscribe(
|
||||
(s) => s.sessions,
|
||||
() => lists++,
|
||||
)
|
||||
receive('session/created', session('s3', { name: 'Carol' }))
|
||||
receive('session/profile', { id: 's3', ...profile({ name: 'Caroline' }) })
|
||||
receive('session/state', { id: 's3', is_connected: false, is_watching: false })
|
||||
receive('session/deleted', { id: 's3' })
|
||||
assert.equal(lists, 4)
|
||||
receive('control/host', { id: 's2', has_host: true, host_id: 's2' })
|
||||
receive('screen/updated', { id: 's2', width: 1920, height: 1080, rate: 60 })
|
||||
client.unlock()
|
||||
assert.equal(lists, 4)
|
||||
|
||||
// one message that sets several fields at once (system/init on a reconnect that keeps us host:
|
||||
// session_id and host_id together) is observed once, with the final state
|
||||
sockets[0].onclose!()
|
||||
await tick(1500)
|
||||
sent.length = 0
|
||||
receive('system/init', { ...init(), control_host: { has_host: true, host_id: 's1' } }, sockets[1])
|
||||
assert.deepEqual(sent.sort(), ['keyboard/map', 'signal/request'])
|
||||
assert.deepEqual(statuses, ['connecting'], 'the lost socket; init itself does not change the status')
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
// The seam between NekoClient and a StreamTransport, with a fake transport: what the client hands
|
||||
// over, how the connection status follows the transport, and the media calls it delegates.
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { tick, settle, reset, sent, sentPayloads, sockets, init, receive, FakeElement } from './test/browser.ts'
|
||||
|
||||
const { NekoClient, Emitter, WebSocketInput, mount } = await import('./index.ts')
|
||||
type Options = ConstructorParameters<typeof NekoClient>[0]
|
||||
type Transport = NonNullable<Exclude<NonNullable<Options>['transport'], () => unknown>>
|
||||
type SessionInfo = Parameters<Transport['connect']>[0]
|
||||
type TransportState = Parameters<Parameters<Transport['on']>[1]>[0]
|
||||
|
||||
class FakeTransport implements Transport {
|
||||
readonly kind = 'ws-mse' as const
|
||||
element = null
|
||||
input?: Transport['input']
|
||||
shareMedia?: Transport['shareMedia']
|
||||
calls: string[] = []
|
||||
session: SessionInfo | null = null
|
||||
startError: Error | null = null
|
||||
readonly events = new Emitter<any>()
|
||||
video = { playable: false, playing: false, volume: 1, muted: false, mutedByAutoplay: false }
|
||||
async connect(session: SessionInfo) {
|
||||
this.calls.push('connect')
|
||||
this.session = session
|
||||
if (this.startError) throw this.startError
|
||||
}
|
||||
close() {
|
||||
this.calls.push('close')
|
||||
}
|
||||
attach(box: HTMLElement) {
|
||||
this.calls.push('attach')
|
||||
const video = new FakeElement('video')
|
||||
;(box as unknown as FakeElement).append(video)
|
||||
return () => {
|
||||
this.calls.push('detach')
|
||||
video.remove()
|
||||
}
|
||||
}
|
||||
async setPlaying(on: boolean) {
|
||||
this.calls.push(`setPlaying ${on}`)
|
||||
}
|
||||
setVolume(v: number) {
|
||||
this.calls.push(`setVolume ${v}`)
|
||||
}
|
||||
setMuted(on: boolean) {
|
||||
this.calls.push(`setMuted ${on}`)
|
||||
}
|
||||
on = (event: string, cb: (...a: any[]) => void) => this.events.on(event, cb)
|
||||
// what the transport reports
|
||||
state(status: TransportState['status'], video = this.video) {
|
||||
this.events.emit('state', { status, size: { width: 0, height: 0 }, video })
|
||||
}
|
||||
fail(err: Error) {
|
||||
this.events.emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (opts: Options = {}) => {
|
||||
reset()
|
||||
const t = new FakeTransport()
|
||||
const client = new NekoClient({ transport: t, ...opts })
|
||||
client.setUrl('http://neko.test/') // closes whatever was there, as always
|
||||
t.calls.length = 0
|
||||
const closed: (Error | undefined)[] = []
|
||||
client.events.on('connection.closed', (e) => closed.push(e))
|
||||
const statuses: string[] = []
|
||||
client.events.on('connection.status', (s) => statuses.push(s))
|
||||
client.store.setState({ authenticated: true, connection: { ...client.state.connection, token: 'tok' } })
|
||||
return { t, client, closed, statuses }
|
||||
}
|
||||
|
||||
test('connect: the transport gets the session (url, token, init, send, on) and nothing else', async () => {
|
||||
const { t, client } = setup()
|
||||
client.connect()
|
||||
assert.deepEqual(t.calls, [], 'not before the server introduced the session')
|
||||
receive('system/init', init())
|
||||
assert.deepEqual(t.calls, ['connect'])
|
||||
const s = t.session!
|
||||
assert.deepEqual(Object.keys(s).sort(), ['init', 'on', 'send', 'token', 'url'])
|
||||
assert.equal(s.url, 'http://neko.test')
|
||||
assert.equal(s.token, 'tok')
|
||||
assert.deepEqual(s.init, init())
|
||||
s.send('signal/request', { video: {} })
|
||||
assert.deepEqual(sentPayloads.at(-1), { event: 'signal/request', payload: { video: {} } })
|
||||
|
||||
const got: unknown[] = []
|
||||
const messages: string[] = []
|
||||
client.events.on('message', (e) => messages.push(e))
|
||||
const off = s.on('signal/provide', (p) => got.push(p))
|
||||
const before = JSON.stringify(client.state)
|
||||
receive('signal/provide', { sdp: 'x' })
|
||||
receive('signal/video', { video: 'hd' })
|
||||
assert.deepEqual(got, [{ sdp: 'x' }])
|
||||
assert.equal(JSON.stringify(client.state), before, 'signal/* is the transport’s: the client does not act on it')
|
||||
assert.deepEqual(messages, ['signal/provide', 'signal/video'], 'but passes it on like any event it does not handle')
|
||||
receive('chat/message', { text: 'hi' })
|
||||
assert.deepEqual(messages, ['signal/provide', 'signal/video', 'chat/message'])
|
||||
off()
|
||||
receive('signal/provide', { sdp: 'y' })
|
||||
assert.equal(got.length, 1)
|
||||
})
|
||||
|
||||
test('status: connected only when the socket and the transport are; a transport error closes', async () => {
|
||||
const { t, client, closed, statuses } = setup()
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
t.state('connecting')
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
t.state('connected')
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
await settle()
|
||||
t.state('connecting') // the media path hiccups
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
await settle()
|
||||
t.state('unavailable') // the server will not stream: the room without video
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
await settle()
|
||||
|
||||
sockets[0].onclose!()
|
||||
assert.deepEqual(t.calls, ['connect'], 'the lost socket leaves the transport alone')
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
t.state('connected') // whatever it reports until the socket is back counts for nothing
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
t.state('disconnected')
|
||||
assert.equal(client.state.connection.status, 'connecting')
|
||||
await tick(1500)
|
||||
receive('system/init', init(), sockets[1])
|
||||
assert.deepEqual(t.calls, ['connect', 'connect'], 'the new socket starts the transport over')
|
||||
t.state('connected')
|
||||
await settle()
|
||||
// every change, as it happens (the proxy store used to merge connect() and the first 'connected')
|
||||
assert.deepEqual(statuses, ['connecting', 'connected', 'connecting', 'connected', 'connecting', 'connected'])
|
||||
|
||||
t.fail(new Error('video connection failed (WebRTC)'))
|
||||
assert.deepEqual(
|
||||
closed.map((e) => e?.message),
|
||||
['video connection failed (WebRTC)'],
|
||||
)
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
assert.equal(sockets[1].code, 1000)
|
||||
assert.deepEqual(t.calls.at(-1), 'close')
|
||||
t.state('connected') // a late report from a closed transport changes nothing
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
await tick(60_000)
|
||||
assert.equal(sockets.length, 2, 'no reconnect after an error')
|
||||
})
|
||||
|
||||
test('a transport that cannot start closes the connection with its error', async () => {
|
||||
const { t, client, closed } = setup()
|
||||
t.startError = new Error('WebCodecs not supported')
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
await settle()
|
||||
assert.deepEqual(
|
||||
closed.map((e) => e?.message),
|
||||
['WebCodecs not supported'],
|
||||
)
|
||||
assert.equal(client.state.connection.status, 'disconnected')
|
||||
})
|
||||
|
||||
test('socket attempts start over when the transport connects, not when the server declines media', async () => {
|
||||
const { t, client } = setup()
|
||||
client.connect()
|
||||
for (let n = 1; n <= 10; n++) {
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(6000)
|
||||
assert.equal(sockets.length, n + 1, `attempt ${n}`)
|
||||
}
|
||||
receive('system/init', init())
|
||||
t.state('unavailable')
|
||||
assert.equal(client.state.connection.status, 'connected')
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(60_000)
|
||||
assert.equal(client.state.connection.status, 'disconnected', 'the 11th loss: given up, as without a transport')
|
||||
|
||||
const second = setup()
|
||||
second.client.connect()
|
||||
for (let n = 1; n <= 10; n++) {
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(6000)
|
||||
}
|
||||
receive('system/init', init())
|
||||
second.t.state('connected')
|
||||
sockets.at(-1)!.onclose!()
|
||||
await tick(6000)
|
||||
assert.equal(sockets.length, 12, 'a connected transport reset the count')
|
||||
})
|
||||
|
||||
test('disconnect closes the transport; the overlay channel is the transport’s input or the websocket', async () => {
|
||||
const { t, client } = setup()
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
client.disconnect()
|
||||
assert.deepEqual(t.calls, ['connect', 'close'])
|
||||
assert.ok(client.input instanceof WebSocketInput)
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
assert.deepEqual(t.calls, ['connect', 'close', 'connect'])
|
||||
|
||||
reset()
|
||||
const own = new FakeTransport()
|
||||
const keys: [number, boolean][] = []
|
||||
own.input = { move() {}, scroll() {}, button() {}, key: (keysym, down) => keys.push([keysym, down]), touch() {} }
|
||||
const c2 = new NekoClient({ transport: () => own })
|
||||
assert.equal(c2.input, own.input, 'the transport’s own channel wins')
|
||||
assert.equal(c2.transport, own, 'a factory is called once')
|
||||
c2.input.key(0xff0d, true)
|
||||
assert.deepEqual(keys, [[0xff0d, true]])
|
||||
assert.deepEqual(sent, [])
|
||||
})
|
||||
|
||||
test('play, pause, mute, unmute, volume and shareMedia go through the transport; the video state mirrors it', async () => {
|
||||
const { t, client } = setup()
|
||||
await client.play()
|
||||
client.pause()
|
||||
client.mute()
|
||||
client.unmute()
|
||||
client.setVolume(1.5)
|
||||
client.setVolume(-1)
|
||||
client.setVolume(0.3)
|
||||
assert.deepEqual(t.calls, [
|
||||
'setPlaying true',
|
||||
'setPlaying false',
|
||||
'setMuted true',
|
||||
'setMuted false',
|
||||
'setVolume 1',
|
||||
'setVolume 0',
|
||||
'setVolume 0.3',
|
||||
])
|
||||
const stream = {} as MediaStream
|
||||
assert.throws(() => client.shareMedia(stream), /transport cannot send media/)
|
||||
t.shareMedia = (s) => {
|
||||
t.calls.push(`shareMedia ${s === stream}`)
|
||||
return () => t.calls.push('stop')
|
||||
}
|
||||
client.shareMedia(stream)()
|
||||
assert.deepEqual(t.calls.slice(7), ['shareMedia true', 'stop'])
|
||||
|
||||
t.state('disconnected', { playable: true, playing: true, volume: 0.3, muted: true, mutedByAutoplay: true })
|
||||
assert.deepEqual(client.state.video, {
|
||||
playable: true,
|
||||
playing: true,
|
||||
volume: 0.3,
|
||||
muted: true,
|
||||
mutedByAutoplay: true,
|
||||
})
|
||||
t.state('disconnected', { playable: true, playing: false, volume: 0.3, muted: false, mutedByAutoplay: false })
|
||||
assert.deepEqual(client.state.video, {
|
||||
playable: true,
|
||||
playing: false,
|
||||
volume: 0.3,
|
||||
muted: false,
|
||||
mutedByAutoplay: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('autoplay: the client starts playback whenever the transport’s stream becomes playable', async () => {
|
||||
const { t, client } = setup({ autoplay: true })
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
const video = { playable: false, playing: false, volume: 1, muted: false, mutedByAutoplay: false }
|
||||
t.state('connected', video)
|
||||
assert.deepEqual(t.calls, ['connect'], 'not before a frame can be shown')
|
||||
t.state('connected', { ...video, playable: true })
|
||||
assert.deepEqual(t.calls, ['connect', 'setPlaying true'])
|
||||
t.state('connected', { ...video, playable: true, playing: true }) // still playable: not again
|
||||
t.state('connected', { ...video, playable: true, playing: false }) // paused by the user: not again
|
||||
assert.deepEqual(t.calls, ['connect', 'setPlaying true'])
|
||||
t.state('connected', video) // a new stream is loading (reconnect)...
|
||||
t.state('connected', { ...video, playable: true })
|
||||
assert.deepEqual(t.calls, ['connect', 'setPlaying true', 'setPlaying true'])
|
||||
|
||||
const off = setup()
|
||||
off.t.state('connected', { ...video, playable: true })
|
||||
assert.deepEqual(off.t.calls, [], 'without the option the user presses play')
|
||||
})
|
||||
|
||||
test('socket lost: playback that was running resumes on the new stream, autoplay or not', async () => {
|
||||
const { t, client } = setup()
|
||||
const video = { playable: true, playing: true, volume: 1, muted: false, mutedByAutoplay: false }
|
||||
const loading = { ...video, playable: false, playing: false }
|
||||
client.connect()
|
||||
receive('system/init', init())
|
||||
t.state('connected', video)
|
||||
sockets[0].onclose!()
|
||||
t.state('disconnected', loading) // the stream died during the outage (the server dropped the peer)
|
||||
await tick(1500)
|
||||
receive('system/init', init(), sockets[1])
|
||||
t.state('connected', loading)
|
||||
t.state('connected', { ...video, playing: false })
|
||||
assert.deepEqual(t.calls, ['connect', 'connect', 'setPlaying true'])
|
||||
t.state('connected', video)
|
||||
|
||||
sockets[1].onclose!() // and again
|
||||
t.state('disconnected', loading)
|
||||
await tick(1500)
|
||||
receive('system/init', init(), sockets[2])
|
||||
t.state('connected', loading)
|
||||
t.state('connected', { ...video, playing: false })
|
||||
assert.deepEqual(t.calls.slice(3), ['connect', 'setPlaying true'], 'the first stream after the loss')
|
||||
t.state('connected', loading) // a replaced peer brings another stream: the transport's business
|
||||
t.state('connected', { ...video, playing: false })
|
||||
assert.deepEqual(t.calls.slice(5), [], 'a later stream does not')
|
||||
|
||||
t.state('connected', video)
|
||||
sockets[2].onclose!() // lost while playing...
|
||||
t.state('disconnected', loading)
|
||||
client.disconnect() // ...and given up on before the stream came back: the next one waits for play (or autoplay)
|
||||
client.connect()
|
||||
receive('system/init', init(), sockets[3])
|
||||
t.state('connected', loading)
|
||||
t.state('connected', { ...video, playing: false })
|
||||
assert.deepEqual(t.calls.slice(5), ['close', 'connect'], 'the one close is the final one')
|
||||
})
|
||||
|
||||
test('mount(client, el): attaches the transport, then the overlay, into a box in el; the returned function detaches both', () => {
|
||||
const { t, client } = setup()
|
||||
const el = new FakeElement('div')
|
||||
const unmount = mount(client, el as any)
|
||||
assert.deepEqual(t.calls, ['attach'])
|
||||
assert.equal(el.children.length, 1, 'the letterboxed box')
|
||||
assert.deepEqual(
|
||||
el.children[0].children.map((c) => c.tag),
|
||||
['video', 'div'],
|
||||
'the stream first, the overlay over it',
|
||||
)
|
||||
unmount()
|
||||
assert.deepEqual(t.calls, ['attach', 'detach'])
|
||||
assert.deepEqual(el.children, [])
|
||||
mount(client, el as any) // and again
|
||||
assert.deepEqual(t.calls, ['attach', 'detach', 'attach'])
|
||||
})
|
||||
@@ -0,0 +1,580 @@
|
||||
// Framework-free neko client for the v3 API: REST (server/openapi.yaml), the /api/ws event
|
||||
// protocol, and the stream through a StreamTransport (WebRTC by default, see transport.ts).
|
||||
// Written against master's server/pkg/types. The state lives in a zustand store (`client.store`,
|
||||
// immutable updates, subscribe with a selector) and is read as `client.state`; one-off happenings
|
||||
// arrive on `client.events`. Input devices (the keyboard/mouse Overlay, or anything else) are
|
||||
// attached by the consumer and send through `client.input`.
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { Emitter } from './emitter.ts'
|
||||
import { NekoApi, ApiError } from './api.ts'
|
||||
import { WebRTCTransport } from './transport/webrtc.ts'
|
||||
import { WebSocketInput } from './input/websocket.ts'
|
||||
import type { InputChannel, SessionInfo, StreamTransport, TransportState } from './transport.ts'
|
||||
import type { State, Settings, ScreenSize, NekoEvents, LoginResponse } from './types.ts'
|
||||
|
||||
const RECONNECT_MAX = 10
|
||||
const RECONNECT_BACKOFF_MS = 1500
|
||||
// a socket that stays silent this long is dead (the server heartbeats every ~10s)
|
||||
const STALE_MS = 25_000
|
||||
|
||||
export interface NekoClientOptions {
|
||||
// remember the session token in localStorage
|
||||
autologin?: boolean
|
||||
// connect as soon as setUrl() finds a valid session
|
||||
autoconnect?: boolean
|
||||
autoplay?: boolean
|
||||
inputMode?: 'auto' | 'touch' | 'mouse'
|
||||
// how the stream arrives; WebRTC unless given
|
||||
transport?: StreamTransport | (() => StreamTransport)
|
||||
}
|
||||
|
||||
export interface Pos {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const defaultSettings = (): Settings => ({
|
||||
private_mode: false,
|
||||
locked_logins: false,
|
||||
locked_controls: false,
|
||||
control_protection: false,
|
||||
implicit_hosting: false,
|
||||
inactive_cursors: false,
|
||||
merciful_reconnect: false,
|
||||
})
|
||||
|
||||
const initialState = (): State => ({
|
||||
authenticated: false,
|
||||
connection: { url: location.href, status: 'disconnected' },
|
||||
video: { playable: false, playing: false, volume: 1, muted: false, mutedByAutoplay: false },
|
||||
control: {
|
||||
host_id: null,
|
||||
locked: false,
|
||||
clipboard: null,
|
||||
scroll: { inverse: true, sensitivity: 0 },
|
||||
keyboard: { layout: 'us', variant: '' },
|
||||
touch: false,
|
||||
},
|
||||
screen: { size: { width: 1280, height: 720, rate: 30 }, configurations: [] },
|
||||
session_id: null,
|
||||
sessions: {},
|
||||
settings: defaultSettings(),
|
||||
mobile_keyboard_open: false,
|
||||
})
|
||||
|
||||
// the top-level slices that are objects, for patch()
|
||||
type Slice = 'connection' | 'video' | 'control' | 'screen'
|
||||
|
||||
// selectors over the state: what the client's getters compute, usable with store.subscribe and
|
||||
// a UI's selector hook (`useStore(client.store, selectControlling)`)
|
||||
export const selectSession = (s: State) => (s.session_id ? (s.sessions[s.session_id] ?? null) : null)
|
||||
export const selectControlling = (s: State) => s.control.host_id !== null && s.control.host_id === s.session_id
|
||||
export const selectIsAdmin = (s: State) => !!selectSession(s)?.profile.is_admin
|
||||
|
||||
export class NekoClient {
|
||||
readonly store = createStore<State>()(subscribeWithSelector(initialState))
|
||||
readonly api = new NekoApi()
|
||||
readonly events = new Emitter<NekoEvents>()
|
||||
readonly transport: StreamTransport
|
||||
// where input devices send: the transport's own channel, or the websocket
|
||||
readonly input: InputChannel
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private introduced = false // system/init arrived on the current socket: the transport's reports count
|
||||
// every websocket message, for the transport's signalling
|
||||
private readonly messages = new Emitter<Record<string, (payload: any) => void>>()
|
||||
private wanted = false // user asked to be connected
|
||||
private attempts = 0
|
||||
private reconnectTimer = 0
|
||||
private reconnectGen = 0 // a close()+connect() while a reconnect was deciding must win
|
||||
private staleTimer = 0
|
||||
private lastMessage = 0
|
||||
private resume = false // playback was running when the socket was lost: pick it up on the new stream
|
||||
private clipboardAt = 0 // when the remote clipboard last changed
|
||||
private localSeen = { text: '', at: 0 } // the local clipboard text last handed over, and when
|
||||
|
||||
private readonly opts: NekoClientOptions
|
||||
|
||||
constructor(opts: NekoClientOptions = {}) {
|
||||
this.opts = opts
|
||||
const t = opts.transport
|
||||
this.transport = typeof t === 'function' ? t() : (t ?? new WebRTCTransport())
|
||||
this.input = this.transport.input ?? new WebSocketInput(this)
|
||||
this.transport.on('state', (s) => this.onTransportState(s))
|
||||
this.transport.on('error', (err) => this.close(err))
|
||||
|
||||
const { store } = this
|
||||
store.subscribe(selectControlling, () => this.sendKeyboardMap())
|
||||
store.subscribe(
|
||||
(s) => s.control.keyboard.layout + '/' + s.control.keyboard.variant,
|
||||
() => this.sendKeyboardMap(),
|
||||
)
|
||||
store.subscribe(
|
||||
(s) => s.connection.status,
|
||||
(s) => this.events.emit('connection.status', s),
|
||||
)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// state
|
||||
/////////////////////////////
|
||||
|
||||
// the current state; a new object after every change, never mutated
|
||||
get state() {
|
||||
return this.store.getState()
|
||||
}
|
||||
|
||||
// an immutable update of one slice: patch('control', { locked: true })
|
||||
private patch<K extends Slice>(key: K, part: Partial<State[K]>) {
|
||||
this.store.setState((s) => ({ ...s, [key]: { ...s[key], ...part } }))
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.state.connection.status === 'connected'
|
||||
}
|
||||
|
||||
get controlling() {
|
||||
return selectControlling(this.state)
|
||||
}
|
||||
|
||||
get session() {
|
||||
return selectSession(this.state)
|
||||
}
|
||||
|
||||
get isAdmin() {
|
||||
return selectIsAdmin(this.state)
|
||||
}
|
||||
|
||||
get privateModeEnabled() {
|
||||
return this.state.settings.private_mode && !this.isAdmin
|
||||
}
|
||||
|
||||
get implicitControl() {
|
||||
return this.state.settings.implicit_hosting && !!this.session?.profile.can_host
|
||||
}
|
||||
|
||||
get isTouchDevice() {
|
||||
if (this.opts.inputMode) return this.opts.inputMode === 'touch'
|
||||
return (
|
||||
('ontouchstart' in window || navigator.maxTouchPoints > 0) &&
|
||||
!matchMedia('(pointer:fine)').matches &&
|
||||
!matchMedia('(hover:hover)').matches
|
||||
)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// auth
|
||||
/////////////////////////////
|
||||
|
||||
// point at a server (defaults to this page); `?token=` in the url logs in with that token
|
||||
setUrl(url = location.href) {
|
||||
const u = new URL(url)
|
||||
const token = u.searchParams.get('token') || undefined
|
||||
const http = (u.origin + u.pathname).replace(/^ws/, 'http').replace(/\/(api\/ws\/?)?$/, '')
|
||||
|
||||
this.close()
|
||||
this.api.url = http
|
||||
this.store.setState({ connection: { ...this.state.connection, url: http, token }, authenticated: false })
|
||||
|
||||
if (this.opts.autoconnect) {
|
||||
this.authenticate()
|
||||
.then(() => this.connect())
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(token = this.state.connection.token) {
|
||||
if (!token && this.opts.autologin) token = localStorage.getItem('neko_session') ?? undefined
|
||||
if (token) this.setToken(token)
|
||||
await this.api.req('GET', '/whoami')
|
||||
this.store.setState({ authenticated: true })
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const res = await this.api.req<LoginResponse>('POST', '/login', { username, password })
|
||||
// a session resumed meanwhile (autologin racing an invite link) must not keep its websocket
|
||||
this.close()
|
||||
// token is only returned when the server does not use cookies
|
||||
if (res.token) this.setToken(res.token)
|
||||
this.store.setState({ authenticated: true })
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.close()
|
||||
try {
|
||||
await this.api.req('POST', '/logout')
|
||||
} finally {
|
||||
this.setToken('')
|
||||
this.store.setState({ authenticated: false })
|
||||
}
|
||||
}
|
||||
|
||||
private setToken(token: string) {
|
||||
this.api.token = token
|
||||
this.patch('connection', { token: token || undefined })
|
||||
if (!this.opts.autologin) return
|
||||
try {
|
||||
token ? localStorage.setItem('neko_session', token) : localStorage.removeItem('neko_session')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// connection: websocket + transport
|
||||
/////////////////////////////
|
||||
|
||||
connect() {
|
||||
if (!this.state.authenticated) throw new Error('client not authenticated')
|
||||
if (this.wanted) return
|
||||
this.wanted = true
|
||||
this.attempts = 0
|
||||
this.patch('connection', { status: 'connecting' })
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.close()
|
||||
}
|
||||
|
||||
// stop everything, the stream included; emits connection.closed if we were connected or connecting
|
||||
private close(error?: Error) {
|
||||
const was = this.wanted
|
||||
this.wanted = false
|
||||
this.introduced = false
|
||||
this.resume = false
|
||||
clearTimeout(this.reconnectTimer)
|
||||
clearInterval(this.staleTimer)
|
||||
this.reconnectGen++
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
// a normal closure: without a status code the server assumes a reconnect is coming and
|
||||
// keeps the session connected (and host) for another 5 s
|
||||
ws?.close(1000)
|
||||
this.transport.close()
|
||||
this.clear()
|
||||
if (was) this.events.emit('connection.closed', error)
|
||||
}
|
||||
|
||||
private openSocket() {
|
||||
const { url, token } = this.state.connection
|
||||
const ws = (this.ws = new WebSocket(
|
||||
url.replace(/^http/, 'ws') + '/api/ws' + (token ? '?token=' + encodeURIComponent(token) : ''),
|
||||
))
|
||||
this.lastMessage = Date.now()
|
||||
ws.onmessage = (e) => {
|
||||
this.lastMessage = Date.now()
|
||||
const { event, payload } = JSON.parse(e.data)
|
||||
this.onMessage(event, payload)
|
||||
}
|
||||
ws.onclose = () => this.ws === ws && this.onSocketLost()
|
||||
// a dropped network often never closes the socket; give up on it and reconnect
|
||||
clearInterval(this.staleTimer)
|
||||
this.staleTimer = window.setInterval(() => {
|
||||
if (this.ws === ws && Date.now() - this.lastMessage > STALE_MS) {
|
||||
this.ws = null
|
||||
ws.close()
|
||||
this.onSocketLost()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// the transport is left alone: its stream may well outlive the socket (the last picture stays
|
||||
// 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 then
|
||||
// starts it over with transport.connect()
|
||||
private onSocketLost() {
|
||||
this.ws = null
|
||||
this.introduced = false
|
||||
this.resume ||= this.state.video.playing
|
||||
if (!this.wanted) return
|
||||
this.patch('connection', { status: 'connecting' })
|
||||
if (++this.attempts > RECONNECT_MAX) return this.close(new Error('connection lost'))
|
||||
|
||||
// a deleted session never comes back; anything else is worth retrying. The lookup is bounded:
|
||||
// on a black-holed network a fetch can otherwise hang for minutes
|
||||
const gen = this.reconnectGen
|
||||
this.api
|
||||
.req('GET', '/whoami', undefined, { signal: AbortSignal.timeout(5000) })
|
||||
.then(
|
||||
() => true,
|
||||
(err) => !(err instanceof ApiError && err.status === 401),
|
||||
)
|
||||
.then((retry) => {
|
||||
if (!this.wanted || gen !== this.reconnectGen) return
|
||||
if (!retry) return this.close(new Error('session expired'))
|
||||
this.reconnectTimer = window.setTimeout(
|
||||
() => this.openSocket(),
|
||||
RECONNECT_BACKOFF_MS * Math.min(this.attempts, 4),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
send(event: string, payload?: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ event, payload }))
|
||||
}
|
||||
|
||||
// the stream: connected only once the transport is; its status counts while the socket is up
|
||||
private onTransportState(s: TransportState) {
|
||||
const playable = s.video.playable && !this.state.video.playable // a stream arrived (or a new one)
|
||||
this.patch('video', s.video)
|
||||
if (playable) {
|
||||
if (this.opts.autoplay || this.resume) this.transport.setPlaying(true).catch(() => {})
|
||||
this.resume = false
|
||||
}
|
||||
if (!this.wanted || !this.introduced) return
|
||||
if (s.status === 'connected') this.attempts = 0
|
||||
this.patch('connection', {
|
||||
status: s.status === 'connected' || s.status === 'unavailable' ? 'connected' : 'connecting',
|
||||
})
|
||||
}
|
||||
|
||||
// what the transport gets of this session, once the server has introduced it (system/init)
|
||||
private sessionInfo(init: SessionInfo['init']): SessionInfo {
|
||||
return {
|
||||
url: this.state.connection.url,
|
||||
token: this.state.connection.token,
|
||||
init,
|
||||
send: (event, payload) => this.send(event, payload),
|
||||
on: (event, cb) => this.messages.on(event, cb),
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// websocket events -> state
|
||||
/////////////////////////////
|
||||
|
||||
// every message goes to the transport's subscribers first (signal/*); the rest is the room.
|
||||
// What one message changes is one setState, so subscribers never see it half-applied.
|
||||
private onMessage(event: string, p: any) {
|
||||
this.messages.emit(event, p)
|
||||
const { state, store, events } = this
|
||||
switch (event) {
|
||||
case 'system/init':
|
||||
store.setState({
|
||||
session_id: p.session_id,
|
||||
control: {
|
||||
...state.control,
|
||||
touch: !!p.touch_events,
|
||||
host_id: p.control_host?.has_host ? p.control_host.host_id : null,
|
||||
},
|
||||
screen: { ...state.screen, size: p.screen_size },
|
||||
sessions: p.sessions ?? {},
|
||||
settings: p.settings,
|
||||
})
|
||||
this.introduced = true
|
||||
this.transport.connect(this.sessionInfo(p)).catch((err) => this.close(err))
|
||||
break
|
||||
case 'system/admin':
|
||||
this.patch('screen', {
|
||||
configurations: [...(p.screen_sizes_list ?? [])].sort(
|
||||
(a: ScreenSize, b: ScreenSize) => b.width - a.width || b.height - a.height || b.rate - a.rate,
|
||||
),
|
||||
})
|
||||
events.emit('room.broadcast.status', p.broadcast_status?.is_active, p.broadcast_status?.url)
|
||||
break
|
||||
case 'system/settings': {
|
||||
const { id, ...settings } = p
|
||||
store.setState({ settings })
|
||||
events.emit('room.settings.updated', settings, id)
|
||||
break
|
||||
}
|
||||
case 'system/disconnect':
|
||||
this.close(new Error(p.message))
|
||||
break
|
||||
case 'system/heartbeat':
|
||||
// lets proxies see client->server traffic; the server ignores the payload
|
||||
this.send('client/heartbeat')
|
||||
break
|
||||
|
||||
case 'session/created':
|
||||
store.setState({ sessions: { ...state.sessions, [p.id]: p } })
|
||||
events.emit('session.created', p.id)
|
||||
break
|
||||
case 'session/deleted': {
|
||||
const sessions = { ...state.sessions }
|
||||
delete sessions[p.id]
|
||||
store.setState({ sessions })
|
||||
events.emit('session.deleted', p.id)
|
||||
break
|
||||
}
|
||||
case 'session/profile': {
|
||||
const { id, ...profile } = p
|
||||
if (!state.sessions[id]) return
|
||||
store.setState({ sessions: { ...state.sessions, [id]: { ...state.sessions[id], profile } } })
|
||||
events.emit('session.updated', id)
|
||||
break
|
||||
}
|
||||
case 'session/state': {
|
||||
const { id, ...st } = p
|
||||
if (!state.sessions[id]) return
|
||||
store.setState({ sessions: { ...state.sessions, [id]: { ...state.sessions[id], state: st } } })
|
||||
events.emit('session.updated', id)
|
||||
break
|
||||
}
|
||||
case 'session/cursors':
|
||||
break // inactive cursors: not shown by this client
|
||||
|
||||
case 'control/host':
|
||||
this.patch('control', { host_id: p.has_host ? p.host_id : null })
|
||||
events.emit('room.control.host', p.has_host, p.host_id, p.id)
|
||||
break
|
||||
case 'control/request':
|
||||
events.emit('room.control.request', p.id)
|
||||
break
|
||||
|
||||
case 'screen/updated': {
|
||||
const { id, ...size } = p
|
||||
this.patch('screen', { size })
|
||||
events.emit('room.screen.updated', size.width, size.height, size.rate, id)
|
||||
break
|
||||
}
|
||||
case 'clipboard/updated':
|
||||
this.patch('control', { clipboard: { text: p.text } })
|
||||
this.clipboardAt = Date.now()
|
||||
navigator.clipboard?.writeText(p.text).catch(() => {}) // only over https
|
||||
events.emit('room.clipboard.updated', p.text)
|
||||
break
|
||||
case 'broadcast/status':
|
||||
events.emit('room.broadcast.status', p.is_active, p.url)
|
||||
break
|
||||
case 'send/unicast':
|
||||
events.emit('receive.unicast', p.sender, p.subject, p.body)
|
||||
break
|
||||
case 'send/broadcast':
|
||||
events.emit('receive.broadcast', p.sender, p.subject, p.body)
|
||||
break
|
||||
|
||||
default:
|
||||
events.emit('message', event, p)
|
||||
}
|
||||
}
|
||||
|
||||
private clear() {
|
||||
const { state } = this
|
||||
this.store.setState({
|
||||
connection: { ...state.connection, status: 'disconnected' },
|
||||
control: { ...state.control, host_id: null, clipboard: null },
|
||||
screen: { ...state.screen, configurations: [] },
|
||||
session_id: null,
|
||||
sessions: {},
|
||||
settings: defaultSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// control
|
||||
/////////////////////////////
|
||||
|
||||
request() {
|
||||
this.send('control/request')
|
||||
}
|
||||
|
||||
release() {
|
||||
this.send('control/release')
|
||||
}
|
||||
|
||||
// local lock: keep control but stop sending input
|
||||
lock() {
|
||||
this.patch('control', { locked: true })
|
||||
}
|
||||
|
||||
unlock() {
|
||||
this.patch('control', { locked: false })
|
||||
}
|
||||
|
||||
// types text remotely via the server's clipboard
|
||||
paste(text: string) {
|
||||
this.send('control/paste', { text })
|
||||
}
|
||||
|
||||
// Before a paste keystroke, with the text the browser's own paste event handed over: the side
|
||||
// that copied most recently wins. The remote reports every change of its clipboard; the local
|
||||
// clipboard can only be looked at when the browser hands it over, so if the remote changed since
|
||||
// the local text was last seen, the remote is taken as fresher and nothing is sent (on https the
|
||||
// remote text is also written to the local clipboard, so the two then match). Otherwise the
|
||||
// local text becomes the remote clipboard, and this resolves once the server has confirmed it.
|
||||
async preparePaste(text: string) {
|
||||
const stale = this.localSeen.at < this.clipboardAt
|
||||
const fresh = text !== '' && text !== this.state.control.clipboard?.text && !stale
|
||||
this.localSeen = { text, at: Date.now() }
|
||||
if (!fresh) return
|
||||
this.send('clipboard/set', { text })
|
||||
await new Promise<void>((done) => {
|
||||
const off = this.events.on('room.clipboard.updated', () => (off(), done()))
|
||||
setTimeout(() => (off(), done()), 500)
|
||||
})
|
||||
}
|
||||
|
||||
setScrollInverse(value = true) {
|
||||
this.patch('control', { scroll: { ...this.state.control.scroll, inverse: value } })
|
||||
}
|
||||
|
||||
setScrollSensitivity(value: number) {
|
||||
this.patch('control', { scroll: { ...this.state.control.scroll, sensitivity: value } })
|
||||
}
|
||||
|
||||
setKeyboard(layout: string, variant = '') {
|
||||
this.patch('control', { keyboard: { layout, variant } })
|
||||
}
|
||||
|
||||
private sendKeyboardMap() {
|
||||
if (this.controlling && this.state.control.keyboard.layout) this.send('keyboard/map', this.state.control.keyboard)
|
||||
}
|
||||
|
||||
setScreenSize(width: number, height: number, rate: number) {
|
||||
this.send('screen/set', { width, height, rate })
|
||||
}
|
||||
|
||||
sendBroadcast(subject: string, body: any) {
|
||||
this.send('send/broadcast', { subject, body })
|
||||
}
|
||||
|
||||
sendUnicast(receiver: string, subject: string, body: any) {
|
||||
this.send('send/unicast', { receiver, subject, body })
|
||||
}
|
||||
|
||||
async uploadDrop({ x, y, files }: { x: number; y: number; files: File[] }) {
|
||||
const form = new FormData()
|
||||
form.append('x', String(x))
|
||||
form.append('y', String(y))
|
||||
for (const f of files) form.append('files', f)
|
||||
try {
|
||||
await this.api.upload('/room/upload/drop', form, (p) => this.events.emit('upload.drop.progress', p))
|
||||
this.events.emit('upload.drop.finished')
|
||||
} catch (err: any) {
|
||||
this.events.emit('upload.drop.finished', err)
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// media (through the transport)
|
||||
/////////////////////////////
|
||||
|
||||
// when the browser refuses sound, playback starts muted (video.mutedByAutoplay) and unmutes on
|
||||
// the first click
|
||||
play() {
|
||||
return this.transport.setPlaying(true)
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.transport.setPlaying(false)
|
||||
}
|
||||
|
||||
mute() {
|
||||
this.transport.setMuted(true)
|
||||
}
|
||||
|
||||
unmute() {
|
||||
this.transport.setMuted(false)
|
||||
}
|
||||
|
||||
setVolume(value: number) {
|
||||
this.transport.setVolume(Math.max(0, Math.min(1, value)))
|
||||
}
|
||||
|
||||
// share local media (microphone), on transports that can; returns what stops sharing it
|
||||
shareMedia(stream: MediaStream): () => void {
|
||||
if (!this.transport.shareMedia) throw new Error('transport cannot send media')
|
||||
return this.transport.shareMedia(stream)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Minimal typed event emitter (on/off/once/emit).
|
||||
export class Emitter<E extends { [K in keyof E]: (...args: any[]) => void }> {
|
||||
private handlers = new Map<keyof E, Set<Function>>()
|
||||
|
||||
on<K extends keyof E>(event: K, fn: E[K]) {
|
||||
if (!this.handlers.has(event)) this.handlers.set(event, new Set())
|
||||
this.handlers.get(event)!.add(fn)
|
||||
return () => this.off(event, fn)
|
||||
}
|
||||
|
||||
off<K extends keyof E>(event: K, fn: E[K]) {
|
||||
this.handlers.get(event)?.delete(fn)
|
||||
}
|
||||
|
||||
once<K extends keyof E>(event: K, fn: E[K]) {
|
||||
const wrap = ((...args: any[]) => (this.off(event, wrap), fn(...args))) as E[K]
|
||||
return this.on(event, wrap)
|
||||
}
|
||||
|
||||
emit<K extends keyof E>(event: K, ...args: Parameters<E[K]>) {
|
||||
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// @m1k1o/neko: everything needed to talk to a neko server, usable from any framework.
|
||||
// The GUI in ../src imports only from here.
|
||||
export {
|
||||
NekoClient,
|
||||
selectSession,
|
||||
selectControlling,
|
||||
selectIsAdmin,
|
||||
type NekoClientOptions,
|
||||
type Pos,
|
||||
} from './client.ts'
|
||||
export { Overlay } from './overlay.ts'
|
||||
export { mount } from './mount.ts'
|
||||
export { Emitter } from './emitter.ts'
|
||||
export { NekoApi, ApiError } from './api.ts'
|
||||
export type {
|
||||
StreamTransport,
|
||||
SessionInfo,
|
||||
InputChannel,
|
||||
TransportKind,
|
||||
TransportStatus,
|
||||
TransportState,
|
||||
TransportEvents,
|
||||
} from './transport.ts'
|
||||
export { WebRTCTransport } from './transport/webrtc.ts'
|
||||
export { DataChannelInput } from './input/datachannel.ts'
|
||||
export { WebSocketInput } from './input/websocket.ts'
|
||||
export * from './types.ts'
|
||||
@@ -0,0 +1,75 @@
|
||||
// The data channel input: the overlay's input as big-endian frames out, the host's cursor in.
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { FakeChannel } from '../test/browser.ts'
|
||||
|
||||
const { DataChannelInput } = await import('../index.ts')
|
||||
|
||||
test('every method is one frame: opcode, u16 payload length, big-endian fields of the server’s widths', () => {
|
||||
const input = new DataChannelInput()
|
||||
const dc = new FakeChannel()
|
||||
input.bind(dc as any)
|
||||
assert.equal(dc.binaryType, 'arraybuffer')
|
||||
dc.readyState = 'open'
|
||||
input.move(100, 200)
|
||||
input.move(65535, 1)
|
||||
input.scroll(-1, 1, true)
|
||||
input.scroll(3, 0, false)
|
||||
input.button(1, true)
|
||||
input.button(3, false)
|
||||
input.key(0xff0d, true)
|
||||
input.key(0xff0d, false)
|
||||
input.key(0x1008ff11, true) // XF86AudioLowerVolume: a keysym above u16
|
||||
input.touch('begin', 7, -5, 6, 255)
|
||||
input.touch('update', 7, 10, 20, 128)
|
||||
input.touch('end', 7, 10, 20, 0)
|
||||
assert.deepEqual(dc.frames, [
|
||||
[1, 0, 4, 0, 100, 0, 200], // MOVE: u16 x, u16 y
|
||||
[1, 0, 4, 255, 255, 0, 1],
|
||||
[2, 0, 5, 255, 255, 0, 1, 1], // SCROLL: i16 delta_x, i16 delta_y, u8 control_key
|
||||
[2, 0, 5, 0, 3, 0, 0, 0],
|
||||
[5, 0, 4, 0, 0, 0, 1], // BTN_DOWN: u32 code
|
||||
[6, 0, 4, 0, 0, 0, 3], // BTN_UP
|
||||
[3, 0, 4, 0, 0, 0xff, 0x0d], // KEY_DOWN: u32 keysym
|
||||
[4, 0, 4, 0, 0, 0xff, 0x0d], // KEY_UP
|
||||
[3, 0, 4, 0x10, 0x08, 0xff, 0x11],
|
||||
[8, 0, 13, 0, 0, 0, 7, 255, 255, 255, 251, 0, 0, 0, 6, 255], // TOUCH_BEGIN: u32 id, i32 x, i32 y, u8 pressure
|
||||
[9, 0, 13, 0, 0, 0, 7, 0, 0, 0, 10, 0, 0, 0, 20, 128], // TOUCH_UPDATE
|
||||
[10, 0, 13, 0, 0, 0, 7, 0, 0, 0, 10, 0, 0, 0, 20, 0], // TOUCH_END
|
||||
])
|
||||
})
|
||||
|
||||
test('nothing is sent while the channel is not open: unbound, connecting, or gone', () => {
|
||||
const input = new DataChannelInput()
|
||||
input.move(1, 2) // no channel at all
|
||||
const dc = new FakeChannel()
|
||||
input.bind(dc as any)
|
||||
input.move(1, 2) // still connecting
|
||||
input.key(1, true)
|
||||
assert.deepEqual(dc.frames, [])
|
||||
dc.readyState = 'open'
|
||||
input.move(1, 2)
|
||||
assert.equal(dc.frames.length, 1)
|
||||
input.bind(null) // the peer is gone
|
||||
input.move(1, 2)
|
||||
input.button(1, true)
|
||||
assert.equal(dc.frames.length, 1)
|
||||
})
|
||||
|
||||
test('receive: cursor position and image frames reach the callbacks', () => {
|
||||
const input = new DataChannelInput()
|
||||
const got: unknown[] = []
|
||||
input.onCursorPosition = (p) => got.push(p)
|
||||
input.onCursorImage = (img) => got.push(img)
|
||||
const dc = new FakeChannel()
|
||||
input.bind(dc as any)
|
||||
const frame = (bytes: number[]) => dc.onmessage!({ data: new Uint8Array(bytes).buffer })
|
||||
frame([1, 0, 4, 0x01, 0x02, 0x03, 0x04]) // CURSOR_POSITION x=258 y=772
|
||||
frame([2, 0, 12, 0, 16, 0, 20, 0, 3, 0, 5, 0x89, 0x50, 0x4e, 0x47]) // CURSOR_IMAGE 16x20 hotspot 3,5 + png bytes
|
||||
frame([9, 0, 0]) // unknown: ignored
|
||||
assert.deepEqual(got[0], { x: 258, y: 772 })
|
||||
const img = got[1] as { width: number; height: number; x: number; y: number; uri: string }
|
||||
assert.deepEqual([img.width, img.height, img.x, img.y], [16, 20, 3, 5])
|
||||
assert.match(img.uri, /^blob:/)
|
||||
assert.equal(got.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// Input over the WebRTC data channel: big-endian binary messages (server/internal/webrtc/payload),
|
||||
// and the host's cursor coming back the same way.
|
||||
import type { InputChannel } from '../transport.ts'
|
||||
import type { CursorImage } from '../types.ts'
|
||||
|
||||
// opcodes (server/internal/webrtc/payload)
|
||||
const OP = {
|
||||
// client -> server
|
||||
MOVE: 1,
|
||||
SCROLL: 2,
|
||||
KEY_DOWN: 3,
|
||||
KEY_UP: 4,
|
||||
BTN_DOWN: 5,
|
||||
BTN_UP: 6,
|
||||
TOUCH_BEGIN: 8,
|
||||
TOUCH_UPDATE: 9,
|
||||
TOUCH_END: 10,
|
||||
// server -> client
|
||||
CURSOR_POSITION: 1,
|
||||
CURSOR_IMAGE: 2,
|
||||
}
|
||||
const TOUCH_OP = { begin: OP.TOUCH_BEGIN, update: OP.TOUCH_UPDATE, end: OP.TOUCH_END }
|
||||
|
||||
// a field of a message: its width in bytes (2 = u16, 4 = u32, -2 = i16, -4 = i32, 1 = u8) and value
|
||||
type Field = [bytes: number, value: number]
|
||||
|
||||
export class DataChannelInput implements InputChannel {
|
||||
private dc: RTCDataChannel | null = null
|
||||
|
||||
onCursorPosition?: (pos: { x: number; y: number }) => void
|
||||
onCursorImage?: (image: CursorImage) => void
|
||||
|
||||
// the channel the server opened on the peer connection; null once the peer is gone
|
||||
bind(dc: RTCDataChannel | null) {
|
||||
if (!dc) {
|
||||
this.dc = null
|
||||
return
|
||||
}
|
||||
dc.binaryType = 'arraybuffer'
|
||||
dc.onmessage = (e) => {
|
||||
const v = new DataView(e.data)
|
||||
const op = v.getUint8(0)
|
||||
if (op === OP.CURSOR_POSITION) {
|
||||
this.onCursorPosition?.({ x: v.getUint16(3), y: v.getUint16(5) })
|
||||
} else if (op === OP.CURSOR_IMAGE) {
|
||||
const uri = URL.createObjectURL(new Blob([e.data.slice(11)], { type: 'image/png' }))
|
||||
this.onCursorImage?.({
|
||||
width: v.getUint16(3),
|
||||
height: v.getUint16(5),
|
||||
x: v.getUint16(7),
|
||||
y: v.getUint16(9),
|
||||
uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
this.dc = dc
|
||||
}
|
||||
|
||||
move(x: number, y: number) {
|
||||
this.send(OP.MOVE, [2, x], [2, y])
|
||||
}
|
||||
|
||||
scroll(deltaX: number, deltaY: number, controlKey: boolean) {
|
||||
this.send(OP.SCROLL, [-2, deltaX], [-2, deltaY], [1, controlKey ? 1 : 0])
|
||||
}
|
||||
|
||||
button(code: number, down: boolean) {
|
||||
this.send(down ? OP.BTN_DOWN : OP.BTN_UP, [4, code])
|
||||
}
|
||||
|
||||
key(keysym: number, down: boolean) {
|
||||
this.send(down ? OP.KEY_DOWN : OP.KEY_UP, [4, keysym])
|
||||
}
|
||||
|
||||
touch(phase: 'begin' | 'update' | 'end', id: number, x: number, y: number, pressure: number) {
|
||||
this.send(TOUCH_OP[phase], [4, id], [-4, x], [-4, y], [1, pressure])
|
||||
}
|
||||
|
||||
// opcode, u16 payload length, the fields; dropped while the channel is not open
|
||||
private send(op: number, ...fields: Field[]) {
|
||||
if (this.dc?.readyState !== 'open') return
|
||||
const len = fields.reduce((n, [b]) => n + Math.abs(b), 0)
|
||||
const v = new DataView(new ArrayBuffer(3 + len))
|
||||
v.setUint8(0, op)
|
||||
v.setUint16(1, len)
|
||||
let o = 3
|
||||
for (const [b, val] of fields) {
|
||||
if (b === 1) v.setUint8(o, val)
|
||||
else if (b === 2) v.setUint16(o, val)
|
||||
else if (b === -2) v.setInt16(o, val)
|
||||
else if (b === 4) v.setUint32(o, val)
|
||||
else v.setInt32(o, val)
|
||||
o += Math.abs(b)
|
||||
}
|
||||
this.dc.send(v.buffer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Input over the websocket: the overlay's input as the server's control/* events.
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { tick, reset, sent, sentPayloads, sockets, init, receive } from '../test/browser.ts'
|
||||
|
||||
const { WebSocketInput, NekoClient } = await import('../index.ts')
|
||||
|
||||
const recorder = () => {
|
||||
const out: { event: string; payload: unknown }[] = []
|
||||
return { out, send: (event: string, payload?: unknown) => out.push({ event, payload }) }
|
||||
}
|
||||
|
||||
test('every method becomes the matching control/* event with the server’s payload fields', async () => {
|
||||
reset()
|
||||
const r = recorder()
|
||||
const input = new WebSocketInput(r)
|
||||
input.scroll(-1, 2, true)
|
||||
input.scroll(3, 0, false)
|
||||
input.key(0xff0d, true)
|
||||
input.key(0xff0d, false)
|
||||
input.button(1, true)
|
||||
input.button(1, false)
|
||||
input.touch('begin', 7, 10, 20, 128)
|
||||
input.touch('update', 7, 11, 21, 129)
|
||||
input.touch('end', 7, 12, 22, 0)
|
||||
assert.deepEqual(r.out, [
|
||||
{ event: 'control/scroll', payload: { delta_x: -1, delta_y: 2, control_key: true } },
|
||||
{ event: 'control/scroll', payload: { delta_x: 3, delta_y: 0, control_key: false } },
|
||||
{ event: 'control/keydown', payload: { keysym: 0xff0d } },
|
||||
{ event: 'control/keyup', payload: { keysym: 0xff0d } },
|
||||
{ event: 'control/buttondown', payload: { code: 1 } },
|
||||
{ event: 'control/buttonup', payload: { code: 1 } },
|
||||
{ event: 'control/touchbegin', payload: { touch_id: 7, x: 10, y: 20, pressure: 128 } },
|
||||
{ event: 'control/touchupdate', payload: { touch_id: 7, x: 11, y: 21, pressure: 129 } },
|
||||
{ event: 'control/touchend', payload: { touch_id: 7, x: 12, y: 22, pressure: 0 } },
|
||||
])
|
||||
await tick(100)
|
||||
assert.equal(r.out.length, 9)
|
||||
})
|
||||
|
||||
test('moves within 16 ms are coalesced, the last one wins; a click flushes the move first', async () => {
|
||||
reset()
|
||||
const r = recorder()
|
||||
const input = new WebSocketInput(r)
|
||||
input.move(10, 20)
|
||||
input.move(11, 21)
|
||||
assert.deepEqual(r.out, [])
|
||||
await tick(15)
|
||||
assert.deepEqual(r.out, [])
|
||||
await tick(1)
|
||||
assert.deepEqual(r.out, [{ event: 'control/move', payload: { x: 11, y: 21 } }])
|
||||
await tick(100)
|
||||
assert.equal(r.out.length, 1, 'a flushed move is not sent again')
|
||||
|
||||
input.move(30, 40)
|
||||
input.button(1, true) // the press lands where the pointer is now
|
||||
assert.deepEqual(r.out.slice(1), [
|
||||
{ event: 'control/move', payload: { x: 30, y: 40 } },
|
||||
{ event: 'control/buttondown', payload: { code: 1 } },
|
||||
])
|
||||
await tick(16)
|
||||
assert.equal(r.out.length, 3)
|
||||
|
||||
input.move(50, 60) // and the timer works again after a flush
|
||||
await tick(16)
|
||||
assert.deepEqual(r.out.at(-1), { event: 'control/move', payload: { x: 50, y: 60 } })
|
||||
})
|
||||
|
||||
test('nothing reaches the server while the socket is not open', async () => {
|
||||
reset()
|
||||
const transport = {
|
||||
kind: 'hls',
|
||||
element: null,
|
||||
connect: async () => {},
|
||||
close() {},
|
||||
attach: () => () => {},
|
||||
setPlaying: async () => {},
|
||||
setVolume() {},
|
||||
setMuted() {},
|
||||
on: () => () => {},
|
||||
} as const
|
||||
const client = new NekoClient({ transport })
|
||||
assert.ok(client.input instanceof WebSocketInput, 'no input channel on the transport: the websocket')
|
||||
client.input.key(1, true) // no socket at all
|
||||
client.store.setState({ authenticated: true })
|
||||
client.connect()
|
||||
sockets[0].readyState = 0 // still connecting
|
||||
client.input.key(1, true)
|
||||
client.input.move(1, 2)
|
||||
await tick(16)
|
||||
assert.deepEqual(sent, [])
|
||||
sockets[0].readyState = 1
|
||||
receive('system/init', init())
|
||||
client.input.key(1, true)
|
||||
assert.deepEqual(sent, ['control/keydown'])
|
||||
assert.deepEqual(sentPayloads.at(-1), { event: 'control/keydown', payload: { keysym: 1 } })
|
||||
client.disconnect()
|
||||
client.input.key(1, false)
|
||||
assert.deepEqual(sent, ['control/keydown'])
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// Input over the main websocket, for transports without an input path of their own: the v3
|
||||
// control/* events the server handles (server/internal/websocket/handler/control.go, payloads in
|
||||
// server/pkg/types/message).
|
||||
import type { InputChannel } from '../transport.ts'
|
||||
|
||||
const MOVE_COALESCE_MS = 16
|
||||
|
||||
export class WebSocketInput implements InputChannel {
|
||||
private readonly socket: { send(event: string, payload?: unknown): void }
|
||||
private pending: { x: number; y: number } | null = null
|
||||
private moveTimer = 0
|
||||
|
||||
constructor(socket: { send(event: string, payload?: unknown): void }) {
|
||||
this.socket = socket
|
||||
}
|
||||
|
||||
// one move per frame, the latest wins
|
||||
move(x: number, y: number) {
|
||||
this.pending = { x, y }
|
||||
if (!this.moveTimer) this.moveTimer = window.setTimeout(() => this.flush(), MOVE_COALESCE_MS)
|
||||
}
|
||||
|
||||
scroll(deltaX: number, deltaY: number, controlKey: boolean) {
|
||||
this.flush()
|
||||
this.socket.send('control/scroll', { delta_x: deltaX, delta_y: deltaY, control_key: controlKey })
|
||||
}
|
||||
|
||||
button(code: number, down: boolean) {
|
||||
this.flush() // a click lands where the pointer is now
|
||||
this.socket.send(down ? 'control/buttondown' : 'control/buttonup', { code })
|
||||
}
|
||||
|
||||
key(keysym: number, down: boolean) {
|
||||
this.flush()
|
||||
this.socket.send(down ? 'control/keydown' : 'control/keyup', { keysym })
|
||||
}
|
||||
|
||||
touch(phase: 'begin' | 'update' | 'end', id: number, x: number, y: number, pressure: number) {
|
||||
this.flush()
|
||||
this.socket.send(`control/touch${phase}`, { touch_id: id, x, y, pressure })
|
||||
}
|
||||
|
||||
private flush() {
|
||||
clearTimeout(this.moveTimer)
|
||||
this.moveTimer = 0
|
||||
if (!this.pending) return
|
||||
this.socket.send('control/move', this.pending)
|
||||
this.pending = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Apache Guacamole keyboard, the same file as upstream master's client/src/utils/guacamole-keyboard.js
|
||||
export default class GuacamoleKeyboard {
|
||||
constructor(element?: Element)
|
||||
// return true to let the browser handle the key, false to prevent default
|
||||
onkeydown?: (keysym: number) => boolean
|
||||
onkeyup?: (keysym: number) => void
|
||||
press(keysym: number): boolean
|
||||
release(keysym: number): void
|
||||
reset(): void
|
||||
listenTo(element: Element | Document): void
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The non-React convenience: the stream with the keyboard/mouse overlay on top fill `el`,
|
||||
// letterboxed to the remote screen. Returns what takes them out again. A stream without input is
|
||||
// just `client.transport.attach(el)`; a UI framework does these two attaches itself (../src/features/video/Player.tsx).
|
||||
import { Overlay } from './overlay.ts'
|
||||
import type { NekoClient } from './client.ts'
|
||||
|
||||
export function mount(client: NekoClient, el: HTMLElement): () => void {
|
||||
const box = document.createElement('div')
|
||||
box.style.position = 'relative'
|
||||
const detach = client.transport.attach(box)
|
||||
el.append(box)
|
||||
const detachOverlay = new Overlay(client).attach(box)
|
||||
return () => {
|
||||
detachOverlay()
|
||||
detach()
|
||||
box.remove()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// The keyboard/mouse/touch overlay on its own: what attach() puts into the box and detach() takes
|
||||
// out, DOM events reaching client.input, the host's cursor drawn from the transport's events,
|
||||
// and the mount() helper around it.
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { tick, settle, reset, init, patch, FakeElement, resizeObservers, windowListeners } from './test/browser.ts'
|
||||
|
||||
const { NekoClient, Overlay, mount } = await import('./index.ts')
|
||||
type Options = ConstructorParameters<typeof NekoClient>[0]
|
||||
type Transport = NonNullable<Exclude<NonNullable<Options>['transport'], () => unknown>>
|
||||
|
||||
const LAYER = 'position:absolute;inset:0;width:100%;height:100%;'
|
||||
|
||||
// a transport with a recording input channel and events a test can emit
|
||||
const fakeTransport = () => {
|
||||
const calls: unknown[][] = []
|
||||
const listeners = new Map<string, Set<(...a: any[]) => void>>()
|
||||
const noop = () => {}
|
||||
const record =
|
||||
(m: string) =>
|
||||
(...a: unknown[]) =>
|
||||
calls.push([m, ...a])
|
||||
const t = {
|
||||
kind: 'ws-mse' as const,
|
||||
element: null,
|
||||
input: {
|
||||
move: record('move'),
|
||||
scroll: record('scroll'),
|
||||
button: record('button'),
|
||||
key: record('key'),
|
||||
touch: record('touch'),
|
||||
},
|
||||
connect: async () => {},
|
||||
close: noop,
|
||||
attach: (box: HTMLElement) => {
|
||||
const video = new FakeElement('video')
|
||||
;(box as unknown as FakeElement).append(video)
|
||||
return () => video.remove()
|
||||
},
|
||||
setPlaying: async () => {},
|
||||
setVolume: noop,
|
||||
setMuted: noop,
|
||||
on: (ev: string, cb: (...a: any[]) => void) => {
|
||||
;(listeners.get(ev) ?? listeners.set(ev, new Set()).get(ev)!).add(cb)
|
||||
return () => listeners.get(ev)!.delete(cb)
|
||||
},
|
||||
emit: (ev: string, ...a: unknown[]) => listeners.get(ev)?.forEach((cb) => cb(...a)),
|
||||
subscribers: (ev: string) => listeners.get(ev)?.size ?? 0,
|
||||
}
|
||||
return { t: t as unknown as Transport, calls, emit: t.emit, subscribers: t.subscribers }
|
||||
}
|
||||
|
||||
// a client in a connected room (s1 = us, s2 = Bob the host) with an area of 1000x500 holding a
|
||||
// box with the transport's element; the overlay is not attached yet
|
||||
const setup = () => {
|
||||
reset()
|
||||
const { t, calls, emit, subscribers } = fakeTransport()
|
||||
const client = new NekoClient({ transport: t })
|
||||
const p = init()
|
||||
const s = client.state
|
||||
client.store.setState({
|
||||
session_id: p.session_id,
|
||||
sessions: p.sessions,
|
||||
settings: p.settings,
|
||||
screen: { ...s.screen, size: p.screen_size },
|
||||
control: { ...s.control, host_id: 's2', touch: true },
|
||||
connection: { ...s.connection, status: 'connected' },
|
||||
})
|
||||
const area = new FakeElement('div')
|
||||
area.offsetWidth = 1000
|
||||
area.offsetHeight = 500
|
||||
const box = new FakeElement('div')
|
||||
area.append(box)
|
||||
t.attach(box as any)
|
||||
const overlay = new Overlay(client)
|
||||
const wrap = () => box.children[1]
|
||||
const canvas = () => wrap().children[0]
|
||||
const textarea = () => wrap().children[1]
|
||||
return { client, calls, emit, subscribers, area, box, overlay, wrap, canvas, textarea }
|
||||
}
|
||||
const host = async (client: InstanceType<typeof NekoClient>) => {
|
||||
patch(client, 'control', { host_id: 's1' })
|
||||
await settle()
|
||||
assert.equal(client.controlling, true)
|
||||
}
|
||||
// the canvas as laid out: 960x540 at (10, 20), half the 1920x1080 remote screen
|
||||
const laidOut = (canvas: FakeElement) => (canvas.rect = { left: 10, top: 20, width: 960, height: 540 })
|
||||
|
||||
test('attach: cursor canvas and textarea after the transport’s element, as the client used to make them; detach takes them out and unsubscribes', async () => {
|
||||
const { client, box, overlay, wrap, canvas, textarea, subscribers } = setup()
|
||||
assert.equal(box.children.length, 1, 'only the stream before attach')
|
||||
const detach = overlay.attach(box as any)
|
||||
assert.deepEqual(
|
||||
box.children.map((c) => c.tag),
|
||||
['video', 'div'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
wrap().children.map((c) => c.tag),
|
||||
['canvas', 'textarea'],
|
||||
)
|
||||
assert.equal(canvas().style.cssText, LAYER)
|
||||
assert.equal(
|
||||
textarea().style.cssText,
|
||||
LAYER +
|
||||
'font-size:16px;resize:none;caret-color:transparent;outline:0;border:0;color:transparent;background:transparent;',
|
||||
)
|
||||
assert.equal(textarea().spellcheck, false)
|
||||
assert.deepEqual(textarea().attrs, { autocapitalize: 'off', autocomplete: 'off', 'data-gramm': 'false' })
|
||||
assert.deepEqual(
|
||||
[wrap().style.display, wrap().style.pointerEvents],
|
||||
['', 'auto'],
|
||||
'shown, and can_host takes the mouse',
|
||||
)
|
||||
|
||||
// the box is letterboxed inside the area (1000x500 for 16:9 -> 888.9x500, centred), the canvas sized with it
|
||||
const w = (500 * 1920) / 1080
|
||||
assert.deepEqual(box.style, { width: `${w}px`, height: '500px', marginTop: '0px', marginLeft: `${(1000 - w) / 2}px` })
|
||||
assert.deepEqual([(canvas() as any).width, (canvas() as any).height], [w, 500], 'the canvas backing store follows')
|
||||
assert.equal(resizeObservers.at(-1)?.observed, box.parent, 'the area is observed')
|
||||
patch(client, 'screen', { size: { width: 1000, height: 1000, rate: 30 } }) // a square screen: 500x500 in the middle
|
||||
await settle()
|
||||
assert.deepEqual(box.style, { width: '500px', height: '500px', marginTop: '0px', marginLeft: '250px' })
|
||||
box.parent!.offsetWidth = 400 // the area shrinks: the observer reports it
|
||||
resizeObservers.at(-1)!.cb()
|
||||
assert.deepEqual(box.style, { width: '400px', height: '400px', marginTop: '50px', marginLeft: '0px' })
|
||||
|
||||
// private mode hides it from users, a local lock or no can_host takes the mouse away
|
||||
patch(client, 'settings', { private_mode: true })
|
||||
await settle()
|
||||
assert.equal(wrap().style.display, 'none')
|
||||
patch(client, 'settings', { private_mode: false })
|
||||
patch(client, 'control', { locked: true })
|
||||
await settle()
|
||||
assert.deepEqual([wrap().style.display, wrap().style.pointerEvents], ['', 'none'])
|
||||
patch(client, 'control', { locked: false })
|
||||
await settle()
|
||||
|
||||
assert.equal(windowListeners.mouseup?.size, 1)
|
||||
assert.deepEqual([subscribers('cursor.position'), subscribers('cursor.image')], [1, 1])
|
||||
const w1 = wrap()
|
||||
detach()
|
||||
assert.deepEqual(
|
||||
box.children.map((c) => c.tag),
|
||||
['video'],
|
||||
)
|
||||
assert.equal(windowListeners.mouseup?.size, 0)
|
||||
assert.deepEqual([subscribers('cursor.position'), subscribers('cursor.image')], [0, 0])
|
||||
assert.ok(resizeObservers.at(-1)!.disconnected)
|
||||
patch(client, 'control', { locked: true }) // the store subscriptions are gone too
|
||||
patch(client, 'screen', { size: { width: 1920, height: 1080, rate: 60 } })
|
||||
await settle()
|
||||
assert.equal(w1.style.pointerEvents, 'auto')
|
||||
assert.equal(box.style.width, '400px')
|
||||
detach() // twice: nothing left to do
|
||||
})
|
||||
|
||||
test('attaching twice replaces the first attachment: one set of elements, listeners and subscriptions', () => {
|
||||
const { box, overlay, wrap, subscribers } = setup()
|
||||
overlay.attach(box as any)
|
||||
const first = resizeObservers.length
|
||||
overlay.attach(box as any)
|
||||
assert.deepEqual(
|
||||
box.children.map((c) => c.tag),
|
||||
['video', 'div'],
|
||||
)
|
||||
assert.equal(wrap().children.length, 2)
|
||||
assert.equal(windowListeners.mouseup.size, 1)
|
||||
assert.deepEqual([subscribers('cursor.position'), subscribers('cursor.image')], [1, 1])
|
||||
assert.ok(resizeObservers[first - 1].disconnected, 'the first observer is gone')
|
||||
assert.equal(resizeObservers.at(-1)!.disconnected, false)
|
||||
const other = new FakeElement('div')
|
||||
box.parent!.append(other)
|
||||
const detach = overlay.attach(other as any) // and it can move to another box
|
||||
assert.deepEqual([box.children.length, other.children.length], [1, 1])
|
||||
detach()
|
||||
assert.deepEqual([box.children.length, other.children.length], [1, 0])
|
||||
assert.equal(windowListeners.mouseup.size, 0)
|
||||
})
|
||||
|
||||
test('keyboard: the browser’s keydown/keypress/keyup on the textarea reach client.input.key as the X11 keysym, only while hosting and unlocked', async () => {
|
||||
const { client, calls, box, overlay, textarea } = setup()
|
||||
overlay.attach(box as any)
|
||||
// what the browser fires for the letter a
|
||||
const press = () => {
|
||||
textarea().dispatch('keydown', { keyCode: 65, key: 'a', location: 0 })
|
||||
textarea().dispatch('keypress', { keyCode: 97, charCode: 97, key: 'a', location: 0 })
|
||||
}
|
||||
const release = () => textarea().dispatch('keyup', { keyCode: 65, key: 'a', location: 0 })
|
||||
press()
|
||||
release()
|
||||
assert.deepEqual(calls, [], 'a viewer’s keys go nowhere')
|
||||
await host(client)
|
||||
press()
|
||||
assert.deepEqual(calls, [['key', 0x61, true]])
|
||||
release()
|
||||
assert.deepEqual(calls, [
|
||||
['key', 0x61, true],
|
||||
['key', 0x61, false],
|
||||
])
|
||||
client.lock()
|
||||
await settle()
|
||||
press()
|
||||
release()
|
||||
assert.equal(calls.length, 2, 'locked: nothing is sent')
|
||||
})
|
||||
|
||||
test('mouse: a mousedown reaches client.input.move and .button with the position in remote screen coordinates; the release may land anywhere', async () => {
|
||||
const { client, calls, box, overlay, canvas, textarea } = setup()
|
||||
overlay.attach(box as any)
|
||||
laidOut(canvas())
|
||||
const press = { clientX: 10 + 480, clientY: 20 + 270, button: 0 }
|
||||
textarea().dispatch('mousedown', press)
|
||||
assert.deepEqual(calls, [], 'not hosting: the press asks for control (implicit hosting) and waits')
|
||||
await host(client) // ...to be replayed once we are host
|
||||
assert.deepEqual(calls, [
|
||||
['move', 960, 540],
|
||||
['button', 1, true],
|
||||
])
|
||||
for (const fn of windowListeners.mouseup) fn({ clientX: 10 + 960 + 50, clientY: 20 - 50, button: 0 }) // outside: clamped
|
||||
assert.deepEqual(calls.slice(2), [
|
||||
['move', 1920, 0],
|
||||
['button', 1, false],
|
||||
])
|
||||
for (const fn of windowListeners.mouseup) fn(press) // a release without a press on the video: nothing
|
||||
assert.equal(calls.length, 4)
|
||||
textarea().dispatch('mousedown', { ...press, button: 2 }) // hosting: straight through
|
||||
assert.deepEqual(calls.slice(4), [
|
||||
['move', 960, 540],
|
||||
['button', 3, true],
|
||||
])
|
||||
})
|
||||
|
||||
test('touch: a touchstart reaches client.input.touch with the id, position and pressure', async () => {
|
||||
const { client, calls, box, overlay, canvas, textarea } = setup()
|
||||
overlay.attach(box as any)
|
||||
laidOut(canvas())
|
||||
await host(client)
|
||||
textarea().dispatch('touchstart', {
|
||||
changedTouches: [{ identifier: 7, clientX: 10 + 96, clientY: 20 + 54, force: 0.5 }],
|
||||
})
|
||||
assert.deepEqual(calls, [['touch', 'begin', 7, 192, 108, 128]])
|
||||
patch(client, 'control', { touch: false }) // a server without touch events: the first finger is the left button
|
||||
textarea().dispatch('touchend', { changedTouches: [{ identifier: 7, clientX: 10 + 96, clientY: 20 + 54, force: 0 }] })
|
||||
assert.deepEqual(calls.slice(1), [
|
||||
['move', 192, 108],
|
||||
['button', 1, false],
|
||||
])
|
||||
})
|
||||
|
||||
test('cursor: the host’s cursor from the transport is drawn on the canvas with their name, cleared on null, and is our own pointer while hosting', async () => {
|
||||
const { client, box, overlay, canvas, textarea, emit } = setup()
|
||||
overlay.attach(box as any)
|
||||
const ctx = canvas().ctx
|
||||
const drawn = () => ctx.calls.filter((c: unknown[]) => c[0] !== 'setTransform').map((c: unknown[]) => c[0])
|
||||
const image = { width: 16, height: 16, x: 1, y: 2, uri: 'blob:cursor' }
|
||||
emit('cursor.image', image)
|
||||
assert.equal(textarea().style.cursor, 'default', 'a viewer keeps their own pointer')
|
||||
emit('cursor.position', { x: 960, y: 540 })
|
||||
await tick(16) // the next frame
|
||||
assert.deepEqual(drawn(), ['clearRect', 'drawImage', 'strokeText', 'fillText'])
|
||||
const w = (500 * 1920) / 1080
|
||||
const [, img, x, y, dw, dh] = ctx.calls.find((c: unknown[]) => c[0] === 'drawImage')!
|
||||
assert.equal((img as { src: string }).src, 'blob:cursor')
|
||||
assert.deepEqual([x, y, dw, dh], [Math.round(w / 2) - 1, 250 - 2, 16, 16])
|
||||
assert.deepEqual(ctx.calls.find((c: unknown[]) => c[0] === 'fillText')!.slice(1), [
|
||||
'Bob',
|
||||
Math.round(w / 2) + 16,
|
||||
250 + 16,
|
||||
])
|
||||
|
||||
ctx.calls.length = 0
|
||||
emit('cursor.position', null) // the stream dropped its peer
|
||||
await tick(16)
|
||||
assert.deepEqual(drawn(), ['clearRect'])
|
||||
|
||||
emit('cursor.position', { x: 1, y: 1 })
|
||||
await host(client)
|
||||
await tick(16)
|
||||
assert.equal(textarea().style.cursor, 'url(blob:cursor) 1 2, default')
|
||||
assert.equal(drawn().includes('drawImage'), false, 'the host sees it as their real pointer, not drawn')
|
||||
})
|
||||
|
||||
test('mobileKeyboardToggle: focuses the textarea to open the on-screen keyboard, blurs it to close', () => {
|
||||
const { client, box, overlay, textarea } = setup()
|
||||
overlay.attach(box as any)
|
||||
overlay.mobileKeyboardToggle()
|
||||
assert.deepEqual([textarea().focused, client.state.mobile_keyboard_open], [true, true])
|
||||
overlay.mobileKeyboardToggle()
|
||||
assert.deepEqual([textarea().focused, client.state.mobile_keyboard_open], [false, false])
|
||||
textarea().focused = true
|
||||
client.store.setState({ mobile_keyboard_open: true })
|
||||
textarea().dispatch('blur') // the keyboard was dismissed by the user
|
||||
assert.equal(client.state.mobile_keyboard_open, false)
|
||||
})
|
||||
|
||||
test('mount(client, el): the stream and the overlay in a letterboxed box inside el; the returned function takes it all out', () => {
|
||||
const { client, area, box } = setup()
|
||||
box.remove()
|
||||
const unmount = mount(client, area as any)
|
||||
assert.equal(area.children.length, 1)
|
||||
const mounted = area.children[0]
|
||||
assert.equal(mounted.style.position, 'relative')
|
||||
assert.deepEqual(
|
||||
mounted.children.map((c) => c.tag),
|
||||
['video', 'div'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
mounted.children[1].children.map((c) => c.tag),
|
||||
['canvas', 'textarea'],
|
||||
)
|
||||
assert.equal(mounted.style.width, `${(500 * 1920) / 1080}px`)
|
||||
assert.equal(windowListeners.mouseup.size, 1)
|
||||
unmount()
|
||||
assert.deepEqual(area.children, [])
|
||||
assert.deepEqual(mounted.children, [], 'the transport’s element and the overlay are detached, not just the box')
|
||||
assert.equal(windowListeners.mouseup.size, 0)
|
||||
})
|
||||
|
||||
test('store subscriptions: resize on the screen size, sync on visibility and interactivity, control and focus on hosting, a redraw on the host; once per change, not for the rest, and one update with several fields once', async () => {
|
||||
const { client, box, overlay } = setup()
|
||||
const calls = { resize: 0, sync: 0, control: [] as boolean[], focus: 0, draw: 0 }
|
||||
const o = overlay as any
|
||||
o.onResize = () => calls.resize++
|
||||
o.sync = () => calls.sync++
|
||||
o.onControl = (c: boolean) => calls.control.push(c)
|
||||
o.focusIfActive = () => calls.focus++
|
||||
o.draw = () => calls.draw++ // before attach: the host subscription takes the function itself
|
||||
overlay.attach(box as any)
|
||||
Object.assign(calls, { resize: 0, sync: 0, draw: 0 }) // attach syncs and sizes once by itself
|
||||
|
||||
patch(client, 'control', { clipboard: { text: 'x' } }) // not watched
|
||||
client.store.setState({ mobile_keyboard_open: true, authenticated: true })
|
||||
patch(client, 'settings', { locked_logins: true })
|
||||
assert.deepEqual(calls, { resize: 0, sync: 0, control: [], focus: 0, draw: 0 })
|
||||
|
||||
patch(client, 'screen', { size: { width: 800, height: 600, rate: 30 } })
|
||||
assert.deepEqual(calls, { resize: 1, sync: 0, control: [], focus: 0, draw: 0 })
|
||||
patch(client, 'settings', { private_mode: true }) // hidden from a user
|
||||
assert.deepEqual(calls, { resize: 1, sync: 1, control: [], focus: 0, draw: 0 })
|
||||
patch(client, 'control', { host_id: 's1' }) // we are host: control, focus, and the drawn cursor goes
|
||||
assert.deepEqual(calls, { resize: 1, sync: 1, control: [true], focus: 1, draw: 1 })
|
||||
client.lock() // interactive and active flip, controlling does not
|
||||
assert.deepEqual(calls, { resize: 1, sync: 2, control: [true], focus: 2, draw: 1 })
|
||||
client.unlock()
|
||||
patch(client, 'control', { host_id: 's2' })
|
||||
assert.deepEqual(calls, { resize: 1, sync: 3, control: [true, false], focus: 4, draw: 2 })
|
||||
|
||||
// system/init changes the screen size, the sessions (we become admin), the settings (private
|
||||
// mode on) and the host in one update: every subscriber runs once, on the final state. Visible
|
||||
// stays true (an admin sees through private mode), so sync does not run at all; applied field
|
||||
// by field, settings before sessions, it would flip to false and back.
|
||||
patch(client, 'settings', { private_mode: false }) // visible again
|
||||
const before = JSON.parse(JSON.stringify(calls))
|
||||
client.connect()
|
||||
const p = init()
|
||||
p.sessions.s1.profile.is_admin = true
|
||||
p.settings.private_mode = true
|
||||
p.control_host = { has_host: true, host_id: 's1' }
|
||||
p.screen_size = { width: 640, height: 480, rate: 30 }
|
||||
;(client as any).onMessage('system/init', p)
|
||||
assert.deepEqual(calls, {
|
||||
resize: before.resize + 1,
|
||||
sync: before.sync,
|
||||
control: [...before.control, true],
|
||||
focus: before.focus + 1,
|
||||
draw: before.draw + 1,
|
||||
})
|
||||
assert.deepEqual([o.visible, o.interactive, o.active], [true, true, true])
|
||||
})
|
||||
@@ -0,0 +1,518 @@
|
||||
// The keyboard/mouse/touch control device: an input layer over the stream (mouse, wheel, keyboard,
|
||||
// touch, file drop) that sends through `client.input`, and the host's cursor drawn for everyone
|
||||
// who is not controlling. Attached by the consumer next to the transport's element (see attach);
|
||||
// the client itself knows nothing of it, so a stream-only consumer never loads it or the keyboard.
|
||||
import GuacamoleKeyboard from './keyboard/guacamole.js'
|
||||
import { selectControlling, type NekoClient, type Pos } from './client.ts'
|
||||
import type { CursorImage } from './types.ts'
|
||||
|
||||
const WHEEL_STEP = 53 // px of wheel delta per scroll step
|
||||
const WHEEL_LINE_HEIGHT = 19 // px per line when the browser reports lines
|
||||
const MOVE_THROTTLE_MS = 1000 / 60
|
||||
const LAYER = 'position:absolute;inset:0;width:100%;height:100%;'
|
||||
|
||||
// X11 keysyms
|
||||
const XK = {
|
||||
v: 0x0076,
|
||||
Control_L: 0xffe3,
|
||||
Control_R: 0xffe4,
|
||||
Meta_L: 0xffe7,
|
||||
Alt_L: 0xffe9,
|
||||
Alt_R: 0xffea,
|
||||
Super_L: 0xffeb,
|
||||
Super_R: 0xffec,
|
||||
Mode_switch: 0xff7e,
|
||||
ISO_Level3_Shift: 0xfe03,
|
||||
}
|
||||
|
||||
// macOS: Cmd acts as Ctrl and Option as AltGr on the remote (same as noVNC / the legacy client)
|
||||
const isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent)
|
||||
const MAC_REMAP: Record<number, number> = {
|
||||
[XK.Meta_L]: XK.Control_L,
|
||||
[XK.Super_L]: XK.Alt_L,
|
||||
[XK.Super_R]: XK.Super_L,
|
||||
[XK.Alt_L]: XK.Mode_switch,
|
||||
[XK.Alt_R]: XK.ISO_Level3_Shift,
|
||||
}
|
||||
const remap = (key: number) => (isMac && MAC_REMAP[key]) || key
|
||||
const isCtrl = (key: number) => key === XK.Control_L || key === XK.Control_R
|
||||
|
||||
const TOUCH_PHASE: Record<string, 'begin' | 'update' | 'end'> = {
|
||||
touchstart: 'begin',
|
||||
touchmove: 'update',
|
||||
touchend: 'end',
|
||||
touchcancel: 'end',
|
||||
}
|
||||
|
||||
export class Overlay {
|
||||
readonly wrap = document.createElement('div')
|
||||
private readonly canvas = document.createElement('canvas')
|
||||
private readonly ctx = this.canvas.getContext('2d')!
|
||||
private readonly input = document.createElement('textarea')
|
||||
private readonly keyboard = new GuacamoleKeyboard()
|
||||
private readonly cleanup: (() => void)[] = []
|
||||
private box: HTMLElement | null = null
|
||||
private size = { width: 0, height: 0 } // of the box, in CSS px
|
||||
|
||||
private cursor: CursorImage | null = null
|
||||
private readonly cursorImg = new Image()
|
||||
private cursorPos: Pos | null = null
|
||||
private drawQueued = false
|
||||
|
||||
private wheel = { x: 0, y: 0, t: 0 }
|
||||
private lastMove = 0
|
||||
private moveTimer = 0
|
||||
private mouseDown = false
|
||||
private pendingPress: { e: MouseEvent; at: number } | null = null // implicit hosting: replayed once we are host
|
||||
private ctrlDown = 0
|
||||
private readonly noKeyUp = new Set<number>()
|
||||
|
||||
private readonly client: NekoClient
|
||||
|
||||
constructor(client: NekoClient) {
|
||||
this.client = client
|
||||
this.canvas.style.cssText = LAYER
|
||||
// a transparent textarea receives keys, IME composition and the mobile keyboard
|
||||
this.input.style.cssText =
|
||||
LAYER +
|
||||
'font-size:16px;resize:none;caret-color:transparent;outline:0;border:0;color:transparent;background:transparent;'
|
||||
this.input.spellcheck = false
|
||||
this.input.setAttribute('autocapitalize', 'off')
|
||||
this.input.setAttribute('autocomplete', 'off')
|
||||
this.input.setAttribute('data-gramm', 'false')
|
||||
this.wrap.append(this.canvas, this.input)
|
||||
|
||||
this.keyboard.onkeydown = (key) => this.onKeyDown(remap(key))
|
||||
this.keyboard.onkeyup = (key) => this.onKeyUp(remap(key))
|
||||
this.keyboard.listenTo(this.input)
|
||||
this.cursorImg.onload = this.draw
|
||||
}
|
||||
|
||||
// `box` is a positioned element that already holds the transport's element (so the stream is
|
||||
// below): the overlay fills it, and keeps it letterboxed inside its parent to the remote screen's
|
||||
// aspect ratio so that its canvas maps onto the picture (a stream alone needs no letterboxing, the
|
||||
// <video> keeps the aspect ratio by itself). Returns what detaches it again; a second attach
|
||||
// replaces the first.
|
||||
attach(box: HTMLElement): () => void {
|
||||
this.detach()
|
||||
this.box = box
|
||||
box.append(this.wrap)
|
||||
const { client, input } = this
|
||||
|
||||
const on = (el: EventTarget, ev: string, fn: (e: any) => void, opts?: AddEventListenerOptions) => {
|
||||
el.addEventListener(ev, fn, opts)
|
||||
this.cleanup.push(() => el.removeEventListener(ev, fn, opts))
|
||||
}
|
||||
const stop = (fn: (e: any) => void) => (e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
fn(e)
|
||||
}
|
||||
on(
|
||||
input,
|
||||
'click',
|
||||
stop((e) => client.events.emit('overlay.click', e)),
|
||||
)
|
||||
on(
|
||||
input,
|
||||
'contextmenu',
|
||||
stop(() => {}),
|
||||
)
|
||||
on(
|
||||
input,
|
||||
'wheel',
|
||||
stop((e) => this.onWheel(e)),
|
||||
{ passive: false },
|
||||
)
|
||||
on(
|
||||
input,
|
||||
'mousemove',
|
||||
stop((e) => this.onMove(e)),
|
||||
)
|
||||
on(
|
||||
input,
|
||||
'mousedown',
|
||||
stop((e) => this.onButton(e, true)),
|
||||
)
|
||||
// mouseup anywhere: the button may be released outside the video
|
||||
on(window, 'mouseup', (e) => this.onButton(e, false), { capture: true })
|
||||
on(input, 'mouseenter', (e) => this.onEnter(e))
|
||||
on(input, 'mouseleave', () => {
|
||||
this.hovering = false
|
||||
this.keyboard.reset()
|
||||
})
|
||||
for (const ev of Object.keys(TOUCH_PHASE)) on(input, ev, (e) => this.onTouch(e), { passive: false })
|
||||
on(
|
||||
input,
|
||||
'dragover',
|
||||
stop((e) => this.onMove(e)),
|
||||
)
|
||||
on(
|
||||
input,
|
||||
'drop',
|
||||
stop((e) => this.onDrop(e)),
|
||||
)
|
||||
// text that arrives as input or composition events (IME, on-screen keyboards) is typed as
|
||||
// key events by the Guacamole keyboard; the textarea only has to be emptied afterwards
|
||||
on(input, 'input', (e) => !e.isComposing && (this.input.value = ''))
|
||||
on(input, 'compositionend', () => (this.input.value = ''))
|
||||
on(
|
||||
input,
|
||||
'paste',
|
||||
stop((e: ClipboardEvent) => this.onPaste(e.clipboardData?.getData('text/plain') ?? '')),
|
||||
)
|
||||
on(input, 'blur', () => client.store.setState({ mobile_keyboard_open: false }))
|
||||
|
||||
// the box follows its parent and the remote screen
|
||||
const observer = new ResizeObserver(() => this.onResize())
|
||||
if (box.parentElement) observer.observe(box.parentElement)
|
||||
this.cleanup.push(
|
||||
() => observer.disconnect(),
|
||||
client.store.subscribe(
|
||||
(s) => s.screen.size,
|
||||
() => this.onResize(),
|
||||
),
|
||||
client.store.subscribe(
|
||||
() => this.visible,
|
||||
() => this.sync(),
|
||||
),
|
||||
client.store.subscribe(
|
||||
() => this.interactive,
|
||||
() => this.sync(),
|
||||
),
|
||||
client.store.subscribe(selectControlling, (c) => this.onControl(c)),
|
||||
client.store.subscribe((s) => s.control.host_id, this.draw),
|
||||
client.store.subscribe(
|
||||
() => this.active,
|
||||
() => this.focusIfActive(),
|
||||
),
|
||||
client.transport.on('cursor.position', (p) => (p ? this.onCursorPosition(p) : this.clearCursor())),
|
||||
client.transport.on('cursor.image', (img) => this.onCursorImage(img)),
|
||||
)
|
||||
this.sync()
|
||||
this.onResize()
|
||||
return () => this.detach()
|
||||
}
|
||||
|
||||
private detach() {
|
||||
if (!this.box) return
|
||||
clearTimeout(this.moveTimer)
|
||||
this.cleanup.splice(0).forEach((fn) => fn())
|
||||
this.keyboard.reset()
|
||||
this.wrap.remove()
|
||||
this.box = null
|
||||
}
|
||||
|
||||
// shown unless the room is private (to non-admins) or there is no connection; takes the mouse
|
||||
// only while this member may host and has not locked themselves out
|
||||
private get visible() {
|
||||
return !this.client.privateModeEnabled && this.client.state.connection.status !== 'disconnected'
|
||||
}
|
||||
|
||||
private get interactive() {
|
||||
return !this.client.state.control.locked && !!this.client.session?.profile.can_host
|
||||
}
|
||||
|
||||
private sync() {
|
||||
this.wrap.style.display = this.visible ? '' : 'none'
|
||||
this.wrap.style.pointerEvents = this.interactive ? 'auto' : 'none'
|
||||
}
|
||||
|
||||
// letterbox the box to the remote screen's aspect ratio inside its parent
|
||||
private onResize() {
|
||||
const area = this.box?.parentElement
|
||||
if (!this.box || !area) return
|
||||
const { width, height } = this.client.state.screen.size
|
||||
const { offsetWidth: W, offsetHeight: H } = area
|
||||
const w = Math.min(W, (H * width) / height)
|
||||
const h = Math.min(H, (W * height) / width)
|
||||
Object.assign(this.box.style, {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
marginTop: `${(H - h) / 2}px`,
|
||||
marginLeft: `${(W - w) / 2}px`,
|
||||
})
|
||||
this.size = { width: w, height: h }
|
||||
this.resize()
|
||||
}
|
||||
|
||||
// input only flows while we are host and have not locked ourselves out
|
||||
private get active() {
|
||||
return this.client.controlling && !this.client.state.control.locked
|
||||
}
|
||||
|
||||
private pos(e: { clientX: number; clientY: number }): Pos {
|
||||
const r = this.canvas.getBoundingClientRect()
|
||||
const { width, height } = this.client.state.screen.size
|
||||
const clamp = (v: number, max: number) => Math.max(0, Math.min(Math.round(v), max))
|
||||
return {
|
||||
x: clamp((width / r.width) * (e.clientX - r.left), width),
|
||||
y: clamp((height / r.height) * (e.clientY - r.top), height),
|
||||
}
|
||||
}
|
||||
|
||||
private move(p: Pos) {
|
||||
this.client.input.move(p.x, p.y)
|
||||
}
|
||||
|
||||
private button(code: number, down: boolean) {
|
||||
this.client.input.button(code, down)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// mouse
|
||||
/////////////////////////////
|
||||
|
||||
// throttled to MOVE_THROTTLE_MS, but the last position always goes out (trailing send),
|
||||
// otherwise the remote pointer stops short of where the local one came to rest
|
||||
private onMove(e: MouseEvent) {
|
||||
if (!this.active) return
|
||||
const pos = this.pos(e)
|
||||
clearTimeout(this.moveTimer)
|
||||
const wait = MOVE_THROTTLE_MS - (performance.now() - this.lastMove)
|
||||
if (wait <= 0) return this.sendMove(pos)
|
||||
this.moveTimer = window.setTimeout(() => this.sendMove(pos), wait)
|
||||
}
|
||||
|
||||
private sendMove(pos: Pos) {
|
||||
this.lastMove = performance.now()
|
||||
if (this.active) this.move(pos)
|
||||
}
|
||||
|
||||
private onButton(e: MouseEvent, down: boolean) {
|
||||
if (down) this.mouseDown = true
|
||||
else if (!this.mouseDown)
|
||||
return // only releases of presses that started on the video
|
||||
else this.mouseDown = false
|
||||
|
||||
if (!this.client.controlling) {
|
||||
if (down && this.client.implicitControl) {
|
||||
this.pendingPress = { e, at: performance.now() }
|
||||
this.client.request()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.client.state.control.locked) return
|
||||
this.move(this.pos(e))
|
||||
this.button(e.button + 1, down)
|
||||
}
|
||||
|
||||
private onWheel(e: WheelEvent) {
|
||||
if (!this.active) return
|
||||
|
||||
// the first event of a gesture scrolls immediately, then one step per WHEEL_STEP px
|
||||
const first = e.timeStamp - this.wheel.t > 250
|
||||
if (first) this.wheel = { x: 0, y: 0, t: e.timeStamp }
|
||||
const k = e.deltaMode !== 0 ? WHEEL_LINE_HEIGHT : 1
|
||||
this.wheel.x += e.deltaX * k
|
||||
this.wheel.y += e.deltaY * k
|
||||
|
||||
// sensitivity -5..5: negative widens the step, positive scrolls more per step
|
||||
const { sensitivity, inverse } = this.client.state.control.scroll
|
||||
const step = sensitivity < 0 ? WHEEL_STEP * (1 - sensitivity) : WHEEL_STEP
|
||||
const amount = (sensitivity > 0 ? sensitivity + 1 : 1) * (inverse ? -1 : 1)
|
||||
const axis = (d: 'x' | 'y') => {
|
||||
const v = this.wheel[d]
|
||||
if (!first && Math.abs(v) < step) return 0
|
||||
if (!first) this.wheel[d] = 0
|
||||
return Math.sign(v) * amount
|
||||
}
|
||||
const dx = axis('x')
|
||||
const dy = axis('y')
|
||||
if (dx || dy) this.client.input.scroll(dx, dy, e.ctrlKey)
|
||||
}
|
||||
|
||||
private hovering = false
|
||||
|
||||
// keys go to the hidden input only while it is useful: a viewer typing in the chat must not lose
|
||||
// the focus by brushing the video (touch devices: focusing would pop up the on-screen keyboard)
|
||||
private focusIfActive() {
|
||||
if (this.hovering && this.active && !this.client.isTouchDevice) this.input.focus()
|
||||
}
|
||||
|
||||
private onEnter(e: MouseEvent) {
|
||||
this.hovering = true
|
||||
this.focusIfActive()
|
||||
if (this.client.controlling) {
|
||||
this.client.send('keyboard/modifiers', {
|
||||
capslock: e.getModifierState('CapsLock'),
|
||||
numlock: e.getModifierState('NumLock'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private onDrop(e: DragEvent) {
|
||||
if (!this.client.controlling && !this.client.implicitControl) return
|
||||
// dropped folders are skipped; walking webkitGetAsEntry() would be needed for them
|
||||
const files = [...(e.dataTransfer?.files ?? [])]
|
||||
if (files.length) this.client.uploadDrop({ ...this.pos(e), files })
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// keyboard
|
||||
/////////////////////////////
|
||||
|
||||
private onKeyDown(key: number): boolean {
|
||||
if (!this.active) {
|
||||
this.noKeyUp.add(key)
|
||||
return true
|
||||
}
|
||||
// Ctrl+V: the keystroke stays with the browser, whose paste event hands over the local
|
||||
// clipboard text (see onPaste). Ctrl itself was sent and stays held, so the next key has it.
|
||||
if (this.ctrlDown && key === XK.v) {
|
||||
this.noKeyUp.add(key)
|
||||
return true
|
||||
}
|
||||
if (isCtrl(key)) this.ctrlDown = key
|
||||
this.client.input.key(key, true)
|
||||
return isCtrl(key) // ctrl must reach the browser for the paste shortcut above
|
||||
}
|
||||
|
||||
private onKeyUp(key: number) {
|
||||
if (this.noKeyUp.delete(key)) return
|
||||
if (isCtrl(key)) this.ctrlDown = 0
|
||||
this.client.input.key(key, false)
|
||||
}
|
||||
|
||||
// the remote gets its paste keystroke once the clipboard is settled. By then the user may have
|
||||
// let go of Ctrl, so the chord is completed with our own Ctrl when needed
|
||||
private async onPaste(text: string) {
|
||||
if (!this.active) return
|
||||
await this.client.preparePaste(text)
|
||||
if (!this.active) return
|
||||
const ctrl = this.ctrlDown
|
||||
if (!ctrl) this.client.input.key(XK.Control_L, true)
|
||||
this.client.input.key(XK.v, true)
|
||||
this.client.input.key(XK.v, false)
|
||||
if (!ctrl) this.client.input.key(XK.Control_L, false)
|
||||
}
|
||||
|
||||
mobileKeyboardToggle() {
|
||||
const open = this.client.state.mobile_keyboard_open
|
||||
if (open)
|
||||
this.input.blur() // the blur handler clears the flag; set it from `open`, not from the flag
|
||||
else this.input.focus()
|
||||
this.client.store.setState({ mobile_keyboard_open: !open })
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// touch
|
||||
/////////////////////////////
|
||||
|
||||
private onTouch(e: TouchEvent) {
|
||||
if (!this.client.controlling) {
|
||||
if (e.type === 'touchstart' && this.client.implicitControl) this.client.request()
|
||||
return // let the page scroll
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (this.client.state.control.locked) return
|
||||
|
||||
if (this.client.state.control.touch) {
|
||||
for (const t of e.changedTouches) {
|
||||
const p = this.pos(t)
|
||||
this.client.input.touch(TOUCH_PHASE[e.type], t.identifier, p.x, p.y, Math.round(t.force * 255))
|
||||
}
|
||||
return
|
||||
}
|
||||
// server without touch support: the first finger drives the left mouse button
|
||||
const t = e.changedTouches[0]
|
||||
this.move(this.pos(t))
|
||||
if (e.type === 'touchstart') this.button(1, true)
|
||||
else if (e.type !== 'touchmove') this.button(1, false)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// host changes + remote cursor
|
||||
/////////////////////////////
|
||||
|
||||
private onControl(controlling: boolean) {
|
||||
this.ctrlDown = 0
|
||||
this.noKeyUp.clear()
|
||||
this.updateCursorStyle()
|
||||
this.draw()
|
||||
|
||||
const pending = this.pendingPress
|
||||
this.pendingPress = null
|
||||
// only a press from just now: control that arrives minutes later by other means (an admin
|
||||
// giving it) must not replay an old click
|
||||
if (controlling && pending && performance.now() - pending.at < 2000) {
|
||||
const e = pending.e
|
||||
this.move(this.pos(e))
|
||||
this.button(e.button + 1, true)
|
||||
if (!this.mouseDown) this.button(e.button + 1, false) // released while we waited
|
||||
}
|
||||
}
|
||||
|
||||
private onCursorImage(img: CursorImage) {
|
||||
const old = this.cursor
|
||||
this.cursor = img
|
||||
this.cursorImg.src = img.uri
|
||||
this.updateCursorStyle()
|
||||
if (old) URL.revokeObjectURL(old.uri)
|
||||
}
|
||||
|
||||
private onCursorPosition(p: Pos) {
|
||||
this.cursorPos = p
|
||||
this.draw()
|
||||
}
|
||||
|
||||
private clearCursor() {
|
||||
this.cursorPos = null
|
||||
this.draw()
|
||||
}
|
||||
|
||||
// the host sees the remote cursor as their real mouse pointer
|
||||
private updateCursorStyle() {
|
||||
const c = this.cursor
|
||||
this.input.style.cursor = this.client.controlling && c ? `url(${c.uri}) ${c.x} ${c.y}, default` : 'default'
|
||||
}
|
||||
|
||||
// devicePixelRatio is read on resize only; listen to matchMedia(resolution) if
|
||||
// cursors look blurry after dragging the window to another monitor
|
||||
private resize() {
|
||||
const { width, height } = this.size
|
||||
this.canvas.width = width * devicePixelRatio
|
||||
this.canvas.height = height * devicePixelRatio
|
||||
this.draw()
|
||||
}
|
||||
|
||||
private draw = () => {
|
||||
if (this.drawQueued) return
|
||||
this.drawQueued = true
|
||||
requestAnimationFrame(() => {
|
||||
this.drawQueued = false
|
||||
this.paint()
|
||||
})
|
||||
}
|
||||
|
||||
// everyone but the host sees the host's cursor drawn here, tagged with their name
|
||||
private paint() {
|
||||
const { ctx, cursor, cursorPos } = this
|
||||
const { width, height } = this.size
|
||||
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (this.client.controlling || !cursor || !cursorPos || (cursor.width <= 1 && cursor.height <= 1)) return
|
||||
|
||||
const screen = this.client.state.screen.size
|
||||
let x = Math.round((cursorPos.x / screen.width) * width)
|
||||
let y = Math.round((cursorPos.y / screen.height) * height)
|
||||
ctx.drawImage(this.cursorImg, x - cursor.x, y - cursor.y, cursor.width, cursor.height)
|
||||
|
||||
const hostId = this.client.state.control.host_id
|
||||
const name = hostId ? this.client.state.sessions[hostId]?.profile.name : ''
|
||||
if (!name) return
|
||||
x += cursor.width
|
||||
y += cursor.height
|
||||
ctx.font = '14px Arial, sans-serif'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.shadowColor = 'black'
|
||||
ctx.shadowBlur = 2
|
||||
ctx.lineWidth = 2
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.strokeText(name, x, y)
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillText(name, x, y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Unit tests of the client's store (zustand, immutable updates, selector subscriptions) and the
|
||||
// emitter: `npm test` (vitest)
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { reset, patch, session } from './test/browser.ts'
|
||||
import { Emitter } from './emitter.ts'
|
||||
const { NekoClient, selectControlling, selectSession, selectIsAdmin } = await import('./index.ts')
|
||||
|
||||
const fresh = () => {
|
||||
reset()
|
||||
return new NekoClient({ autologin: false, autoconnect: false })
|
||||
}
|
||||
|
||||
test('a selector subscription fires only when the selected value changes, with the value before', () => {
|
||||
const client = fresh()
|
||||
const seen: unknown[] = []
|
||||
const off = client.store.subscribe(
|
||||
(s) => s.video.playing,
|
||||
(v, old) => seen.push([v, old]),
|
||||
)
|
||||
// writes elsewhere in the state: not seen (the cursor never enters the store at all, it is a
|
||||
// transport event the overlay draws)
|
||||
patch(client, 'control', { clipboard: { text: 'x' } })
|
||||
client.store.setState({ mobile_keyboard_open: true })
|
||||
patch(client, 'video', { volume: 0.5 }) // the same slice, another field: a new object, the same value
|
||||
assert.deepEqual(seen, [])
|
||||
patch(client, 'video', { playing: true })
|
||||
assert.deepEqual(seen, [[true, false]])
|
||||
patch(client, 'video', { playing: true }) // no change
|
||||
assert.deepEqual(seen, [[true, false]])
|
||||
off()
|
||||
patch(client, 'video', { playing: false })
|
||||
assert.equal(seen.length, 1, 'unsubscribed')
|
||||
})
|
||||
|
||||
test('updates are immutable: the changed slice is a new object, untouched slices keep their identity', () => {
|
||||
const client = fresh()
|
||||
const before = client.state
|
||||
client.lock()
|
||||
const after = client.state
|
||||
assert.notEqual(after, before)
|
||||
assert.notEqual(after.control, before.control)
|
||||
assert.equal(after.control.locked, true)
|
||||
assert.equal(before.control.locked, false, 'the state before is not touched')
|
||||
for (const k of ['connection', 'video', 'screen', 'sessions', 'settings'] as const)
|
||||
assert.equal(after[k], before[k], `${k} keeps its identity`)
|
||||
client.setScrollInverse(false)
|
||||
assert.equal(
|
||||
client.state.control.keyboard,
|
||||
after.control.keyboard,
|
||||
'the sibling of a nested write keeps its identity',
|
||||
)
|
||||
assert.notEqual(client.state.control.scroll, after.control.scroll)
|
||||
})
|
||||
|
||||
test('client.state is the store’s current state; a plain listener sees every update', () => {
|
||||
const client = fresh()
|
||||
assert.equal(client.state, client.store.getState())
|
||||
let runs = 0
|
||||
const off = client.store.subscribe(() => runs++)
|
||||
client.store.setState({ authenticated: true })
|
||||
assert.equal(client.state, client.store.getState())
|
||||
assert.deepEqual([client.state.authenticated, runs], [true, 1])
|
||||
patch(client, 'video', { volume: 0.5 })
|
||||
assert.equal(runs, 2)
|
||||
off()
|
||||
assert.ok(!('watch' in client.store) && !('version' in client.store), 'the proxy store’s API is gone')
|
||||
})
|
||||
|
||||
test('selectors: session, controlling and isAdmin from the state, as the getters report them', () => {
|
||||
const client = fresh()
|
||||
const s1 = session('s1', { is_admin: true })
|
||||
assert.deepEqual(
|
||||
[selectSession(client.state), selectControlling(client.state), selectIsAdmin(client.state)],
|
||||
[null, false, false],
|
||||
)
|
||||
client.store.setState({ session_id: 's1', sessions: { s1 } })
|
||||
assert.deepEqual([selectSession(client.state), client.session], [s1, s1])
|
||||
assert.deepEqual([selectIsAdmin(client.state), client.isAdmin], [true, true])
|
||||
patch(client, 'control', { host_id: 's2' })
|
||||
assert.deepEqual([selectControlling(client.state), client.controlling], [false, false])
|
||||
patch(client, 'control', { host_id: 's1' })
|
||||
assert.deepEqual([selectControlling(client.state), client.controlling], [true, true])
|
||||
})
|
||||
|
||||
test('emitter: on returns the unsubscribe, once fires one time', () => {
|
||||
const e = new Emitter<{ x: (v: number) => void }>()
|
||||
const got: number[] = []
|
||||
const off = e.on('x', (v) => got.push(v))
|
||||
e.once('x', (v) => got.push(v * 10))
|
||||
e.emit('x', 1)
|
||||
off()
|
||||
e.emit('x', 2)
|
||||
assert.deepEqual(got, [1, 10])
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
// The browser, faked just far enough for the core to load and run under vitest: globals, a few
|
||||
// elements, a websocket, a peer connection and fetch. Imported first; the core is then imported
|
||||
// dynamically, after the globals exist. The clock is vitest's (see tick/settle).
|
||||
import { vi } from 'vitest'
|
||||
import type { NekoClient } from '../client.ts'
|
||||
import type { State } from '../types.ts'
|
||||
|
||||
const g = globalThis as any
|
||||
|
||||
// elements: enough of a <video>, <div>, <canvas> and <textarea> for the transport and the overlay
|
||||
export class FakeElement {
|
||||
readonly tag: string
|
||||
children: FakeElement[] = []
|
||||
parent: FakeElement | null = null
|
||||
style: Record<string, string> = {}
|
||||
playsInline = false
|
||||
spellcheck = true
|
||||
value = ''
|
||||
attrs: Record<string, string> = {}
|
||||
paused = true
|
||||
playError: Error | null = null // what play() rejects with, once
|
||||
plays = 0 // how often play() was called
|
||||
videoWidth = 0
|
||||
videoHeight = 0
|
||||
offsetWidth = 0
|
||||
offsetHeight = 0
|
||||
rect = { left: 0, top: 0, width: 0, height: 0 } // what getBoundingClientRect reports
|
||||
focused = false
|
||||
// a <canvas> context that records what is drawn
|
||||
readonly ctx = new Proxy({ calls: [] as unknown[][] } as Record<string, any>, {
|
||||
get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => t.calls.push([k, ...a])),
|
||||
})
|
||||
private listeners = new Map<string, Set<(e: any) => void>>()
|
||||
private _muted = false
|
||||
private _volume = 1
|
||||
private _srcObject: unknown = null
|
||||
|
||||
constructor(tag: string) {
|
||||
this.tag = tag
|
||||
}
|
||||
get muted() {
|
||||
return this._muted
|
||||
}
|
||||
set muted(v: boolean) {
|
||||
this._muted = v
|
||||
this.dispatch('volumechange')
|
||||
}
|
||||
get volume() {
|
||||
return this._volume
|
||||
}
|
||||
set volume(v: number) {
|
||||
this._volume = v
|
||||
this.dispatch('volumechange')
|
||||
}
|
||||
get srcObject() {
|
||||
return this._srcObject
|
||||
}
|
||||
set srcObject(v: unknown) {
|
||||
const had = this._srcObject
|
||||
this._srcObject = v
|
||||
if (had && !v) this.dispatch('emptied')
|
||||
}
|
||||
addEventListener(ev: string, fn: (e: any) => void) {
|
||||
if (!this.listeners.has(ev)) this.listeners.set(ev, new Set())
|
||||
this.listeners.get(ev)!.add(fn)
|
||||
}
|
||||
removeEventListener(ev: string, fn: (e: any) => void) {
|
||||
this.listeners.get(ev)?.delete(fn)
|
||||
}
|
||||
// one event object for every listener, as the DOM does; a test's fields override the defaults
|
||||
dispatch(ev: string, e: any = {}) {
|
||||
const event = { type: ev, preventDefault() {}, stopPropagation() {}, ...e }
|
||||
for (const fn of [...(this.listeners.get(ev) ?? [])]) fn(event)
|
||||
}
|
||||
get parentElement() {
|
||||
return this.parent
|
||||
}
|
||||
append(...els: FakeElement[]) {
|
||||
for (const el of els) {
|
||||
el.parent = this
|
||||
this.children.push(el)
|
||||
}
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this)
|
||||
this.parent = null
|
||||
}
|
||||
setAttribute(k: string, v: string) {
|
||||
this.attrs[k] = v
|
||||
}
|
||||
async play() {
|
||||
this.plays++
|
||||
if (this.playError) {
|
||||
const err = this.playError
|
||||
this.playError = null
|
||||
throw err
|
||||
}
|
||||
this.paused = false
|
||||
this.dispatch('playing')
|
||||
}
|
||||
pause() {
|
||||
this.paused = true
|
||||
this.dispatch('pause')
|
||||
}
|
||||
getContext() {
|
||||
return this.ctx
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return this.rect
|
||||
}
|
||||
focus() {
|
||||
this.focused = true
|
||||
}
|
||||
blur() {
|
||||
this.focused = false
|
||||
}
|
||||
}
|
||||
|
||||
g.window = g
|
||||
g.location = { href: 'http://neko.test/' }
|
||||
Object.defineProperty(g, 'navigator', {
|
||||
value: { platform: 'Linux', userAgent: 'test', maxTouchPoints: 0 },
|
||||
configurable: true,
|
||||
})
|
||||
export const storage = new Map<string, string>()
|
||||
g.localStorage = {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storage.set(k, v),
|
||||
removeItem: (k: string) => storage.delete(k),
|
||||
}
|
||||
g.matchMedia = () => ({ matches: false })
|
||||
// what the overlay needs: observers and window listeners are recorded so tests can fire and count them
|
||||
export const resizeObservers: { cb: () => void; observed: FakeElement | null; disconnected: boolean }[] = []
|
||||
g.ResizeObserver = class {
|
||||
private readonly rec: (typeof resizeObservers)[number]
|
||||
constructor(cb: () => void) {
|
||||
resizeObservers.push((this.rec = { cb, observed: null, disconnected: false }))
|
||||
}
|
||||
observe(el: FakeElement) {
|
||||
this.rec.observed = el
|
||||
}
|
||||
disconnect() {
|
||||
this.rec.disconnected = true
|
||||
}
|
||||
}
|
||||
g.Image = class {}
|
||||
g.devicePixelRatio = 1
|
||||
g.requestAnimationFrame = (fn: () => void) => setTimeout(fn, 16)
|
||||
export const windowListeners: Record<string, Set<(e: any) => void>> = {}
|
||||
g.addEventListener = (ev: string, fn: (e: any) => void) => (windowListeners[ev] ??= new Set()).add(fn)
|
||||
g.removeEventListener = (ev: string, fn: (e: any) => void) => windowListeners[ev]?.delete(fn)
|
||||
export const documentListeners: Record<string, ((e: any) => void)[]> = {}
|
||||
g.document = {
|
||||
createElement: (tag: string) => new FakeElement(tag),
|
||||
addEventListener: (ev: string, fn: (e: any) => void) => (documentListeners[ev] ??= []).push(fn),
|
||||
removeEventListener() {},
|
||||
hasFocus: () => true,
|
||||
}
|
||||
|
||||
// the clock: timers and Date are fake, setImmediate stays real so promise chains can be drained
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] })
|
||||
// one macrotask turn drains every pending promise chain (the handshake, a fetch)
|
||||
export const settle = () => new Promise<void>((r) => setImmediate(r))
|
||||
export const tick = async (ms: number) => {
|
||||
await settle() // whatever is in flight may still schedule a timer
|
||||
await vi.advanceTimersByTimeAsync(ms) // fires the due timers in order, settling after each
|
||||
}
|
||||
|
||||
// the network
|
||||
export const sent: string[] = []
|
||||
export const sentPayloads: { event: string; payload: unknown }[] = []
|
||||
export const sockets: FakeSocket[] = []
|
||||
export class FakeSocket {
|
||||
static OPEN = 1
|
||||
readyState = 1
|
||||
url: string
|
||||
closed = false
|
||||
code?: number
|
||||
onmessage?: (e: { data: string }) => void
|
||||
onclose?: () => void
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
sockets.push(this)
|
||||
}
|
||||
send(data: string) {
|
||||
const m = JSON.parse(data)
|
||||
sent.push(m.event)
|
||||
sentPayloads.push(m)
|
||||
}
|
||||
close(code?: number) {
|
||||
this.closed = true
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
g.WebSocket = FakeSocket
|
||||
|
||||
export const OFFER =
|
||||
'v=0\r\nm=audio 9 RTP/AVP 0\r\na=rtpmap:0 opus/48000\r\nm=video 9 RTP/AVP 96\r\na=rtpmap:96 VP8/90000\r\n'
|
||||
export const ANSWER = 'v=0\r\nm=audio 9 RTP/AVP 0\r\nm=video 9 RTP/AVP 96\r\n'
|
||||
export class FakePeer {
|
||||
static all: FakePeer[] = []
|
||||
static answerSdp = ANSWER
|
||||
config: unknown
|
||||
connectionState = 'new'
|
||||
signalingState = 'stable'
|
||||
remoteDescription: unknown = null
|
||||
localDescription: { sdp: string } | null = null
|
||||
candidates: unknown[] = []
|
||||
senders: unknown[] = []
|
||||
closed = false
|
||||
onconnectionstatechange?: () => void
|
||||
ontrack?: (e: unknown) => void
|
||||
ondatachannel?: (e: { channel: unknown }) => void
|
||||
onicecandidate?: (e: { candidate: { toJSON(): unknown } | null }) => void
|
||||
onnegotiationneeded?: () => Promise<void>
|
||||
constructor(config?: unknown) {
|
||||
this.config = config
|
||||
FakePeer.all.push(this)
|
||||
}
|
||||
async setRemoteDescription(d: unknown) {
|
||||
this.remoteDescription = d
|
||||
}
|
||||
async createAnswer() {
|
||||
return { type: 'answer', sdp: FakePeer.answerSdp }
|
||||
}
|
||||
async createOffer() {
|
||||
return { type: 'offer', sdp: 'local-offer' }
|
||||
}
|
||||
async setLocalDescription(d: { sdp: string }) {
|
||||
this.localDescription = d
|
||||
}
|
||||
async addIceCandidate(c: unknown) {
|
||||
this.candidates.push(c)
|
||||
}
|
||||
addTrack(track: unknown) {
|
||||
const sender = { track }
|
||||
this.senders.push(sender)
|
||||
return sender
|
||||
}
|
||||
removeTrack(sender: unknown) {
|
||||
if (!this.senders.includes(sender)) throw new Error('InvalidAccessError')
|
||||
this.senders = this.senders.filter((s) => s !== sender)
|
||||
}
|
||||
getSenders() {
|
||||
return this.senders
|
||||
}
|
||||
close() {
|
||||
this.closed = true
|
||||
}
|
||||
// what the browser's ICE agent would report
|
||||
transition(s: string) {
|
||||
this.connectionState = s
|
||||
this.onconnectionstatechange?.()
|
||||
}
|
||||
}
|
||||
g.RTCPeerConnection = FakePeer
|
||||
|
||||
export const fetches: { url: string; init: RequestInit }[] = []
|
||||
// what fetch answers; tests replace net.respond
|
||||
export const net = {
|
||||
respond: (_url: string, _init: RequestInit): { status: number; body?: unknown } => ({ status: 200 }),
|
||||
}
|
||||
g.fetch = async (url: string, init: RequestInit) => {
|
||||
fetches.push({ url, init })
|
||||
const { status, body } = net.respond(url, init)
|
||||
const text = body === undefined ? '' : JSON.stringify(body)
|
||||
return {
|
||||
ok: status < 300,
|
||||
status,
|
||||
statusText: 'status ' + status,
|
||||
json: async () => JSON.parse(text),
|
||||
text: async () => text,
|
||||
}
|
||||
}
|
||||
|
||||
// a data channel as the peer would hand it over
|
||||
export class FakeChannel {
|
||||
readyState = 'connecting'
|
||||
binaryType = ''
|
||||
frames: number[][] = []
|
||||
onmessage?: (e: { data: ArrayBuffer }) => void
|
||||
send(b: ArrayBuffer) {
|
||||
this.frames.push([...new Uint8Array(b)])
|
||||
}
|
||||
}
|
||||
|
||||
// server payloads
|
||||
export const profile = (over = {}) => ({
|
||||
name: 'Alice',
|
||||
is_admin: false,
|
||||
can_login: true,
|
||||
can_connect: true,
|
||||
can_watch: true,
|
||||
can_host: true,
|
||||
can_share_media: false,
|
||||
can_access_clipboard: true,
|
||||
sends_inactive_cursor: false,
|
||||
can_see_inactive_cursors: false,
|
||||
...over,
|
||||
})
|
||||
export const session = (id: string, over = {}) => ({
|
||||
id,
|
||||
profile: profile(over),
|
||||
state: { is_connected: true, is_watching: false },
|
||||
})
|
||||
export const settings = (over = {}) => ({
|
||||
private_mode: false,
|
||||
locked_logins: false,
|
||||
locked_controls: false,
|
||||
control_protection: false,
|
||||
implicit_hosting: true,
|
||||
inactive_cursors: false,
|
||||
merciful_reconnect: true,
|
||||
...over,
|
||||
})
|
||||
export const init = () => ({
|
||||
session_id: 's1',
|
||||
sessions: { s1: session('s1'), s2: session('s2', { name: 'Bob' }) },
|
||||
settings: settings(),
|
||||
screen_size: { width: 1920, height: 1080, rate: 60 },
|
||||
control_host: { has_host: true, host_id: 's2' },
|
||||
touch_events: true,
|
||||
})
|
||||
export const receive = (event: string, payload?: unknown, ws = sockets.at(-1)!) =>
|
||||
ws.onmessage!({ data: JSON.stringify({ event, payload }) })
|
||||
|
||||
// a clean slate for a test
|
||||
export const reset = () => {
|
||||
sent.length = sentPayloads.length = sockets.length = fetches.length = FakePeer.all.length = 0
|
||||
resizeObservers.length = 0
|
||||
for (const k of Object.keys(documentListeners)) delete documentListeners[k]
|
||||
for (const k of Object.keys(windowListeners)) delete windowListeners[k]
|
||||
vi.clearAllTimers()
|
||||
storage.clear()
|
||||
net.respond = () => ({ status: 200 })
|
||||
FakePeer.answerSdp = ANSWER
|
||||
}
|
||||
|
||||
// a nested write of the client state, one slice at a time (the store takes immutable updates)
|
||||
type Slice = 'connection' | 'video' | 'control' | 'screen' | 'settings'
|
||||
export const patch = <K extends Slice>(client: NekoClient, key: K, part: Partial<State[K]>) =>
|
||||
client.store.setState((s) => ({ ...s, [key]: { ...s[key], ...part } }))
|
||||
@@ -0,0 +1,86 @@
|
||||
// The seam between the session (NekoClient: auth, the main websocket, room state) and the media
|
||||
// stream. A transport owns its media element and its own connection; it talks to the server only
|
||||
// through the SessionInfo the client hands it. Modelled on guacamole-common-js, where Guacamole.Client
|
||||
// runs over a Guacamole.Tunnel that may be HTTP or WebSocket.
|
||||
import type { CursorImage, InitPayload } from './types.ts'
|
||||
|
||||
export type TransportKind = 'webrtc' | 'webcodecs-ws' | 'ws-mse' | 'hls'
|
||||
|
||||
export type TransportStatus =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
// the server will not stream to this session (e.g. can_watch is off): the room works without video
|
||||
| 'unavailable'
|
||||
|
||||
export interface TransportState {
|
||||
status: TransportStatus
|
||||
// the stream's own size once a frame has arrived (letterboxing uses the server's screen size)
|
||||
size: { width: number; height: number }
|
||||
video: {
|
||||
playable: boolean
|
||||
playing: boolean
|
||||
volume: number
|
||||
muted: boolean
|
||||
// the browser refused to autoplay with sound, so playback started muted
|
||||
mutedByAutoplay: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransportEvents {
|
||||
state: (state: TransportState) => void
|
||||
// bitrate, fps, rtt, ... (nothing emits it yet)
|
||||
stats: (stats: Record<string, number>) => void
|
||||
// the stream is lost for good; the client closes the connection with it
|
||||
error: (error: Error) => void
|
||||
// the host's cursor, for transports whose server feeds it back (the WebRTC data channel);
|
||||
// null when the stream is dropped
|
||||
'cursor.position': (pos: { x: number; y: number } | null) => void
|
||||
'cursor.image': (image: CursorImage) => void
|
||||
}
|
||||
|
||||
// what a transport gets from the client: where the server is, who we are, and the main websocket
|
||||
// for signalling
|
||||
export interface SessionInfo {
|
||||
url: string // http(s) origin and path of the server
|
||||
token?: string
|
||||
init: InitPayload // the system/init payload
|
||||
send(event: string, payload?: unknown): void
|
||||
on(event: string, cb: (payload: any) => void): () => void
|
||||
}
|
||||
|
||||
// input to the remote desktop, in the server's terms (keysyms, button codes, touch ids); how it
|
||||
// travels is the channel's business
|
||||
export interface InputChannel {
|
||||
move(x: number, y: number): void
|
||||
scroll(deltaX: number, deltaY: number, controlKey: boolean): void
|
||||
button(code: number, down: boolean): void
|
||||
key(keysym: number, down: boolean): void
|
||||
touch(phase: 'begin' | 'update' | 'end', id: number, x: number, y: number, pressure: number): void
|
||||
}
|
||||
|
||||
export interface StreamTransport {
|
||||
readonly kind: TransportKind
|
||||
// the media element created by attach(), for Picture-in-Picture and the like
|
||||
readonly element: HTMLElement | null
|
||||
// the transport's own input path, when it has one (the WebRTC data channel)
|
||||
readonly input?: InputChannel
|
||||
|
||||
// start streaming for a session; called again on every reconnect, also while the previous
|
||||
// session's stream is still up: it is replaced (its last picture stays until the new one shows)
|
||||
connect(session: SessionInfo): Promise<void>
|
||||
// stop for good: the media path, timers, counters and the picture on the element go
|
||||
close(): void
|
||||
|
||||
// create the media element inside the container; returns what removes it again
|
||||
attach(container: HTMLElement): () => void
|
||||
|
||||
setPlaying(on: boolean): Promise<void>
|
||||
setVolume(volume: number): void
|
||||
setMuted(on: boolean): void
|
||||
|
||||
// share local media (microphone), where the transport can; returns what stops sharing it
|
||||
shareMedia?(stream: MediaStream): () => void
|
||||
|
||||
on<K extends keyof TransportEvents>(event: K, cb: TransportEvents[K]): () => void
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// The WebRTC transport on its own: its element, the signalling it does over the SessionInfo, the
|
||||
// state it reports, and what close() leaves behind.
|
||||
import { test, vi } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
tick,
|
||||
settle,
|
||||
reset,
|
||||
FakePeer,
|
||||
FakeElement,
|
||||
FakeChannel,
|
||||
OFFER,
|
||||
init,
|
||||
documentListeners,
|
||||
} from '../test/browser.ts'
|
||||
|
||||
const { WebRTCTransport, DataChannelInput, Emitter } = await import('../index.ts')
|
||||
type Transport = InstanceType<typeof WebRTCTransport>
|
||||
type State = Parameters<Parameters<Transport['on']>[1]>[0]
|
||||
|
||||
// a SessionInfo as the client builds it, with the socket recorded
|
||||
const sessionInfo = () => {
|
||||
const out: { event: string; payload: unknown }[] = []
|
||||
const messages = new Emitter<Record<string, (p: any) => void>>()
|
||||
const s = {
|
||||
url: 'http://neko.test',
|
||||
token: 'tok',
|
||||
init: init(),
|
||||
send: (event: string, payload?: unknown) => out.push({ event, payload }),
|
||||
on: (event: string, cb: (p: any) => void) => messages.on(event, cb),
|
||||
}
|
||||
return { s, out, events: () => out.map((m) => m.event), receive: (e: string, p?: unknown) => messages.emit(e, p) }
|
||||
}
|
||||
const setup = () => {
|
||||
reset()
|
||||
const t = new WebRTCTransport()
|
||||
const states: State[] = []
|
||||
const errors: Error[] = []
|
||||
const cursors: unknown[] = []
|
||||
t.on('state', (s) => states.push(s))
|
||||
t.on('error', (e) => errors.push(e))
|
||||
t.on('cursor.position', (p) => cursors.push(p))
|
||||
t.on('cursor.image', (i) => cursors.push(i))
|
||||
const container = new FakeElement('div')
|
||||
const detach = t.attach(container as any)
|
||||
const video = container.children[0]
|
||||
return { t, states, errors, cursors, container, video, detach, status: () => states.at(-1)?.status }
|
||||
}
|
||||
const connected = async () => {
|
||||
const r = setup()
|
||||
const si = sessionInfo()
|
||||
await r.t.connect(si.s)
|
||||
si.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
FakePeer.all[0].transition('connected')
|
||||
si.out.length = 0
|
||||
return { ...r, ...si }
|
||||
}
|
||||
const micStream = () => ({ getTracks: () => [{ kind: 'audio' }] }) as unknown as MediaStream
|
||||
|
||||
test('supported: a static check for RTCPeerConnection', () => {
|
||||
assert.equal(WebRTCTransport.supported(), true)
|
||||
const g = globalThis as any
|
||||
const pc = g.RTCPeerConnection
|
||||
delete g.RTCPeerConnection
|
||||
assert.equal(WebRTCTransport.supported(), false)
|
||||
g.RTCPeerConnection = pc
|
||||
})
|
||||
|
||||
test('attach: the <video> the client used to create, inside the container; the returned detach removes it', () => {
|
||||
const { t, container, video, states, detach } = setup()
|
||||
assert.equal(t.kind, 'webrtc')
|
||||
assert.equal(container.children.length, 1)
|
||||
assert.equal(video.tag, 'video')
|
||||
assert.equal(video.playsInline, true)
|
||||
assert.equal(video.style.cssText, 'position:absolute;inset:0;width:100%;height:100%;background:transparent')
|
||||
assert.equal(t.element, video)
|
||||
assert.deepEqual(states.at(-1), {
|
||||
status: 'disconnected',
|
||||
size: { width: 0, height: 0 },
|
||||
video: { playable: false, playing: false, volume: 1, muted: false, mutedByAutoplay: false },
|
||||
})
|
||||
detach()
|
||||
assert.deepEqual(container.children, [])
|
||||
assert.equal(t.element, null)
|
||||
t.attach(container as any) // and again
|
||||
assert.equal(container.children.length, 1)
|
||||
t.attach(container as any) // a second attach replaces the element
|
||||
assert.equal(container.children.length, 1)
|
||||
detach() // the first one's: long gone, and not the current element
|
||||
assert.equal(container.children.length, 1)
|
||||
assert.equal(t.element, container.children[0])
|
||||
})
|
||||
|
||||
test('connect: signal/* over the session, the track on the element, state with status and size', async () => {
|
||||
const { t, video, states, status } = setup()
|
||||
const { s, out, events, receive } = sessionInfo()
|
||||
await t.connect(s)
|
||||
assert.deepEqual(out, [{ event: 'signal/request', payload: { video: {}, audio: {} } }])
|
||||
assert.equal(status(), 'connecting')
|
||||
receive('signal/candidate', { candidate: 'early' })
|
||||
receive('signal/provide', { sdp: OFFER, iceservers: [{ urls: 'stun:x' }] })
|
||||
await settle()
|
||||
const pc = FakePeer.all[0]
|
||||
assert.deepEqual(pc.config, { iceServers: [{ urls: 'stun:x' }] })
|
||||
assert.deepEqual(pc.remoteDescription, { type: 'offer', sdp: OFFER })
|
||||
assert.deepEqual(pc.candidates, [{ candidate: 'early' }])
|
||||
assert.deepEqual(events(), ['signal/request', 'signal/answer'])
|
||||
pc.onicecandidate!({ candidate: { toJSON: () => ({ candidate: 'mine' }) } })
|
||||
pc.onicecandidate!({ candidate: null })
|
||||
assert.deepEqual(out.at(-1), { event: 'signal/candidate', payload: { candidate: 'mine' } })
|
||||
receive('signal/answer', { sdp: 'renegotiated' })
|
||||
assert.deepEqual(pc.remoteDescription, { type: 'answer', sdp: 'renegotiated' })
|
||||
|
||||
const stream = { id: 'stream' }
|
||||
const receiver: Record<string, unknown> = { jitterBufferTarget: 100 }
|
||||
pc.ontrack!({ track: { kind: 'audio' }, streams: [stream], receiver })
|
||||
assert.equal(video.srcObject, null, 'the audio track rides on the video stream')
|
||||
pc.ontrack!({ track: { kind: 'video' }, streams: [stream], receiver })
|
||||
assert.equal(video.srcObject, stream)
|
||||
assert.equal(receiver.jitterBufferTarget, 0, 'the smallest jitter buffer')
|
||||
await settle()
|
||||
assert.equal(video.paused, true, 'starting playback is the client’s call (setPlaying)')
|
||||
|
||||
pc.transition('connected')
|
||||
assert.equal(status(), 'connected')
|
||||
video.videoWidth = 1920
|
||||
video.videoHeight = 1080
|
||||
video.dispatch('resize')
|
||||
assert.deepEqual(states.at(-1)!.size, { width: 1920, height: 1080 })
|
||||
pc.transition('disconnected')
|
||||
assert.equal(status(), 'connecting')
|
||||
})
|
||||
|
||||
test('playback: a refused play starts muted, reports mutedByAutoplay and unmutes on the first click', async () => {
|
||||
const { t, video, states } = setup()
|
||||
const { s, receive } = sessionInfo()
|
||||
await t.connect(s)
|
||||
receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
FakePeer.all[0].ontrack!({ track: { kind: 'video' }, streams: [{}], receiver: {} })
|
||||
await settle()
|
||||
assert.equal(video.plays, 0)
|
||||
video.playError = Object.assign(new Error('blocked'), { name: 'NotAllowedError' })
|
||||
await t.setPlaying(true)
|
||||
assert.equal(video.paused, false)
|
||||
assert.equal(video.muted, true)
|
||||
assert.deepEqual(states.at(-1)!.video, {
|
||||
playable: false,
|
||||
playing: true,
|
||||
volume: 1,
|
||||
muted: true,
|
||||
mutedByAutoplay: true,
|
||||
})
|
||||
documentListeners.click[0]({})
|
||||
assert.equal(video.muted, false)
|
||||
assert.equal(states.at(-1)!.video.mutedByAutoplay, false)
|
||||
|
||||
video.dispatch('canplaythrough')
|
||||
assert.equal(states.at(-1)!.video.playable, true)
|
||||
await t.setPlaying(false)
|
||||
assert.equal(video.paused, true)
|
||||
assert.equal(states.at(-1)!.video.playing, false)
|
||||
t.setVolume(0.5)
|
||||
t.setMuted(true)
|
||||
assert.deepEqual([states.at(-1)!.video.volume, states.at(-1)!.video.muted], [0.5, true])
|
||||
video.playError = Object.assign(new Error('nope'), { name: 'AbortError' })
|
||||
await assert.rejects(t.setPlaying(true), /nope/, 'other failures are the caller’s')
|
||||
video.playError = Object.assign(new Error('blocked'), { name: 'NotAllowedError' })
|
||||
await assert.rejects(t.setPlaying(true), /blocked/, 'already muted: nothing more to try')
|
||||
})
|
||||
|
||||
test('no offer within OFFER_TIMEOUT_MS: "unavailable", the room without video', async () => {
|
||||
const { t, status } = setup()
|
||||
const { s } = sessionInfo()
|
||||
await t.connect(s)
|
||||
await tick(7999)
|
||||
assert.equal(status(), 'connecting')
|
||||
await tick(1)
|
||||
assert.equal(status(), 'unavailable', 'the server will not stream to this session')
|
||||
assert.equal(vi.getTimerCount(), 0)
|
||||
|
||||
const again = setup()
|
||||
const si = sessionInfo()
|
||||
await again.t.connect(si.s)
|
||||
si.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
assert.equal(vi.getTimerCount(), 0, 'an offer that arrived stops the clock')
|
||||
await tick(8000)
|
||||
assert.equal(again.status(), 'connecting')
|
||||
})
|
||||
|
||||
test('a new track resumes playback that was running (reconnect), autoplay or not', async () => {
|
||||
const { t, video } = await connected()
|
||||
const pc = FakePeer.all[0]
|
||||
pc.ontrack!({ track: { kind: 'video' }, streams: [{ id: 1 }], receiver: {} })
|
||||
await settle()
|
||||
assert.equal(video.plays, 0, 'the transport does not start playback by itself')
|
||||
await t.setPlaying(true)
|
||||
assert.equal(video.plays, 1)
|
||||
pc.ontrack!({ track: { kind: 'video' }, streams: [{ id: 2 }], receiver: {} }) // the peer was replaced
|
||||
await settle()
|
||||
assert.equal(video.plays, 2, 'kept playing')
|
||||
await t.setPlaying(false)
|
||||
pc.ontrack!({ track: { kind: 'video' }, streams: [{ id: 3 }], receiver: {} })
|
||||
await settle()
|
||||
assert.equal(video.plays, 2, 'paused by the user: stays paused')
|
||||
})
|
||||
|
||||
test('input: a DataChannelInput bound to the channel the peer opens; cursor frames become events', async () => {
|
||||
const { t, cursors } = await connected()
|
||||
assert.ok(t.input instanceof DataChannelInput)
|
||||
const dc = new FakeChannel()
|
||||
FakePeer.all[0].ondatachannel!({ channel: dc })
|
||||
dc.readyState = 'open'
|
||||
t.input.move(1, 2)
|
||||
assert.deepEqual(dc.frames, [[1, 0, 4, 0, 1, 0, 2]])
|
||||
dc.onmessage!({ data: new Uint8Array([1, 0, 4, 0, 7, 0, 9]).buffer }) // CURSOR_POSITION
|
||||
assert.deepEqual(cursors.at(-1), { x: 7, y: 9 })
|
||||
dc.onmessage!({ data: new Uint8Array([2, 0, 9, 0, 2, 0, 3, 0, 0, 0, 1, 0]).buffer }) // CURSOR_IMAGE
|
||||
assert.deepEqual((cursors.at(-1) as { width: number }).width, 2)
|
||||
t.close()
|
||||
assert.equal(cursors.at(-1), null, 'the dropped peer takes the cursor with it')
|
||||
t.input.move(1, 2)
|
||||
assert.equal(dc.frames.length, 1, 'nothing is sent on the old channel')
|
||||
})
|
||||
|
||||
test('close: peer, timers, failure count, playback and the picture go; it can be connected again', async () => {
|
||||
const old = await connected()
|
||||
const { t, video, errors, states, status } = old
|
||||
const stream = { id: 'stream' }
|
||||
FakePeer.all[0].ontrack!({ track: { kind: 'video' }, streams: [stream], receiver: {} })
|
||||
await t.setPlaying(true)
|
||||
video.dispatch('canplaythrough')
|
||||
assert.deepEqual([states.at(-1)!.video.playable, states.at(-1)!.video.playing], [true, true])
|
||||
FakePeer.all[0].transition('failed') // one failure, a retry is pending
|
||||
assert.equal(vi.getTimerCount(), 1)
|
||||
t.close()
|
||||
assert.ok(FakePeer.all[0].closed)
|
||||
assert.equal(vi.getTimerCount(), 0)
|
||||
assert.equal(status(), 'disconnected')
|
||||
assert.equal(video.srcObject, null, 'the element is emptied: black')
|
||||
assert.equal(video.paused, true)
|
||||
assert.deepEqual([states.at(-1)!.video.playable, states.at(-1)!.video.playing], [false, false])
|
||||
await tick(60_000)
|
||||
assert.equal(FakePeer.all.length, 1, 'nothing is requested while closed')
|
||||
old.receive('signal/provide', { sdp: OFFER }) // the old session's messages are not its business any more
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.length, 1)
|
||||
|
||||
// connected again: the count started over (the first failure waits the first step of the backoff)
|
||||
const si = sessionInfo()
|
||||
await t.connect(si.s)
|
||||
si.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
FakePeer.all[1].ontrack!({ track: { kind: 'video' }, streams: [{ id: 'new' }], receiver: {} })
|
||||
await settle()
|
||||
assert.deepEqual(video.srcObject, { id: 'new' })
|
||||
assert.equal(video.plays, 1, 'starting again is the client’s call (it remembers what was playing)')
|
||||
si.out.length = 0
|
||||
FakePeer.all[1].transition('failed')
|
||||
await tick(1499)
|
||||
assert.deepEqual(si.events(), [])
|
||||
await tick(1)
|
||||
assert.deepEqual(si.events(), ['signal/request'])
|
||||
assert.deepEqual(errors, [])
|
||||
})
|
||||
|
||||
test('connect while a stream is up (the socket reconnected): the old peer, its retry and count go, the picture stays until the new stream', async () => {
|
||||
const old = await connected()
|
||||
const { t, video, status } = old
|
||||
const stream = { id: 'stream' }
|
||||
FakePeer.all[0].ontrack!({ track: { kind: 'video' }, streams: [stream], receiver: {} })
|
||||
await t.setPlaying(true)
|
||||
video.dispatch('canplaythrough')
|
||||
for (const backoff of [1500, 3000]) {
|
||||
// two failures: the third would wait 4500 ms
|
||||
FakePeer.all.at(-1)!.transition('failed')
|
||||
await tick(backoff)
|
||||
old.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
}
|
||||
FakePeer.all.at(-1)!.transition('failed') // and a retry pending
|
||||
assert.equal(vi.getTimerCount(), 1)
|
||||
|
||||
const si = sessionInfo()
|
||||
await t.connect(si.s)
|
||||
assert.ok(
|
||||
FakePeer.all.every((p) => p.closed),
|
||||
'no peer of the old session survives',
|
||||
)
|
||||
assert.equal(vi.getTimerCount(), 1, 'its retry is gone; only the new offer timeout runs')
|
||||
assert.deepEqual(si.events(), ['signal/request'])
|
||||
assert.deepEqual([video.srcObject, video.paused], [stream, false], 'the last picture stays, still playing')
|
||||
assert.equal(status(), 'connecting')
|
||||
old.receive('signal/provide', { sdp: OFFER }) // the old session's provide: ignored
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.length, 3)
|
||||
si.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
assert.equal(FakePeer.all.filter((p) => !p.closed).length, 1, 'exactly one live peer')
|
||||
si.out.length = 0
|
||||
FakePeer.all.at(-1)!.transition('failed') // the count started over: the first step of the backoff, not the fourth
|
||||
await tick(1499)
|
||||
assert.deepEqual(si.events(), [])
|
||||
await tick(1)
|
||||
assert.deepEqual(si.events(), ['signal/request'])
|
||||
si.receive('signal/provide', { sdp: OFFER })
|
||||
await settle()
|
||||
const pc = FakePeer.all.at(-1)!
|
||||
pc.ontrack!({ track: { kind: 'video' }, streams: [{ id: 'new' }], receiver: {} })
|
||||
await settle()
|
||||
assert.deepEqual(
|
||||
[video.srcObject, video.plays],
|
||||
[{ id: 'new' }, 2],
|
||||
'the new stream replaces the picture and keeps playing',
|
||||
)
|
||||
pc.transition('connected')
|
||||
assert.equal(status(), 'connected')
|
||||
})
|
||||
|
||||
test('errors: a codec mismatch and the give-up are reported, the transport stays usable', async () => {
|
||||
const { t, errors } = setup()
|
||||
const { s, out, receive } = sessionInfo()
|
||||
await t.connect(s)
|
||||
FakePeer.answerSdp = 'v=0\r\nm=audio 9 RTP/AVP 0\r\nm=video 0 RTP/AVP 96\r\n'
|
||||
receive('signal/provide', { sdp: OFFER.replace('VP8', 'AV1') })
|
||||
await settle()
|
||||
assert.deepEqual(
|
||||
errors.map((e) => e.message),
|
||||
['this browser cannot play AV1 video'],
|
||||
)
|
||||
assert.equal(out.length, 1, 'no answer')
|
||||
})
|
||||
|
||||
test('microphone: shareMedia adds the tracks and renegotiates through the session; stop removes them', async () => {
|
||||
const { t, out } = await connected()
|
||||
assert.throws(() => new WebRTCTransport().shareMedia(micStream()), /not connected/)
|
||||
const pc = FakePeer.all[0]
|
||||
const stream = micStream()
|
||||
const stop = t.shareMedia(stream)
|
||||
assert.deepEqual(pc.senders, [{ track: stream.getTracks()[0] }])
|
||||
await pc.onnegotiationneeded!()
|
||||
assert.deepEqual(out, [{ event: 'signal/offer', payload: { sdp: 'local-offer' } }])
|
||||
stop()
|
||||
assert.deepEqual(pc.senders, [])
|
||||
stop() // already gone: ignored, not thrown (the fake peer throws for an unknown sender)
|
||||
assert.deepEqual(pc.senders, [])
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
// WebRTC: the server's offer arrives over the main socket (signal/*), media on an
|
||||
// RTCPeerConnection, input and the host's cursor on its data channel.
|
||||
import { Emitter } from '../emitter.ts'
|
||||
import { DataChannelInput } from '../input/datachannel.ts'
|
||||
import type { SessionInfo, StreamTransport, TransportEvents, TransportState, TransportStatus } from '../transport.ts'
|
||||
|
||||
const RECONNECT_MAX = 10
|
||||
const RECONNECT_BACKOFF_MS = 1500
|
||||
// no offer within this long after asking for one: the server will not send media (e.g. can_watch
|
||||
// is off), so the room is shown without video instead of a spinner for ever; a dead socket is the
|
||||
// client's stale check's business
|
||||
const OFFER_TIMEOUT_MS = 8_000
|
||||
|
||||
export class WebRTCTransport implements StreamTransport {
|
||||
readonly kind = 'webrtc' as const
|
||||
readonly input = new DataChannelInput()
|
||||
|
||||
static supported() {
|
||||
return typeof RTCPeerConnection !== 'undefined'
|
||||
}
|
||||
|
||||
private readonly events = new Emitter<TransportEvents>()
|
||||
private readonly state: TransportState = {
|
||||
status: 'disconnected',
|
||||
size: { width: 0, height: 0 },
|
||||
video: { playable: false, playing: false, volume: 1, muted: false, mutedByAutoplay: false },
|
||||
}
|
||||
private video: HTMLVideoElement | null = null
|
||||
private session: SessionInfo | null = null
|
||||
private unsubs: (() => void)[] = []
|
||||
private pc: RTCPeerConnection | null = null
|
||||
// the server trickles its candidates before signal/provide; hold them until the offer is applied
|
||||
private candidates: RTCIceCandidateInit[] = []
|
||||
private failures = 0
|
||||
private retryTimer = 0
|
||||
private offerTimer = 0
|
||||
|
||||
constructor() {
|
||||
this.input.onCursorPosition = (p) => this.events.emit('cursor.position', p)
|
||||
this.input.onCursorImage = (img) => this.events.emit('cursor.image', img)
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this.video
|
||||
}
|
||||
|
||||
on<K extends keyof TransportEvents>(event: K, cb: TransportEvents[K]) {
|
||||
return this.events.on(event, cb)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// signalling + peer
|
||||
/////////////////////////////
|
||||
|
||||
// again on every reconnect: whatever a previous session left (its peer, retries, the count)
|
||||
// goes, and its last picture stays on the element until the new stream replaces it
|
||||
async connect(session: SessionInfo) {
|
||||
this.unsubscribe()
|
||||
this.closePeer()
|
||||
this.failures = 0
|
||||
this.session = session
|
||||
const on = (event: string, cb: (p: any) => void) => this.unsubs.push(session.on(event, cb))
|
||||
on('signal/provide', (p) => this.onProvide(p).catch((err) => console.error('[neko] webrtc setup failed', err)))
|
||||
const renegotiate = (p: { sdp: string }) =>
|
||||
this.onOffer(p.sdp).catch((err) => console.error('[neko] webrtc renegotiation failed', err))
|
||||
on('signal/offer', renegotiate)
|
||||
on('signal/restart', renegotiate)
|
||||
on('signal/answer', (p) => this.pc?.setRemoteDescription({ type: 'answer', sdp: p.sdp }).catch(() => {}))
|
||||
on('signal/candidate', (p) => {
|
||||
if (this.pc?.remoteDescription) this.pc.addIceCandidate(p).catch(() => {})
|
||||
else this.candidates.push(p)
|
||||
})
|
||||
// the server dropped its peer (it saw the media path fail first): ask for a new one,
|
||||
// with the same backoff as a failure the browser noticed itself
|
||||
on('signal/close', () => this.pc && this.onPeerState('failed'))
|
||||
this.setStatus('connecting')
|
||||
this.requestPeer()
|
||||
}
|
||||
|
||||
// the stream is over: the peer, timers, counters and the picture go (a reconnect does not come
|
||||
// through here but through connect(), which keeps the picture)
|
||||
close() {
|
||||
this.unsubscribe()
|
||||
this.session = null
|
||||
this.closePeer()
|
||||
this.failures = 0
|
||||
if (this.video) {
|
||||
this.video.pause()
|
||||
this.video.srcObject = null
|
||||
}
|
||||
this.state.video.playable = false
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
private unsubscribe() {
|
||||
this.unsubs.splice(0).forEach((off) => off())
|
||||
}
|
||||
|
||||
private requestPeer() {
|
||||
this.candidates = []
|
||||
this.session?.send('signal/request', { video: {}, audio: {} })
|
||||
clearTimeout(this.offerTimer)
|
||||
this.offerTimer = window.setTimeout(
|
||||
() => this.session && !this.pc && this.setStatus('unavailable'),
|
||||
OFFER_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
private closePeer() {
|
||||
clearTimeout(this.retryTimer)
|
||||
clearTimeout(this.offerTimer)
|
||||
this.pc?.close()
|
||||
this.pc = null
|
||||
this.input.bind(null)
|
||||
this.events.emit('cursor.position', null)
|
||||
}
|
||||
|
||||
private async onProvide({ sdp, iceservers }: { sdp: string; iceservers?: RTCIceServer[] }) {
|
||||
this.closePeer()
|
||||
const pc = (this.pc = new RTCPeerConnection({ iceServers: iceservers ?? [] }))
|
||||
pc.onicecandidate = (e) => e.candidate && this.session?.send('signal/candidate', e.candidate.toJSON())
|
||||
pc.ontrack = (e) => this.onTrack(e)
|
||||
pc.ondatachannel = (e) => this.input.bind(e.channel)
|
||||
pc.onconnectionstatechange = () => this.pc === pc && this.onPeerState(pc.connectionState)
|
||||
// only fires after a local addTrack (microphone), the server offers everything else
|
||||
pc.onnegotiationneeded = async () => {
|
||||
if (pc.signalingState !== 'stable' || !pc.remoteDescription) return
|
||||
await pc.setLocalDescription(await pc.createOffer())
|
||||
this.session?.send('signal/offer', { sdp: pc.localDescription!.sdp })
|
||||
}
|
||||
await this.onOffer(sdp)
|
||||
}
|
||||
|
||||
private async onOffer(sdp: string) {
|
||||
const pc = this.pc
|
||||
if (!pc) return
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp })
|
||||
for (const c of this.candidates.splice(0)) pc.addIceCandidate(c).catch(() => {})
|
||||
await pc.setLocalDescription(await pc.createAnswer())
|
||||
// no video codec in common (e.g. an H264 stream and a Firefox without the OpenH264 plugin):
|
||||
// the browser rejects the video section, the server cannot start the track, retrying won't help
|
||||
if (/^m=video 0 /m.test(pc.localDescription!.sdp)) {
|
||||
const codec = sdp.match(/^m=video[\s\S]*?a=rtpmap:\d+ ([\w-]+)/m)?.[1] ?? 'the stream'
|
||||
return this.events.emit('error', new Error(`this browser cannot play ${codec} video`))
|
||||
}
|
||||
this.session?.send('signal/answer', { sdp: pc.localDescription!.sdp })
|
||||
}
|
||||
|
||||
private onPeerState(s: RTCPeerConnectionState) {
|
||||
if (s === 'connected') {
|
||||
this.failures = 0
|
||||
this.setStatus('connected')
|
||||
} else if (s === 'disconnected') {
|
||||
this.setStatus('connecting') // ICE may still recover
|
||||
} else if (s === 'failed') {
|
||||
this.setStatus('connecting')
|
||||
this.closePeer()
|
||||
// no media route (firewall, NAT1TO1, missing TURN): retry with backoff, then give up
|
||||
// instead of asking the server for a new peer several times a second
|
||||
if (++this.failures > RECONNECT_MAX)
|
||||
return this.events.emit('error', new Error('video connection failed (WebRTC)'))
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = window.setTimeout(() => this.requestPeer(), RECONNECT_BACKOFF_MS * Math.min(this.failures, 4))
|
||||
}
|
||||
}
|
||||
|
||||
private onTrack({ track, streams, receiver }: RTCTrackEvent) {
|
||||
// ask the browser for the smallest jitter buffer: interactive desktop, not a movie
|
||||
if ('jitterBufferTarget' in receiver) (receiver as any).jitterBufferTarget = 0
|
||||
if (track.kind !== 'video' || !this.video) return
|
||||
this.video.srcObject = streams[0]
|
||||
// a replaced peer keeps playing; starting (autoplay, after a socket loss) is the client's call
|
||||
if (this.state.video.playing) this.play().catch(() => {})
|
||||
}
|
||||
|
||||
// share local media (microphone); renegotiation happens via onnegotiationneeded
|
||||
shareMedia(stream: MediaStream) {
|
||||
const pc = this.pc
|
||||
if (!pc) throw new Error('not connected')
|
||||
const senders = stream.getTracks().map((track) => pc.addTrack(track, stream))
|
||||
return () => {
|
||||
// senders of a previous peer connection are already gone; removing them from the current one throws
|
||||
for (const s of senders) if (this.pc?.getSenders().includes(s)) this.pc.removeTrack(s)
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// the element
|
||||
/////////////////////////////
|
||||
|
||||
attach(container: HTMLElement) {
|
||||
this.video?.remove() // a second attach replaces the element
|
||||
const video = (this.video = document.createElement('video'))
|
||||
video.playsInline = true
|
||||
video.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;background:transparent'
|
||||
const v = this.state.video
|
||||
const sync = () => this.emitState()
|
||||
video.addEventListener('canplaythrough', () => ((v.playable = true), sync()))
|
||||
video.addEventListener('playing', () => ((v.playing = true), sync()))
|
||||
video.addEventListener('pause', () => ((v.playing = false), sync()))
|
||||
video.addEventListener('emptied', () => ((v.playable = v.playing = false), sync()))
|
||||
video.addEventListener('volumechange', () => ((v.muted = video.muted), (v.volume = video.volume), sync()))
|
||||
video.addEventListener('resize', () => {
|
||||
this.state.size = { width: video.videoWidth, height: video.videoHeight }
|
||||
sync()
|
||||
})
|
||||
v.muted = video.muted
|
||||
v.volume = video.volume
|
||||
container.append(video)
|
||||
this.emitState()
|
||||
return () => {
|
||||
video.remove()
|
||||
if (this.video === video) this.video = null
|
||||
}
|
||||
}
|
||||
|
||||
setPlaying(on: boolean) {
|
||||
if (on) return this.play()
|
||||
this.video?.pause()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
if (this.video) this.video.volume = volume
|
||||
}
|
||||
|
||||
setMuted(on: boolean) {
|
||||
if (this.video) this.video.muted = on
|
||||
if (!on) {
|
||||
this.state.video.mutedByAutoplay = false
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
|
||||
private async play() {
|
||||
const video = this.video
|
||||
if (!video) return
|
||||
try {
|
||||
await video.play()
|
||||
} catch (err) {
|
||||
if (video.muted || (err as DOMException).name !== 'NotAllowedError') throw err
|
||||
// autoplay with sound is blocked until the user interacts: play muted,
|
||||
// unmute on the first click anywhere
|
||||
video.muted = true
|
||||
this.state.video.mutedByAutoplay = true
|
||||
this.emitState()
|
||||
await video.play()
|
||||
document.addEventListener('click', () => this.setMuted(false), { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: TransportStatus) {
|
||||
this.state.status = status
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
private emitState() {
|
||||
const { status, size, video } = this.state
|
||||
this.events.emit('state', { status, size: { ...size }, video: { ...video } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Client state + wire types, mirroring server/pkg/types (master).
|
||||
|
||||
export interface MemberProfile {
|
||||
name: string
|
||||
avatar?: string
|
||||
is_admin: boolean
|
||||
can_login: boolean
|
||||
can_connect: boolean
|
||||
can_watch: boolean
|
||||
can_host: boolean
|
||||
can_share_media: boolean
|
||||
can_access_clipboard: boolean
|
||||
sends_inactive_cursor: boolean
|
||||
can_see_inactive_cursors: boolean
|
||||
plugins?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
is_connected: boolean
|
||||
connected_since?: string
|
||||
not_connected_since?: string
|
||||
is_watching: boolean
|
||||
watching_since?: string
|
||||
not_watching_since?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
profile: MemberProfile
|
||||
state: SessionState
|
||||
}
|
||||
|
||||
// REST shapes the client reads (server/internal/api)
|
||||
export interface LoginResponse extends Session {
|
||||
token?: string // only when the server does not use cookies
|
||||
}
|
||||
|
||||
export interface MemberData {
|
||||
id: string
|
||||
profile: MemberProfile
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
private_mode: boolean
|
||||
locked_logins: boolean
|
||||
locked_controls: boolean
|
||||
control_protection: boolean
|
||||
implicit_hosting: boolean
|
||||
inactive_cursors: boolean
|
||||
merciful_reconnect: boolean
|
||||
heartbeat_interval?: number
|
||||
plugins?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ScreenSize {
|
||||
width: number
|
||||
height: number
|
||||
rate: number
|
||||
}
|
||||
|
||||
// the system/init payload (message.SystemInit)
|
||||
export interface InitPayload {
|
||||
session_id: string
|
||||
control_host: { has_host: boolean; host_id?: string }
|
||||
screen_size: ScreenSize
|
||||
sessions: Record<string, Session>
|
||||
settings: Settings
|
||||
touch_events: boolean
|
||||
screencast_enabled?: boolean
|
||||
webrtc?: { videos?: string[] }
|
||||
}
|
||||
|
||||
export interface State {
|
||||
authenticated: boolean
|
||||
connection: {
|
||||
url: string
|
||||
token?: string
|
||||
status: 'disconnected' | 'connecting' | 'connected'
|
||||
}
|
||||
video: {
|
||||
playable: boolean
|
||||
playing: boolean
|
||||
volume: number
|
||||
muted: boolean
|
||||
// the browser refused to autoplay with sound, so playback started muted
|
||||
mutedByAutoplay: boolean
|
||||
}
|
||||
control: {
|
||||
host_id: string | null
|
||||
// local lock: stop sending input without giving up control
|
||||
locked: boolean
|
||||
clipboard: { text: string } | null
|
||||
scroll: { inverse: boolean; sensitivity: number }
|
||||
keyboard: { layout: string; variant: string }
|
||||
// server supports native touch events (otherwise touch emulates the mouse)
|
||||
touch: boolean
|
||||
}
|
||||
screen: {
|
||||
size: ScreenSize
|
||||
configurations: ScreenSize[]
|
||||
}
|
||||
session_id: string | null
|
||||
sessions: Record<string, Session>
|
||||
settings: Settings
|
||||
mobile_keyboard_open: boolean
|
||||
}
|
||||
|
||||
export interface CursorImage {
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
uri: string
|
||||
}
|
||||
|
||||
// events emitted by NekoClient.events
|
||||
export interface NekoEvents {
|
||||
'connection.status': (status: State['connection']['status']) => void
|
||||
'connection.closed': (error?: Error) => void
|
||||
'session.created': (id: string) => void
|
||||
'session.deleted': (id: string) => void
|
||||
'session.updated': (id: string) => void
|
||||
'room.control.host': (hasHost: boolean, hostId: string | undefined, by: string) => void
|
||||
'room.control.request': (id: string) => void
|
||||
'room.screen.updated': (width: number, height: number, rate: number, by: string) => void
|
||||
'room.settings.updated': (settings: Settings, by: string) => void
|
||||
'room.clipboard.updated': (text: string) => void
|
||||
'room.broadcast.status': (active: boolean, url?: string) => void
|
||||
'receive.unicast': (sender: string, subject: string, body: any) => void
|
||||
'receive.broadcast': (sender: string, subject: string, body: any) => void
|
||||
'upload.drop.progress': (p: { loaded: number; total: number }) => void
|
||||
'upload.drop.finished': (error?: Error) => void
|
||||
'overlay.click': (e: MouseEvent) => void
|
||||
// anything the core does not handle itself (plugins: chat/*, filetransfer/*, ...)
|
||||
message: (event: string, payload: any) => void
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/test"]
|
||||
}
|
||||
@@ -7,4 +7,4 @@ docker run --rm -it \
|
||||
--volume "${PWD}/../:/app" \
|
||||
--entrypoint="npm" \
|
||||
--workdir="/app" \
|
||||
node:18-bullseye-slim run build
|
||||
node:24-trixie-slim run build
|
||||
|
||||
@@ -7,4 +7,4 @@ docker run --rm -it \
|
||||
--volume "${PWD}/../:/app" \
|
||||
--entrypoint="/bin/bash" \
|
||||
--workdir="/app" \
|
||||
node:18-bullseye-slim
|
||||
node:24-trixie-slim
|
||||
|
||||
@@ -7,4 +7,4 @@ docker run --rm -it \
|
||||
--volume "${PWD}/../:/app" \
|
||||
--entrypoint="npm" \
|
||||
--workdir="/app" \
|
||||
node:18-bullseye-slim "$@"
|
||||
node:24-trixie-slim "$@"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -z $VUE_APP_SERVER_PORT ]; then
|
||||
VUE_APP_SERVER_PORT="3000"
|
||||
# backend started by server/dev/start; override with NEKO_URL=http://host:port
|
||||
if [ -z "$NEKO_URL" ]; then
|
||||
NEKO_URL="http://host.docker.internal:3000"
|
||||
fi
|
||||
|
||||
echo "Using VUE_APP_SERVER_PORT: ${VUE_APP_SERVER_PORT}"
|
||||
echo "Using NEKO_URL: ${NEKO_URL}"
|
||||
|
||||
# use -i to install
|
||||
if [ ! -d "${PWD}/../node_modules" ] || [ "$1" == "-i" ]; then
|
||||
@@ -13,15 +14,16 @@ if [ ! -d "${PWD}/../node_modules" ] || [ "$1" == "-i" ]; then
|
||||
--volume "${PWD}/../:/app" \
|
||||
--workdir="/app" \
|
||||
--entrypoint="npm" \
|
||||
node:18-bullseye-slim install
|
||||
node:24-trixie-slim install
|
||||
fi
|
||||
|
||||
# npm run serve
|
||||
# vite dev server with hot reload, /api is proxied to NEKO_URL
|
||||
docker run --rm -it \
|
||||
-p 3001:3001 \
|
||||
-e "VUE_APP_SERVER_PORT=$VUE_APP_SERVER_PORT" \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e "NEKO_URL=$NEKO_URL" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--volume "${PWD}/../:/app" \
|
||||
--entrypoint="npm" \
|
||||
--workdir="/app" \
|
||||
node:18-bullseye-slim run serve -- --port 3001
|
||||
node:24-trixie-slim run dev -- --host 0.0.0.0 --port 3001
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import { chromium } from 'playwright'
|
||||
// Two-user smoke test against a running v3 server with the multiuser provider:
|
||||
// NEKO_URL=https://host/ NEKO_ADMIN_PASSWORD=admin NEKO_USER_PASSWORD=neko npm run test:e2e
|
||||
// Screenshots land in ./e2e-out. Needs a KDE/X desktop that opens KRunner on Alt+F2 for the typing step.
|
||||
import fs from 'node:fs'
|
||||
const out = 'e2e-out'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const URL = process.env.NEKO_URL || 'http://localhost:8080/'
|
||||
const ADMIN = process.env.NEKO_ADMIN_PASSWORD || 'admin'
|
||||
const USER = process.env.NEKO_USER_PASSWORD || 'neko'
|
||||
// NEKO_CDP=http://127.0.0.1:9222 runs the same steps in a real Chromium-based browser (Brave, Chrome, Edge)
|
||||
// started with --remote-debugging-port and the three flags below
|
||||
const browser = process.env.NEKO_CDP
|
||||
? await chromium.connectOverCDP(process.env.NEKO_CDP)
|
||||
: await chromium.launch({
|
||||
args: [
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--use-fake-device-for-media-stream',
|
||||
],
|
||||
})
|
||||
const errors = []
|
||||
let A, B
|
||||
let fails = 0
|
||||
const log = (s) => console.log(new Date().toISOString().slice(11, 19), s)
|
||||
async function step(name, fn) {
|
||||
try {
|
||||
await fn()
|
||||
log('PASS ' + name)
|
||||
} catch (e) {
|
||||
fails++
|
||||
log('FAIL ' + name + ' :: ' + e.message.split('\n')[0])
|
||||
// evidence for flaky failures: both users' screens and member lists
|
||||
const slug = name.replace(/\W+/g, '-')
|
||||
for (const [who, p] of [
|
||||
['alice', A],
|
||||
['bob', B],
|
||||
]) {
|
||||
if (!p) continue
|
||||
await p.screenshot({ path: `${out}/fail-${slug}-${who}.png` }).catch(() => {})
|
||||
const members = await p
|
||||
.$$eval('[data-testid=member]', (els) => els.map((e) => e.getAttribute('aria-label') || 'self'))
|
||||
.catch(() => '?')
|
||||
log(` ${who} members: ${JSON.stringify(members)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
async function user(name, password, init) {
|
||||
const ctx = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1400, height: 850 },
|
||||
permissions: ['clipboard-read', 'clipboard-write', 'microphone'],
|
||||
})
|
||||
if (init) await ctx.addInitScript(init) // runs before the app: a browser quirk to emulate
|
||||
const page = await ctx.newPage()
|
||||
page.on('pageerror', (e) => errors.push(`${name} pageerror: ${e.message}`))
|
||||
page.on(
|
||||
'console',
|
||||
(m) =>
|
||||
m.type() === 'error' &&
|
||||
// the outage steps make the socket fail on purpose; Brave reports that without the ERR_ text
|
||||
!/401|STUN|ERR_INTERNET_DISCONNECTED|WebSocket connection to .* failed/.test(m.text()) &&
|
||||
errors.push(`${name} console: ${m.text()}`),
|
||||
)
|
||||
await page.goto(URL)
|
||||
await page.fill('input[placeholder="Enter your display name"]', name)
|
||||
await page.fill('input[type=password]', password)
|
||||
await page.click('button[type=submit]')
|
||||
await page.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const v = document.querySelector('video')
|
||||
return v && !v.paused && v.readyState >= 2
|
||||
},
|
||||
null,
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
return page
|
||||
}
|
||||
const api = (p, method, path, body) =>
|
||||
p.evaluate(
|
||||
async ([method, path, body]) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + localStorage.getItem('neko_session'),
|
||||
}
|
||||
const r = await fetch('api' + path, { method, headers, body: body && JSON.stringify(body) })
|
||||
if (!r.ok) throw new Error(r.status + ' ' + (await r.text()))
|
||||
return r.text()
|
||||
},
|
||||
[method, path, body],
|
||||
)
|
||||
const openSide = async (p) => {
|
||||
if (!(await p.locator('[data-testid=side]').count())) await p.click('[data-testid=side-toggle]')
|
||||
await p.waitForSelector('[data-testid=side]', { timeout: 3000 })
|
||||
}
|
||||
const playing = (p) =>
|
||||
p.evaluate(() => {
|
||||
const v = document.querySelector('video')
|
||||
return `${v.videoWidth}x${v.videoHeight} paused=${v.paused} muted=${v.muted}`
|
||||
})
|
||||
|
||||
await step('alice (admin) logs in, video plays', async () => {
|
||||
A = await user('alice', ADMIN)
|
||||
log(' alice video ' + (await playing(A)))
|
||||
})
|
||||
await step('bob (user) logs in, video plays', async () => {
|
||||
B = await user('bob', USER)
|
||||
log(' bob video ' + (await playing(B)))
|
||||
})
|
||||
await step('bob sees pre-existing alice in members', async () => {
|
||||
await B.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
await step('alice sees 2 members, admin shield', async () => {
|
||||
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
await A.waitForSelector('[data-testid=admin-badge]', { timeout: 5000 })
|
||||
})
|
||||
await step('side panel: bob joined event line', async () => {
|
||||
await openSide(A)
|
||||
await A.waitForSelector('[data-testid=chat]', { timeout: 3000 })
|
||||
await A.waitForFunction(
|
||||
() => [...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /bob\s+connected/.test(e.textContent)),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
})
|
||||
await step('chat: bob -> alice', async () => {
|
||||
await openSide(B)
|
||||
await B.fill('[data-testid=chat-input]', 'hi from bob')
|
||||
await B.press('[data-testid=chat-input]', 'Enter')
|
||||
await A.waitForFunction(
|
||||
() => [...document.querySelectorAll('[data-testid=chat-body]')].some((e) => e.textContent === 'hi from bob'),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
})
|
||||
await step('viewer typing in chat keeps the focus when the mouse crosses the video', async () => {
|
||||
await B.click('[data-testid=chat-input]')
|
||||
await B.keyboard.type('hello ')
|
||||
const box = await B.locator('[data-testid=player]').boundingBox()
|
||||
await B.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await B.mouse.move(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5)
|
||||
await B.keyboard.type('world')
|
||||
const v = await B.inputValue('[data-testid=chat-input]')
|
||||
if (v !== 'hello world') throw new Error('chat box has ' + JSON.stringify(v))
|
||||
await B.fill('[data-testid=chat-input]', '')
|
||||
})
|
||||
await step('bob takes control, alice sees host + event', async () => {
|
||||
await B.click('[data-testid=control-request]')
|
||||
await A.waitForFunction(() => document.querySelector('[data-testid=member][data-host]:not([data-self])'), null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
await A.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
||||
/bob\s+took the controls/.test(e.textContent),
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
})
|
||||
await step('alice: right-click bob -> Force Release', async () => {
|
||||
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
||||
await A.click('[data-testid=member-menu] >> text=Force Release Controls')
|
||||
await B.waitForFunction(() => !document.querySelector('[data-testid=member][data-self][data-host]'), null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
await step('alice sends emote, bob sees animation', async () => {
|
||||
await A.click('[data-testid=emotes-open]')
|
||||
await A.click('[data-testid=emotes-menu] [data-emote=wave]')
|
||||
await B.waitForSelector('[data-testid=emote-animation]', { timeout: 3000 })
|
||||
})
|
||||
await step('files: alice uploads, the list shows the file', async () => {
|
||||
if (!(await A.locator('[data-testid=tabs] >> text=Files').count()))
|
||||
throw new Error('no Files tab: start the server with NEKO_FILETRANSFER_ENABLED=true')
|
||||
await A.click('[data-testid=tabs] >> text=Files')
|
||||
await A.setInputFiles('[data-testid=upload-input]', {
|
||||
name: 'react-e2e.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('hello'),
|
||||
})
|
||||
await A.waitForSelector('[data-testid=transfer][data-status=completed]', { timeout: 10000 })
|
||||
await A.waitForSelector('[data-testid=file-name] >> text=react-e2e.txt', { timeout: 5000 })
|
||||
})
|
||||
await step('files: download link works', async () => {
|
||||
const href = await A.getAttribute('[data-testid=file]:has-text("react-e2e.txt") a[download]', 'href')
|
||||
const r = await A.request.get(href)
|
||||
if ((await r.text()) !== 'hello') throw new Error('download body mismatch: ' + r.status())
|
||||
await A.click('[data-testid=file]:has-text("react-e2e.txt") [data-testid=file-delete]')
|
||||
await A.click('[data-testid=dialog-confirm]')
|
||||
await A.waitForSelector('[data-testid=file-name] >> text=react-e2e.txt', { state: 'detached', timeout: 5000 })
|
||||
})
|
||||
await step('markdown: bob -> alice renders safely', async () => {
|
||||
await B.click('[data-testid=tabs] >> text=Chat')
|
||||
await B.fill('[data-testid=chat-input]', '**bold** ||secret|| :smile: https://example.com [x](javascript:alert(1))')
|
||||
await B.press('[data-testid=chat-input]', 'Enter')
|
||||
await A.click('[data-testid=tabs] >> text=Chat')
|
||||
const last = A.locator('[data-testid=chat-body]').last()
|
||||
await last.locator('strong >> text=bold').waitFor({ timeout: 5000 })
|
||||
await last.locator('[data-testid=spoiler]:not([data-shown])').click()
|
||||
await last.locator('[data-testid=spoiler][data-shown]').waitFor({ timeout: 2000 })
|
||||
await last.locator('[data-emoji="smile"]').waitFor({ timeout: 5000 })
|
||||
if ((await last.locator('a').count()) !== 1) throw new Error('expected exactly one (safe) link')
|
||||
if ((await last.locator('a').getAttribute('href')) !== 'https://example.com/') throw new Error('bad href')
|
||||
})
|
||||
await step('narrow screen: the member menu opens from a chat author', async () => {
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 700, height: 900 } })
|
||||
const N = await ctx.newPage()
|
||||
await N.goto(URL + '?usr=nina&pwd=' + USER + '&show_side=1')
|
||||
await N.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
||||
await B.fill('[data-testid=chat-input]', 'hi nina')
|
||||
await B.press('[data-testid=chat-input]', 'Enter')
|
||||
await N.waitForSelector('[data-testid=chat-author]', { timeout: 5000 })
|
||||
await N.click('[data-testid=chat-author]', { button: 'right' })
|
||||
await N.waitForSelector('[data-testid=member-menu]', { state: 'visible', timeout: 3000 })
|
||||
await ctx.close()
|
||||
})
|
||||
await step('muting the sound keeps the desktop usable', async () => {
|
||||
await A.click('[data-testid=mute]')
|
||||
await A.waitForFunction(() => document.querySelector('video').muted, null, { timeout: 3000 })
|
||||
if (await A.locator('[data-testid=player-overlay]').count()) throw new Error('unmute overlay covers the video')
|
||||
await A.click('[data-testid=mute]')
|
||||
await A.waitForFunction(() => !document.querySelector('video').muted, null, { timeout: 3000 })
|
||||
})
|
||||
await step('emoji picker inserts :name:', async () => {
|
||||
await A.click('[data-testid=emoji-open]')
|
||||
await A.fill('[data-testid=emoji-search]', 'thumbsup')
|
||||
await A.click('[data-testid=emoji-list] [data-emoji] >> nth=0')
|
||||
const v = await A.inputValue('[data-testid=chat-input]')
|
||||
if (!/^:[^:]+:$/.test(v)) throw new Error('textarea is ' + JSON.stringify(v))
|
||||
// the keyboard stays in the message box after a pick (the picker's close must not focus its button)
|
||||
await A.keyboard.type('x')
|
||||
const v2 = await A.inputValue('[data-testid=chat-input]')
|
||||
if (!/^:[^:]+:x$/.test(v2)) throw new Error('after the pick the keyboard went elsewhere: ' + JSON.stringify(v2))
|
||||
await A.fill('[data-testid=chat-input]', '')
|
||||
})
|
||||
await step('language switch (de) and back', async () => {
|
||||
await A.selectOption('[data-testid=lang]', 'de')
|
||||
await A.waitForSelector('[data-testid=tabs] >> text=Einstellungen', { timeout: 3000 })
|
||||
await A.selectOption('[data-testid=lang]', 'en')
|
||||
await A.waitForSelector('[data-testid=tabs] >> text=Settings', { timeout: 3000 })
|
||||
})
|
||||
await step('admin locks controls, bob sees lock + event', async () => {
|
||||
await A.click('[data-testid=lock-control]')
|
||||
await B.waitForSelector('[data-testid=lock-control][data-locked]', { timeout: 5000 })
|
||||
await B.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
||||
/alice\s+locked controls/.test(e.textContent),
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await A.click('[data-testid=lock-control]')
|
||||
await B.waitForSelector('[data-testid=lock-control]:not([data-locked])', { timeout: 5000 })
|
||||
})
|
||||
await step('admin mutes bob: input gone, event line; then unmute', async () => {
|
||||
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
||||
await A.click('[data-testid=member-menu] >> text=Mute')
|
||||
await A.click('[data-testid=dialog-confirm]')
|
||||
await B.waitForSelector('[data-testid=chat-input]', { state: 'detached', timeout: 5000 })
|
||||
await A.waitForFunction(
|
||||
() => [...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /You\s+muted bob/.test(e.textContent)),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
||||
await A.click('[data-testid=member-menu] >> text=Unmute')
|
||||
await A.click('[data-testid=dialog-confirm]')
|
||||
await B.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
||||
})
|
||||
await step("a display name with # and ? cannot redirect the admin's mute", async () => {
|
||||
// multiuser session ids start with the login name; unencoded, this id would turn
|
||||
// POST /api/members/<id> into a request for a different path
|
||||
const E = await user('evil#?x', USER)
|
||||
await openSide(E)
|
||||
await E.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
||||
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 3, null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
await A.click('[data-testid=member][aria-label="evil#?x"]', { button: 'right' })
|
||||
await A.click('[data-testid=member-menu] >> text=Mute')
|
||||
await A.click('[data-testid=dialog-confirm]')
|
||||
await E.waitForSelector('[data-testid=chat-input]', { state: 'detached', timeout: 5000 })
|
||||
if (await A.locator('[data-testid=connect]').count()) throw new Error('admin lost the session')
|
||||
await E.context().close()
|
||||
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
await step('keyboard: Enter on focused icon toggles side panel', async () => {
|
||||
await B.focus('[data-testid=side-toggle]')
|
||||
await B.keyboard.press('Enter')
|
||||
await B.waitForSelector('[data-testid=side]', { state: 'detached', timeout: 3000 })
|
||||
await B.keyboard.press('Enter')
|
||||
await B.waitForSelector('[data-testid=side]', { timeout: 3000 })
|
||||
})
|
||||
await step('a member who may not watch gets the room without video, not a spinner', async () => {
|
||||
const bobId = JSON.parse(await api(A, 'GET', '/sessions')).find(
|
||||
(s) => s.profile.name === 'bob' && s.state.is_connected,
|
||||
).id
|
||||
await api(A, 'POST', '/members/' + encodeURIComponent(bobId), { can_watch: false })
|
||||
try {
|
||||
await B.reload()
|
||||
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 }) // the offer timeout (8 s) ends the spinner
|
||||
await openSide(B)
|
||||
await B.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
||||
} finally {
|
||||
await api(A, 'POST', '/members/' + encodeURIComponent(bobId), { can_watch: true })
|
||||
}
|
||||
await B.reload()
|
||||
await B.waitForFunction(
|
||||
() => {
|
||||
const v = document.querySelector('video')
|
||||
return v && !v.paused && v.readyState >= 2
|
||||
},
|
||||
null,
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
await openSide(B)
|
||||
})
|
||||
await step('admin changes resolution, bob sees event; revert', async () => {
|
||||
await A.click('[data-testid=resolution-open]')
|
||||
const current = (await A.locator('[data-testid=resolution-item][data-active]').textContent()) ?? ''
|
||||
const width = await A.evaluate(() => document.querySelector('video').videoWidth)
|
||||
await A.locator('[data-testid=resolution-item]').filter({ hasNotText: current }).first().click()
|
||||
await B.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
||||
/changed the resolution/.test(e.textContent),
|
||||
),
|
||||
null,
|
||||
{ timeout: 8000 },
|
||||
)
|
||||
await A.click('[data-testid=resolution-open]')
|
||||
await A.locator('[data-testid=resolution-item]', { hasText: current }).first().click()
|
||||
await A.mouse.click(5, 5)
|
||||
await A.waitForFunction((w) => document.querySelector('video').videoWidth === w, width, { timeout: 15000 })
|
||||
})
|
||||
await step('settings: keyboard layouts loaded, about opens', async () => {
|
||||
await A.click('[data-testid=tabs] >> text=Settings')
|
||||
const n = await A.locator('[data-testid=settings] select option').count()
|
||||
if (n < 20) throw new Error('only ' + n + ' keyboard layouts')
|
||||
await A.click('[data-testid=about-open]')
|
||||
await A.waitForSelector('[data-testid=about] >> text=m1k1o/neko', { timeout: 3000 })
|
||||
await A.click('[data-testid=about-close]')
|
||||
await A.waitForSelector('[data-testid=about]', { state: 'detached', timeout: 3000 })
|
||||
await A.click('[data-testid=tabs] >> text=Chat')
|
||||
})
|
||||
await step('implicit hosting: bob gets control by clicking', async () => {
|
||||
await api(A, 'POST', '/room/settings', { implicit_hosting: true })
|
||||
await B.waitForSelector('[data-testid=control-implicit]', { timeout: 5000 })
|
||||
const box = await B.locator('[data-testid=player]').boundingBox()
|
||||
await B.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await B.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
||||
await api(A, 'POST', '/room/settings', { implicit_hosting: false })
|
||||
await A.waitForSelector('[data-testid=control-request]', { timeout: 5000 })
|
||||
await api(A, 'POST', '/room/control/reset')
|
||||
await B.waitForSelector('[data-testid=member][data-self][data-host]', { state: 'detached', timeout: 5000 })
|
||||
})
|
||||
await step('clipboard: local clipboard reaches the remote when hovering', async () => {
|
||||
await A.click('[data-testid=control-request]')
|
||||
await A.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
||||
// give the X clipboard an owner first: reading it while nothing owns it is a server 500 (xclip
|
||||
// exits) that the browser logs as an error, which is not what this step is about
|
||||
await api(A, 'POST', '/room/clipboard', { text: 'seed' })
|
||||
const text = 'neko-clip-' + Date.now()
|
||||
await A.evaluate((t) => navigator.clipboard.writeText(t), text)
|
||||
await A.mouse.move(5, 5)
|
||||
const box = await A.locator('[data-testid=player]').boundingBox()
|
||||
await A.mouse.move(box.x + 50, box.y + 50)
|
||||
await A.waitForFunction(
|
||||
async (t) => {
|
||||
const headers = { Authorization: 'Bearer ' + localStorage.getItem('neko_session') }
|
||||
const r = await fetch('api/room/clipboard', { headers })
|
||||
return r.ok && (await r.json()).text === t
|
||||
},
|
||||
text,
|
||||
{ timeout: 8000, polling: 500 },
|
||||
)
|
||||
})
|
||||
await step('microphone: host enables and disables (fake device)', async () => {
|
||||
await A.click('[data-testid=mic]:not([data-on])')
|
||||
await A.waitForSelector('[data-testid=mic][data-on]', { timeout: 5000 })
|
||||
if (await A.locator('[data-testid=dialog]').count())
|
||||
throw new Error(await A.locator('[data-testid=dialog]').textContent())
|
||||
await A.click('[data-testid=mic][data-on]')
|
||||
await A.waitForSelector('[data-testid=mic]:not([data-on])', { timeout: 5000 })
|
||||
await api(A, 'POST', '/room/control/release')
|
||||
})
|
||||
// the textarea fallback shows when the browser cannot read the local clipboard (Firefox, Safari):
|
||||
// emulated by removing readText before the app loads
|
||||
await step('clipboard fallback: the popover keeps the focus while the mouse crosses the video', async () => {
|
||||
const C = await user('carol', ADMIN, () =>
|
||||
Object.defineProperty(Clipboard.prototype, 'readText', { value: undefined }),
|
||||
)
|
||||
try {
|
||||
await C.click('[data-testid=control-request]')
|
||||
await C.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
||||
await C.click('[data-testid=clipboard-open]')
|
||||
const inPopover = () => document.activeElement?.closest('[data-testid=clipboard] textarea') != null
|
||||
await C.waitForFunction(inPopover, null, { timeout: 3000 })
|
||||
const box = await C.locator('[data-testid=player]').boundingBox()
|
||||
await C.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 12 })
|
||||
await C.mouse.move(box.x + 40, box.y + box.height - 40, { steps: 12 })
|
||||
await C.waitForTimeout(500)
|
||||
if (!(await C.locator('[data-testid=clipboard]').count())) throw new Error('the clipboard popover closed')
|
||||
if (!(await C.evaluate(inPopover))) throw new Error('the clipboard textarea lost the focus')
|
||||
// what is typed lands in the textarea, not on the remote, and can be selected to copy
|
||||
await C.keyboard.type('neko-fallback')
|
||||
await C.keyboard.press('Control+A')
|
||||
const sel = await C.evaluate(() => {
|
||||
const t = document.activeElement
|
||||
return t.value === 'neko-fallback' && t.selectionEnd - t.selectionStart === t.value.length
|
||||
})
|
||||
if (!sel) throw new Error('typing or select-all did not reach the clipboard textarea')
|
||||
await C.keyboard.press('Escape')
|
||||
await C.waitForSelector('[data-testid=clipboard]', { state: 'detached', timeout: 3000 })
|
||||
} finally {
|
||||
await api(C, 'POST', '/room/control/release').catch(() => {})
|
||||
await C.context().close()
|
||||
}
|
||||
})
|
||||
// offline emulation only freezes an open websocket in Chromium (WebKit and Firefox keep delivering
|
||||
// heartbeats to it, measured); elsewhere these two steps have nothing to notice and are skipped
|
||||
const offlineWorks = browser.browserType().name() === 'chromium'
|
||||
await step('network drop: bob notices the dead socket and reconnects', async () => {
|
||||
if (!offlineWorks) return log(' skipped: offline emulation does not reach the websocket here')
|
||||
// offline emulation freezes the websocket but not WebRTC: only the stale check (~25s) notices
|
||||
await B.context().setOffline(true)
|
||||
try {
|
||||
await B.waitForSelector('[data-testid=connect]', { timeout: 45000 })
|
||||
} finally {
|
||||
await B.context().setOffline(false)
|
||||
}
|
||||
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 30000 })
|
||||
await B.waitForFunction(
|
||||
() => {
|
||||
const v = document.querySelector('video')
|
||||
return v && !v.paused && v.readyState >= 2
|
||||
},
|
||||
null,
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
})
|
||||
await step('long outage: session kept, Connect button instead of the login form', async () => {
|
||||
if (!offlineWorks) return log(' skipped: offline emulation does not reach the websocket here')
|
||||
// the core gives up after RECONNECT_MAX attempts (about 80 s); the session is still valid
|
||||
await B.context().setOffline(true)
|
||||
try {
|
||||
await B.waitForSelector('[data-testid=connect] form button[type=submit]', { timeout: 150000 })
|
||||
} finally {
|
||||
await B.context().setOffline(false)
|
||||
}
|
||||
if (await B.locator('[data-testid=connect] input[type=password]').count())
|
||||
throw new Error('thrown back to the login form')
|
||||
await B.click('[data-testid=dialog-confirm]') // "Disconnected: connection lost"
|
||||
await B.click('[data-testid=connect] form button[type=submit]')
|
||||
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 30000 })
|
||||
await B.waitForFunction(
|
||||
() => {
|
||||
const v = document.querySelector('video')
|
||||
return v && !v.paused && v.readyState >= 2
|
||||
},
|
||||
null,
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
})
|
||||
await step('alice takes control and types into KDE', async () => {
|
||||
await A.click('[data-testid=control-request]')
|
||||
await A.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
||||
const box = await A.locator('[data-testid=player]').boundingBox()
|
||||
await A.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await A.mouse.move(box.x + box.width / 2 + 3, box.y + box.height / 2 + 3)
|
||||
// what arrived: select all and copy inside the remote, then read the remote clipboard
|
||||
const copyAll = async (settle = 600) => {
|
||||
await A.keyboard.press('Control+a')
|
||||
await A.keyboard.press('Control+c')
|
||||
await A.waitForTimeout(settle)
|
||||
return JSON.parse(await api(A, 'GET', '/room/clipboard')).text
|
||||
}
|
||||
await A.keyboard.press('Alt+F2')
|
||||
// KRunner is started by that shortcut and takes the keyboard focus only once it is up; a key
|
||||
// that lands on the focus change is lost. So type a probe and read it back until KRunner
|
||||
// demonstrably inserts keys, then type the text over the still selected probe
|
||||
const deadline = Date.now() + 15000
|
||||
for (;;) {
|
||||
await A.keyboard.type('x')
|
||||
if ((await copyAll(300).catch(() => '')) === 'x') break
|
||||
if (Date.now() > deadline) throw new Error('KRunner did not take the keyboard')
|
||||
}
|
||||
await A.keyboard.type('react gui works', { delay: 30 })
|
||||
await A.waitForTimeout(1200)
|
||||
await A.screenshot({ path: out + '/e2e-alice.png' })
|
||||
await B.screenshot({ path: out + '/e2e-bob.png' })
|
||||
const typed = await copyAll()
|
||||
if (typed !== 'react gui works') throw new Error('remote received ' + JSON.stringify(typed))
|
||||
// Ctrl+V inside the remote pastes what was copied there, with the real Ctrl held. Home collapses
|
||||
// the selection to the start; End would not: with the caret already at the end, KRunner's results
|
||||
// list takes it (milou ResultsView.qml navigationKeyHandler) and swallows the following Ctrl+V
|
||||
await A.keyboard.press('Home')
|
||||
await A.keyboard.press('Control+v')
|
||||
await A.waitForTimeout(800)
|
||||
const pasted = await copyAll()
|
||||
if (pasted !== 'react gui worksreact gui works')
|
||||
throw new Error('after Ctrl+V the field has ' + JSON.stringify(pasted))
|
||||
await A.keyboard.press('Escape')
|
||||
})
|
||||
await step('alice kicks bob -> bob back at login', async () => {
|
||||
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
||||
await A.click('[data-testid=member-menu] >> text=Kick')
|
||||
await A.click('[data-testid=dialog-confirm]')
|
||||
await B.waitForSelector('[data-testid=connect] input[type=password]', { timeout: 8000 })
|
||||
await A.click('[data-testid=tabs] >> text=Chat')
|
||||
await A.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /bob\s+disconnected/.test(e.textContent)),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await B.screenshot({ path: out + '/e2e-bob-kicked.png' })
|
||||
})
|
||||
await step('url params: ?usr&pwd&lang&show_side auto-login, url cleaned, logout', async () => {
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true })
|
||||
const C = await ctx.newPage()
|
||||
await C.goto(URL + '?usr=carol&pwd=' + USER + '&lang=de&show_side=1')
|
||||
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
||||
await C.waitForSelector('[data-testid=side] >> text=Einstellungen', { timeout: 5000 })
|
||||
if (/pwd=/.test(C.url())) throw new Error('password left in url: ' + C.url())
|
||||
// the same browser profile opens another invite link while its session is still valid:
|
||||
// the new login must win on both the websocket and REST (no split identity)
|
||||
await C.goto(URL + '?usr=carol2&pwd=' + USER + '&show_side=1')
|
||||
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
||||
// the browser closes the first page's socket on navigation; Firefox does so without a close
|
||||
// code and the server then keeps that session for its 5 s reconnect grace
|
||||
let connected
|
||||
for (let i = 0; i < 16; i++) {
|
||||
await C.waitForTimeout(500)
|
||||
connected = JSON.parse(await api(A, 'GET', '/sessions'))
|
||||
.filter((s) => s.state.is_connected)
|
||||
.map((s) => s.profile.name)
|
||||
if (connected.includes('carol2') && !connected.includes('carol')) break
|
||||
}
|
||||
if (!connected.includes('carol2') || connected.includes('carol'))
|
||||
throw new Error('connected sessions after the second invite: ' + JSON.stringify(connected))
|
||||
await C.click('[data-testid=tabs] >> text=Einstellungen')
|
||||
await C.click('[data-testid=logout]')
|
||||
await C.waitForSelector('[data-testid=connect] input[type=password]', { timeout: 8000 })
|
||||
await ctx.close()
|
||||
})
|
||||
await step('url params: ?embed=1 hides header and room bar', async () => {
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true })
|
||||
const C = await ctx.newPage()
|
||||
await C.goto(URL + '?embed=1&usr=dave&pwd=' + USER)
|
||||
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
||||
if ((await C.locator('[data-testid=header]').count()) || (await C.locator('[data-testid=room-bar]').count()))
|
||||
throw new Error('chrome visible in embed mode')
|
||||
await ctx.close()
|
||||
})
|
||||
console.log(
|
||||
`\n${fails} failed. page errors:`,
|
||||
errors.length ? '\n ' + [...new Set(errors)].slice(0, 12).join('\n ') : 'none',
|
||||
)
|
||||
await browser.close()
|
||||
process.exit(fails || errors.length ? 1 : 0)
|
||||
@@ -0,0 +1,62 @@
|
||||
// The two React hook rules, and the import rules of the layered layout (see README.md): types and
|
||||
// formatting are checked by tsc and prettier.
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
|
||||
// Dependencies point one way: app -> layout -> features -> components -> design, with state, i18n
|
||||
// and lib below everything but design. The app shell and the layout reach plugins only through the
|
||||
// registry (`@/plugins`); features see only the contract (`@/plugins/types`) and get plugin
|
||||
// contributions passed in, and plugins import neither features nor each other.
|
||||
// Between folders only `@/` imports are used (a feature by its main file, `@/features/video/Stage`);
|
||||
// a folder's own files are imported relatively.
|
||||
const only = (allowed) => ({
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{ group: ['../*'], message: 'import other layers with @/ (relative imports stay inside the folder)' },
|
||||
{ group: ['@m1k1o/neko/*', '@/core/*'], message: 'the core is imported from its entry point @m1k1o/neko' },
|
||||
{ group: ['@/plugins/*', '!@/plugins/types'], message: 'plugins are reached through the registry @/plugins' },
|
||||
...allowed,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const not = (...layers) => layers.map((l) => ({ group: [`@/${l}`, `@/${l}/*`], message: `${l} is above this layer` }))
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist', 'core/dist', 'core/src/keyboard/guacamole.js'] },
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'core/src/**/*.ts'],
|
||||
languageOptions: { parser: tseslint.parser },
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'error' },
|
||||
},
|
||||
{
|
||||
// deprecated APIs fail the lint, type-aware so it costs a few seconds; tests are outside the tsconfigs
|
||||
files: ['src/**/*.{ts,tsx}', 'core/src/**/*.ts'],
|
||||
ignores: ['**/*.test.{ts,tsx}', 'core/src/test/**'],
|
||||
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
|
||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||
rules: { '@typescript-eslint/no-deprecated': 'error' },
|
||||
},
|
||||
{ files: ['src/components/**'], rules: only(not('app', 'layout', 'features', 'plugins')) },
|
||||
{
|
||||
files: ['src/state/**', 'src/i18n/**', 'src/lib/**'],
|
||||
rules: only(not('app', 'layout', 'features', 'plugins', 'components')),
|
||||
},
|
||||
{
|
||||
files: ['src/features/**'],
|
||||
rules: only([
|
||||
...not('app', 'layout'),
|
||||
// the registry and everything under it but the contract (a group cannot re-include a child of `@/plugins`)
|
||||
{
|
||||
regex: '^@/plugins(/(?!types$)|$)',
|
||||
message: 'features see only the plugin contract @/plugins/types; the app shell passes contributions in',
|
||||
},
|
||||
]),
|
||||
},
|
||||
{ files: ['src/plugins/**'], rules: only(not('app', 'layout', 'features')) },
|
||||
{ files: ['src/layout/**'], rules: only(not('app')) },
|
||||
{ files: ['src/app/**', 'src/main.tsx'], rules: only([]) },
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<title>n.eko</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#19bd9c" />
|
||||
<meta name="msapplication-TileColor" content="#19bd9c" />
|
||||
<meta name="theme-color" content="#19bd9c" />
|
||||
<!-- iOS: video does not start unless the container has a size before the app renders -->
|
||||
<style>
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but n.eko doesn't work properly without JavaScript enabled. Please enable it to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,69 +1,62 @@
|
||||
{
|
||||
"name": "neko-client",
|
||||
"version": "2.5.0",
|
||||
"description": "Client for neko streaming server",
|
||||
"license": "Apache License 2.0",
|
||||
"author": "Nurdism <https://github.com/nurdism>",
|
||||
"homepage": "https://github.com/m1k1o/neko/client#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/m1k1o/neko.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/m1k1o/neko/issues"
|
||||
},
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"description": "Client for neko: React GUI + framework-free core on the v3 API.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --mode development",
|
||||
"build": "vue-cli-service build",
|
||||
"build:lib": "vue-cli-service build --target lib --name neko-lib 'src/lib.ts'",
|
||||
"build:emoji": "ts-node --files --project tools/tsconfig.json tools/emoji.ts",
|
||||
"lint": "vue-cli-service lint"
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && node tools/dist.check.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "node e2e.mjs",
|
||||
"format": "prettier --write .",
|
||||
"check": "run-p -l --aggregate-output check:types check:format check:lint check:cycles test && run-s build:core check:dist",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check .",
|
||||
"check:lint": "eslint src core/src",
|
||||
"check:cycles": "node tools/cycles.mjs",
|
||||
"check:dist": "node core/dist.check.mjs",
|
||||
"build:emoji": "node tools/emoji.ts",
|
||||
"build:core": "npm --prefix core run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.2.0",
|
||||
"animejs": "^3.2.0",
|
||||
"axios": "^1.2.3",
|
||||
"date-fns": "^2.29.3",
|
||||
"emoji-datasource": "^6.0.1",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"simple-markdown": "^0.7.2",
|
||||
"sweetalert2": "11.4.8",
|
||||
"typed-vuex": "^0.1.21",
|
||||
"v-tooltip": "^2.0.3",
|
||||
"vue": "^2.7.13",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-context": "^5.2.0",
|
||||
"vue-i18n": "^8.27.2",
|
||||
"vue-notification": "^1.3.20",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vuex": "^3.5.1"
|
||||
"@radix-ui/react-dialog": "^1.2.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.25",
|
||||
"@radix-ui/react-menu": "^2.1.25",
|
||||
"@radix-ui/react-popover": "^1.2.0",
|
||||
"@radix-ui/react-tabs": "^1.1.22",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.4.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/animejs": "^3.1.6",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/vue": "^2.0.0",
|
||||
"@types/vue-clickaway": "^2.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.8",
|
||||
"@typescript-eslint/parser": "^5.0.8",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||
"@vue/cli-plugin-typescript": "^5.0.8",
|
||||
"@vue/cli-plugin-vuex": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"core-js": "^3.26.0",
|
||||
"emojilib": "^3.0.7",
|
||||
"eslint": "^8.32.0",
|
||||
"eslint-plugin-prettier": "^3.4.1",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.55.0",
|
||||
"sass-loader": "^10.3.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.8.4",
|
||||
"vue-template-compiler": "^2.7.13"
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"emoji-datasource": "^6.1.1",
|
||||
"emojilib": "3.0.8",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"npm-run-all2": "^9.0.3",
|
||||
"playwright": "^1.63.0",
|
||||
"prettier": "^3.9.9",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.8.0",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>n.eko</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#19bd9c">
|
||||
<meta name="msapplication-TileColor" content="#19bd9c">
|
||||
<meta name="theme-color" content="#19bd9c">
|
||||
<style> /* weird iOS bug, if this is not set right here, video just does not start */ .video-container { width: 100%; height: 100%; } </style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but n.eko doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="neko"></div>
|
||||
<p>
|
||||
A self hosted virtual browser (<a href="https://github.com/m1k1o/neko">m1k1o/neko</a>) that runs in docker.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<div id="neko" :class="[!videoOnly && side ? 'expanded' : '']">
|
||||
<template v-if="!$client.supported">
|
||||
<neko-unsupported />
|
||||
</template>
|
||||
<template v-else>
|
||||
<main class="neko-main">
|
||||
<div v-if="!videoOnly" class="header-container">
|
||||
<neko-header />
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<neko-video
|
||||
ref="video"
|
||||
:hideControls="hideControls"
|
||||
:extraControls="isEmbedMode"
|
||||
@control-attempt="controlAttempt"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!videoOnly" class="room-container">
|
||||
<neko-members />
|
||||
<div class="room-menu">
|
||||
<div class="settings">
|
||||
<neko-menu />
|
||||
</div>
|
||||
<div class="controls">
|
||||
<neko-controls :shakeKbd="shakeKbd" />
|
||||
</div>
|
||||
<div class="emotes">
|
||||
<neko-emotes />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<neko-side v-if="!videoOnly && side" />
|
||||
<neko-connect v-if="!connected" />
|
||||
<neko-about v-if="about" />
|
||||
<notifications
|
||||
v-if="!videoOnly"
|
||||
group="neko"
|
||||
position="top left"
|
||||
style="top: 50px; pointer-events: none"
|
||||
:ignoreDuplicates="true"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#neko {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
|
||||
.neko-main {
|
||||
min-width: 360px;
|
||||
max-width: 100%;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
|
||||
.header-container {
|
||||
background: $background-tertiary;
|
||||
height: $menu-height;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
background: rgba($color: #000, $alpha: 0.4);
|
||||
max-width: 100%;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.room-container {
|
||||
background: $background-tertiary;
|
||||
height: $controls-height;
|
||||
max-width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
|
||||
.room-menu {
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.settings {
|
||||
margin-left: 10px;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.emotes {
|
||||
margin-right: 10px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1024px) {
|
||||
html,
|
||||
body {
|
||||
overflow-y: auto !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
body > p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#neko {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
max-height: initial !important;
|
||||
|
||||
.neko-main {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.neko-menu {
|
||||
height: 100vh;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1024px) and (orientation: portrait) {
|
||||
#neko {
|
||||
&.expanded .neko-main {
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
&.expanded .neko-menu {
|
||||
height: 60vh;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
#neko .neko-main .room-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Ref, Watch } from 'vue-property-decorator'
|
||||
|
||||
import Connect from '~/components/connect.vue'
|
||||
import Video from '~/components/video.vue'
|
||||
import Menu from '~/components/menu.vue'
|
||||
import Side from '~/components/side.vue'
|
||||
import Controls from '~/components/controls.vue'
|
||||
import Members from '~/components/members.vue'
|
||||
import Emotes from '~/components/emotes.vue'
|
||||
import About from '~/components/about.vue'
|
||||
import Header from '~/components/header.vue'
|
||||
import Unsupported from '~/components/unsupported.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko',
|
||||
components: {
|
||||
'neko-connect': Connect,
|
||||
'neko-video': Video,
|
||||
'neko-menu': Menu,
|
||||
'neko-side': Side,
|
||||
'neko-controls': Controls,
|
||||
'neko-members': Members,
|
||||
'neko-emotes': Emotes,
|
||||
'neko-about': About,
|
||||
'neko-header': Header,
|
||||
'neko-unsupported': Unsupported,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
@Ref('video') video!: Video
|
||||
|
||||
shakeKbd = false
|
||||
|
||||
get volume() {
|
||||
const numberParam = parseFloat(new URL(location.href).searchParams.get('volume') || '1.0')
|
||||
return Math.max(0.0, Math.min(!isNaN(numberParam) ? numberParam * 100 : 100, 100))
|
||||
}
|
||||
|
||||
get isCastMode() {
|
||||
return !!new URL(location.href).searchParams.get('cast')
|
||||
}
|
||||
|
||||
get isEmbedMode() {
|
||||
return !!new URL(location.href).searchParams.get('embed')
|
||||
}
|
||||
|
||||
get hideControls() {
|
||||
return this.isCastMode
|
||||
}
|
||||
|
||||
get videoOnly() {
|
||||
return this.isCastMode || this.isEmbedMode
|
||||
}
|
||||
|
||||
@Watch('volume', { immediate: true })
|
||||
onVolume(volume: number) {
|
||||
if (new URL(location.href).searchParams.has('volume')) {
|
||||
this.$accessor.video.setVolume(volume)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('hideControls', { immediate: true })
|
||||
onHideControls(enabled: boolean) {
|
||||
if (enabled) {
|
||||
this.$accessor.video.setMuted(false)
|
||||
this.$accessor.settings.setSound(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('side')
|
||||
onSide(side: boolean) {
|
||||
if (side) {
|
||||
console.log('side enabled')
|
||||
// scroll to the side
|
||||
this.$nextTick(() => {
|
||||
const side = document.querySelector('aside')
|
||||
if (side) {
|
||||
side.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
controlAttempt() {
|
||||
if (this.shakeKbd || this.$accessor.remote.hosted) return
|
||||
|
||||
this.shakeKbd = true
|
||||
window.setTimeout(() => (this.shakeKbd = false), 5000)
|
||||
}
|
||||
|
||||
get about() {
|
||||
return this.$accessor.client.about
|
||||
}
|
||||
|
||||
get side() {
|
||||
return this.$accessor.client.side
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.$accessor.connected
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useStore } from 'zustand'
|
||||
import { IconContext } from '@phosphor-icons/react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNeko } from '@/state/provider'
|
||||
import { useSlot } from '@/plugins'
|
||||
import { WebRTCTransport } from '@m1k1o/neko'
|
||||
import { cn } from '@/lib/utils'
|
||||
import logo from '@/assets/images/logo.svg'
|
||||
import { Dialog } from '@/components/Dialog'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Header } from '@/layout/Header'
|
||||
import { Side } from '@/layout/Side'
|
||||
import { RoomBar } from '@/layout/RoomBar'
|
||||
import { Stage } from '@/features/video/Stage'
|
||||
import { MemberMenu } from '@/features/members/MemberMenu'
|
||||
import { Connect } from '@/features/connect/Connect'
|
||||
import { About } from '@/features/about/About'
|
||||
|
||||
const params = new URL(location.href).searchParams
|
||||
const cast = !!params.get('cast')
|
||||
const videoOnly = cast || !!params.get('embed')
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation()
|
||||
const { client, app } = useNeko()
|
||||
const connected = useStore(client.store, (s) => s.connection.status === 'connected')
|
||||
const { side, about } = useStore(
|
||||
app,
|
||||
useShallow((s) => ({ side: s.side, about: s.about })),
|
||||
)
|
||||
const memberMenu = useSlot('member.menu')
|
||||
|
||||
if (!WebRTCTransport.supported()) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-[320px] rounded-[5px] bg-background-secondary p-2.5">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<img src={logo} alt="n.eko" className="mr-2.5 h-[90px]" />
|
||||
<span className="text-[30px] leading-14">
|
||||
<b className="font-black">n</b>.eko
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="block text-center leading-7.5 uppercase">{t('unsupported')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const expanded = !videoOnly && side
|
||||
return (
|
||||
// every icon filled, as in the v2 client (phosphor defaults to the outline `regular` weight)
|
||||
<IconContext.Provider value={{ weight: 'fill' }}>
|
||||
<div
|
||||
id="neko"
|
||||
className="tablet:relative tablet:max-h-none tablet:flex-col absolute inset-0 flex max-h-[100vh] max-w-[100vw]"
|
||||
>
|
||||
<main
|
||||
className={cn(
|
||||
'flex min-w-[360px] max-w-full grow flex-col overflow-auto',
|
||||
expanded ? 'tablet:portrait:h-[40vh] tablet:landscape:h-screen' : 'tablet:h-screen',
|
||||
)}
|
||||
>
|
||||
{!videoOnly && (
|
||||
<div className="flex h-menu shrink-0 bg-background-tertiary" data-testid="header">
|
||||
<Header />
|
||||
</div>
|
||||
)}
|
||||
{/* .video-container: index.html sizes it before the app renders (iOS) */}
|
||||
<div className="video-container flex max-w-full grow bg-black/40">
|
||||
<Stage hideControls={cast} extraControls={videoOnly} />
|
||||
</div>
|
||||
{!videoOnly && <RoomBar />}
|
||||
</main>
|
||||
{!videoOnly && <MemberMenu items={memberMenu} />}
|
||||
{expanded && <Side />}
|
||||
{!connected && <Connect />}
|
||||
{!videoOnly && <Toaster />}
|
||||
{about && <About />}
|
||||
<Dialog />
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import logo from '@/assets/images/logo.svg'
|
||||
|
||||
// a render bug should not leave a blank page
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
|
||||
state: { error?: Error } = {}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-[320px] rounded-[5px] bg-background-secondary p-2.5">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<img src={logo} alt="n.eko" className="mr-2.5 h-[90px]" />
|
||||
<span className="text-[30px] leading-14">
|
||||
<b className="font-black">n</b>.eko
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="block text-center leading-7.5 uppercase">
|
||||
Something went wrong: {this.state.error.message}
|
||||
</span>
|
||||
<Button className="my-[5px]" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 725 B |
|
Before Width: | Height: | Size: 561 B |
|
Before Width: | Height: | Size: 390 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 858 B |
|
Before Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 928 B |
|
Before Width: | Height: | Size: 929 B |
|
Before Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 447 B |
|
Before Width: | Height: | Size: 720 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 640 B |
|
Before Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 742 B |
|
Before Width: | Height: | Size: 620 B |
|
Before Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 919 B |
|
Before Width: | Height: | Size: 940 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 880 B |