neko Documentation

A self hosted virtual browser that runs in docker and uses WebRTC.

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy neko on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name neko -p 8080:8080 --restart always ghcr.io/m1k1o/neko/firefox:latest
Configured Services & Ports
โš™๏ธ neko
image: ghcr.io/m1k1o/neko/firefox:latest
๐Ÿ”Œ Ports: 8080:8080, 52000-52100:52000-52100/udp
docker-compose.yml
yaml (docker-compose.yml)
services:
  neko:
    # Runs Firefox in Neko. Firefox is the default, most tested image, but Neko
    # supports many other browsers and applications - see the full list here:
    # https://neko.m1k1o.net/docs/v3/installation/docker-images#apps
    image: "ghcr.io/m1k1o/neko/firefox:latest"
    restart: "unless-stopped"
    shm_size: "2gb"
    ports:
      - "8080:8080"
      - "52000-52100:52000-52100/udp"
    # volumes:
      # Persist Firefox settings, bookmarks, extensions and history across
      # container restarts by mounting the profile directory.
      # See: https://neko.m1k1o.net/docs/v3/customization/browsers#persistent-profile
      # - "./profile:/home/neko/.mozilla/firefox/profile.default"

      # Or mount your own pre-configured Firefox profile instead of an empty one.
      # See: https://neko.m1k1o.net/docs/v3/customization/browsers#persistent-profile
      # - "./my-custom-profile:/home/neko/.mozilla/firefox/profile.default"
    environment:
      # You can configure either EPR or MUX ports for WebRTC.
      # See: https://neko.m1k1o.net/docs/v3/configuration/webrtc#network
      NEKO_WEBRTC_EPR: 52000-52100
      # Specify the screen resolution and refresh rate for the virtual desktop. The
      # default is 1920x1080@30, but you can change it to any other resolution.
      # See: https://neko.m1k1o.net/docs/v3/configuration/desktop#screen
      NEKO_DESKTOP_SCREEN: 1920x1080@30
      # You can configure a password for the default user and admin accounts. The
      # default user account is "neko" and the default admin account is "admin".
      # See: https://neko.m1k1o.net/docs/v3/configuration/authentication#member.multiuser
      NEKO_MEMBER_MULTIUSER_USER_PASSWORD: neko
      NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: admin
      # Specifying reachable IP by clients is required when running Neko.
      # See: https://neko.m1k1o.net/docs/v3/configuration/webrtc#nat1to1
      NEKO_WEBRTC_NAT1TO1: 

File: README.md

n.eko

Welcome to Neko, a self-hosted virtual browser that runs in Docker and uses WebRTC technology. Neko is a powerful tool that allows you to run a fully-functional browser in a virtual environment, giving you the ability to access the internet securely and privately from anywhere. With Neko, you can browse the web, run applications, and perform other tasks just as you would on a regular browser, all within a secure and isolated environment. Whether you are a developer looking to test web applications, a privacy-conscious user seeking a secure browsing experience, or simply someone who wants to take advantage of the convenience and flexibility of a virtual browser, Neko is the perfect solution.

In addition to its security and privacy features, Neko offers the ability for multiple users to access it simultaneously. This makes it an ideal solution for teams or organizations that need to share access to a browser, as well as for individuals who want to use multiple devices to access the same virtual environment. With Neko, you can easily and securely share access to a browser with others, without having to worry about maintaining separate configurations or settings. Whether you need to collaborate on a project, access shared resources, or simply want to share access to a browser with friends or family, Neko makes it easy to do so.

Neko is also a great tool for hosting watch parties and interactive presentations. With its virtual browser capabilities, Neko allows you to host watch parties and presentations that are accessible from anywhere, without the need for in-person gatherings. This makes it easy to stay connected with friends and colleagues, even when you are unable to meet in person. With Neko, you can easily host a watch party or give an interactive presentation, whether it's for leisure or work. Simply invite your guests to join the virtual environment, and you can share the screen and interact with them in real-time.

About

