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:
- Migration from V2
- Getting Started
- Installation
- Examples
- Configuration
- Frequently Asked Questions
- Troubleshooting
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"
# 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.
Send an email to
[email protected].Describe the vulnerability.
If you have a fix, that is most welcome -- please attach or summarize it in your message!
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!
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).
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 variantsLayering
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 intocore/. - State is zustand: the core's
client.store, the GUI'sappstore (state/app.ts) and each plugin'sstore.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)(useShallowfor several fields; the core'sselectControlling,selectIsAdmin,selectSessionfor the computed values), and re-renders only
when that value changes. Everything outside React takes the instance as its first argument, readsstore.getState()and writesstore.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 onstate/bus.tsand shown by the chat
plugin;logoutis 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, aNekoApp (src/state/app.ts): the NekoClient (WebRTC transport, autologin, autoconnect,autoplay from the viewer settings), its input Overlay, the GUI store app and the bus, with the
event wiring (server events to event lines and toasts, state/events.ts) and the settings (stored
and from the URL, state/settings.ts) applied. main.tsx composes the page in the order the pieces
need:
const ready = initI18n(plugins.map((p) => p.ns)) // the strings of the active language
const neko = createNekoApp() // the client, its wiring, the settings
initPlugins(neko) // the slots, the event dispatch, each plugin's init
ready.then(() => {
neko.client.setUrl(location.href) // the connection: resume a saved session and connect
createRoot(root).render(
<NekoProvider neko={neko}>
<App />
</NekoProvider>,
)
})<NekoProvider> (src/state/provider.tsx) puts the instance in a React context; a component reads it
with useNeko() (the whole NekoApp), useClient(), useApp() (the GUI store) and useActions()
(state/actions.ts, the room actions bound to the instance), and the rest, setSetting(neko, ...),ask(app, ...), api(neko, ...), a plugin's store(neko), takes it as the first argument. Two
instances on one page share nothing but the page (the strings, the URL parameters, localStorage).
To embed the GUI in another React app, compose the same pieces and render <App /> under a provider
(?embed=1 and ?cast=1 on the page's URL still give the video-only layouts, for an iframe or a kiosk):
import { initI18n } from '@/i18n'
import { plugins, initPlugins } from '@/plugins'
import { createNekoApp } from '@/state/neko'
import { NekoProvider } from '@/state/provider'
import { App } from '@/app/App'
await initI18n(plugins.map((p) => p.ns))
const neko = createNekoApp()
initPlugins(neko)
neko.client.setUrl('https://neko.example.com/')
export const Room = () => (
<NekoProvider neko={neko}>
<App />
</NekoProvider>
)Design
Styling is Tailwind v4 (tailwindcss, @tailwindcss/vite): the classes are in the markup, there is no
stylesheet per component. src/index.css is the whole of the CSS:
- Tokens: an
@themeblock with the values of the previous SCSS variables under the same names, so$background-primaryis--color-background-primaryand the utilitybg-background-primary;$text-normalistext-text-normal,$style-primaryisbg-style-primary/text-style-primary,$elevation-highisshadow-elevation-high,$side-width/$menu-height/$controls-heightarew-side/h-menu/h-controls,$text-sizeistext-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
onbody(sorem, 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'sportrait:/landscape:for the split),phone:(up to 768px: no room bar). - The animations (
animate-shake,animate-loader,animate-badge) and thesliderutility (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) andstore(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 andmemberMenu 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 eachinit(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:
- Create
src/plugins/<name>/with anindex.tsexporting the plugin object, its components (Tailwind
classes, the tokens ofsrc/index.css), and astore.tsmade withscoped(() => createStore(...))
(components select from it withuseStore(store(useNeko()), (s) => s.field), the rest readsstore(neko).getState()and writesstore(neko).setState()). - Add its strings as
src/locales/<lang>/<ns>.jsonin every language (see i18n). - 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 withreact-i18next (src/i18n/index.ts): components call useTranslation() and rendert('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
enand to the other fourteen files.src/i18n/i18n.test.tslists 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 intsconfig.json and vite.config.ts; npm run build:core builds the package (core/dist/). The API
reference is core/README.md.
import { NekoClient } from '@m1k1o/neko'
const client = new NekoClient({ autologin: true, autoconnect: true, autoplay: true })
client.store.subscribe((s) => s.sessions, render) // state: connection, sessions, control, screen, ...
client.events.on('message', (event, payload) => {}) // plugin events (chat/*, filetransfer/*, ...)
client.setUrl('https://neko.example.com/')
client.mount(document.getElementById('video')!)Development
Backend from server/dev, then:
cd client/dev && ./serve # http://localhost:3001, /api proxied to the backend on :3000
# or without docker:
NEKO_URL=http://localhost:3000 npm run dev| script | |
|---|---|
npm run build |
type-check + production build into dist/, then tools/dist.check.mjs (no language in the main chunk) |
npm run build:core |
build the @m1k1o/neko package into core/dist/ |
npm run check |
check:types, check:format, check:lint (hooks, layering), check:cycles and test in parallel (npm-run-all2, one label per script), then build:core and check:dist (the built core package); each check:* script also runs on its own |
npm test |
unit tests (vitest): the core's connection state machine and store, the chat's markdown (rendered with react-dom/server), the i18n runtime and locale files, the plugin registry, an instance and two of them, the provider; no browser |
npm run format |
prettier |
npm run build:emoji |
regenerate the emoji data public/emoji.json (names, characters, groups, keywords; tools/emoji.ts) |
npm run test:e2e |
two-user browser test against a running server, see below |
e2e
e2e.mjs drives two (or more) headless Chromium users through login, video, chat, control, files,
admin actions, reconnects and URL parameters. It needs a disposable server (it takes control, types
into the desktop and kicks users) with the multiuser provider:
docker run -d -p 8080:8080 -p 52000:52000/udp \
-e NEKO_MEMBER_PROVIDER=multiuser -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
-e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=admin -e NEKO_FILETRANSFER_ENABLED=true \
-e NEKO_WEBRTC_UDPMUX=52000 \
-e NEKO_WEBRTC_NAT1TO1=127.0.0.1 ghcr.io/m1k1o/neko/kde
npx playwright install chromium
NEKO_URL=http://localhost:8080/ npm run test:e2eThe 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.kickinsrc/state/actions.ts. - Settings use the same
localStoragekeys and format as the previous client, so preferences carry
over. Scroll sensitivity now uses v3 steps (-5..5, keyscroll_sensitivity);?scroll=is mapped. - Chat markdown (
src/plugins/chat/markdown.ts) is rendered byreact-markdownwithremark-gfm
(~~strike~~, tables, bare links) andremark-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 defaultchat.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.
npm install @m1k1o/nekoQuick start
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:
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 withsubscribeWithSelector), 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:
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 onlyselectSession, 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.
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: watchclient.controlling / client.state.control.locked to know when input is accepted, callclient.request() to become host, and send through client.input:
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:
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 aStreamTransport (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:
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, theinit 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'ssystem/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 asnew 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
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 keyboardThe 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"]
}