56 Commits

Author SHA1 Message Date
7cc53a0429 remove locks. 2024-04-21 20:37:58 +02:00
014b68e1fb remove control protection. 2024-04-21 20:16:09 +02:00
5c683fb1b8 remove filetransfer. 2024-04-20 11:08:17 +02:00
e26e4d2004 Add zh_TW Traditional Chinese locale (#388) 2024-04-17 15:49:57 +02:00
5f698330fc hide controls on mobile when locked. #381 2024-04-04 23:55:13 +02:00
f32fc989b9 overlay: no pointer events when not hosting. #381 2024-04-04 23:48:59 +02:00
d1f1be4e86 add to docs faq: Run neko without docker on host #314. 2024-03-28 23:54:26 +01:00
e754e66878 added Frequently Asked Questions to docs. 2024-03-28 23:47:20 +01:00
26af1dc7f5 add Nat Hairpinning deployment to docs #378. 2024-03-28 23:31:22 +01:00
0c9055e4f6 update docs, nvidia docker is replaced by NVIDIA Container Toolkit. 2024-03-28 23:30:34 +01:00
c200326512 update changelog. 2024-03-28 22:57:54 +01:00
b1ce755210 Add glib main loop to capture manager (#383)
The gstreamer documentation is not particularly amazing on whether or
not this is necessary, but it's clear that some gstreamer events will
not be delivered to their handlers without a running glib loop. This
runs one loop for all pipelines, which should be more than enough.

Disclaimer: This may conflict in demodesk/neko with the dragdrop
feature. Anyone backporting this bug fix to that repo should
investigate whether the loop created by `gtk_main()` will conflict with
this one before blindly porting.

Fixes #380
Fixes #284
2024-03-27 21:35:04 +01:00
2b13220d63 Fix buffer overflow in Gstreamer log function (#382)
vsprintf() is dangerous, and can overflow easily, especially with small
buffers like the 100 byte one that was being used. This changes the
buffer size to a more sane 4KiB, and uses vsnprintf() to automatically
concatenate a large log message instead of overflowing and crashing.
2024-03-27 21:32:47 +01:00
db6f9c957e add check for volume parameter in URL before setting volume (#372) 2024-03-10 13:43:30 +01:00
798bf579c0 fix typo (#367) 2024-02-09 23:49:11 +01:00
2f9964580f use 1 instead of true for consistency. 2024-01-06 17:33:39 +01:00
b8881b3a46 added doc 2024-01-06 15:05:20 +01:00
355c0eac0d add parameter for chat mute and show side 2024-01-06 15:02:13 +01:00
4d023df692 update docs to explicitly differentiate between letter O and zero (#354) 2024-01-04 17:18:36 +01:00
792b1ac111 remove any temporary files associated with a Form, fixes #347. 2023-12-26 15:49:16 +01:00
a03b29ba01 add version sort to get the latest version, fixes #348. 2023-12-26 15:43:50 +01:00
683b750189 move proxy option to server. 2023-11-19 14:50:23 +01:00
3c4d7b9d60 upgrade go-chi to v5. 2023-10-15 13:18:43 +02:00
7a9b33706a chore: remove refs to deprecated io/ioutil (#329) 2023-08-31 13:32:16 +02:00
052a961fd9 fix docs #327, 2023-08-26 21:13:20 +01:00
8ef9c1aff5 add firefox nvidia image to docs. 2023-06-26 17:39:34 +02:00
6ed3493aa0 update LICENCE. 2023-05-11 00:42:12 +02:00
5959d056f3 Fix arm build (#302)
* WIP.

* WIP.

* WIP.
2023-04-29 18:35:11 +02:00
92ad202bfe Add version to docker build (#301)
* add version to build.

* update docs.
2023-04-29 00:12:56 +02:00
cd4acb5eec lang url parameter (#296)
* lang url parameter

* add ?lang to readme.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
2023-04-22 01:24:23 +02:00
a32be0b44a Update Dockerfile.nvidia for google-chrome 2023-04-16 00:31:05 +02:00
b2080649ea add docs fixes #275. 2023-04-10 13:05:56 +02:00
851c38b8fd update docs. 2023-04-10 13:01:00 +02:00
e2336be568 update changelog. 2023-04-09 15:45:14 +02:00
e417ec5dbe add XDG_RUNTIME_DIR to env. 2023-04-09 15:42:33 +02:00
ad7e1f2b7b allow using supervisorctl. 2023-04-09 15:24:16 +02:00
91e1a8b502 add firefox nvidia. 2023-04-09 15:22:44 +02:00
9bdf9c8851 fix nvidia google chrome version, #229. 2023-04-07 20:20:05 +02:00
c1360d3abc ensure that paths are writable by neko user, #277. 2023-04-06 00:00:59 +02:00
950095d6d8 update Dockerfile.nvidia, #274. 2023-04-03 20:06:52 +02:00
009bc20969 update docs, #273. 2023-04-02 12:35:06 +02:00
395db0fd54 version 2.8.0. 2023-04-01 23:01:10 +02:00
13fa86d543 update changelog. 2023-04-01 22:59:39 +02:00
8308c13382 update nvenc pipeline. 2023-04-01 22:59:39 +02:00
ec175909a3 improve quality a little bit 2023-04-01 22:59:39 +02:00
d2f51fa10f add h264parse to nvidia pipeline 2023-04-01 22:59:39 +02:00
70325e0277 add nvenc pipeline. 2023-04-01 22:59:39 +02:00
bdff0841ec add nvenc support for gstreamer. 2023-04-01 22:59:39 +02:00
3c17dbe282 h264 encoding profile constrained-baseline, #109. 2023-03-31 21:43:20 +02:00
887413d536 update changelog. 2023-03-27 21:55:52 +02:00
f9228e653d Merge branch 'pu/touch' 2023-03-27 21:47:49 +02:00
df634be1c5 lint. 2023-03-27 21:47:41 +02:00
2130daa02d add volume to docs. 2023-03-27 18:22:31 +02:00
aee7650d47 add touch events on touch monitor 2023-03-27 14:28:42 +02:00
98ba32c574 add url param for audio volume. 2023-03-27 13:04:11 +02:00
d08d3eccef configure pulseaudio from ENV, #267. 2023-03-26 20:13:35 +02:00
81 changed files with 782 additions and 1833 deletions

View File

@ -27,7 +27,7 @@ RUN set -eux; apt-get update; \
#
# build server
COPY server/ .
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
RUN ./build
#
# STAGE 2: CLIENT
@ -90,19 +90,18 @@ RUN set -eux; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# setup pulseaudio
mkdir -p /home/$USERNAME/.config/pulse/; \
echo "default-server=unix:/tmp/pulseaudio.socket" > /home/$USERNAME/.config/pulse/client.conf; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www /var/log/neko; \
mkdir -p /etc/neko /var/www /var/log/neko \
/tmp/runtime-$USERNAME \
/home/$USERNAME/.config/pulse \
/home/$USERNAME/.local/share/xorg; \
chmod 1777 /var/log/neko; \
chown $USERNAME /var/log/neko/; \
chown $USERNAME /var/log/neko/ /tmp/runtime-$USERNAME; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# clean up
@ -120,6 +119,8 @@ COPY .docker/base/xorg.conf /etc/neko/xorg.conf
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV XDG_RUNTIME_DIR=/tmp/runtime-$USERNAME
ENV NEKO_PASSWORD=neko
ENV NEKO_PASSWORD_ADMIN=admin
ENV NEKO_BIND=:8080

View File

@ -27,29 +27,32 @@ RUN set -eux; apt-get update; \
#
# build server
COPY server/ .
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
RUN ./build
#
# STAGE 2: CLIENT
#
FROM node:18-bullseye-slim as client
# install dependencies
RUN set -eux; apt-get update; \
apt-get install -y --no-install-recommends python2 build-essential
WORKDIR /src
#
# install dependencies
COPY client/package*.json ./
RUN npm install
# Because client builds fail in Github Actions, therefor we build it outside of Docker.
#
# FROM node:18-bullseye-slim as client
#
# # install dependencies
# RUN set -eux; apt-get update; \
# apt-get install -y --no-install-recommends python2 build-essential
#
# WORKDIR /src
#
# #
# # install dependencies
# COPY client/package*.json ./
# RUN npm install
#
# #
# # build client
# COPY client/ .
# RUN npm run build
#
# build client
COPY client/ .
RUN npm run build
#
# STAGE 3: RUNTIME
#
@ -96,19 +99,18 @@ RUN set -eux; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# setup pulseaudio
mkdir -p /home/$USERNAME/.config/pulse/; \
echo "default-server=unix:/tmp/pulseaudio.socket" > /home/$USERNAME/.config/pulse/client.conf; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www /var/log/neko; \
mkdir -p /etc/neko /var/www /var/log/neko \
/tmp/runtime-$USERNAME \
/home/$USERNAME/.config/pulse \
/home/$USERNAME/.local/share/xorg; \
chmod 1777 /var/log/neko; \
chown $USERNAME /var/log/neko/; \
chown $USERNAME /var/log/neko/ /tmp/runtime-$USERNAME; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# clean up
@ -126,6 +128,8 @@ COPY .docker/base/xorg.conf /etc/neko/xorg.conf
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV XDG_RUNTIME_DIR=/tmp/runtime-$USERNAME
ENV NEKO_PASSWORD=neko
ENV NEKO_PASSWORD_ADMIN=admin
ENV NEKO_BIND=:8080
@ -133,7 +137,8 @@ ENV NEKO_BIND=:8080
#
# copy static files from previous stages
COPY --from=server /src/bin/neko /usr/bin/neko
COPY --from=client /src/dist/ /var/www
# COPY --from=client /src/dist/ /var/www
COPY client/dist/ /var/www
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
CMD wget -O - http://localhost:${NEKO_BIND#*:}/health || exit 1

View File

@ -27,7 +27,7 @@ RUN set -eux; apt-get update; \
#
# build server
COPY server/ .
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
RUN ./build
#
# STAGE 2: CLIENT
@ -99,19 +99,18 @@ RUN set -eux; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# setup pulseaudio
mkdir -p /home/$USERNAME/.config/pulse/; \
echo "default-server=unix:/tmp/pulseaudio.socket" > /home/$USERNAME/.config/pulse/client.conf; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www /var/log/neko; \
mkdir -p /etc/neko /var/www /var/log/neko \
/tmp/runtime-$USERNAME \
/home/$USERNAME/.config/pulse \
/home/$USERNAME/.local/share/xorg; \
chmod 1777 /var/log/neko; \
chown $USERNAME /var/log/neko/; \
chown $USERNAME /var/log/neko/ /tmp/runtime-$USERNAME; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# clean up
@ -130,6 +129,8 @@ COPY .docker/base/intel/add-render-group.sh /usr/bin/add-render-group.sh
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV XDG_RUNTIME_DIR=/tmp/runtime-$USERNAME
ENV NEKO_PASSWORD=neko
ENV NEKO_PASSWORD_ADMIN=admin
ENV NEKO_BIND=:8080

View File

@ -1,10 +1,62 @@
ARG UBUNTU_RELEASE=20.04
ARG CUDA_VERSION=11.2.2
ARG CUDA_VERSION=11.4.3
ARG VIRTUALGL_VERSION=3.1
ARG GSTREAMER_VERSION=1.20
#
# STAGE 0: Build gstreamer with nvidia plugins.
#
FROM ubuntu:${UBUNTU_RELEASE} AS gstreamer
ARG GSTREAMER_VERSION
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
# Install essentials
curl build-essential ca-certificates git \
# Install pip and ninja
python3-pip python-gi-dev ninja-build \
# Install build deps
autopoint autoconf automake autotools-dev libtool gettext bison flex gtk-doc-tools \
# Install libraries
librtmp-dev \
libvo-aacenc-dev \
libtool-bin \
libgtk2.0-dev \
libgl1-mesa-dev \
libopus-dev \
libpulse-dev \
libssl-dev \
libx264-dev \
libvpx-dev; \
# Install meson
pip3 install meson; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# build gstreamer
RUN set -eux; \
git clone --depth 1 --branch $GSTREAMER_VERSION https://gitlab.freedesktop.org/gstreamer/gstreamer.git /gstreamer/src; \
cd /gstreamer/src; \
mkdir -p /opt/gstreamer; \
meson --prefix /opt/gstreamer \
-Dgpl=enabled \
-Dugly=enabled \
-Dgst-plugins-ugly:x264=enabled \
build; \
ninja -C build; \
meson install -C build;
#
# STAGE 1: SERVER
#
FROM golang:1.18-bullseye as server
FROM golang:1.20-bullseye as server
WORKDIR /src
#
@ -30,12 +82,12 @@ RUN set -eux; apt-get update; \
#
# build server
COPY server/ .
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
RUN ./build
#
# STAGE 2: CLIENT
#
FROM node:14-bullseye-slim as client
FROM node:18-bullseye-slim as client
WORKDIR /src
#
@ -51,13 +103,13 @@ RUN npm run build
#
# STAGE 3: RUNTIME
#
FROM nvcr.io/nvidia/cudagl:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
ARG UBUNTU_RELEASE
ARG CUDA_VERSION
ARG VIRTUALGL_VERSION
# Make all NVIDIA GPUs visible by default
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_VISIBLE_DEVICES all
# All NVIDIA driver capabilities should preferably be used, check `NVIDIA_DRIVER_CAPABILITIES` inside the container if things do not work
ENV NVIDIA_DRIVER_CAPABILITIES all
@ -75,20 +127,55 @@ ARG USERNAME=neko
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN set -eux; \
dpkg --add-architecture i386; \
apt-get update; \
apt-get install -y --no-install-recommends \
# opengl base: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/base/Dockerfile
libxau6 libxau6:i386 \
libxdmcp6 libxdmcp6:i386 \
libxcb1 libxcb1:i386 \
libxext6 libxext6:i386 \
libx11-6 libx11-6:i386 \
# opengl runtime: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/glvnd/runtime/Dockerfile
libglvnd0 libglvnd0:i386 \
libgl1 libgl1:i386 \
libglx0 libglx0:i386 \
libegl1 libegl1:i386 \
libgles2 libgles2:i386 \
# hardware accleration utilities
libglu1 libglu1:i386 \
libvulkan-dev libvulkan-dev:i386 \
mesa-utils mesa-utils-extra \
mesa-va-drivers mesa-vulkan-drivers \
vainfo vdpauinfo; \
#
# install vulkan-utils or vulkan-tools depending on ubuntu release
if [ "${UBUNTU_RELEASE}" = "18.04" ]; then \
apt-get install -y --no-install-recommends vulkan-utils; \
else \
apt-get install -y --no-install-recommends vulkan-tools; \
fi; \
#
# create symlink for libnvrtc.so (needed for cudaconvert)
find /usr/local/cuda/lib64/ -maxdepth 1 -type l -name "*libnvrtc.so.*" -exec sh -c 'ln -sf {} /usr/local/cuda/lib64/libnvrtc.so' \;; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# add cuda to ld path, for gstreamer cuda plugins
ENV LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:/usr/lib/i386-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}:/usr/local/cuda/lib:/usr/local/cuda/lib64"
RUN set -eux; \
apt-get update; \
#
# install dependencies
apt-get install -y --no-install-recommends wget ca-certificates supervisor; \
apt-get install -y --no-install-recommends pulseaudio dbus-x11 xserver-xorg-video-dummy; \
apt-get install -y --no-install-recommends libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6; \
#
# hardware acclerations utilities
apt-get install -y --no-install-recommends libgtk-3-bin mesa-utils mesa-utils-extra mesa-va-drivers mesa-vulkan-drivers libvulkan-dev libvulkan-dev:i386 vdpauinfo; \
#
# gst
apt-get install -y --no-install-recommends libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pulseaudio; \
apt-get install -y --no-install-recommends libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 libx264-155 libvo-aacenc0 librtmp1; \
apt-get install -y --no-install-recommends libgtk-3-bin software-properties-common cabextract aptitude vim curl; \
#
# install fonts
apt-get install -y --no-install-recommends \
@ -108,19 +195,18 @@ RUN set -eux; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# setup pulseaudio
mkdir -p /home/$USERNAME/.config/pulse/; \
echo "default-server=unix:/tmp/pulseaudio.socket" > /home/$USERNAME/.config/pulse/client.conf; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www /var/log/neko; \
mkdir -p /etc/neko /var/www /var/log/neko \
/tmp/runtime-$USERNAME \
/home/$USERNAME/.config/pulse \
/home/$USERNAME/.local/share/xorg; \
chmod 1777 /var/log/neko; \
chown $USERNAME /var/log/neko/; \
chown $USERNAME /var/log/neko/ /tmp/runtime-$USERNAME; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# clean up
@ -128,18 +214,18 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# install and configure Vulkan manually
RUN set -eux; \
apt-get update; \
if [ "${UBUNTU_RELEASE}" = "18.04" ]; then apt-get install -y --no-install-recommends vulkan-utils; else apt-get install -y --no-install-recommends vulkan-tools; fi; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*; \
#
# configure vulkan
VULKAN_API_VERSION=$(dpkg -s libvulkan1 | grep -oP 'Version: [0-9|\.]+' | grep -oP '[0-9]+(\.[0-9]+)(\.[0-9]+)'); \
mkdir -p /etc/vulkan/icd.d/; \
# configure EGL and Vulkan manually
RUN VULKAN_API_VERSION=$(dpkg -s libvulkan1 | grep -oP 'Version: [0-9|\.]+' | grep -oP '[0-9]+(\.[0-9]+)(\.[0-9]+)') && \
# Configure EGL manually
mkdir -p /usr/share/glvnd/egl_vendor.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
\"library_path\": \"libEGL_nvidia.so.0\"\n\
}\n\
}" > /usr/share/glvnd/egl_vendor.d/10_nvidia.json && \
# Configure Vulkan manually
mkdir -p /etc/vulkan/icd.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
@ -150,7 +236,6 @@ RUN set -eux; \
#
# install VirtualGL and make libraries available for preload
ARG VIRTUALGL_VERSION=3.1
RUN set -eux; \
apt-get update; \
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl_${VIRTUALGL_VERSION}_amd64.deb"; \
@ -180,10 +265,22 @@ COPY .docker/base/nvidia/entrypoint.sh /bin/entrypoint.sh
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV XDG_RUNTIME_DIR=/tmp/runtime-$USERNAME
ENV NEKO_PASSWORD=neko
ENV NEKO_PASSWORD_ADMIN=admin
ENV NEKO_BIND=:8080
#
# set gstreamer envs
ENV PATH="/opt/gstreamer/bin:${PATH}"
ENV LD_LIBRARY_PATH="/opt/gstreamer/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
ENV PKG_CONFIG_PATH="/opt/gstreamer/lib/x86_64-linux-gnu/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
#
# copy gstreamer from previous stage
COPY --from=gstreamer /opt/gstreamer /opt/gstreamer
#
# copy static files from previous stages
COPY --from=server /src/bin/neko /usr/bin/neko

View File

@ -54,3 +54,14 @@ stdout_logfile=/var/log/neko/neko.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
chown=root:root
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

View File

@ -10,7 +10,7 @@ RUN set -eux; \
#
# install widevine module
CHROMIUM_DIR="/usr/lib/chromium"; \
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
mkdir -p "${CHROMIUM_DIR}/WidevineCdm/_platform_specific/linux_x64"; \
unzip -p /tmp/widevine.zip LICENSE.txt > "${CHROMIUM_DIR}/WidevineCdm/LICENSE"; \

View File

@ -14,7 +14,7 @@ RUN set -eux; \
#
# install widevine module
CHROMIUM_DIR="/usr/lib/chromium"; \
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
mkdir -p "${CHROMIUM_DIR}/WidevineCdm/_platform_specific/linux_x64"; \
unzip -p /tmp/widevine.zip LICENSE.txt > "${CHROMIUM_DIR}/WidevineCdm/LICENSE"; \

View File

@ -0,0 +1,35 @@
ARG BASE_IMAGE=m1k1o/neko:base
FROM $BASE_IMAGE
ARG SRC_URL="https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US"
#
# install firefox
RUN set -eux; apt-get update; \
apt-get install -y --no-install-recommends openbox \
xz-utils bzip2 libgtk-3-0 libdbus-glib-1-2; \
#
# fetch latest release
wget -O /tmp/firefox-setup.tar.bz2 "${SRC_URL}"; \
mkdir /usr/lib/firefox; \
tar -xjf /tmp/firefox-setup.tar.bz2 -C /usr/lib; \
rm -f /tmp/firefox-setup.tar.bz2; \
ln -s /usr/lib/firefox/firefox /usr/bin/firefox; \
#
# create a profile directory
mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions; \
chown -R neko:neko /home/neko/.mozilla/firefox/profile.default; \
#
# clean up
apt-get --purge autoremove -y xz-utils bzip2; \
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# copy configuation files
COPY supervisord.nvidia.conf /etc/neko/supervisord/firefox.conf
COPY neko.js /usr/lib/firefox/mozilla.cfg
COPY autoconfig.js /usr/lib/firefox/defaults/pref/autoconfig.js
COPY policies.json /usr/lib/firefox/distribution/policies.json
COPY --chown=neko profiles.ini /home/neko/.mozilla/firefox/profiles.ini
COPY openbox.xml /etc/neko/openbox.xml

View File

@ -0,0 +1,28 @@
[program:firefox]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/bin/entrypoint.sh /usr/bin/firefox
--no-remote
-P default
--display=%(ENV_DISPLAY)s
-setDefaultBrowser
-width 1280
-height 720
stopsignal=INT
autorestart=true
priority=800
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/firefox.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
[program:openbox]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/openbox --config-file /etc/neko/openbox.xml
autorestart=true
priority=300
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/openbox.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true

View File

@ -1,7 +1,9 @@
ARG BASE_IMAGE=m1k1o/neko:nvidia-base
FROM $BASE_IMAGE
ARG SRC_URL="https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb"
# latest working version with EGL: 111.0.5563.146, revert when resolved
# 112.0.5615.49 fails: https://github.com/VirtualGL/virtualgl/issues/229
ARG SRC_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_111.0.5563.146-1_amd64.deb"
#
# install google chrome

View File

@ -8,7 +8,7 @@ ARG API_URL="https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edg
RUN set -eux; apt-get update; \
#
# fetch latest release
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | tail -1)"; \
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | sort --version-sort | tail -1)"; \
wget -O /tmp/microsoft-edge.deb "${SRC_URL}"; \
apt-get install -y --no-install-recommends openbox /tmp/microsoft-edge.deb; \
#

View File

@ -8,7 +8,7 @@ ARG API_URL="https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edg
RUN set -eux; apt-get update; \
#
# fetch latest release
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | tail -1)"; \
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | sort --version-sort | tail -1)"; \
wget -O /tmp/microsoft-edge.deb "${SRC_URL}"; \
apt-get install -y --no-install-recommends openbox /tmp/microsoft-edge.deb; \
#

View File

@ -9,7 +9,7 @@ ARG LIBFFMPEG_API_URL="https://api.github.com/repos/nwjs-ffmpeg-prebuilt/nwjs-ff
RUN set -eux; apt-get update; \
#
# fetch latest release
VERSION="$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"/]*\).*/\1/p' | tail -1)"; \
VERSION="$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"/]*\).*/\1/p' | sort --version-sort | tail -1)"; \
wget -O /tmp/opera.deb "${API_URL}${VERSION}/linux/opera-stable_${VERSION}_amd64.deb"; \
apt-get install -y --no-install-recommends openbox jq unzip /tmp/opera.deb; \
#

View File

@ -21,7 +21,7 @@ RUN set -eux; apt-get update; \
chmod 4755 /usr/lib/chromium/chrome-sandbox; \
#
# install widevine module
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
unzip -p /tmp/widevine.zip libwidevinecdm.so > /usr/lib/chromium/libwidevinecdm.so; \
chmod 644 /usr/lib/chromium/libwidevinecdm.so; \

View File

@ -13,7 +13,7 @@ env:
PLATFORMS: linux/arm64,linux/arm/v7
jobs:
build-base:
build-client:
runs-on: ubuntu-latest
#
# do not run on forks
@ -23,6 +23,41 @@ jobs:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up node
uses: actions/setup-node@v3
with:
node-version: 18.x
-
name: Build client
run: |
cd client
npm install
npm run build
-
name: Upload client dist
uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
build-base:
runs-on: ubuntu-latest
#
# do not run on forks
#
if: github.repository_owner == 'm1k1o'
needs: [ build-client ]
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Download client dist
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1

View File

@ -69,6 +69,8 @@ jobs:
# Will build all images even if some fail.
matrix:
include:
- tag: firefox
dockerfile: Dockerfile.nvidia
- tag: brave
dockerfile: Dockerfile.nvidia
- tag: chromium

View File

@ -186,7 +186,9 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2020 Nurdism <nurdism.io@gmail.com>, 2020-2021 m1k1o
Copyright (C) 2020 Nurdism <nurdism.io@gmail.com>
Copyright (C) 2020-2023 m1k1o
All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -181,6 +181,11 @@
shakeKbd = false
get volume() {
const numberParam = parseFloat(new URL(location.href).searchParams.get('volume') || '1.0')
return Math.max(0.0, Math.min(!isNaN(numberParam) ? numberParam * 100 : 100, 100))
}
get isCastMode() {
return !!new URL(location.href).searchParams.get('cast')
}
@ -197,6 +202,13 @@
return this.isCastMode || this.isEmbedMode
}
@Watch('volume', { immediate: true })
onVolume(volume: number) {
if (new URL(location.href).searchParams.has('volume')) {
this.$accessor.video.setVolume(volume)
}
}
@Watch('hideControls', { immediate: true })
onHideControls(enabled: boolean) {
if (enabled) {

View File

@ -1,6 +1,6 @@
<template>
<ul>
<li v-if="!implicitHosting && (!controlLocked || hosting)">
<li v-if="!implicitHosting && hosting">
<i
:class="[
!disabeld && shakeKbd ? 'shake' : '',
@ -22,9 +22,9 @@
</li>
<li class="no-pointer" v-if="implicitHosting">
<i
:class="[controlLocked ? 'disabled' : '', 'fas', 'fa-mouse-pointer']"
:class="['fas', 'fa-mouse-pointer']"
v-tooltip="{
content: controlLocked ? $t('controls.hasnot') : $t('controls.has'),
content: $t('controls.has'),
placement: 'top',
offset: 5,
boundariesElement: 'body',
@ -32,7 +32,7 @@
}"
/>
</li>
<li v-if="implicitHosting || (!implicitHosting && (!controlLocked || hosting))">
<li v-if="implicitHosting || (!implicitHosting && hosting)">
<label
class="switch"
v-tooltip="{
@ -43,7 +43,7 @@
delay: { show: 300, hide: 100 },
}"
>
<input type="checkbox" v-model="locked" :disabled="!hosting || (implicitHosting && controlLocked)" />
<input type="checkbox" v-model="locked" :disabled="!hosting || implicitHosting" />
<span />
</label>
</li>
@ -258,10 +258,6 @@
export default class extends Vue {
@Prop(Boolean) readonly shakeKbd!: boolean
get controlLocked() {
return 'control' in this.$accessor.locked && this.$accessor.locked['control'] && !this.$accessor.user.admin
}
get disabeld() {
return this.$accessor.remote.hosted
}

View File

@ -1,520 +0,0 @@
<template>
<div class="files">
<div class="files-cwd">
<p>{{ cwd }}</p>
<i class="fas fa-rotate-right refresh" @click="refresh" />
</div>
<div class="files-list">
<div v-for="item in files" :key="item.name" class="files-list-item">
<i :class="fileIcon(item)" />
<p class="file-name" :title="item.name">{{ item.name }}</p>
<p class="file-size">{{ fileSize(item.size) }}</p>
<i v-if="item.type !== 'dir'" class="fas fa-download download" @click="download(item)" />
</div>
</div>
<div class="transfer-area">
<div class="transfers" v-if="transfers.length > 0">
<p v-if="downloads.length > 0" class="transfers-list-header">
<span>{{ $t('files.downloads') }}</span>
<i class="fas fa-xmark remove-transfer" @click="downloads.forEach((t) => removeTransfer(t))"></i>
</p>
<div v-for="download in downloads" :key="download.id" class="transfers-list-item">
<div class="transfer-info">
<i
class="fas transfer-status"
:class="{
'fa-clock': download.status === 'pending',
'fa-arrows-rotate': download.status === 'inprogress',
'fa-check': download.status === 'completed',
'fa-warning': download.status === 'failed',
}"
></i>
<p class="file-name" :title="download.name">{{ download.name }}</p>
<p class="file-size">{{ Math.min(100, Math.round((download.progress / download.size) * 100)) }}%</p>
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(download)"></i>
</div>
<div v-if="download.status === 'failed'" class="transfer-error">{{ download.error }}</div>
<progress
v-else
class="transfer-progress"
:aria-label="download.name + ' progress'"
:value="download.progress"
:max="download.size"
></progress>
</div>
<p v-if="uploads.length > 0" class="transfers-list-header">
<span>{{ $t('files.uploads') }}</span>
<i class="fas fa-xmark remove-transfer" @click="uploads.forEach((t) => removeTransfer(t))"></i>
</p>
<div v-for="upload in uploads" :key="upload.id" class="transfers-list-item">
<div class="transfer-info">
<i
class="fas transfer-status"
:title="upload.status"
:class="{
'fa-clock': upload.status === 'pending',
'fa-arrows-rotate': upload.status === 'inprogress',
'fa-check': upload.status === 'completed',
'fa-warning': upload.status === 'failed',
}"
></i>
<p class="file-name" :title="upload.name">{{ upload.name }}</p>
<p class="file-size">{{ Math.min(100, Math.round((upload.progress / upload.size) * 100)) }}%</p>
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(upload)"></i>
</div>
<div v-if="upload.status === 'failed'" class="transfer-error">{{ upload.error }}</div>
<progress
v-else
class="transfer-progress"
:aria-label="upload.name + ' progress'"
:value="upload.progress"
:max="upload.size"
></progress>
</div>
</div>
<div
class="upload-area"
:class="{ 'upload-area-drag': uploadAreaDrag }"
@dragover.prevent="uploadAreaDrag = true"
@dragleave.prevent="uploadAreaDrag = false"
@drop.prevent="(e) => upload(e.dataTransfer)"
@click="openFileBrowser"
>
<i class="fas fa-file-arrow-up" />
<p>{{ $t('files.upload_here') }}</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.files {
flex: 1;
flex-direction: column;
display: flex;
max-width: 100%;
.files-cwd {
display: flex;
flex-direction: row;
margin: 10px 10px 0px 10px;
padding: 0.5em;
font-weight: 600;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
}
.files-list {
margin: 10px 10px 10px 10px;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border: 2px solid $background-primary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
}
.files-list-item {
padding: 0.5em;
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
display: flex;
flex-direction: row;
line-height: 1.2;
}
.transfers-list-header {
display: flex;
justify-content: space-between;
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
}
.file-icon,
.transfer-status {
width: 14px;
margin-right: 0.5em;
}
.transfer-error {
border: 1px solid $style-error;
border-radius: 5px;
padding: 10px;
}
.files-list-item:last-child {
border-bottom: 0px;
}
.refresh {
margin-left: auto;
}
.file-name {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.file-size {
margin-left: auto;
margin-right: 0.5em;
color: rgba($color: #fff, $alpha: 0.4);
white-space: nowrap;
}
.refresh:hover,
.download:hover,
.remove-transfer:hover {
cursor: pointer;
}
.transfer-area {
margin-top: auto;
}
.transfers {
margin: 10px 10px 10px 10px;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
max-height: 50vh;
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border: 2px solid $background-primary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
}
.transfers > p {
padding: 10px;
font-weight: 600;
}
.transfer-info {
display: flex;
flex-direction: row;
max-width: 100%;
padding: 10px;
}
.transfer-progress {
margin: 0px 10px 10px 10px;
width: 95%;
}
.upload-area {
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
margin: 10px 10px 10px 10px;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
}
.upload-area:hover {
cursor: pointer;
}
.upload-area-drag,
.upload-area:hover {
background-color: rgba($color: #fff, $alpha: 0.1);
}
.upload-area > i {
font-size: 4em;
margin: 10px 10px 10px 10px;
}
.upload-area > p {
margin: 0px 10px 10px 10px;
}
}
</style>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import Markdown from './markdown'
import Content from './context.vue'
import { FileTransfer, FileListItem } from '~/neko/types'
@Component({
name: 'neko-files',
components: {
'neko-markdown': Markdown,
'neko-context': Content,
},
})
export default class extends Vue {
public uploadAreaDrag: boolean = false
get cwd() {
return this.$accessor.files.cwd
}
get files() {
return this.$accessor.files.files
}
get transfers() {
return this.$accessor.files.transfers
}
get downloads() {
return this.$accessor.files.transfers.filter((t) => t.direction === 'download')
}
get uploads() {
return this.$accessor.files.transfers.filter((t) => t.direction === 'upload')
}
refresh() {
this.$accessor.files.refresh()
}
download(item: FileListItem) {
if (this.downloads.map((t) => t.name).includes(item.name)) {
return
}
const url =
'/file?pwd=' + encodeURIComponent(this.$accessor.password) + '&filename=' + encodeURIComponent(item.name)
const abortController = new AbortController()
let transfer: FileTransfer = {
id: Math.round(Math.random() * 10000),
name: item.name,
direction: 'download',
// this may be smaller than the actual transfer amount, but for large files the
// content length is not sent (chunked transfer)
size: item.size,
progress: 0,
status: 'pending',
abortController: abortController,
}
this.$http
.get(url, {
responseType: 'blob',
signal: abortController.signal,
withCredentials: false,
onDownloadProgress: (x) => {
transfer.progress = x.loaded
if (x.total && transfer.size !== x.total) {
transfer.size = x.total
}
if (transfer.progress === transfer.size) {
transfer.status = 'completed'
} else if (transfer.status !== 'inprogress') {
transfer.status = 'inprogress'
}
},
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', item.name)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
transfer.progress = transfer.size
transfer.status = 'completed'
})
.catch((error) => {
this.$log.error(error)
transfer.status = 'failed'
transfer.error = error.message
})
this.$accessor.files.addTransfer(transfer)
}
upload(dt: DataTransfer) {
const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password)
this.uploadAreaDrag = false
for (const file of dt.files) {
const abortController = new AbortController()
const formdata = new FormData()
formdata.append('files', file, file.name)
let transfer: FileTransfer = {
id: Math.round(Math.random() * 10000),
name: file.name,
direction: 'upload',
size: file.size,
progress: 0,
status: 'pending',
abortController: abortController,
}
this.$http
.post(url, formdata, {
signal: abortController.signal,
withCredentials: false,
onUploadProgress: (x: any) => {
transfer.progress = x.loaded
if (transfer.size !== x.total) {
transfer.size = x.total
}
if (transfer.progress === transfer.size) {
transfer.status = 'completed'
} else if (transfer.status !== 'inprogress') {
transfer.status = 'inprogress'
}
},
})
.catch((error) => {
this.$log.error(error)
transfer.status = 'failed'
transfer.error = error.message
})
this.$accessor.files.addTransfer(transfer)
}
}
openFileBrowser() {
const input = document.createElement('input')
input.type = 'file'
input.setAttribute('multiple', 'true')
input.onchange = (e: Event) => {
if (e === null) return
const dt = new DataTransfer()
const target = e.target as HTMLInputElement
if (target.files === null) return
for (const f of target.files) {
dt.items.add(f)
}
this.upload(dt)
}
input.click()
}
removeTransfer(transfer: FileTransfer) {
if (transfer.status !== 'completed') {
transfer.abortController?.abort()
}
this.$accessor.files.removeTransfer(transfer)
}
fileIcon(file: FileListItem) {
let className = 'file-icon fas '
// if is directory
if (file.type === 'dir') {
className += 'fa-folder'
return className
}
// try to get file extension
const ext = file.name.split('.').pop()
if (ext === undefined) {
className += 'fa-file'
return className
}
// try to find icon
switch (ext.toLowerCase()) {
case 'txt':
case 'md':
className += 'fa-file-text'
break
case 'pdf':
className += 'fa-file-pdf'
break
case 'zip':
case 'rar':
case '7z':
case 'gz':
className += 'fa-archive'
break
case 'aac':
case 'flac':
case 'midi':
case 'mp3':
case 'ogg':
case 'wav':
className += 'fa-music'
break
case 'avi':
case 'mkv':
case 'mov':
case 'mpeg':
case 'mp4':
case 'webm':
className += 'fa-film'
break
case 'bmp':
case 'gif':
case 'jpeg':
case 'jpg':
case 'png':
case 'svg':
case 'tiff':
case 'webp':
className += 'fa-image'
break
default:
className += 'fa-file'
}
return className
}
fileSize(size: number) {
if (size < 1024) {
return size + ' B'
}
if (size < 1024 * 1024) {
return Math.round(size / 1024) + ' KB'
}
if (size < 1024 * 1024 * 1024) {
return Math.round(size / (1024 * 1024)) + ' MB'
}
if (size < 1024 * 1024 * 1024 * 1024) {
return Math.round(size / (1024 * 1024 * 1024)) + ' GB'
}
return Math.round(size / (1024 * 1024 * 1024 * 1024)) + ' TB'
}
}
</script>

View File

@ -5,45 +5,6 @@
<span><b>n</b>.eko</span>
</a>
<ul class="menu">
<li>
<i
:class="[{ disabled: !admin }, { locked: isLocked('control') }, 'fas', 'fa-mouse']"
@click="toggleLock('control')"
v-tooltip="{
content: lockedTooltip('control'),
placement: 'bottom',
offset: 5,
boundariesElement: 'body',
delay: { show: 300, hide: 100 },
}"
/>
</li>
<li>
<i
:class="[{ disabled: !admin }, { locked: isLocked('login') }, locked ? 'fa-lock' : 'fa-lock-open', 'fas']"
@click="toggleLock('login')"
v-tooltip="{
content: lockedTooltip('login'),
placement: 'bottom',
offset: 5,
boundariesElement: 'body',
delay: { show: 300, hide: 100 },
}"
/>
</li>
<li v-if="fileTransfer">
<i
:class="[{ disabled: !admin }, { locked: isLocked('file_transfer') }, 'fas', 'fa-file']"
@click="toggleLock('file_transfer')"
v-tooltip="{
content: lockedTooltip('file_transfer'),
placement: 'bottom',
offset: 5,
boundariesElement: 'body',
delay: { show: 300, hide: 100 },
}"
/>
</li>
<li>
<span v-if="showBadge" class="badge">&bull;</span>
<i class="fas fa-bars toggle" @click="toggleMenu" />
@ -162,14 +123,6 @@
@Component({ name: 'neko-settings' })
export default class extends Vue {
get admin() {
return this.$accessor.user.admin
}
get locked() {
return this.$accessor.locked
}
get side() {
return this.$accessor.client.side
}
@ -182,30 +135,10 @@
return !this.side && this.readTexts != this.texts
}
get fileTransfer() {
return this.$accessor.remote.fileTransfer
}
toggleLock(resource: AdminLockResource) {
this.$accessor.toggleLock(resource)
}
isLocked(resource: AdminLockResource): boolean {
return this.$accessor.isLocked(resource)
}
readTexts: number = 0
toggleMenu() {
this.$accessor.client.toggleSide()
this.readTexts = this.texts
}
lockedTooltip(resource: AdminLockResource) {
if (this.admin) {
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `unlock` : `lock`))
}
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `locked` : `unlocked`))
}
}
</script>