This app uses WebRTC to stream a desktop inside of a docker container, original author made this because rabb.it went under and his internet could not handle streaming and discord kept crashing when his friend attempted to. He just wanted to watch anime with his friends แƒš(เฒ ็›Šเฒ แƒš) so he started digging throughout the internet and found a few kinda clones, but none of them had the virtual browser, then he found Turtus and he was able to figure out the rest.

Then I found this project and started to dig into it. I really liked the idea of having collaborative browser browsing together with multiple people, so I created a fork. Initially, I wanted to merge my changes to the upstream repository, but the original author did not have time for this project anymore and it got eventually archived.

Use-cases and comparison

Neko started as a virtual browser that is streamed using WebRTC to multiple users.

  • It is not only limited to a browser; it can run anything that runs on linux (e.g. VLC). Browser only happens to be the most popular and widely used use-case.
  • In fact, it is not limited to a single program either; you can install a full desktop environment (e.g. XFCE, KDE).
  • Speaking of limits, it does not need to run in a container; you could install neko on your host, connect to your X server and control your whole VM.
  • Theoretically it is not limited to only X server, anything that can be controlled and scraped periodically for images could be used instead.
    • Like implementing RDP or VNC protocol, where neko would only act as WebRTC relay server. This is currently only future.

Primary use case is connecting with multiple people, leveraging real time synchronization and interactivity:

  • Watch party - watching video content together with multiple people and reacting to it (chat, emotes) - open source alternative to giggl.app or hyperbeam.
  • Interactive presentation - not only screen sharing, but others can control the screen.
  • Collaborative tool - brainstorming ideas, cobrowsing, code debugging together.
  • Support/Teaching - interactively guiding people in controlled environment.
  • Embed anything - embed virtual browser in your web app - open source alternative to hyperbeam API.
    • open any third-party website or application, synchronize audio and video flawlessly among multiple participants.
    • request rooms using API with neko-rooms.
  • Neko inside VR Chat - watch remote browser together with friends in VR.

Other use cases that benefit from single-user:

  • Personal workspace - streaming containerized apps and desktops to end-users - similar to kasm.
  • Persistent browser - own browser with persistent cookies available anywhere - similar to mightyapp.
    • no state is left on the host browser after terminating the connection.
    • sensitive data like cookies are not transferred - only video is shared.
  • Throwaway browser - a better solution for planning secret parties and buying birthday gifts off the internet.
    • use Tor Browser and VPN for additional anonymity.
    • mitigates risk of OS fingerprinting and browser vulnerabilities by running in container.
  • Session broadcasting - broadcast room content using RTMP (to e.g. twitch or youtube...).
  • Session recording - broadcast RTMP can be saved to a file using e.g. nginx-rtmp
    • have clean environment when recording tutorials.
    • no need to hide bookmarks or use incognito mode.
  • Jump host - access your internal applications securely without the need for VPN.
  • Automated browser - you can install playwright or puppeteer and automate tasks while being able to actively intercept them.

Compared to clientless remote desktop gateway (e.g. Apache Guacamole or websockify with noVNC), installed with remote desktop server along with desired program (e.g. linuxserver/firefox) provides neko additionally:

  • Smooth video because it uses WebRTC and not images sent over WebSockets.
  • Built in audio support, what is not part of Apache Guacamole or noVNC.
  • Multi-participant control, what is not natively supported by Apache Guacamole or noVNC.

Supported browsers

Other applications

Why neko?

I like cats ๐Ÿฑ (Neko is the Japanese word for cat), I'm a weeb/nerd.

But why the cat butt? Because cats are assholes, but you love them anyways.

Multiple rooms

For neko room management software, visit neko-rooms.

It also offers Zero-knowledge installation (with HTTPS).

Documentation

Full documentation is available at neko.m1k1o.net. Key sections include:

How to Contribute

Contributions are welcome! Check the Contributing Guide for details.

Support

If you find Neko useful, consider supporting the project via GitHub Sponsors.


File: docker-compose.yaml