View File

@ -76,5 +76,20 @@
about() {
this.$accessor.client.toggleAbout()
}
mounted() {
const default_lang = new URL(location.href).searchParams.get('lang')
if (default_lang && this.langs.includes(default_lang)) {
this.$i18n.locale = default_lang
}
const show_side = new URL(location.href).searchParams.get('show_side')
if (show_side !== null) {
this.$accessor.client.setSide(show_side === '1')
}
const mute_chat = new URL(location.href).searchParams.get('mute_chat')
if (mute_chat !== null) {
this.$accessor.settings.setSound(mute_chat !== '1')
}
}
}
</script>

View File

@ -6,10 +6,6 @@
<i class="fas fa-comment-alt" />
<span>{{ $t('side.chat') }}</span>
</li>
<li v-if="filetransferAllowed" :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
<i class="fas fa-file" />
<span>{{ $t('side.files') }}</span>
</li>
<li :class="{ active: tab === 'settings' }" @click.stop.prevent="change('settings')">
<i class="fas fa-sliders-h" />
<span>{{ $t('side.settings') }}</span>
@ -79,47 +75,23 @@
</style>
<script lang="ts">
import { Vue, Component, Watch } from 'vue-property-decorator'
import { Vue, Component } from 'vue-property-decorator'
import Settings from '~/components/settings.vue'
import Chat from '~/components/chat.vue'
import Files from '~/components/files.vue'
@Component({
name: 'neko',
components: {
'neko-settings': Settings,
'neko-chat': Chat,
'neko-files': Files,
},
})
export default class extends Vue {
get filetransferAllowed() {
return (
this.$accessor.remote.fileTransfer && (this.$accessor.user.admin || !this.$accessor.isLocked('file_transfer'))
)
}
get tab() {
return this.$accessor.client.tab
}
@Watch('tab', { immediate: true })
@Watch('filetransferAllowed', { immediate: true })
onTabChange() {
// do not show the files tab if file transfer is disabled
if (this.tab === 'files' && !this.filetransferAllowed) {
this.change('chat')
}
}
@Watch('filetransferAllowed')
onFileTransferAllowedChange() {
if (this.filetransferAllowed) {
this.$accessor.files.refresh()
}
}
change(tab: string) {
this.$accessor.client.setTab(tab)
}

View File

@ -13,6 +13,7 @@
class="overlay"
tabindex="0"
data-gramm="false"
:style="{ pointerEvents: hosting ? 'auto' : 'none' }"
@click.stop.prevent
@contextmenu.stop.prevent
@wheel.stop.prevent="onWheel"
@ -21,6 +22,9 @@
@mouseup.stop.prevent="onMouseUp"
@mouseenter.stop.prevent="onMouseEnter"
@mouseleave.stop.prevent="onMouseLeave"
@touchmove.stop.prevent="onTouchHandler"
@touchstart.stop.prevent="onTouchHandler"
@touchend.stop.prevent="onTouchHandler"
/>
<div v-if="!playing && playable" class="player-overlay" @click.stop.prevent="playAndUnmute">
<i class="fas fa-play-circle" />
@ -33,7 +37,7 @@
<ul v-if="!fullscreen && !hideControls" class="video-menu top">
<li><i @click.stop.prevent="requestFullscreen" class="fas fa-expand"></i></li>
<li v-if="admin"><i @click.stop.prevent="openResolution" class="fas fa-desktop"></i></li>
<li v-if="!implicitHosting" :class="extraControls || 'extra-control'">
<li v-if="!controlLocked && !implicitHosting" :class="extraControls || 'extra-control'">
<i
:class="[hosted && !hosting ? 'disabled' : '', !hosted && !hosting ? 'faded' : '', 'fas', 'fa-keyboard']"
@click.stop.prevent="toggleControl"
@ -688,6 +692,35 @@
}
}
onTouchHandler(e: TouchEvent) {
let first = e.changedTouches[0]
let type = ''
switch (e.type) {
case 'touchstart':
type = 'mousedown'
break
case 'touchmove':
type = 'mousemove'
break
case 'touchend':
type = 'mouseup'
break
default:
return
}
const simulatedEvent = new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
screenX: first.screenX,
screenY: first.screenY,
clientX: first.clientX,
clientY: first.clientY,
})
first.target.dispatchEvent(simulatedEvent)
}
onMouseDown(e: MouseEvent) {
if (!this.hosting) {
this.$emit('control-attempt', e)

View File

@ -52,33 +52,6 @@ export const controls = {
unlock: 'Steuerung entsperren',
}
export const locks = {
control: {
lock: 'Steuerung sperren (für Nutzer)',
unlock: 'Steuerung entsperren (für Nutzer)',
locked: 'Steuerung gesperrt (für Nutzer)',
unlocked: 'Steuerung entsperrt (für Nutzer)',
notif_locked: 'Steuerung sperren für Nutzer',
notif_unlocked: 'Steuerung entsperren für Nutzer',
},
login: {
lock: 'Raum sperren (für Nutzer)',
unlock: 'Raum entsperren (für Nutzer)',
locked: 'Raum gesperrt (für Nutzer)',
unlocked: 'Raum entsperrt (für Nutzer)',
notif_locked: 'Raum gesperrt',
notif_unlocked: 'Raum entsperrt',
},
file_transfer: {
lock: 'Dateiübertragung sperren (für Nutzer)',
unlock: 'Dateiübertragung entsperren (für Nutzer)',
locked: 'Dateiübertragung gesperrt (für Nutzer)',
unlocked: 'Dateiübertragung entsperrt (für Nutzer)',
notif_locked: 'Dateiübertragung gesperrt',
notif_unlocked: 'Dateiübertragung entsperrt',
},
}
export const setting = {
scroll: 'Scroll-Empfindlichkeit',
scroll_invert: 'Bildlauf umkehren',

View File

@ -54,33 +54,6 @@ export const controls = {
hasnot: 'You do not have control',
}
export const locks = {
control: {
lock: 'Lock Controls (for users)',
unlock: 'Unlock Controls (for users)',
locked: 'Controls Locked (for users)',
unlocked: 'Controls Unlocked (for users)',
notif_locked: 'locked controls for users',
notif_unlocked: 'unlocked controls for users',
},
login: {
lock: 'Lock Room (for users)',
unlock: 'Unlock Room (for users)',
locked: 'Room Locked (for users)',
unlocked: 'Room Unlocked (for users)',
notif_locked: 'locked the room',
notif_unlocked: 'unlocked the room',
},
file_transfer: {
lock: 'Lock File Transfer (for users)',
unlock: 'Unlock File Transfer (for users)',
locked: 'File Transfer Locked (for users)',
unlocked: 'File Transfer Unlocked (for users)',
notif_locked: 'locked file transfer',
notif_unlocked: 'unlocked file transfer',
},
}
export const setting = {
scroll: 'Scroll Sensitivity',
scroll_invert: 'Invert Scroll',

View File

@ -57,35 +57,6 @@ export const controls = {
//hasnot: 'You do not have control',
}
export const locks = {
// TODO
//control: {
// lock: 'Lock Controls (for users)',
// unlock: 'Unlock Controls (for users)',
// locked: 'Controls Locked (for users)',
// unlocked: 'Controls Unlocked (for users)',
// notif_locked: 'locked controls for users',
// notif_unlocked: 'unlocked controls for users',
//},
login: {
lock: 'Bloquear sala (para usuarios)',
unlock: 'Desbloquear sala (para usuarios)',
locked: 'Sala bloqueada (para usuarios)',
unlocked: 'Sala desbloqueada (para usuarios)',
notif_locked: 'bloqueó la sala',
notif_unlocked: 'desbloqueó la sala',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Sensibilidad del Scroll',
scroll_invert: 'Invertir Scroll',

View File

@ -54,34 +54,6 @@ export const controls = {
hasnot: 'Sinulle ei ole kontrolleja',
}
export const locks = {
control: {
lock: 'Lukitse kontrollit (käyttäjiltä)',
unlock: 'Vapauta kontrollit (käyttäjiltä)',
locked: 'Kontrollit lukittu (käyttäjiltä)',
unlocked: 'Kontrollit vapautettu (käyttäjiltä)',
notif_locked: 'kontrollit on lukittu käyttäjiltä',
notif_unlocked: 'kontrollit on vapautettu käyttäjille',
},
login: {
lock: 'Lukitse huone (käyttäjiltä)',
unlock: 'Vapauta huone (käyttäjiltä)',
locked: 'Huone lukittu (käyttäjiltä)',
unlocked: 'Huone vapautettu (käyttäjiltä)',
notif_locked: 'lukittu huone',
notif_unlocked: 'vapautettu huone',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Scrollin herkkyys',
scroll_invert: 'Käänteinen Scroll',

View File

@ -57,35 +57,6 @@ export const controls = {
// hasnot: 'You do not have control',
}
export const locks = {
// TODO
//control: {
// lock: 'Lock Controls (for users)',
// unlock: 'Unlock Controls (for users)',
// locked: 'Controls Locked (for users)',
// unlocked: 'Controls Unlocked (for users)',
// notif_locked: 'locked controls for users',
// notif_unlocked: 'unlocked controls for users',
//},
login: {
lock: 'Vérouiller la salle (pour les utilisateurs)',
unlock: 'Dévérouiller la salle (pour les utilisateurs)',
locked: 'Salle vérouillée (pour les utilisateurs)',
unlocked: 'Salle dévérouillée (pour les utilisateurs)',
notif_locked: 'a vérouillé la salle',
notif_unlocked: 'a dévérouillé la salle',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Sensibilité de défilement (scroll)',
scroll_invert: 'Inverser le défilement (scroll)',

View File

@ -9,6 +9,7 @@ import * as ko from './ko-kr'
import * as fi from './fi-fi'
import * as ru from './ru-ru'
import * as cn from './zh-cn'
import * as tw from './zh-tw'
export const messages = {
en,
@ -22,4 +23,5 @@ export const messages = {
fi,
ru,
cn,
tw,
}

View File

@ -52,34 +52,6 @@ export const controls = {
unlock: '조작 잠금 해제하기',
}
export const locks = {
control: {
lock: '조작 잠그기 (사용자)',
unlock: '조작 잠금 해제하기 (사용자)',
locked: '조작이 잠겼습니다 (사용자)',
unlocked: '조작 잠금이 해제됐습니다 (사용자)',
notif_locked: '사용자의 조작을 잠궜습니다',
notif_unlocked: '사용자의 조작 잠금을 해제했습니다',
},
login: {
lock: '방 잠그기 (사용자)',
unlock: '방 잠금 해제하기 (사용자)',
locked: '방이 잠겼습니다 (사용자)',
unlocked: '방 잠금이 해제됐습니다 (사용자)',
notif_locked: '방이 잠겼습니다',
notif_unlocked: '방 잠금이 해제됐습니다',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: '스크롤 감도',
scroll_invert: '스크롤 반전',

View File

@ -57,35 +57,6 @@ export const controls = {
//hasnot: 'You do not have control',
}
export const locks = {
// TODO
//control: {
// lock: 'Lock Controls (for users)',
// unlock: 'Unlock Controls (for users)',
// locked: 'Controls Locked (for users)',
// unlocked: 'Controls Unlocked (for users)',
// notif_locked: 'locked controls for users',
// notif_unlocked: 'unlocked controls for users',
//},
login: {
lock: 'Lås rommet (for brukere)',
unlock: 'Lås opp rommet (for brukere)',
locked: 'Rom låst (for brukere)',
unlocked: 'Rom opplåst (for brukere)',
notif_locked: 'låste rommet',
notif_unlocked: 'låste opp rommet',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Rullingssensitivitet',
scroll_invert: 'Inverter rulling',

View File

@ -54,34 +54,6 @@ export const controls = {
hasnot: 'Вы не управляете',
}
export const locks = {
control: {
lock: 'Закрепить управление (для пользователей)',
unlock: 'Открепить управление (для пользователей)',
locked: 'Управление закреплено (для пользователей)',
unlocked: 'Управление откреплено (для пользователей)',
notif_locked: 'закреплено управление для пользователей',
notif_unlocked: 'откреплено управление для пользователей',
},
login: {
lock: 'Закрыть комнату (для пользователей)',
unlock: 'Открыть комнату (для пользователей)',
locked: 'Комната закрыта (для пользователей)',
unlocked: 'Комната открыта (для пользователей)',
notif_locked: 'комната закрыта',
notif_unlocked: 'комната открыта',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Чувствительность прокрутки',
scroll_invert: 'Инвертировать прокрутку',

View File

@ -57,34 +57,6 @@ export const controls = {
//hasnot: 'You do not have control',
}
export const locks = {
control: {
lock: 'Zakázať ovládanie (pre používateľov)',
unlock: 'Povoliť ovládanie (pre používateľov)',
locked: 'Ovládanie je zakázané (pre používateľov)',
unlocked: 'Ovládanie je povolené (pre používateľov)',
notif_locked: 'zakázal/a ovládanie pre používateľov',
notif_unlocked: 'povolil/a ovládanie pre používateľov',
},
login: {
lock: 'Zamknúť miestnosť (pre používateľov)',
unlock: 'Odomknúť miestnosť (pre používateľov)',
locked: 'Miestnosť je zamknutá (pre používateľov)',
unlocked: 'Miestnosť odomknutá (pre používateľov)',
notif_locked: 'miestnosť bola zamknutá',
notif_unlocked: 'miestnosť bola odomknutá',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Citlivosť kolieska myši',
scroll_invert: 'Invertovať koliesko myši',

View File

@ -57,35 +57,6 @@ export const controls = {
//hasnot: 'You do not have control',
}
export const locks = {
// TODO
//control: {
// lock: 'Lock Controls (for users)',
// unlock: 'Unlock Controls (for users)',
// locked: 'Controls Locked (for users)',
// unlocked: 'Controls Unlocked (for users)',
// notif_locked: 'locked controls for users',
// notif_unlocked: 'unlocked controls for users',
//},
login: {
lock: 'Lås rum (för användare)',
unlock: 'Lås upp rummet (för användare)',
locked: 'Rum låst (för användare)',
unlocked: 'Rum upplåst (för användare)',
notif_locked: 'låste rummet',
notif_unlocked: 'låste upp rummet',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: 'Scrollkänslighet',
scroll_invert: 'Vänd Scrollen',

View File

@ -54,34 +54,6 @@ export const controls = {
hasnot: '你没有控制',
}
export const locks = {
control: {
lock: '对所有用户进行锁定控制',
unlock: '对所有用户进行解锁控制',
locked: '锁定的控制装置',
unlocked: '解锁的控制装置',
notif_locked: '为用户锁定控制',
notif_unlocked: '为用户解锁控制',
},
login: {
lock: '所有用户的锁定室',
unlock: '所有用户的解锁室',
locked: '为所有用户锁定的房间',
unlocked: '为所有用户解锁的房间',
notif_locked: '锁上房间',
notif_unlocked: '解锁房间',
},
// TODO
//file_transfer: {
// lock: 'Lock File Transfer (for users)',
// unlock: 'Unlock File Transfer (for users)',
// locked: 'File Transfer Locked (for users)',
// unlocked: 'File Transfer Unlocked (for users)',
// notif_locked: 'locked file transfer',
// notif_unlocked: 'unlocked file transfer',
//},
}
export const setting = {
scroll: '滚动敏感度',
scroll_invert: '反转滚动敏感度',

100
client/src/locale/zh-tw.ts Normal file
View File

@ -0,0 +1,100 @@
export const logout = '登出'
export const unsupported = '您的網頁瀏覽器不支援 WebRTC'
export const admin_loggedin = '您已經以管理員身份登入'
export const you = '您'
export const somebody = '某人'
export const send_a_message = '傳送訊息'
export const side = {
chat: '聊天',
files: '檔案',
settings: '設定',
}
export const connect = {
login_title: '請登入',
invitation_title: '您已被邀請進入此房間',
displayname: '輸入您的顯示名稱',
password: '密碼',
connect: '連線',
error: '登入錯誤',
empty_displayname: '顯示名稱不能為空。',
}
export const context = {
ignore: '忽略',
unignore: '取消忽略',
mute: '靜音',
unmute: '解除靜音',
release: '強制釋放控制',
take: '強制接管控制',
give: '移交控制',
kick: '踢出',
ban: '封鎖 IP',
confirm: {
kick_title: '踢出 {name}',
kick_text: '您確定要踢出 {name} 嗎?',
ban_title: '封鎖 {name}',
ban_text: '您是否要封鎖 {name}?封鎖後需要重新啟動伺服器才能取消封鎖。',
mute_title: '靜音 {name}',
mute_text: '您確定要將 {name} 設為靜音嗎?',
unmute_title: '解除靜音 {name}',
unmute_text: '您是否要解除 {name} 的靜音?',
button_yes: '是',
button_cancel: '取消',
},
}
export const controls = {
release: '釋放控制',
request: '請求控制',
lock: '鎖定控制',
unlock: '解鎖控制',
has: '您擁有控制權',
hasnot: '您沒有控制權',
}
export const setting = {
scroll: '滾動靈敏度',
scroll_invert: '反向滾動',
autoplay: '自動播放影片',
ignore_emotes: '忽略表情符號',
chat_sound: '播放聊天音效',
keyboard_layout: '鍵盤配置',
broadcast_title: '直播',
}
export const connection = {
logged_out: '您已登出。',
reconnecting: '正在重新連線…',
connected: '已連線',
disconnected: '已斷線',
kicked: '您已被移出此房間。',
button_confirm: '確定',
}
export const notifications = {
connected: '{name} 已連線',
disconnected: '{name} 已斷線',
controls_taken: '{name} 接管了控制權',
controls_taken_force: '強制接管控制權',
controls_taken_steal: '從 {name} 奪取了控制權',
controls_released: '{name} 釋放了控制權',
controls_released_force: '強制釋放控制權',
controls_released_steal: '從 {name} 強制釋放控制權',
controls_given: '將控制權交給 {name}',
controls_has: '{name} 擁有控制權',
controls_has_alt: '但我已通知對方您有意接管',
controls_requesting: '{name} 正在請求控制權',
resolution: '將解析度改為 {width}x{height}@{rate}',
banned: '已封鎖 {name}',
kicked: '已踢出 {name}',
muted: '已將 {name} 靜音',
unmuted: '已解除 {name} 的靜音',
}
export const files = {
downloads: '下載',
uploads: '上傳',
upload_here: '點選或拖曳檔案至此上傳',
}

View File

@ -38,10 +38,6 @@ export const EVENT = {
MESSAGE: 'chat/message',
EMOTE: 'chat/emote',
},
FILETRANSFER: {
LIST: 'filetransfer/list',
REFRESH: 'filetransfer/refresh',
},
SCREEN: {
CONFIGURATIONS: 'screen/configurations',
RESOLUTION: 'screen/resolution',
@ -55,8 +51,6 @@ export const EVENT = {
ADMIN: {
BAN: 'admin/ban',
KICK: 'admin/kick',
LOCK: 'admin/lock',
UNLOCK: 'admin/unlock',
MUTE: 'admin/mute',
UNMUTE: 'admin/unmute',
CONTROL: 'admin/control',
@ -73,7 +67,6 @@ export type WebSocketEvents =
| MemberEvents
| SignalEvents
| ChatEvents
| FileTransferEvents
| ScreenEvents
| BroadcastEvents
| AdminEvents
@ -97,8 +90,6 @@ export type SignalEvents =
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type FileTransferEvents = typeof EVENT.FILETRANSFER.LIST | typeof EVENT.FILETRANSFER.REFRESH
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
export type BroadcastEvents =
@ -109,8 +100,6 @@ export type BroadcastEvents =
export type AdminEvents =
| typeof EVENT.ADMIN.BAN
| typeof EVENT.ADMIN.KICK
| typeof EVENT.ADMIN.LOCK
| typeof EVENT.ADMIN.UNLOCK
| typeof EVENT.ADMIN.MUTE
| typeof EVENT.ADMIN.UNMUTE
| typeof EVENT.ADMIN.CONTROL

View File

@ -22,7 +22,6 @@ import {
AdminLockMessage,
SystemInitPayload,
AdminLockResource,
FileTransferListPayload,
} from './messages'
interface NekoEvents extends BaseEvents {}
@ -134,17 +133,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
/////////////////////////////
// System Events
/////////////////////////////
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, locks, file_transfer }: SystemInitPayload) {
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, file_transfer }: SystemInitPayload) {
this.$accessor.remote.setImplicitHosting(implicit_hosting)
this.$accessor.remote.setFileTransfer(file_transfer)
for (const resource in locks) {
this[EVENT.ADMIN.LOCK]({
event: EVENT.ADMIN.LOCK,
resource: resource as AdminLockResource,
id: locks[resource],
})
}
}
protected [EVENT.SYSTEM.DISCONNECT]({ message }: SystemMessagePayload) {
@ -353,14 +344,6 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
this.$accessor.chat.newEmote({ type: emote })
}
/////////////////////////////
// File Transfer Events
/////////////////////////////
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
this.$accessor.files.setCwd(cwd)
this.$accessor.files.setFileList(files)
}
/////////////////////////////
// Screen Events
/////////////////////////////
@ -486,28 +469,6 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
})
}
protected [EVENT.ADMIN.LOCK]({ id, resource }: AdminLockMessage) {
this.$accessor.setLocked(resource)
this.$accessor.chat.newMessage({
id,
content: this.$vue.$t(`locks.${resource}.notif_locked`) as string,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.UNLOCK]({ id, resource }: AdminLockMessage) {
this.$accessor.setUnlocked(resource)
this.$accessor.chat.newMessage({
id,
content: this.$vue.$t(`locks.${resource}.notif_unlocked`) as string,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.CONTROL]({ id, target }: AdminTargetPayload) {
this.$accessor.remote.setHost(id)
this.$accessor.remote.changeKeyboard()

View File

@ -8,9 +8,8 @@ import {
ChatEvents,
ScreenEvents,
AdminEvents,
FileTransferEvents,
} from './events'
import { FileListItem, Member, ScreenConfigurations, ScreenResolution } from './types'
import { Member, ScreenConfigurations, ScreenResolution } from './types'
export type WebSocketMessages =
| WebSocketMessage
@ -194,18 +193,6 @@ export interface EmojiSendPayload {
emote: string
}
/*
FILE TRANSFER PAYLOADS
*/
export interface FileTransferListMessage extends WebSocketMessage, FileTransferListPayload {
event: FileTransferEvents
}
export interface FileTransferListPayload {
cwd: string
files: FileListItem[]
}
/*
SCREEN PAYLOADS
*/

View File

@ -22,20 +22,3 @@ export interface ScreenResolution {
height: number
rate: number
}
export interface FileListItem {
name: string
type: 'file' | 'dir'
size: number
}
export interface FileTransfer {
id: number
name: string
direction: 'upload' | 'download'
size: number
progress: number
status: 'pending' | 'inprogress' | 'completed' | 'failed'
error?: string
abortController?: AbortController
}

View File

@ -6,5 +6,6 @@ Vue.use(VueI18n)
export const i18n = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
messages,
})

View File

@ -27,6 +27,10 @@ export const mutations = mutationTree(state, {
state.side = !state.side
set('side', state.side)
},
setSide(state, side: boolean) {
state.side = side
set('side', side)
},
})
export const actions = actionTree({ state, getters, mutations }, {})

View File

@ -1,72 +0,0 @@
import { actionTree, getterTree, mutationTree } from 'typed-vuex'
import { FileListItem, FileTransfer } from '~/neko/types'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store'
export const state = () => ({
cwd: '',
files: [] as FileListItem[],
transfers: [] as FileTransfer[],
})
export const getters = getterTree(state, {
//
})
export const mutations = mutationTree(state, {
_setCwd(state, cwd: string) {
state.cwd = cwd
},
_setFileList(state, files: FileListItem[]) {
state.files = files
},
_addTransfer(state, transfer: FileTransfer) {
state.transfers = [...state.transfers, transfer]
},
_removeTransfer(state, transfer: FileTransfer) {
state.transfers = state.transfers.filter((t) => t.id !== transfer.id)
},
})
export const actions = actionTree(
{ state, getters, mutations },
{
setCwd(store, cwd: string) {
accessor.files._setCwd(cwd)
},
setFileList(store, files: FileListItem[]) {
accessor.files._setFileList(files)
},
addTransfer(store, transfer: FileTransfer) {
if (transfer.status !== 'pending') {
return
}
accessor.files._addTransfer(transfer)
},
removeTransfer(store, transfer: FileTransfer) {
accessor.files._removeTransfer(transfer)
},
cancelAllTransfers() {
for (const t of accessor.files.transfers) {
if (t.status !== 'completed') {
t.abortController?.abort()
}
accessor.files.removeTransfer(t)
}
},
refresh() {
if (!accessor.connected) {
return
}
$client.sendMessage(EVENT.FILETRANSFER.REFRESH)
},
},
)

View File

@ -7,7 +7,6 @@ import { get, set } from '~/utils/localstorage'
import * as video from './video'
import * as chat from './chat'
import * as files from './files'
import * as remote from './remote'
import * as user from './user'
import * as settings from './settings'
@ -20,7 +19,6 @@ export const state = () => ({
active: false,
connecting: false,
connected: false,
locked: {} as Record<string, boolean>,
})
export const mutations = mutationTree(state, {
@ -33,14 +31,6 @@ export const mutations = mutationTree(state, {
state.password = password
},
setLocked(state, resource: string) {
Vue.set(state.locked, resource, true)
},
setUnlocked(state, resource: string) {
Vue.set(state.locked, resource, false)
},
setConnnecting(state) {
state.connected = false
state.connecting = true
@ -56,9 +46,7 @@ export const mutations = mutationTree(state, {
},
})
export const getters = getterTree(state, {
isLocked: (state) => (resource: AdminLockResource) => resource in state.locked && state.locked[resource],
})
export const getters = getterTree(state, {})
export const actions = actionTree(
{ state, getters, mutations },
@ -68,30 +56,6 @@ export const actions = actionTree(
accessor.settings.initialise()
},
lock(_, resource: AdminLockResource) {
if (!accessor.connected || !accessor.user.admin) {
return
}
$client.sendMessage(EVENT.ADMIN.LOCK, { resource })
},
unlock(_, resource: AdminLockResource) {
if (!accessor.connected || !accessor.user.admin) {
return
}
$client.sendMessage(EVENT.ADMIN.UNLOCK, { resource })
},
toggleLock(_, resource: AdminLockResource) {
if (accessor.isLocked(resource)) {
accessor.unlock(resource)
} else {
accessor.lock(resource)
}
},
login(store, { displayname, password }: { displayname: string; password: string }) {
accessor.setLogin({ displayname, password })
$client.login(password, displayname)
@ -111,7 +75,7 @@ export const storePattern = {
mutations,
actions,
getters,
modules: { video, chat, files, user, remote, settings, client, emoji },
modules: { video, chat, user, remote, settings, client, emoji },
}
Vue.use(Vuex)

View File

@ -6,6 +6,7 @@
* [Reverse Proxy](/getting-started/reverse-proxy)
* [Configuration](/getting-started/configuration)
* [Troubleshooting](/getting-started/troubleshooting)
* [Frequently Asked Questions](/getting-started/faq)
* [Mobile Support](/mobile-support)
* [Contributing](/contributing)
* [Non Goals](/non-goals)

View File

@ -2,11 +2,34 @@
## master branch
### New Features
- Added nvidia support for firefox.
- Added `?lang=<lang>` parameter to the URL, which will set the language of the interface (by @mbattista).
- Added `?show_side=1` and `?mute_chat=1` parameter to the URL, for chat mute and show side (by @mbattista).
### Bugs
- Fix incorrect version sorting for chromium, microsoft-edge, opera and ungoogledchromium.
- Fix buffer overflow in Gstreamer log function [#382](https://github.com/m1k1o/neko/pull/382) (by @@tt2468).
### Misc
- Added RTMP broadcast support to nvidia docker image [#274](https://github.com/m1k1o/neko/issues/274).
- Ensured that paths are writable by neko user [#277](https://github.com/m1k1o/neko/issues/277).
- Git commit and tag are now included in the build when creating a docker image.
- Remove any temporary files associated with a Form after file upload, that would be otherwise never removed.
- Add check for volume parameter in URL before setting volume (by @FapFapDragon).
- Add glib main loop to capture manager [#383](https://github.com/m1k1o/neko/pull/383) (by @tt2468).
## [n.eko v2.8.0](https://github.com/m1k1o/neko/releases/tag/v2.8.0)
### New Features
- Added AV1 tag, metadata and pipeline. Unfortunately does not work yet, since the encoding is way too slow (by @mbattista).
- Added `m1k1o/neko:kde` tag as an alternative to `m1k1o/neko:xfce`.
- New VirtualGL version 3.1 was released, adding support for Chromium browsers to use Nvidia GPU acceleration!
- Added `?embed=1` parameter to the URL, which will hide the sidebar and the top bar, so that it can be embedded in other websites.
- Added `?volume=<0-1>` parameter to the URL, which will set the inital volume of the player (by @urbanekpj).
- Touch events are now supported on mobile devices (by @urbanekpj).
- Added NVENC support, hardware h264 encoding for Nvidia GPUs!
- Fixed an issue where `nvh264enc` did not send SPS and PPS NAL units (by @mbattista).
### Bugs
- Fixed TCP mux occasional freeze by adding write buffer to it.
@ -23,6 +46,7 @@
- Disabled autolock for kde, so that it does not lock the screen when you are not using it.
- Refactored autoplay, so that it will start playing audio, if it's allowed by the browser (by @urbanekpj).
- Renamed pulseaudio sink from `auto_null` to `audio_output`, because it was ignored by KDE.
- Pulseaudio is now configured using environment variables, so that users can mount `/home/neko` without losing audio configuration.
## [n.eko v2.7](https://github.com/m1k1o/neko/releases/tag/v2.7)

View File

@ -10,7 +10,7 @@
## Server build dependencies
If you want to compile goalng code locally, you must install additional dependencies in order for it to compile.
If you want to compile Golang code locally, you must install additional dependencies in order for it to compile.
```shell
apt-get install -y --no-install-recommends libx11-dev libxrandr-dev libxtst-dev libgstreamer1.0-dev

View File

@ -50,7 +50,7 @@ All images are also available on [GitHub Container Registry](https://github.com/
- `ghcr.io/m1k1o/neko/xfce:latest`
- `ghcr.io/m1k1o/neko/kde:latest`
For ARM-based images (like Raspberry Pi - with GPU hardware acceleration, Oracle Cloud ARM tier). Currently, not all images are available for ARM, because not all applications are available for ARM.
For ARM-based images (like Raspberry Pi - with GPU hardware acceleration, Oracle Cloud ARM tier). Currently, not all images are available for ARM, because not all applications are available for ARM. Please note, that `m1k1o/neko:arm-*` images from dockerhub are currently not maintained and they can contain outdated software. Please use images below:
- `ghcr.io/m1k1o/neko/arm-firefox:latest`
- `ghcr.io/m1k1o/neko/arm-chromium:latest`
@ -74,8 +74,9 @@ For images with VAAPI GPU hardware acceleration using intel drivers use:
- `ghcr.io/m1k1o/neko/intel-xfce:latest`
- `ghcr.io/m1k1o/neko/intel-kde:latest`
For images with Nvidia GPU hardware acceleration using EGL use:
For images with Nvidia GPU hardware acceleration using EGL (see example below) use (please note, there is a known issue with EGL and Chromium-based browsers, see [here](https://github.com/m1k1o/neko/issues/279)):
- `ghcr.io/m1k1o/neko/nvidia-firefox:latest`
- `ghcr.io/m1k1o/neko/nvidia-chromium:latest`
- `ghcr.io/m1k1o/neko/nvidia-google-chrome:latest`
- `ghcr.io/m1k1o/neko/nvidia-microsoft-edge:latest`
@ -86,7 +87,48 @@ GHCR images are built using GitHub actions for every tag.
### Networking:
- If you want to use n.eko in **external** network, you can omit `NEKO_NAT1TO1`. It will automatically get your Public IP.
- If you want to use n.eko in **internal** network, set `NEKO_NAT1TO1` to your local IP address (e.g. `NEKO_NAT1TO1: 192.168.1.20`)-
- Currently, it is not supported to supply multiple NAT addresses (see https://github.com/m1k1o/neko/issues/47).
Currently, it is not supported to supply multiple NAT addresses directly to neko (see https://github.com/m1k1o/neko/issues/47).
But it can be acheived by deploying own turn server alongside neko that is accessible from your LAN:
```yaml
version: "3.4"
services:
neko:
image: "m1k1o/neko:firefox"
restart: "unless-stopped"
shm_size: "2gb"
ports:
- "8080:8080"
- "52000-52100:52000-52100/udp"
environment:
NEKO_SCREEN: 1920x1080@30
NEKO_PASSWORD: neko
NEKO_PASSWORD_ADMIN: admin
NEKO_EPR: 52000-52100
NEKO_ICESERVERS: '[{ "urls": [ "turn:192.168.1.60:3478" ], "username":"neko", "credential":"neko" }, { "urls": [ "stun:stun.nextcloud.com:3478" ] }]'
coturn:
image: 'coturn/coturn:latest'
network_mode: "host"
command: |
-n
--realm=localhost
--fingerprint
--listening-ip=0.0.0.0
--external-ip=192.168.1.60
--listening-port=3478
--min-port=49160
--max-port=49200
--log-file=stdout
--user=neko:neko
--lt-cred-mech
```
- Replace `192.168.1.60` with your LAN IP address, and allow ports `49160-49200/udp` and `3478/tcp` in your LAN.
- Make sure you don't use `NEKO_ICELITE: true` because ICE LITE does not support TURN servers.
This setup adds local turn server to neko. It won't be reachable by your remote clients and your own IP won't be reachable from your lan. So it effectively just adds local candidate and allows connections from LAN.
### Why so many ports?
- WebRTC needs UDP ports in order to transfer Audio/Video towards user and Mouse/Keyboard events to the server in real time.
@ -131,6 +173,17 @@ services:
- UDP is generally better for latency. But some networks block UDP so it is good to have TCP available as fallback.
- Still, using `NEKO_ICELITE=true` is recommended.
### Using turn servers instead of port forwarding
- If you don't want to use port forwarding, you can use turn servers.
- But you need to have your own turn server (e.g. [cotrun](https://github.com/coturn/coturn)) or have access to one.
- They are generally not free, because they require a lot of bandwidth.
- Please make sure that you correctly escape your turn server credentials in the environment variable or use aphostrophes.
```yaml
NEKO_ICESERVERS: '[{"urls": ["turn:<MY-COTURN-SERVER>:443?transport=udp", "turn:<MY-COTURN-SERVER>:443?transport=tcp", "turns:<MY-COTURN-SERVER>:443?transport=udp", "turns:<MY-COTURN-SERVER>:443?transport=tcp"], "credential": "<MY-COTURN-CREDENTIAL"}, {"urls": ["stun:stun.nextcloud.com:443"]}]'
```
### Want to customize and install own add-ons, set custom bookmarks?
- You would need to modify the existing policy file and mount it to your container.
- For Firefox, copy [this](https://github.com/m1k1o/neko/blob/master/.docker/firefox/policies.json) file, modify and mount it as: ` -v '${PWD}/policies.json:/usr/lib/firefox/distribution/policies.json'`
@ -164,7 +217,7 @@ services:
### Nvidia GPU acceleration
You need to have nvidia-docker installed, start the container with `--gpus all` flag and use images built for nvidia (see above).
You need to have [NVIDIA Container Toolkit](https://github.com/NVIDIA/nvidia-container-toolkit) installed, start the container with `--gpus all` flag and use images built for nvidia (see above).
```bash
docker run -d --gpus all \
@ -176,6 +229,8 @@ docker run -d --gpus all \
-e NEKO_EPR=56000-56100 \
-e NEKO_NAT1TO1=192.168.1.10 \
-e NEKO_ICELITE=1 \
-e NEKO_VIDEO_CODEC=h264 \
-e NEKO_HWENC=nvenc \
--shm-size=2gb \
--cap-add=SYS_ADMIN \
--name neko \
@ -202,6 +257,8 @@ services:
NEKO_PASSWORD_ADMIN: admin
NEKO_EPR: 56000-56100
NEKO_NAT1TO1: 192.168.1.10
NEKO_VIDEO_CODEC: h264
NEKO_HWENC: nvenc
deploy:
resources:
reservations:
@ -211,7 +268,15 @@ services:
capabilities: [gpu]
```
Note, currently only browser GPU acceleration is supported, not encoding.
- You can verify that GPU is available inside the container by running `docker exec -it neko nvidia-smi` command.
- You can verify that GPU is used for encoding by searching for `nvh264enc` in `docker logs neko` output.
- If you don'ŧ specify `NEKO_HWENC: nvenc` environment variable, CPU encoding will be used but GPU will still be available for browser rendering.
Broadcast pipeline is not hardware accelerated by default. You can use this pipeline created by [@evilalmus](https://github.com/m1k1o/neko/issues/276#issuecomment-1498362533).
```yaml
NEKO_BROADCAST_PIPELINE: "flvmux name=mux ! rtmpsink location={url} pulsesrc device={device} ! audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. ximagesrc display-name={display} show-pointer=false use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert ! queue ! video/x-raw,format=NV12 ! nvh264enc name=encoder preset=low-latency-hq gop-size=25 spatial-aq=true temporal-aq=true bitrate=2800 vbv-buffer-size=2800 rc-mode=6 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=high ! h264parse ! mux."
```
### Want to use VPN for your n.eko browsing?
- Check this out: https://github.com/m1k1o/neko-vpn
@ -231,6 +296,10 @@ Note, currently only browser GPU acceleration is supported, not encoding.
- Adding `?usr=<display-name>` will prefill username.
- Adding `?cast=1` will hide all control and show only video.
- Adding `?embed=1` will hide most additional components and show only video.
- Adding `?volume=<0-1>` will set volume to given value.
- Adding `?lang=<language>` will set language to given value.
- Adding `?show_side=1` will show the sidebar on startup.
- Adding `?mute_chat=1` will mute the chat on startup.
- e.g. `http(s)://<URL:Port>/?pwd=neko&usr=guest&cast=1`
### Screen size

View File

@ -79,13 +79,14 @@ nat1to1: <ip>
- `gstreamer1.0-plugins-good`
- `gstreamer1.0-plugins-bad`
- `gstreamer1.0-plugins-ugly`
- e.g. `ximagesrc display-name=%s show-pointer=true use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert ! queue ! video/x-raw,format=NV12 ! x264enc threads=4 bitrate=3500 key-int-max=60 vbv-buf-capacity=4000 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream`
- e.g. `ximagesrc display-name=%s show-pointer=true use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert ! queue ! video/x-raw,format=NV12 ! x264enc threads=4 bitrate=3500 key-int-max=60 vbv-buf-capacity=4000 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline`
#### `NEKO_MAX_FPS`:
- The resulting stream frames per seconds should be capped *(0 for uncapped)*.
- e.g. `0`
#### `NEKO_HWENC`:
- Use hardware accelerated encoding, for now supported only `VAAPI`.
- e.g. `VAAPI`
- none *(default CPU encoding)*
- vaapi
- nvenc
### Audio

View File

@ -72,7 +72,8 @@ services:
version: "3.4"
services:
neko:
image: "m1k1o/neko:arm-chromium"
# see docs for more variants
image: "ghcr.io/m1k1o/neko/arm-chromium:latest"
restart: "unless-stopped"
# increase on rpi's with more then 1gb ram.
shm_size: "520mb"
@ -95,9 +96,9 @@ services:
! videoconvert
! queue
! video/x-raw,framerate=30/1,format=NV12
! v4l2h264enc extra-controls="controls,h264_profile=0,video_bitrate=1250000;"
! v4l2h264enc extra-controls="controls,h264_profile=1,video_bitrate=1250000;"
! h264parse config-interval=3
! video/x-h264,profile=baseline,stream-format=byte-stream
! video/x-h264,stream-format=byte-stream,profile=constrained-baseline
NEKO_VIDEO_CODEC: h264
```

View File

@ -0,0 +1,57 @@
# Frequently Asked Questions
## How to enable debug mode?
To see verbose information from n.eko server, you can enable debug mode using `NEKO_DEBUG`.
```diff
version: "3.4"
services:
neko:
image: "m1k1o/neko:firefox"
restart: "unless-stopped"
shm_size: "2gb"
ports:
- "8080:8080"
- "52000-52100:52000-52100/udp"
environment:
NEKO_SCREEN: 1920x1080@30
NEKO_PASSWORD: neko
NEKO_PASSWORD_ADMIN: admin
NEKO_EPR: 52000-52100
NEKO_ICELITE: 1
+ NEKO_DEBUG: 1
```
Ensure, that you have enabled debug mode in javascript console too, in order to see verbose information from client.
## Chinese input method is not working
There exists an extension for Chrome that allows you to use Chinese input method. You can install it from [here](https://chrome.google.com/webstore/detail/mclkkofklkfljcocdinagocijmpgbhab). Alternatively, you can use Google Input Tools from [here](https://www.google.com/inputtools/chrome/).
## Only black screen is displayed but remote cursor is moving for Chromium-based browsers (Chrome, Edge, etc.)
Check if you did not forget to add cap_add to your docker-compose file.
```yaml
cap_add:
- SYS_ADMIN
```
## How can I embed the Neko desktop into web page without login prompt coming up for viewers?
You can use the following URL to embed the Neko desktop into a web page without login prompt coming up for viewers:
```
http://<your-neko-server-ip>:8080/?usr=neko&pwd=neko
```
https://stackoverflow.com/questions/15276929/how-to-make-a-video-fullscreen-when-it-is-placed-inside-an-iframe
Your iframe needs an attribute: `allowfullscreen="true" webkitallowfullscreen="true" mozallowfullscreen="true"` or more modern `allow="fullscreen *"`. For the second you can remove the star if your iframe has the same origin or replace it with your iframe origin.
## Can I use neko without docker?
It is strongly recommended to use Neko with Docker, as it is the easiest way to run it. But you should be able to install Neko "natively" on your host system. Neko is based on Debian and uses Xorg and Pulseaudio. You would just need to follow steps that are in Dockerfile, install all dependencies on your host system and then just run it.
However, it is recommend to start with existing system that has GUI with desktop manager, is based on Xorg and uses Pulseaudio (e.g. Ubuntu Desktop 22.04). For that matter you only need to install gstreamer dependencies, configure pulseaudio properly and run neko binary (you don't need to build it from scratch, you can copy it from docker image).

View File

@ -94,7 +94,7 @@ services:
+ NEKO_IPFETCH: https://ifconfig.co/ip
```
Or you can specify your IP address manually using `NEKO_NAT1TO1`:
Or you can specify your IP address manually using `NEKO_NAT1TO1`: (It's read as NAT 1 to 1, so it's capital letter 'O', not zero '0', in NAT1`TO`1)
```diff
version: "3.4"
@ -129,6 +129,7 @@ Example for pfsense with truecharts docker container:
- Test externally to confirm it works.
- Internally you have to access it using `<your-public-ip>:port`
If your router does not support NAT Loopback (NAT Hairpinning), you can use turn servers to overcome this issue. See [more details here](https://neko.m1k1o.net/#/getting-started/?id=networking) on how to setup local coturn instance.
### Neko works locally, but not externally
@ -210,3 +211,17 @@ Most likely you forgot to add `-cap-add=SYS_ADMIN` when using chromium-based bro
```
This error originates from browser, that it could not connect to dbus. This does not affect us and can be ignored.
### Broadcast pipeline not working with some ingest servers
See [related issue](https://github.com/m1k1o/neko/issues/276).
```
Could not connect to RTMP stream "'rtmp://<ingest-url>/live/<stream-key-removed> live=1'" for writing
```
Some ingest servers require `live=1` parameter in the URL (e.g. nginx-rtmp-module). Some do not and do not accept aphostrophes (e.g. owncast). You can try to change the pipeline to:
```yaml
NEKO_BROADCAST_PIPELINE: "flvmux name=mux ! rtmpsink location={url} pulsesrc device={device} ! audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. ximagesrc display-name={display} show-pointer=false use-damage=false ! video/x-raw,framerate=28/1 ! videoconvert ! queue ! x264enc bframes=0 key-int-max=0 byte-stream=true tune=zerolatency speed-preset=veryfast ! mux."
```

View File

@ -1,24 +1,35 @@
#!/bin/bash
set -ex
#
# aborting if any command returns a non-zero value
set -e
BUILD_TIME=`date -u +'%Y-%m-%dT%H:%M:%SZ'`
#
# set git build variables if git exists
if git status > /dev/null 2>&1 && [ -z $GIT_COMMIT ] && [ -z $GIT_BRANCH ] && [ -z $GIT_DIRTY ];
if git status > /dev/null 2>&1 && [ -z $GIT_COMMIT ] && [ -z $GIT_BRANCH ] && [ -z $GIT_TAG ];
then
GIT_COMMIT=`git rev-parse --short HEAD`
GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD`
GIT_TAG=`git tag --points-at $GIT_COMMIT | head -n 1`
GIT_DIRTY=`git diff-index --quiet HEAD -- || echo "✗-"`
GIT_COMMIT="${GIT_DIRTY}${GIT_COMMIT}"
fi
#
# load dependencies
go get -v -t -d .
#
# build server
go build \
-o bin/neko \
-ldflags "
-s -w
-X 'm1k1o/neko.buildDate=${BUILD_TIME}'
-X 'm1k1o/neko.gitCommit=${GIT_DIRTY}${GIT_COMMIT}'
-X 'm1k1o/neko.gitCommit=${GIT_COMMIT}'
-X 'm1k1o/neko.gitBranch=${GIT_BRANCH}'
-X 'm1k1o/neko.gitTag=${GIT_TAG}'
" \
cmd/neko/main.go;

View File

@ -4,7 +4,7 @@ go 1.20
require (
github.com/fsnotify/fsnotify v1.6.0
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.0
github.com/pion/ice/v2 v2.3.0

View File

@ -63,8 +63,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=

View File

@ -3,8 +3,8 @@
static void gstreamer_pipeline_log(GstPipelineCtx *ctx, char* level, const char* format, ...) {
va_list argptr;
va_start(argptr, format);
char buffer[100];
vsprintf(buffer, format, argptr);
char buffer[4096];
vsnprintf(buffer, sizeof(buffer), format, argptr);
va_end(argptr);
goPipelineLog(level, buffer, ctx->pipelineId);
}

View File

@ -31,12 +31,29 @@ var pSerial int32
var pipelines = make(map[int]*Pipeline)
var pipelinesLock sync.Mutex
var registry *C.GstRegistry
var gMainLoop *C.GMainLoop
func init() {
C.gst_init(nil, nil)
registry = C.gst_registry_get()
}
func RunMainLoop() {
if gMainLoop != nil {
return
}
gMainLoop = C.g_main_loop_new(nil, C.int(0))
C.g_main_loop_run(gMainLoop)
}
func QuitMainLoop() {
if gMainLoop == nil {
return
}
C.g_main_loop_quit(gMainLoop)
gMainLoop = nil
}
func CreatePipeline(pipelineStr string) (*Pipeline, error) {
id := atomic.AddInt32(&pSerial, 1)

View File

@ -6,6 +6,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/types"
)
@ -53,6 +54,7 @@ func (manager *CaptureManagerCtx) Start() {
}
}
go gst.RunMainLoop()
go func() {
for {
before, ok := <-manager.desktop.GetScreenSizeChangeChannel()
@ -100,6 +102,8 @@ func (manager *CaptureManagerCtx) Shutdown() error {
manager.audio.shutdown()
manager.video.shutdown()
gst.QuitMainLoop()
return nil
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/types/codec"
)
@ -52,7 +53,7 @@ func NewBroadcastPipeline(device string, display string, pipelineSrc string, url
return pipelineStr, nil
}
func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc string, fps int16, bitrate uint, hwenc string) (string, error) {
func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc string, fps int16, bitrate uint, hwenc config.HwEnc) (string, error) {
pipelineStr := " ! appsink name=appsinkvideo"
// if using custom pipeline
@ -68,7 +69,7 @@ func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc strin
switch rtpCodec.Name {
case codec.VP8().Name:
if hwenc == "VAAPI" {
if hwenc == config.HwEncVAAPI {
if err := gst.CheckPlugins([]string{"ximagesrc", "vaapi"}); err != nil {
return "", err
}
@ -138,35 +139,40 @@ func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc strin
return "", err
}
if hwenc == "VAAPI" {
vbvbuf := uint(1000)
if bitrate > 1000 {
vbvbuf = bitrate
}
if hwenc == config.HwEncVAAPI {
if err := gst.CheckPlugins([]string{"vaapi"}); err != nil {
return "", err
}
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! vaapih264enc rate-control=vbr bitrate=%d keyframe-period=180 quality-level=7 ! video/x-h264,stream-format=byte-stream"+pipelineStr, display, fps, bitrate)
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! vaapih264enc rate-control=vbr bitrate=%d keyframe-period=180 quality-level=7 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"+pipelineStr, display, fps, bitrate)
} else if hwenc == config.HwEncNVENC {
if err := gst.CheckPlugins([]string{"nvcodec"}); err != nil {
return "", err
}
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! nvh264enc name=encoder preset=2 gop-size=25 spatial-aq=true temporal-aq=true bitrate=%d vbv-buffer-size=%d rc-mode=6 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"+pipelineStr, display, fps, bitrate, vbvbuf)
} else {
// https://gstreamer.freedesktop.org/documentation/openh264/openh264enc.html?gi-language=c#openh264enc
// gstreamer1.0-plugins-bad
// openh264enc multi-thread=4 complexity=high bitrate=3072000 max-bitrate=4096000
if err := gst.CheckPlugins([]string{"openh264"}); err == nil {
pipelineStr = fmt.Sprintf(videoSrc+"openh264enc multi-thread=4 complexity=high bitrate=%d max-bitrate=%d ! video/x-h264,stream-format=byte-stream"+pipelineStr, display, fps, bitrate*1000, (bitrate+1024)*1000)
pipelineStr = fmt.Sprintf(videoSrc+"openh264enc multi-thread=4 complexity=high bitrate=%d max-bitrate=%d ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"+pipelineStr, display, fps, bitrate*1000, (bitrate+1024)*1000)
break
}
// https://gstreamer.freedesktop.org/documentation/x264/index.html?gi-language=c
// gstreamer1.0-plugins-ugly
// video/x-raw,format=I420 ! x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream
// video/x-raw,format=I420 ! x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline
if err := gst.CheckPlugins([]string{"x264"}); err != nil {
return "", err
}
vbvbuf := uint(1000)
if bitrate > 1000 {
vbvbuf = bitrate
}
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! x264enc threads=4 bitrate=%d key-int-max=60 vbv-buf-capacity=%d byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream"+pipelineStr, display, fps, bitrate, vbvbuf)
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! x264enc threads=4 bitrate=%d key-int-max=60 vbv-buf-capacity=%d byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"+pipelineStr, display, fps, bitrate, vbvbuf)
}
default:
return "", fmt.Errorf("unknown codec %s", rtpCodec.Name)

View File

@ -2,6 +2,7 @@ package config
import (
"m1k1o/neko/internal/types/codec"
"strings"
"github.com/pion/webrtc/v3"
"github.com/rs/zerolog/log"
@ -9,13 +10,21 @@ import (
"github.com/spf13/viper"
)
type HwEnc int
const (
HwEncNone HwEnc = iota
HwEncVAAPI
HwEncNVENC
)
type Capture struct {
// video
Display string
VideoCodec codec.RTPCodec
VideoHWEnc string // TODO: Pipeline builder.
VideoBitrate uint // TODO: Pipeline builder.
VideoMaxFPS int16 // TODO: Pipeline builder.
VideoHWEnc HwEnc // TODO: Pipeline builder.
VideoBitrate uint // TODO: Pipeline builder.
VideoMaxFPS int16 // TODO: Pipeline builder.
VideoPipeline string
// audio
@ -184,11 +193,19 @@ func (s *Capture) Set() {
log.Warn().Msg("you are using deprecated config setting 'NEKO_AV1=true', use 'NEKO_VIDEO_CODEC=av1' instead")
}
videoHWEnc := ""
if viper.GetString("hwenc") == "VAAPI" {
videoHWEnc = "VAAPI"
videoHWEnc := strings.ToLower(viper.GetString("hwenc"))
switch videoHWEnc {
case "":
fallthrough
case "none":
s.VideoHWEnc = HwEncNone
case "vaapi":
s.VideoHWEnc = HwEncVAAPI
case "nvenc":
s.VideoHWEnc = HwEncNVENC
default:
log.Warn().Str("hwenc", videoHWEnc).Msgf("unknown video hw encoder, using CPU")
}
s.VideoHWEnc = videoHWEnc
s.VideoBitrate = viper.GetUint("video_bitrate")
s.VideoMaxFPS = int16(viper.GetInt("max_fps"))

View File

@ -14,6 +14,7 @@ type Server struct {
Cert string
Key string
Bind string
Proxy bool
Static string
PathPrefix string
CORS []string
@ -35,6 +36,11 @@ func (Server) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode")
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
return err
}
cmd.PersistentFlags().String("static", "./www", "path to neko client files to serve")
if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil {
return err
@ -57,6 +63,7 @@ func (s *Server) Set() {
s.Cert = viper.GetString("cert")
s.Key = viper.GetString("key")
s.Bind = viper.GetString("bind")
s.Proxy = viper.GetBool("proxy")
s.Static = viper.GetString("static")
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))

View File

@ -1,8 +1,6 @@
package config
import (
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -10,13 +8,6 @@ import (
type WebSocket struct {
Password string
AdminPassword string
Proxy bool
Locks []string
ControlProtection bool
FileTransferEnabled bool
FileTransferPath string
}
func (WebSocket) Init(cmd *cobra.Command) error {
@ -30,45 +21,10 @@ func (WebSocket) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode")
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("locks", []string{}, "resources, that will be locked when starting (control, login)")
if err := viper.BindPFlag("locks", cmd.PersistentFlags().Lookup("locks")); err != nil {
return err
}
cmd.PersistentFlags().Bool("control_protection", false, "control protection means, users can gain control only if at least one admin is in the room")
if err := viper.BindPFlag("control_protection", cmd.PersistentFlags().Lookup("control_protection")); err != nil {
return err
}
// File transfer
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
if err := viper.BindPFlag("file_transfer_enabled", cmd.PersistentFlags().Lookup("file_transfer_enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("file_transfer_path", "/home/neko/Downloads", "path to use for file transfer")
if err := viper.BindPFlag("file_transfer_path", cmd.PersistentFlags().Lookup("file_transfer_path")); err != nil {
return err
}
return nil
}
func (s *WebSocket) Set() {
s.Password = viper.GetString("password")
s.AdminPassword = viper.GetString("password_admin")
s.Proxy = viper.GetBool("proxy")
s.Locks = viper.GetStringSlice("locks")
s.ControlProtection = viper.GetBool("control_protection")
s.FileTransferEnabled = viper.GetBool("file_transfer_enabled")
s.FileTransferPath = viper.GetString("file_transfer_path")
s.FileTransferPath = filepath.Clean(s.FileTransferPath)
}

View File

@ -3,16 +3,13 @@ package http
import (
"context"
"encoding/json"
"fmt"
"image/jpeg"
"io"
"net/http"
"os"
"regexp"
"strconv"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@ -21,8 +18,6 @@ import (
"m1k1o/neko/internal/types"
)
const FILE_UPLOAD_BUF_SIZE = 65000
type Server struct {
logger zerolog.Logger
router *chi.Mux
@ -35,6 +30,9 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
router := chi.NewRouter()
router.Use(middleware.RequestID) // Create a request ID for each request
if conf.Proxy {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestLogger(&logformatter{logger}))
router.Use(middleware.Recoverer) // Recover from panics without crashing server
router.Use(middleware.Compress(5, "application/octet-stream"))
@ -95,11 +93,6 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
return
}
if webSocketHandler.IsLocked("login") {
http.Error(w, "room is locked", http.StatusLocked)
return
}
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
if err != nil {
quality = 90
@ -115,78 +108,6 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
}
})
// allow downloading and uploading files
if webSocketHandler.FileTransferEnabled() {
router.Get("/file", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAuthorized {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
filename := r.URL.Query().Get("filename")
badChars, _ := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename)
if filename == "" || badChars {
http.Error(w, "bad filename", http.StatusBadRequest)
return
}
filePath := webSocketHandler.FileTransferPath(filename)
f, err := os.Open(filePath)
if err != nil {
http.Error(w, "not found or unable to open", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
io.Copy(w, f)
})
router.Post("/file", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAuthorized {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
r.ParseMultipartForm(32 << 20)
for _, formheader := range r.MultipartForm.File["files"] {
filePath := webSocketHandler.FileTransferPath(formheader.Filename)
formfile, err := formheader.Open()
if err != nil {
logger.Warn().Err(err).Msg("failed to open formdata file")
http.Error(w, "error writing file", http.StatusInternalServerError)
return
}
defer formfile.Close()
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
http.Error(w, "unable to open file for writing", http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(f, formfile)
}
})
}
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("true"))
})

View File

@ -5,7 +5,7 @@ import (
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
)

View File

@ -28,8 +28,6 @@ type SessionManager struct {
capture types.CaptureManager
members map[string]*Session
eventsChannel chan types.SessionEvent
// TODO: Handle locks in sessions as flags.
controlLocked bool
}
func (manager *SessionManager) New(id string, admin bool, socket types.WebSocket) types.Session {
@ -118,16 +116,6 @@ func (manager *SessionManager) Get(id string) (types.Session, bool) {
return session, ok
}
// TODO: Handle locks in sessions as flags.
func (manager *SessionManager) SetControlLocked(locked bool) {
manager.controlLocked = locked
}
func (manager *SessionManager) CanControl(id string) bool {
session, ok := manager.Get(id)
return ok && (!manager.controlLocked || session.Admin())
}
func (manager *SessionManager) Admins() []*types.Member {
manager.mu.Lock()
defer manager.mu.Unlock()

View File

@ -34,11 +34,6 @@ const (
CHAT_EMOTE = "chat/emote"
)
const (
FILETRANSFER_LIST = "filetransfer/list"
FILETRANSFER_REFRESH = "filetransfer/refresh"
)
const (
SCREEN_CONFIGURATIONS = "screen/configurations"
SCREEN_RESOLUTION = "screen/resolution"
@ -54,9 +49,7 @@ const (
const (
ADMIN_BAN = "admin/ban"
ADMIN_KICK = "admin/kick"
ADMIN_LOCK = "admin/lock"
ADMIN_MUTE = "admin/mute"
ADMIN_UNLOCK = "admin/unlock"
ADMIN_UNMUTE = "admin/unmute"
ADMIN_CONTROL = "admin/control"
ADMIN_RELEASE = "admin/release"

View File

@ -11,10 +11,8 @@ type Message struct {
}
type SystemInit struct {
Event string `json:"event"`
ImplicitHosting bool `json:"implicit_hosting"`
Locks map[string]string `json:"locks"`
FileTransfer bool `json:"file_transfer"`
Event string `json:"event"`
ImplicitHosting bool `json:"implicit_hosting"`
}
type SystemMessage struct {
@ -107,12 +105,6 @@ type EmoteSend struct {
Emote string `json:"emote"`
}
type FileTransferList struct {
Event string `json:"event"`
Cwd string `json:"cwd"`
Files []types.FileListItem `json:"files"`
}
type Admin struct {
Event string `json:"event"`
ID string `json:"id"`

View File

@ -55,8 +55,6 @@ type SessionManager interface {
ClearHost()
Has(id string) bool
Get(id string) (Session, bool)
SetControlLocked(locked bool)
CanControl(id string) bool
Members() []*Member
Admins() []*Member
Destroy(id string)

View File

@ -11,14 +11,12 @@ type Stats struct {
Members []*Member `json:"members"`
Banned map[string]string `json:"banned"` // IP -> session ID (that banned it)
Locked map[string]string `json:"locked"` // resource name -> session ID (that locked it)
ServerStartedAt time.Time `json:"server_started_at"`
LastAdminLeftAt *time.Time `json:"last_admin_left_at"`
LastUserLeftAt *time.Time `json:"last_user_left_at"`
ControlProtection bool `json:"control_protection"`
ImplicitControl bool `json:"implicit_control"`
ImplicitControl bool `json:"implicit_control"`
}
type WebSocket interface {
@ -32,17 +30,5 @@ type WebSocketHandler interface {
Shutdown() error
Upgrade(w http.ResponseWriter, r *http.Request) error
Stats() Stats
IsLocked(resource string) bool
IsAdmin(password string) (bool, error)
// File Transfer
CanTransferFiles(password string) (bool, error)
FileTransferPath(filename string) string
FileTransferEnabled() bool
}
type FileListItem struct {
Filename string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
}

View File

@ -1,36 +0,0 @@
package utils
import (
"os"
"m1k1o/neko/internal/types"
)
func ListFiles(path string) ([]types.FileListItem, error) {
items, err := os.ReadDir(path)
if err != nil {
return nil, err
}
out := make([]types.FileListItem, len(items))
for i, item := range items {
var itemType string = ""
var size int64 = 0
if item.IsDir() {
itemType = "dir"
} else {
itemType = "file"
info, err := item.Info()
if err == nil {
size = info.Size()
}
}
out[i] = types.FileListItem{
Filename: item.Name(),
Type: itemType,
Size: size,
}
}
return out, nil
}

View File

@ -2,7 +2,7 @@ package utils
import (
"bytes"
"io/ioutil"
"io"
"net"
"net/http"
"time"
@ -31,21 +31,10 @@ func GetIP(serverUrl string) (string, error) {
}
defer rsp.Body.Close()
buf, err := ioutil.ReadAll(rsp.Body)
buf, err := io.ReadAll(rsp.Body)
if err != nil {
return "", err
}
return string(bytes.TrimSpace(buf)), nil
}
func GetHttpRequestIP(r *http.Request, proxy bool) string {
IPAddress := r.Header.Get("X-Real-Ip")
if IPAddress == "" {
IPAddress = r.Header.Get("X-Forwarded-For")
}
if IPAddress == "" || !proxy {
IPAddress = r.RemoteAddr
}
return IPAddress
}

View File

@ -39,7 +39,7 @@ type PayloadKey struct {
}
func (manager *WebRTCManager) handle(id string, msg webrtc.DataChannelMessage) error {
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || (manager.config.ImplicitControl && !manager.sessions.CanControl(id)) {
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || manager.config.ImplicitControl {
return nil
}

View File

@ -8,78 +8,6 @@ import (
"m1k1o/neko/internal/types/message"
)
func (h *MessageHandler) adminLock(id string, session types.Session, payload *message.AdminLock) error {
if !session.Admin() {
h.logger.Debug().Msg("user not admin")
return nil
}
if h.state.IsLocked(payload.Resource) {
h.logger.Debug().Str("resource", payload.Resource).Msg("resource already locked...")
return nil
}
// allow only known resources
switch payload.Resource {
case "login":
case "control":
case "file_transfer":
default:
h.logger.Debug().Msg("unknown lock resource")
return nil
}
// TODO: Handle locks in sessions as flags.
if payload.Resource == "control" {
h.sessions.SetControlLocked(true)
}
h.state.Lock(payload.Resource, id)
if err := h.sessions.Broadcast(
message.AdminLock{
Event: event.ADMIN_LOCK,
ID: id,
Resource: payload.Resource,
}, nil); err != nil {
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
return err
}
return nil
}
func (h *MessageHandler) adminUnlock(id string, session types.Session, payload *message.AdminLock) error {
if !session.Admin() {
h.logger.Debug().Msg("user not admin")
return nil
}
if !h.state.IsLocked(payload.Resource) {
h.logger.Debug().Str("resource", payload.Resource).Msg("resource not locked...")
return nil
}
// TODO: Handle locks in sessions as flags.
if payload.Resource == "control" {
h.sessions.SetControlLocked(false)
}
h.state.Unlock(payload.Resource)
if err := h.sessions.Broadcast(
message.AdminLock{
Event: event.ADMIN_UNLOCK,
ID: id,
Resource: payload.Resource,
}, nil); err != nil {
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
return err
}
return nil
}
func (h *MessageHandler) adminControl(id string, session types.Session) error {
if !session.Admin() {
h.logger.Debug().Msg("user not admin")

View File

@ -33,12 +33,6 @@ func (h *MessageHandler) controlRelease(id string, session types.Session) error
func (h *MessageHandler) controlRequest(id string, session types.Session) error {
// check for host
if !h.sessions.HasHost() {
// check if control is locked or user is admin
if h.state.IsLocked("control") && !session.Admin() {
h.logger.Debug().Msg("control is locked")
return nil
}
// set host
err := h.sessions.SetHost(id)
if err != nil {
@ -96,12 +90,6 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
return nil
}
// check if control is locked or giver is admin
if h.state.IsLocked("control") && !session.Admin() {
h.logger.Debug().Msg("control is locked")
return nil
}
// set host
err := h.sessions.SetHost(payload.ID)
if err != nil {
@ -124,7 +112,7 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
func (h *MessageHandler) controlClipboard(id string, session types.Session, payload *message.Clipboard) error {
// check if session can access clipboard
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
h.logger.Debug().Str("id", id).Msg("cannot access clipboard")
return nil
}
@ -135,7 +123,7 @@ func (h *MessageHandler) controlClipboard(id string, session types.Session, payl
func (h *MessageHandler) controlKeyboard(id string, session types.Session, payload *message.Keyboard) error {
// check if session can control keyboard
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
h.logger.Debug().Str("id", id).Msg("cannot control keyboard")
return nil
}

View File

@ -1,47 +0,0 @@
package handler
import (
"m1k1o/neko/internal/types"
"m1k1o/neko/internal/types/event"
"m1k1o/neko/internal/types/message"
"m1k1o/neko/internal/utils"
)
func (h *MessageHandler) FileTransferRefresh(session types.Session) error {
if !h.state.FileTransferEnabled() {
return nil
}
fileTransferPath := h.state.FileTransferPath("") // root
// allow users only if file transfer is not locked
if session != nil && !(session.Admin() || !h.state.IsLocked("file_transfer")) {
h.logger.Debug().Msg("file transfer is locked for users")
return nil
}
// TODO: keep list of files in memory and update it on file changes
files, err := utils.ListFiles(fileTransferPath)
if err != nil {
return err
}
message := message.FileTransferList{
Event: event.FILETRANSFER_LIST,
Cwd: fileTransferPath,
Files: files,
}
// send to just one user
if session != nil {
return session.Send(message)
}
// broadcast to all admins
if h.state.IsLocked("file_transfer") {
return h.sessions.AdminBroadcast(message, nil)
}
// broadcast to all users
return h.sessions.Broadcast(message, nil)
}

View File

@ -50,11 +50,6 @@ func (h *MessageHandler) Connected(admin bool, address string) (bool, string) {
}
}
if h.state.IsLocked("login") && !admin {
h.logger.Debug().Msg("server locked")
return false, "locked"
}
return true, ""
}
@ -132,10 +127,6 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
return h.chatEmote(id, session, payload)
}), "%s failed", header.Event)
// File Transfer Events
case event.FILETRANSFER_REFRESH:
return errors.Wrapf(h.FileTransferRefresh(session), "%s failed", header.Event)
// Screen Events
case event.SCREEN_RESOLUTION:
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
@ -159,18 +150,6 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
return errors.Wrapf(h.boradcastDestroy(session), "%s failed", header.Event)
// Admin Events
case event.ADMIN_LOCK:
payload := &message.AdminLock{}
return errors.Wrapf(
utils.Unmarshal(payload, raw, func() error {
return h.adminLock(id, session, payload)
}), "%s failed", header.Event)
case event.ADMIN_UNLOCK:
payload := &message.AdminLock{}
return errors.Wrapf(
utils.Unmarshal(payload, raw, func() error {
return h.adminUnlock(id, session, payload)
}), "%s failed", header.Event)
case event.ADMIN_CONTROL:
return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event)
case event.ADMIN_RELEASE:

View File

@ -16,8 +16,6 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
if err := session.Send(message.SystemInit{
Event: event.SYSTEM_INIT,
ImplicitHosting: h.webrtc.ImplicitControl(),
Locks: h.state.AllLocked(),
FileTransfer: h.state.FileTransferEnabled(),
}); err != nil {
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.SYSTEM_INIT)
return err
@ -35,13 +33,6 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
}
}
// send file list if file transfer is enabled
if h.state.FileTransferEnabled() && (session.Admin() || !h.state.IsLocked("file_transfer")) {
if err := h.FileTransferRefresh(session); err != nil {
return err
}
}
return nil
}

View File

@ -1,22 +1,14 @@
package state
import "path/filepath"
type State struct {
banned map[string]string // IP -> session ID (that banned it)
locked map[string]string // resource name -> session ID (that locked it)
fileTransferEnabled bool
fileTransferPath string // path where files are located
}
func New(fileTransferEnabled bool, fileTransferPath string) *State {
func New() *State {
return &State{
banned: make(map[string]string),
locked: make(map[string]string),
fileTransferEnabled: fileTransferEnabled,
fileTransferPath: fileTransferPath,
}
}
@ -43,42 +35,3 @@ func (s *State) GetBanned(ip string) (string, bool) {
func (s *State) AllBanned() map[string]string {
return s.banned
}
// Lock
func (s *State) Lock(resource, id string) {
s.locked[resource] = id
}
func (s *State) Unlock(resource string) {
delete(s.locked, resource)
}
func (s *State) IsLocked(resource string) bool {
_, ok := s.locked[resource]
return ok
}
func (s *State) GetLocked(resource string) (string, bool) {
id, ok := s.locked[resource]
return id, ok
}
func (s *State) AllLocked() map[string]string {
return s.locked
}
// File transfer
func (s *State) FileTransferPath(filename string) string {
if filename == "" {
return s.fileTransferPath
}
cleanPath := filepath.Clean(filename)
return filepath.Join(s.fileTransferPath, cleanPath)
}
func (s *State) FileTransferEnabled() bool {
return s.fileTransferEnabled
}

View File

@ -3,12 +3,10 @@ package websocket
import (
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@ -22,35 +20,10 @@ import (
"m1k1o/neko/internal/websocket/state"
)
const CONTROL_PROTECTION_SESSION = "by_control_protection"
func New(sessions types.SessionManager, desktop types.DesktopManager, capture types.CaptureManager, webrtc types.WebRTCManager, conf *config.WebSocket) *WebSocketHandler {
logger := log.With().Str("module", "websocket").Logger()
state := state.New(conf.FileTransferEnabled, conf.FileTransferPath)
// if control protection is enabled
if conf.ControlProtection {
state.Lock("control", CONTROL_PROTECTION_SESSION)
logger.Info().Msgf("control locked on behalf of control protection")
}
// create file transfer directory if not exists
if conf.FileTransferEnabled {
if _, err := os.Stat(conf.FileTransferPath); os.IsNotExist(err) {
err = os.Mkdir(conf.FileTransferPath, os.ModePerm)
logger.Err(err).Msg("creating file transfer directory")
}
}
// apply default locks
for _, lock := range conf.Locks {
state.Lock(lock, "") // empty session ID
}
if len(conf.Locks) > 0 {
logger.Info().Msgf("locked resources: %+v", conf.Locks)
}
state := state.New()
handler := handler.New(
sessions,
@ -123,24 +96,6 @@ func (ws *WebSocketHandler) Start() {
ws.logger.Debug().Str("id", e.Id).Msg("session connected")
}
// if control protection is enabled and at least one admin
// and if room was locked on behalf control protection, unlock
sess, ok := ws.state.GetLocked("control")
if ok && ws.conf.ControlProtection && sess == CONTROL_PROTECTION_SESSION && len(ws.sessions.Admins()) > 0 {
ws.state.Unlock("control")
ws.sessions.SetControlLocked(false) // TODO: Handle locks in sessions as flags.
ws.logger.Info().Msgf("control unlocked on behalf of control protection")
if err := ws.sessions.Broadcast(
message.AdminLock{
Event: event.ADMIN_UNLOCK,
ID: e.Id,
Resource: "control",
}, nil); err != nil {
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
}
}
// remove outdated stats
if e.Session.Admin() {
ws.lastAdminLeftAt = nil
@ -157,25 +112,6 @@ func (ws *WebSocketHandler) Start() {
membersCount := len(ws.sessions.Members())
adminCount := len(ws.sessions.Admins())
// if control protection is enabled and no admin
// and room is not locked, lock
ok := ws.state.IsLocked("control")
if !ok && ws.conf.ControlProtection && adminCount == 0 {
ws.state.Lock("control", CONTROL_PROTECTION_SESSION)
ws.sessions.SetControlLocked(true) // TODO: Handle locks in sessions as flags.
ws.logger.Info().Msgf("control locked and released on behalf of control protection")
ws.handler.AdminRelease(e.Id, e.Session)
if err := ws.sessions.Broadcast(
message.AdminLock{
Event: event.ADMIN_LOCK,
ID: e.Id,
Resource: "control",
}, nil); err != nil {
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
}
}
// if this was the last admin
if e.Session.Admin() && adminCount == 0 {
now := time.Now()
@ -216,37 +152,6 @@ func (ws *WebSocketHandler) Start() {
ws.logger.Err(err).Msg("sync clipboard")
}
}()
// watch for file changes and send file list if file transfer is enabled
if ws.conf.FileTransferEnabled {
watcher, err := fsnotify.NewWatcher()
if err != nil {
ws.logger.Err(err).Msg("unable to start file transfer dir watcher")
return
}
go func() {
for {
select {
case e, ok := <-watcher.Events:
if !ok {
ws.logger.Info().Msg("file transfer dir watcher closed")
return
}
if e.Has(fsnotify.Create) || e.Has(fsnotify.Remove) || e.Has(fsnotify.Rename) {
ws.logger.Debug().Str("event", e.String()).Msg("file transfer dir watcher event")
ws.handler.FileTransferRefresh(nil)
}
case err := <-watcher.Errors:
ws.logger.Err(err).Msg("error in file transfer dir watcher")
}
}
}()
if err := watcher.Add(ws.conf.FileTransferPath); err != nil {
ws.logger.Err(err).Msg("unable to add file transfer path to watcher")
}
}
}
func (ws *WebSocketHandler) Shutdown() error {
@ -290,7 +195,7 @@ func (ws *WebSocketHandler) Upgrade(w http.ResponseWriter, r *http.Request) erro
socket := &WebSocket{
id: id,
ws: ws,
address: utils.GetHttpRequestIP(r, ws.conf.Proxy),
address: r.RemoteAddr,
connection: connection,
}
@ -347,21 +252,15 @@ func (ws *WebSocketHandler) Stats() types.Stats {
Members: ws.sessions.Members(),
Banned: ws.state.AllBanned(),
Locked: ws.state.AllLocked(),
ServerStartedAt: ws.serverStartedAt,
LastAdminLeftAt: ws.lastAdminLeftAt,
LastUserLeftAt: ws.lastUserLeftAt,
ControlProtection: ws.conf.ControlProtection,
ImplicitControl: ws.webrtc.ImplicitControl(),
ImplicitControl: ws.webrtc.ImplicitControl(),
}
}
func (ws *WebSocketHandler) IsLocked(resource string) bool {
return ws.state.IsLocked(resource)
}
func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
if password == ws.conf.AdminPassword {
return true, nil
@ -444,28 +343,3 @@ func (ws *WebSocketHandler) handle(connection *websocket.Conn, id string) {
}
}
}
//
// File transfer
//
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
if !ws.conf.FileTransferEnabled {
return false, nil
}
isAdmin, err := ws.IsAdmin(password)
if err != nil {
return false, err
}
return isAdmin || !ws.state.IsLocked("file_transfer"), nil
}
func (ws *WebSocketHandler) FileTransferPath(filename string) string {
return ws.state.FileTransferPath(filename)
}
func (ws *WebSocketHandler) FileTransferEnabled() bool {
return ws.conf.FileTransferEnabled
}

View File

@ -5,6 +5,7 @@ import (
"os"
"os/signal"
"runtime"
"strings"
"m1k1o/neko/internal/capture"
"m1k1o/neko/internal/config"
@ -25,7 +26,7 @@ const Header = `&34
/ |/ / _ \/ //_/ __ \ ) ( ')
/ /| / __/ ,< / /_/ / ( / )
/_/ |_/\___/_/|_|\____/ \(__)|
&1&37 nurdism/m1k1o &33%s v%s&0
&1&37 nurdism/m1k1o &33%s %s&0
`
var (
@ -35,13 +36,8 @@ var (
gitCommit = "dev"
//
gitBranch = "dev"
// Major version when you make incompatible API changes,
major = "2"
// Minor version when you add functionality in a backwards-compatible manner, and
minor = "7"
// Patch version when you make backwards-compatible bug fixes.
patch = "0"
//
gitTag = "dev"
)
var Service *Neko
@ -49,11 +45,9 @@ var Service *Neko
func init() {
Service = &Neko{
Version: &Version{
Major: major,
Minor: minor,
Patch: patch,
GitCommit: gitCommit,
GitBranch: gitBranch,
GitTag: gitTag,
BuildDate: buildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
@ -69,11 +63,9 @@ func init() {
}
type Version struct {
Major string
Minor string
Patch string
GitCommit string
GitBranch string
GitTag string
BuildDate string
GoVersion string
Compiler string
@ -81,20 +73,25 @@ type Version struct {
}
func (i *Version) String() string {
return fmt.Sprintf("%s.%s.%s %s", i.Major, i.Minor, i.Patch, i.GitCommit)
version := i.GitTag
if version == "" || version == "dev" {
version = i.GitBranch
}
return fmt.Sprintf("%s@%s", version, i.GitCommit)
}
func (i *Version) Details() string {
return fmt.Sprintf(
"%s\n%s\n%s\n%s\n%s\n%s\n%s\n",
fmt.Sprintf("Version %s.%s.%s", i.Major, i.Minor, i.Patch),
return "\n" + strings.Join([]string{
fmt.Sprintf("Version %s", i.String()),
fmt.Sprintf("GitCommit %s", i.GitCommit),
fmt.Sprintf("GitBranch %s", i.GitBranch),
fmt.Sprintf("GitTag %s", i.GitTag),
fmt.Sprintf("BuildDate %s", i.BuildDate),
fmt.Sprintf("GoVersion %s", i.GoVersion),
fmt.Sprintf("Compiler %s", i.Compiler),
fmt.Sprintf("Platform %s", i.Platform),
)
}, "\n") + "\n"
}
type Neko struct {