services:
neko:
# Runs Firefox in Neko. Firefox is the default, most tested image, but Neko
# supports many other browsers and applications - see the full list here:
# https://neko.m1k1o.net/docs/v3/installation/docker-images#apps
image: "ghcr.io/m1k1o/neko/firefox:latest"
restart: "unless-stopped"
shm_size: "2gb"
ports:
- "8080:8080"
- "52000-52100:52000-52100/udp"
# volumes:
# Persist Firefox settings, bookmarks, extensions and history across
# container restarts by mounting the profile directory.
# See: https://neko.m1k1o.net/docs/v3/customization/browsers#persistent-profile
# - "./profile:/home/neko/.mozilla/firefox/profile.default"

text
# Or mount your own pre-configured Firefox profile instead of an empty one.
  # See: https://neko.m1k1o.net/docs/v3/customization/browsers#persistent-profile
  # - "./my-custom-profile:/home/neko/.mozilla/firefox/profile.default"
environment:
  # You can configure either EPR or MUX ports for WebRTC.
  # See: https://neko.m1k1o.net/docs/v3/configuration/webrtc#network
  NEKO_WEBRTC_EPR: 52000-52100
  # Specify the screen resolution and refresh rate for the virtual desktop. The
  # default is 1920x1080@30, but you can change it to any other resolution.
  # See: https://neko.m1k1o.net/docs/v3/configuration/desktop#screen
  NEKO_DESKTOP_SCREEN: 1920x1080@30
  # You can configure a password for the default user and admin accounts. The
  # default user account is "neko" and the default admin account is "admin".
  # See: https://neko.m1k1o.net/docs/v3/configuration/authentication#member.multiuser
  NEKO_MEMBER_MULTIUSER_USER_PASSWORD: neko
  NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: admin
  # Specifying reachable IP by clients is required when running Neko.
  # See: https://neko.m1k1o.net/docs/v3/configuration/webrtc#nat1to1
  NEKO_WEBRTC_NAT1TO1: <your-IP>

File: SECURITY.md

Security Policy

Reporting a Vulnerability

If there are any vulnerabilities in m1k1o/neko, don't hesitate to report them.

  1. Send an email to [email protected].

  2. Describe the vulnerability.

    If you have a fix, that is most welcome -- please attach or summarize it in your message!

  3. We will evaluate the vulnerability and, if necessary, release a fix or mitigating steps to address it. We will contact you to let you know the outcome, and will credit you in the report.

    Please do not disclose the vulnerability publicly until a fix is released!

  4. Once we have either a) published a fix, or b) declined to address the vulnerability for whatever reason, you are free to publicly disclose it.

We appreciate your help in keeping Neko secure.


File: tsconfig.json

{
"extends": "./client/tsconfig.json"
}


File: apps/brave/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"chrome://policy"
],
"ExtensionInstallForcelist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh;https://clients2.google.com/service/update2/crx",
"mnjggcdmjocbbbhaepdhchncahnbgone;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/brave/preferences.json

{
"homepage": "http://www.google.com",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://www.google.com/_/chrome/newtab?ie=UTF-8"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"profile": {
"avatar_index": 19,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
}
}


File: apps/chromium/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"BrowserLabsEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"chrome://policy"
],
"ExtensionInstallForcelist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh;https://clients2.google.com/service/update2/crx",
"mnjggcdmjocbbbhaepdhchncahnbgone;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/chromium/preferences.json

{
"homepage": "http://www.google.com",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://www.google.com/_/chrome/newtab?ie=UTF-8"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"profile": {
"avatar_index": 19,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
}
}


File: apps/firefox/policies.json

{
"policies": {
"BlockAboutAddons": false,
"BlockAboutConfig": true,
"BlockAboutProfiles": true,
"BlockAboutSupport": true,
"Bookmarks": [
{
"Title": "IPLeak",
"URL": "https://ipleak.net/",
"Favicon": "https://ipleak.net/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
},
{
"Title": "YouTube",
"URL": "https://www.youtube.com/",
"Favicon": "https://www.youtube.com/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
},
{
"Title": "Google",
"URL": "https://www.google.com/",
"Favicon": "https://www.google.com/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
}
],
"CaptivePortal": false,
"DisableAppUpdate": true,
"DisableBuiltinPDFViewer": true,
"DisableDeveloperTools": false,
"DisableFeedbackCommands": true,
"DisableFirefoxAccounts": true,
"DisableFirefoxScreenshots": true,
"DisableFirefoxStudies": true,
"DisableForgetButton": true,
"DisableMasterPasswordCreation": true,
"DisablePocket": true,
"DisablePrivateBrowsing": true,
"DisableProfileImport": true,
"DisableProfileRefresh": true,
"DisableSafeMode": true,
"DisableSetDesktopBackground": true,
"DisableSystemAddonUpdate": true,
"DisableTelemetry": true,
"DisplayBookmarksToolbar": false,
"DontCheckDefaultBrowser": true,
"EnableTrackingProtection": {
"Cryptomining": true,
"Fingerprinting": true,
"Value": true
},
"ExtensionSettings": {
"*": {
"installation_mode": "blocked"
},
"[email protected]": {
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi",
"installation_mode": "force_installed"
},
"[email protected]": {
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi",
"installation_mode": "force_installed"
}
},
"ExtensionUpdate": false,
"FirefoxHome": {
"Highlights": false,
"Pocket": false,
"Search": true,
"Snippets": false,
"TopSites": true
},
"FlashPlugin": {},
"HardwareAcceleration": false,
"Homepage": {
"Additional": [],
"StartPage": "home"
},
"NewTabPage": true,
"NoDefaultBookmarks": true,
"OfferToSaveLogins": false,
"OfferToSaveLoginsDefault": false,
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": "",
"PasswordManagerEnabled": false,
"Permissions": {
"Camera": {
"BlockNewRequests": true
},
"Location": {
"BlockNewRequests": true
},
"Microphone": {
"BlockNewRequests": true
},
"Notifications": {
"BlockNewRequests": true
}
},
"Preferences": {
"browser.tabs.warnOnClose": false,
"browser.urlbar.suggest.bookmark": false,
"browser.urlbar.suggest.history": false,
"browser.urlbar.suggest.openpage": false,
"datareporting.policy.dataSubmissionPolicyBypassNotification": true,
"dom.disable_window_flip": true,
"dom.disable_window_move_resize": true,
"dom.event.contextmenu.enabled": true,
"extensions.getAddons.showPane": false,
"places.history.enabled": false,
"privacy.file_unique_origin": true,
"ui.key.menuAccessKeyFocuses": false,
"browser.tabs.allowTabDetach": false
},
"PromptForDownloadLocation": false,
"SanitizeOnShutdown": {
"Cache": true,
"Cookies": true,
"Downloads": true,
"FormData": true,
"History": true,
"OfflineApps": true,
"Sessions": true,
"SiteSettings": true
}
}
}


File: apps/google-chrome/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"chrome://policy"
],
"ExtensionInstallForcelist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh;https://clients2.google.com/service/update2/crx",
"mnjggcdmjocbbbhaepdhchncahnbgone;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/google-chrome/preferences.json

{
"homepage": "http://www.google.com",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://www.google.com/_/chrome/newtab?ie=UTF-8"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"profile": {
"avatar_index": 19,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
}
}


File: apps/microsoft-edge/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"edge://policy"
],
"ExtensionInstallForcelist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh;https://clients2.google.com/service/update2/crx",
"mnjggcdmjocbbbhaepdhchncahnbgone;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/microsoft-edge/preferences.json

{
"homepage": "http://www.google.com",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://www.google.com/_/chrome/newtab?ie=UTF-8"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"profile": {
"avatar_index": 19,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
}
}


File: apps/ungoogled-chromium/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"chrome://policy"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/ungoogled-chromium/preferences.json

{
"homepage": "https://duckduckgo.com/",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://duckduckgo.com/"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"profile": {
"avatar_index": 19,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
}
}


File: apps/vivaldi/policies.json

{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
"DefaultNotificationsSetting": 2,
"DeveloperToolsAvailability": 2,
"EditBookmarksEnabled": false,
"FullscreenAllowed": true,
"IncognitoModeAvailability": 1,
"SyncDisabled": true,
"AutoplayAllowed": true,
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"DefaultPopupsSetting": 2,
"DownloadRestrictions": 3,
"VideoCaptureAllowed": true,
"AllowFileSelectionDialogs": false,
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"URLAllowlist": [
"file:///home/neko/Downloads"
],
"URLBlocklist": [
"file://",
"chrome://policy"
],
"ExtensionInstallForcelist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh;https://clients2.google.com/service/update2/crx",
"mnjggcdmjocbbbhaepdhchncahnbgone;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"ddkjiahejlhfcafbddmgiahcphecmpfh",
"mnjggcdmjocbbbhaepdhchncahnbgone"
],
"ExtensionInstallBlocklist": [
"
"
]
}


File: apps/vivaldi/preferences.json

{
"homepage": "http://www.google.com",
"homepage_is_newtabpage": false,
"first_run_tabs": [
"https://www.google.com/_/chrome/newtab?ie=UTF-8"
],
"custom_links": {
"initialized": true,
"list": [
{
"title": "YouTube",
"url": "https://www.youtube.com/"
},
{
"title": "Netflix",
"url": "https://netflix.com"
},
{
"title": "Hulu",
"url": "https://www.hulu.com/"
},
{
"title": "9Anime",
"url": "https://9anime.to/"
},
{
"title": "Crunchy Roll",
"url": "https://www.crunchyroll.com/"
},
{
"title": "Funimation",
"url": "https://www.funimation.com/"
},
{
"title": "Disney+",
"url": "https://www.disneyplus.com/"
},
{
"title": "HBO Now",
"url": "https://play.hbonow.com/"
},
{
"title": "Amazon Video",
"url": "https://www.amazon.com/Amazon-Video/b?node=2858778011"
},
{
"title": "VRV",
"url": "https://vrv.co/"
},
{
"title": "Twitch",
"url": "https://www.twitch.tv/"
},
{
"title": "Mixer",
"url": "https://mixer.com/"
}
]
},
"browser": {
"custom_chrome_frame": false,
"show_home_button": true,
"window_placement": {
"maximized": true
}
},
"bookmark_bar": {
"show_on_all_tabs": false
},
"sync_promo": {
"show_on_first_run_allowed": false
},
"default_search_provider_data": {
"image_template_url_data": {
"alternate_urls": ["https://www.google.com/#q={searchTerms}", "https://www.google.com/search#q={searchTerms}", "https://www.google.com/webhp#q={searchTerms}", "https://www.google.com/s#q={searchTerms}", "https://www.google.com/s?q={searchTerms}"],
"contextual_search_url": "",
"created_by_policy": false,
"created_from_play_api": false,
"date_created": "0",
"doodle_url": "",
"favicon_url": "https://www.google.com/favicon.ico",
"id": "11",
"image_url": "https://www.google.com/searchbyimage/upload",
"image_url_post_params": "encoded_image={google:imageThumbnail},image_url={google:imageURL},sbisrc={google:imageSearchSource},original_width={google:imageOriginalWidth},original_height={google:imageOriginalHeight}",
"input_encodings": ["UTF-8"],
"is_active": 0,
"keyword": "g",
"last_modified": "0",
"last_visited": "0",
"logo_url": "",
"new_tab_url": "",
"originating_url": "",
"position": "IhxEc1dKb3ZJanppM1NCWTZhRHNGN0ljYkRHeVk9",
"preconnect_to_search_url": false,
"prepopulate_id": 1,
"safe_for_autoreplace": true,
"search_url_post_params": "",
"short_name": "Google",
"side_search_param": "",
"suggestions_url": "",
"suggestions_url_post_params": "",
"url": "https://www.google.com/search?q={searchTerms}&{google:originalQueryForSuggestion}{google:prefetchSource}{google:sourceId}{google:contextualSearchVersion}ie={inputEncoding}",
"usage_count": 0
},
"private_template_url_data": {
"alternate_urls": [],
"contextual_search_url": "",
"created_by_policy": false,
"created_from_play_api": false,
"date_created": "0",
"doodle_url": "",
"favicon_url": "https://duckduckgo.com/favicon.ico",
"id": "4",
"image_url": "",
"image_url_post_params": "",
"input_encodings": ["UTF-8"],
"is_active": 0,
"keyword": "d",
"last_modified": "0",
"last_visited": "0",
"logo_url": "",
"new_tab_url": "",
"originating_url": "",
"position": "Ih3bMkFheVB5WitEdGZmeU9hV0d5K3RGUXRhR3RFPQ==",
"preconnect_to_search_url": false,
"prepopulate_id": 7,
"safe_for_autoreplace": true,
"search_url_post_params": "",
"short_name": "DuckDuckGo",
"side_search_param": "",
"suggestions_url": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"suggestions_url_post_params": "",
"url": "https://duckduckgo.com/?q={searchTerms}&{ddg:Referral}",
"usage_count": 0
},
"speeddials_template_url_data": {
"alternate_urls": ["https://www.google.com/#q={searchTerms}", "https://www.google.com/search#q={searchTerms}", "https://www.google.com/webhp#q={searchTerms}", "https://www.google.com/s#q={searchTerms}", "https://www.google.com/s?q={searchTerms}"],
"contextual_search_url": "",
"created_by_policy": false,
"created_from_play_api": false,
"date_created": "0",
"doodle_url": "",
"favicon_url": "https://www.google.com/favicon.ico",
"id": "11",
"image_url": "https://www.google.com/searchbyimage/upload",
"image_url_post_params": "encoded_image={google:imageThumbnail},image_url={google:imageURL},sbisrc={google:imageSearchSource},original_width={google:imageOriginalWidth},original_height={google:imageOriginalHeight}",
"input_encodings": ["UTF-8"],
"is_active": 0,
"keyword": "g",
"last_modified": "0",
"last_visited": "0",
"logo_url": "",
"new_tab_url": "",
"originating_url": "",
"position": "IhxEc1dKb3ZJanppM1NCWTZhRHNGN0ljYkRHeVk9",
"preconnect_to_search_url": false,
"prepopulate_id": 1,
"safe_for_autoreplace": true,
"search_url_post_params": "",
"short_name": "Google",
"side_search_param": "",
"suggestions_url": "",
"suggestions_url_post_params": "",
"url": "https://www.google.com/search?q={searchTerms}&{google:originalQueryForSuggestion}{google:prefetchSource}{google:sourceId}{google:contextualSearchVersion}ie={inputEncoding}",
"usage_count": 0
},
"template_url_data": {
"alternate_urls": ["https://www.google.com/#q={searchTerms}", "https://www.google.com/search#q={searchTerms}", "https://www.google.com/webhp#q={searchTerms}", "https://www.google.com/s#q={searchTerms}", "https://www.google.com/s?q={searchTerms}"],
"contextual_search_url": "",
"created_by_policy": false,
"created_from_play_api": false,
"date_created": "0",
"doodle_url": "",
"favicon_url": "https://www.google.com/favicon.ico",
"id": "11",
"image_url": "https://www.google.com/searchbyimage/upload",
"image_url_post_params": "encoded_image={google:imageThumbnail},image_url={google:imageURL},sbisrc={google:imageSearchSource},original_width={google:imageOriginalWidth},original_height={google:imageOriginalHeight}",
"input_encodings": ["UTF-8"],
"is_active": 0,
"keyword": "g",
"last_modified": "0",
"last_visited": "0",
"logo_url": "",
"new_tab_url": "",
"originating_url": "",
"position": "IhxEc1dKb3ZJanppM1NCWTZhRHNGN0ljYkRHeVk9",
"preconnect_to_search_url": false,
"prepopulate_id": 1,
"safe_for_autoreplace": true,
"search_url_post_params": "",
"short_name": "Google",
"side_search_param": "",
"suggestions_url": "",
"suggestions_url_post_params": "",
"url": "https://www.google.com/search?q={searchTerms}&{google:originalQueryForSuggestion}{google:prefetchSource}{google:sourceId}{google:contextualSearchVersion}ie={inputEncoding}",
"usage_count": 0
}
},
"distribution": {
"import_bookmarks_from_file": "bookmarks.html",
"import_bookmarks": true,
"import_history": true,
"import_home_page": true,
"import_search_engine": true,
"ping_delay": 60,
"do_not_create_desktop_shortcut": true,
"do_not_create_quick_launch_shortcut": true,
"do_not_create_taskbar_shortcut": true,
"do_not_launch_chrome": true,
"do_not_register_for_update_launch": true,
"make_chrome_default": true,
"make_chrome_default_for_user": true,
"system_level": false,
"verbose_logging": false
},
"enable_do_not_track": true,
"profile": {
"avatar_index": 34,
"default_content_setting_values": {
"clipboard": 2,
"cookies": 4,
"geolocation": 2,
"media_stream_camera": 2,
"media_stream_mic": 2,
"midi_sysex": 2,
"payment_handler": 2,
"usb_guard": 2
},
"name": "Neko",
"using_default_avatar": false,
"using_default_name": false,
"using_gaia_avatar": false
},
"signin": {
"allowed": false
},
"vivaldi": {
"address_bar": {
"autocomplete": {
"enabled": false
},
"omnibox": {
"show_browser_history": false,
"show_search_history": false,
"show_typed_history": false
},
"search": {
"display": 1,
"in_new_tab": false
},
"show_full_url": true,
"show_qr_generator": true,
"visible": true
},
"bookmarks": {
"deleted_partners": [
"f79cd6e8-ebc0-444d-ac96-00da456dcb59",
"d680347f-1073-46b9-a546-ae0238e7b9d9"
],
"language": "en-US",
"version": "24"
},
"downloads": {
"notify_on_complete": false,
"open_panel_on_new": false,
"start_automatically": false,
"update_default_download_when_saving_as": false
},
"history": {
"days_to_keep_visits": 0
},
"homepage": "vivaldi://startpage",
"incognito": {
"show_intro": false
},
"language_at_install": "en-US",
"menu": {
"icon_type": 1
},
"mouse_gestures": {
"enabled": false,
"rocker_gestures": {
"enabled": false
}
},
"popups": {
"show_in_tab": true
},
"settings": {
"in_tab": true
},
"startpage": {
"navigation": 1,
"speed_dial": {
"add_button_visible": false,
"allow_dnd": false,
"columns": 4,
"delete_visible": false,
"display_search": true,
"privacy_stats_show": false,
"tracker_suggestion_show": false,
"width": 170
}
},
"startup": {
"check_is_default": false,
"has_seen_feature": 1
},
"status_bar": {
"display": 0,
"minimized": 0
},
"system": {
"show_exit_confirmation_dialog": true
},
"tabs": {
"new_placement": 3,
"open_new_in_background": false,
"stacking": {
"open_accordions": []
}
},
"theme": {
"schedule": {
"o_s": {
"dark": "Vivaldi2",
"light": "Vivaldi2"
}
},
"use_animation": false
},
"translate": {
"enabled": false
},
"windows": {
"use_native_decoration": true
}
}
}


File: apps/waterfox/policies.json

{
"policies": {
"BlockAboutAddons": false,
"BlockAboutConfig": true,
"BlockAboutProfiles": true,
"BlockAboutSupport": true,
"Bookmarks": [
{
"Title": "IPLeak",
"URL": "https://ipleak.net/",
"Favicon": "https://ipleak.net/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
},
{
"Title": "YouTube",
"URL": "https://www.youtube.com/",
"Favicon": "https://www.youtube.com/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
},
{
"Title": "Google",
"URL": "https://www.google.com/",
"Favicon": "https://www.google.com/favicon.ico",
"Folder": "Pages",
"Placement": "toolbar"
}
],
"CaptivePortal": false,
"DisableAppUpdate": true,
"DisableBuiltinPDFViewer": true,
"DisableDeveloperTools": false,
"DisableFeedbackCommands": true,
"DisableFirefoxAccounts": true,
"DisableFirefoxScreenshots": true,
"DisableFirefoxStudies": true,
"DisableForgetButton": true,
"DisableMasterPasswordCreation": true,
"DisablePocket": true,
"DisablePrivateBrowsing": true,
"DisableProfileImport": true,
"DisableProfileRefresh": true,
"DisableSafeMode": true,
"DisableSetDesktopBackground": true,
"DisableSystemAddonUpdate": true,
"DisableTelemetry": true,
"DisplayBookmarksToolbar": false,
"DontCheckDefaultBrowser": true,
"EnableTrackingProtection": {
"Cryptomining": true,
"Fingerprinting": true,
"Value": true
},
"ExtensionSettings": {
"*": {
"installation_mode": "blocked"
},
"[email protected]": {
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi",
"installation_mode": "force_installed"
},
"[email protected]": {
"install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi",
"installation_mode": "force_installed"
}
},
"ExtensionUpdate": false,
"FirefoxHome": {
"Highlights": false,
"Pocket": false,
"Search": true,
"Snippets": false,
"TopSites": true
},
"FlashPlugin": {},
"HardwareAcceleration": false,
"Homepage": {
"Additional": [],
"StartPage": "home"
},
"NewTabPage": true,
"NoDefaultBookmarks": true,
"OfferToSaveLogins": false,
"OfferToSaveLoginsDefault": false,
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": "",
"PasswordManagerEnabled": false,
"Permissions": {
"Camera": {
"BlockNewRequests": true
},
"Location": {
"BlockNewRequests": true
},
"Microphone": {
"BlockNewRequests": true
},
"Notifications": {
"BlockNewRequests": true
}
},
"Preferences": {
"browser.tabs.warnOnClose": false,
"browser.urlbar.suggest.bookmark": false,
"browser.urlbar.suggest.history": false,
"browser.urlbar.suggest.openpage": false,
"datareporting.policy.dataSubmissionPolicyBypassNotification": true,
"dom.disable_window_flip": true,
"dom.disable_window_move_resize": true,
"dom.event.contextmenu.enabled": true,
"extensions.getAddons.showPane": false,
"places.history.enabled": false,
"privacy.file_unique_origin": true,
"ui.key.menuAccessKeyFocuses": false
},
"PromptForDownloadLocation": false,
"SanitizeOnShutdown": {
"Cache": true,
"Cookies": true,
"Downloads": true,
"FormData": true,
"History": true,
"OfflineApps": true,
"Sessions": true,
"SiteSettings": true
}
}
}


File: client/README.md

neko client

The web client for neko: React 19 + TypeScript, built with Vite.
It speaks only the v3 API (/api/ws + REST).

text
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 `` 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 /... 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= --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.

File: client/components.json

{
"$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"
}
}


File: client/package.json

{
"name": "neko-client",
"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": {
"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": {
"@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": {
"@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"
}
}


File: client/tsconfig.json

{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowJs": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"baseUrl": ".",
"paths": {
"@/": ["./src/"],
"@m1k1o/neko": ["./core/src/index.ts"]
}
},
"include": ["src", "core/src"],
"exclude": ["**/*.test.ts", "core/src/test"]
}


File: client/core/README.md

@m1k1o/neko

Framework-free client library for the neko v3 API: REST, the
/api/ws event protocol, the stream through a pluggable transport (WebRTC with its binary input
channel, see Transports), and a pluggable control device, with the keyboard/mouse/touch
overlay (wheel, file drop, the host's cursor) included (see 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
(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

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)
transport the StreamTransport in use

State

client.store is a 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). 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/.


File: client/core/package.json

{
"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"
}
}


File: client/core/tsconfig.json

{
"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"]
}