mirror of
https://github.com/m1k1o/neko.git
synced 2025-04-28 18:06:20 +02:00
Merge remote-tracking branch 'demodesk-neko/master' into v3-phase1
This commit is contained in:
commit
3e1def9041
207 changed files with 77712 additions and 4 deletions
148
.devcontainer/Dockerfile
Normal file
148
.devcontainer/Dockerfile
Normal file
|
@ -0,0 +1,148 @@
|
|||
#
|
||||
# Stage 0: Build xorg dependencies.
|
||||
#
|
||||
FROM debian:bullseye-slim as xorg-deps
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y \
|
||||
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
WORKDIR /xorg
|
||||
|
||||
COPY xorg/ /xorg/
|
||||
|
||||
# build xserver-xorg-video-dummy 0.3.8-2 with RandR support.
|
||||
RUN set -eux; \
|
||||
cd xf86-video-dummy; \
|
||||
git clone --depth 1 --branch xserver-xorg-video-dummy-1_0.3.8-2 https://salsa.debian.org/xorg-team/driver/xserver-xorg-video-dummy; \
|
||||
cd xserver-xorg-video-dummy; \
|
||||
patch -p1 < ../xdummy-randr.patch; \
|
||||
./autogen.sh; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# build custom input driver
|
||||
RUN set -eux; \
|
||||
cd xf86-input-neko; \
|
||||
./autogen.sh --prefix=/usr; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.166.0/containers/go/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Go version: 1, 1.16, 1.15
|
||||
ARG VARIANT="1"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
|
||||
|
||||
# [Option] Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
# build dependencies
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
|
||||
# install libxcvt-dev (not available in base image)
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
|
||||
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb;
|
||||
|
||||
# runtime dependencies
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
wget ca-certificates supervisor \
|
||||
pulseaudio dbus-x11 xserver-xorg-video-dummy \
|
||||
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 \
|
||||
#
|
||||
# needed for profile upload preStop hook
|
||||
zip curl \
|
||||
#
|
||||
# file chooser handler, clipboard, drop
|
||||
xdotool xclip libgtk-3-0 \
|
||||
#
|
||||
# gst
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-pulseaudio;
|
||||
# libxcvt already installed
|
||||
|
||||
# dev runtime dependencies
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
xfce4 xfce4-terminal firefox-esr sudo;
|
||||
|
||||
# configure runtime
|
||||
ARG USERNAME=neko
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=$USER_UID
|
||||
RUN set -eux; \
|
||||
#
|
||||
# create a non-root user
|
||||
groupadd --gid $USER_GID $USERNAME; \
|
||||
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
|
||||
adduser $USERNAME audio; \
|
||||
adduser $USERNAME video; \
|
||||
adduser $USERNAME pulse; \
|
||||
#
|
||||
# add sudo support
|
||||
echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME; \
|
||||
chmod 0440 /etc/sudoers.d/$USERNAME; \
|
||||
#
|
||||
# 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; \
|
||||
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
|
||||
#
|
||||
# install fonts
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Emojis
|
||||
fonts-noto-color-emoji \
|
||||
# Chinese fonts
|
||||
fonts-arphic-ukai fonts-arphic-uming \
|
||||
# Japanese fonts
|
||||
fonts-ipafont-mincho fonts-ipafont-gothic \
|
||||
# Korean fonts
|
||||
fonts-unfonts-core \
|
||||
# Indian fonts
|
||||
fonts-indic;
|
||||
|
||||
# copy dependencies from previous stage
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
# copy runtime files
|
||||
COPY runtime/dbus /usr/bin/dbus
|
||||
COPY runtime/default.pa /etc/pulse/default.pa
|
||||
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
|
||||
COPY runtime/xorg.conf /etc/neko/xorg.conf
|
||||
COPY runtime/icon-theme /home/$USERNAME/.icons/default
|
||||
|
||||
# copy dev runtime files
|
||||
COPY dev/runtime/config.yml /etc/neko/neko.yml
|
||||
COPY dev/runtime/supervisord.conf /etc/neko/supervisord/dev.conf
|
||||
|
||||
# customized scripts
|
||||
RUN chmod +x /usr/bin/dbus;\
|
||||
echo '#!/bin/sh\nsleep infinity' > /usr/bin/neko; \
|
||||
chmod +x /usr/bin/neko; \
|
||||
echo '#!/bin/sh\nsudo sh -c "export USER='$USERNAME'\nexport HOME=/home/'$USERNAME'\n/usr/bin/supervisord -c /etc/neko/supervisord.conf"' > /usr/bin/deps; \
|
||||
chmod +x /usr/bin/deps; \
|
||||
touch .env.development;
|
||||
|
||||
# set default envs
|
||||
ENV USER=$USERNAME
|
||||
ENV DISPLAY=:99.0
|
||||
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
|
||||
ENV NEKO_SERVER_BIND=:3000
|
||||
ENV NEKO_WEBRTC_EPR=3001-3004
|
20
.devcontainer/README.md
Normal file
20
.devcontainer/README.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
# dev container
|
||||
|
||||
You need to run all dependencies with `deps` command before you start debugging.
|
||||
|
||||
Create `.env.development` in repository root. Make sure your local IP is correct.
|
||||
|
||||
```sh
|
||||
NEKO_WEBRTC_NAT1TO1=10.0.0.8
|
||||
```
|
||||
|
||||
# without container
|
||||
|
||||
- Make sure `pulseaudio` contains correct configuration.
|
||||
- Specify `DISPLAY` that is being used by xorg.
|
||||
|
||||
```sh
|
||||
DISPLAY=:0
|
||||
NEKO_WEBRTC_NAT1TO1=10.0.0.8
|
||||
NEKO_SERVER_BIND=:3000
|
||||
```
|
44
.devcontainer/devcontainer.json
Normal file
44
.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.166.0/containers/go
|
||||
{
|
||||
"name": "Go",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "../",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.15
|
||||
"VARIANT": "1.20",
|
||||
// Options
|
||||
"INSTALL_NODE": "false",
|
||||
"NODE_VERSION": "lts/*"
|
||||
}
|
||||
},
|
||||
"runArgs": [ "--cap-add=SYS_PTRACE", "--cap-add=SYS_ADMIN", "--shm-size=2G", "--security-opt", "seccomp=unconfined" ],
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.toolsManagement.checkForUpdates": "local",
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go"
|
||||
},
|
||||
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"appPort": ["3000:3000", "3001:3001/udp", "3002:3002/udp", "3003:3003/udp", "3004:3004/udp"],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "go version",
|
||||
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "neko"
|
||||
}
|
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
2
.gitattributes
vendored
2
.gitattributes
vendored
|
@ -19,4 +19,4 @@
|
|||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.mp3 binary
|
||||
|
|
44
.github/workflows/build.yml
vendored
Normal file
44
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
53
.github/workflows/build_variants.yml
vendored
Normal file
53
.github/workflows/build_variants.yml
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
name: Create and publish a Docker image variant
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image-variant:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- variant: bookworm
|
||||
dockerfile: Dockerfile.bookworm
|
||||
- variant: nvidia
|
||||
dockerfile: Dockerfile.nvidia
|
||||
- variant: nvidia_bookworm
|
||||
dockerfile: Dockerfile.nvidia.bookworm
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.variant }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
21
.github/workflows/pull_requests.yml
vendored
Normal file
21
.github/workflows/pull_requests.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Build a Docker image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
with:
|
||||
context: .
|
17
.gitignore
vendored
17
.gitignore
vendored
|
@ -33,3 +33,20 @@ bin
|
|||
|
||||
# Environment files
|
||||
*.env
|
||||
|
||||
#
|
||||
# Neko files
|
||||
#
|
||||
|
||||
bin/
|
||||
.idea
|
||||
.env.development
|
||||
|
||||
runtime/fonts/*
|
||||
!runtime/fonts/.gitkeep
|
||||
|
||||
runtime/icon-theme/*
|
||||
!runtime/icon-theme/.gitkeep
|
||||
|
||||
plugins/*
|
||||
!plugins/.gitkeep
|
||||
|
|
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "launch",
|
||||
"type": "go",
|
||||
"debugAdapter": "dlv-dap",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/cmd/neko",
|
||||
"output": "${workspaceFolder}/bin/debug/neko",
|
||||
"cwd": "${workspaceFolder}/",
|
||||
"args": ["serve", "-d", "-c", "dev/runtime/config.yml"],
|
||||
"envFile": "${workspaceFolder}/.env.development"
|
||||
}
|
||||
]
|
||||
}
|
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
|
@ -1 +1,14 @@
|
|||
{}
|
||||
{
|
||||
"go.formatTool": "goformat",
|
||||
"go.inferGopath": false,
|
||||
"go.autocompleteUnimportedPackages": true,
|
||||
"go.delveConfig": {
|
||||
"useApiV1": false
|
||||
},
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
182
Dockerfile
Normal file
182
Dockerfile
Normal file
|
@ -0,0 +1,182 @@
|
|||
ARG BASE_IMAGE=debian:bullseye-slim
|
||||
ARG BUILD_IMAGE=golang:1.21-bullseye
|
||||
|
||||
#
|
||||
# Stage 0: Build xorg dependencies.
|
||||
#
|
||||
FROM $BASE_IMAGE as xorg-deps
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y \
|
||||
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
WORKDIR /xorg
|
||||
|
||||
COPY xorg/ /xorg/
|
||||
|
||||
# build xf86-video-dummy v0.3.8 with RandR support
|
||||
RUN set -eux; \
|
||||
cd xf86-video-dummy/v0.3.8; \
|
||||
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
|
||||
autoreconf -v --install; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# build custom input driver
|
||||
RUN set -eux; \
|
||||
cd xf86-input-neko; \
|
||||
./autogen.sh --prefix=/usr; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
#
|
||||
# Stage 1: Build.
|
||||
#
|
||||
FROM $BUILD_IMAGE as build
|
||||
WORKDIR /src
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
|
||||
# install libxcvt-dev (not available in debian:bullseye)
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
|
||||
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
|
||||
#
|
||||
# build server
|
||||
COPY . .
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# Stage 2: Runtime.
|
||||
#
|
||||
FROM $BASE_IMAGE as runtime
|
||||
|
||||
#
|
||||
# set custom user
|
||||
ARG USERNAME=neko
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates supervisor \
|
||||
pulseaudio dbus-x11 xserver-xorg-video-dummy \
|
||||
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 \
|
||||
#
|
||||
# needed for profile upload preStop hook
|
||||
zip curl \
|
||||
#
|
||||
# file chooser handler, clipboard, drop
|
||||
xdotool xclip libgtk-3-0 \
|
||||
#
|
||||
# gst
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-pulseaudio; \
|
||||
# install libxcvt0 (not available in debian:bullseye)
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
|
||||
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb; \
|
||||
rm ./libxcvt0_0.1.2-1_amd64.deb; \
|
||||
#
|
||||
# create a non-root user
|
||||
groupadd --gid $USER_GID $USERNAME; \
|
||||
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
|
||||
adduser $USERNAME audio; \
|
||||
adduser $USERNAME video; \
|
||||
adduser $USERNAME pulse; \
|
||||
#
|
||||
# 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; \
|
||||
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
|
||||
#
|
||||
# install fonts
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Emojis
|
||||
fonts-noto-color-emoji \
|
||||
# Chinese fonts
|
||||
fonts-arphic-ukai fonts-arphic-uming \
|
||||
# Japanese fonts
|
||||
fonts-ipafont-mincho fonts-ipafont-gothic \
|
||||
# Korean fonts
|
||||
fonts-unfonts-core \
|
||||
# Indian fonts
|
||||
fonts-indic; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
# copy dependencies from previous stage
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
#
|
||||
# copy runtime configs
|
||||
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
|
||||
COPY runtime/dbus /usr/bin/dbus
|
||||
COPY runtime/default.pa /etc/pulse/default.pa
|
||||
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
|
||||
COPY runtime/supervisord.dbus.conf /etc/neko/supervisord.dbus.conf
|
||||
COPY runtime/xorg.conf /etc/neko/xorg.conf
|
||||
|
||||
#
|
||||
# copy runtime folders
|
||||
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
|
||||
COPY runtime/fontconfig/* /etc/fonts/conf.d/
|
||||
COPY runtime/fonts /usr/local/share/fonts
|
||||
|
||||
#
|
||||
# set default envs
|
||||
ENV USER=$USERNAME
|
||||
ENV DISPLAY=:99.0
|
||||
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
|
||||
ENV NEKO_SERVER_BIND=:8080
|
||||
ENV NEKO_PLUGINS_ENABLED=true
|
||||
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
|
||||
|
||||
#
|
||||
# copy plugins from previous stage
|
||||
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
|
||||
|
||||
#
|
||||
# copy executable from previous stage
|
||||
COPY --from=build /src/bin/neko /usr/bin/neko
|
||||
|
||||
#
|
||||
# add healthcheck
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
|
||||
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
|
||||
|
||||
#
|
||||
# run neko
|
||||
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]
|
172
Dockerfile.bookworm
Normal file
172
Dockerfile.bookworm
Normal file
|
@ -0,0 +1,172 @@
|
|||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
ARG BUILD_IMAGE=golang:1.21-bookworm
|
||||
|
||||
#
|
||||
# Stage 0: Build xorg dependencies.
|
||||
#
|
||||
FROM $BASE_IMAGE as xorg-deps
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y \
|
||||
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
WORKDIR /xorg
|
||||
|
||||
COPY xorg/ /xorg/
|
||||
|
||||
# build xf86-video-dummy v0.3.8 with RandR support
|
||||
RUN set -eux; \
|
||||
cd xf86-video-dummy/v0.3.8; \
|
||||
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
|
||||
autoreconf -v --install; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# build custom input driver
|
||||
RUN set -eux; \
|
||||
cd xf86-input-neko; \
|
||||
./autogen.sh --prefix=/usr; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
#
|
||||
# Stage 1: Build.
|
||||
#
|
||||
FROM $BUILD_IMAGE as build
|
||||
WORKDIR /src
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev libxcvt-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
|
||||
#
|
||||
# build server
|
||||
COPY . .
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# Stage 2: Runtime.
|
||||
#
|
||||
FROM $BASE_IMAGE as runtime
|
||||
|
||||
#
|
||||
# set custom user
|
||||
ARG USERNAME=neko
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates supervisor \
|
||||
pulseaudio xserver-xorg-video-dummy \
|
||||
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 libxcvt0 \
|
||||
#
|
||||
# needed for profile upload preStop hook
|
||||
zip curl \
|
||||
#
|
||||
# file chooser handler, clipboard, drop
|
||||
xdotool xclip libgtk-3-0 \
|
||||
#
|
||||
# gst
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-pulseaudio; \
|
||||
#
|
||||
# create a non-root user
|
||||
groupadd --gid $USER_GID $USERNAME; \
|
||||
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
|
||||
adduser $USERNAME audio; \
|
||||
adduser $USERNAME video; \
|
||||
adduser $USERNAME pulse; \
|
||||
#
|
||||
# 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; \
|
||||
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
|
||||
#
|
||||
# install fonts
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Emojis
|
||||
fonts-noto-color-emoji \
|
||||
# Chinese fonts
|
||||
fonts-arphic-ukai fonts-arphic-uming \
|
||||
# Japanese fonts
|
||||
fonts-ipafont-mincho fonts-ipafont-gothic \
|
||||
# Korean fonts
|
||||
fonts-unfonts-core \
|
||||
# Indian fonts
|
||||
fonts-indic; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
# copy dependencies from previous stage
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
#
|
||||
# copy runtime configs
|
||||
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
|
||||
COPY runtime/default.pa /etc/pulse/default.pa
|
||||
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
|
||||
COPY runtime/xorg.conf /etc/neko/xorg.conf
|
||||
|
||||
#
|
||||
# copy runtime folders
|
||||
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
|
||||
COPY runtime/fontconfig/* /etc/fonts/conf.d/
|
||||
COPY runtime/fonts /usr/local/share/fonts
|
||||
|
||||
#
|
||||
# set default envs
|
||||
ENV USER=$USERNAME
|
||||
ENV DISPLAY=:99.0
|
||||
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
|
||||
ENV NEKO_SERVER_BIND=:8080
|
||||
ENV NEKO_PLUGINS_ENABLED=true
|
||||
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
|
||||
|
||||
#
|
||||
# copy plugins from previous stage
|
||||
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
|
||||
|
||||
#
|
||||
# copy executable from previous stage
|
||||
COPY --from=build /src/bin/neko /usr/bin/neko
|
||||
|
||||
#
|
||||
# add healthcheck
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
|
||||
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
|
||||
|
||||
#
|
||||
# run neko
|
||||
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]
|
335
Dockerfile.nvidia
Normal file
335
Dockerfile.nvidia
Normal file
|
@ -0,0 +1,335 @@
|
|||
ARG UBUNTU_RELEASE=20.04
|
||||
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 /usr/share/gstreamer; \
|
||||
meson --prefix /usr/share/gstreamer \
|
||||
-Dgpl=enabled \
|
||||
-Dugly=enabled \
|
||||
-Dgst-plugins-ugly:x264=enabled \
|
||||
build; \
|
||||
ninja -C build; \
|
||||
meson install -C build;
|
||||
|
||||
#
|
||||
# Stage 0: Build xorg dependencies.
|
||||
#
|
||||
FROM debian:bullseye-slim as xorg-deps
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y \
|
||||
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
WORKDIR /xorg
|
||||
|
||||
COPY xorg/ /xorg/
|
||||
|
||||
# build xf86-video-dummy v0.3.8 with RandR support
|
||||
RUN set -eux; \
|
||||
cd xf86-video-dummy/v0.3.8; \
|
||||
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
|
||||
autoreconf -v --install; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# build custom input driver
|
||||
RUN set -eux; \
|
||||
cd xf86-input-neko; \
|
||||
./autogen.sh --prefix=/usr; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
#
|
||||
# Stage 1: Build.
|
||||
#
|
||||
FROM golang:1.21-bullseye as build
|
||||
WORKDIR /src
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
|
||||
# install libxcvt-dev (not available in debian:bullseye)
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
|
||||
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
|
||||
#
|
||||
# build server
|
||||
COPY . .
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# Stage 2: Runtime.
|
||||
#
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
|
||||
ARG UBUNTU_RELEASE
|
||||
ARG VIRTUALGL_VERSION
|
||||
|
||||
# Make all NVIDIA GPUs visible by default
|
||||
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
|
||||
|
||||
#
|
||||
# set vgl-display to headless 3d gpu card/// correct values are egl[n] or /dev/dri/card0:if this is passed into container
|
||||
ENV VGL_DISPLAY egl
|
||||
|
||||
#
|
||||
# set custom user
|
||||
ARG USERNAME=neko
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
#
|
||||
# install hardware accleration dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
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"
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates supervisor \
|
||||
pulseaudio dbus-x11 xserver-xorg-video-dummy \
|
||||
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 libx264-155 libvo-aacenc0 librtmp1 \
|
||||
libgtk-3-bin software-properties-common cabextract aptitude vim curl \
|
||||
#
|
||||
# needed for profile upload preStop hook
|
||||
zip curl \
|
||||
#
|
||||
# file chooser handler, clipboard, drop
|
||||
xdotool xclip libgtk-3-0; \
|
||||
# install libxcvt0 (not available in debian:bullseye)
|
||||
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
|
||||
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb; \
|
||||
rm ./libxcvt0_0.1.2-1_amd64.deb; \
|
||||
#
|
||||
# create a non-root user
|
||||
groupadd --gid $USER_GID $USERNAME; \
|
||||
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
|
||||
adduser $USERNAME audio; \
|
||||
adduser $USERNAME video; \
|
||||
adduser $USERNAME pulse; \
|
||||
#
|
||||
# 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; \
|
||||
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
|
||||
#
|
||||
# install fonts
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Emojis
|
||||
fonts-noto-color-emoji \
|
||||
# Chinese fonts
|
||||
fonts-arphic-ukai fonts-arphic-uming \
|
||||
# Japanese fonts
|
||||
fonts-ipafont-mincho fonts-ipafont-gothic \
|
||||
# Korean fonts
|
||||
fonts-unfonts-core \
|
||||
# Indian fonts
|
||||
fonts-indic; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
# copy dependencies from previous stage
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
#
|
||||
# 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\
|
||||
\"library_path\": \"libGLX_nvidia.so.0\",\n\
|
||||
\"api_version\" : \"${VULKAN_API_VERSION}\"\n\
|
||||
}\n\
|
||||
}" > /etc/vulkan/icd.d/nvidia_icd.json
|
||||
|
||||
#
|
||||
# install VirtualGL and make libraries available for preload
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
apt-get install -y --no-install-recommends ./virtualgl_${VIRTUALGL_VERSION}_amd64.deb ./virtualgl32_${VIRTUALGL_VERSION}_amd64.deb; \
|
||||
rm -f "virtualgl_${VIRTUALGL_VERSION}_amd64.deb" "virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
chmod u+s /usr/lib/libvglfaker.so; \
|
||||
chmod u+s /usr/lib/libdlfaker.so; \
|
||||
chmod u+s /usr/lib32/libvglfaker.so; \
|
||||
chmod u+s /usr/lib32/libdlfaker.so; \
|
||||
chmod u+s /usr/lib/i386-linux-gnu/libvglfaker.so; \
|
||||
chmod u+s /usr/lib/i386-linux-gnu/libdlfaker.so; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*;
|
||||
|
||||
#
|
||||
# copy runtime configs
|
||||
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
|
||||
COPY runtime/dbus /usr/bin/dbus
|
||||
COPY runtime/default.pa /etc/pulse/default.pa
|
||||
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
|
||||
COPY runtime/supervisord.dbus.conf /etc/neko/supervisord.dbus.conf
|
||||
COPY runtime/xorg.conf /etc/neko/xorg.conf
|
||||
|
||||
#
|
||||
# copy runtime folders
|
||||
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
|
||||
COPY runtime/fontconfig/* /etc/fonts/conf.d/
|
||||
COPY runtime/fonts /usr/local/share/fonts
|
||||
|
||||
#
|
||||
# set default envs
|
||||
ENV USER=$USERNAME
|
||||
ENV DISPLAY=:99.0
|
||||
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
|
||||
ENV NEKO_SERVER_BIND=:8080
|
||||
ENV NEKO_PLUGINS_ENABLED=true
|
||||
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
|
||||
|
||||
#
|
||||
# set gstreamer envs
|
||||
ENV PATH="/usr/share/gstreamer/bin:${PATH}"
|
||||
ENV LD_LIBRARY_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||
ENV PKG_CONFIG_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
|
||||
|
||||
#
|
||||
# copy gstreamer from previous stage
|
||||
COPY --from=gstreamer /usr/share/gstreamer /usr/share/gstreamer
|
||||
|
||||
#
|
||||
# copy plugins from previous stage
|
||||
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
|
||||
|
||||
#
|
||||
# copy executable from previous stage
|
||||
COPY --from=build /src/bin/neko /usr/bin/neko
|
||||
|
||||
#
|
||||
# add healthcheck
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
|
||||
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
|
||||
|
||||
#
|
||||
# run neko
|
||||
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]
|
325
Dockerfile.nvidia.bookworm
Normal file
325
Dockerfile.nvidia.bookworm
Normal file
|
@ -0,0 +1,325 @@
|
|||
ARG UBUNTU_RELEASE=22.04
|
||||
ARG CUDA_VERSION=12.2.0
|
||||
ARG VIRTUALGL_VERSION=3.1
|
||||
ARG GSTREAMER_VERSION=1.22
|
||||
|
||||
#
|
||||
# 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 /usr/share/gstreamer; \
|
||||
meson --prefix /usr/share/gstreamer \
|
||||
-Dgpl=enabled \
|
||||
-Dugly=enabled \
|
||||
-Dgst-plugins-ugly:x264=enabled \
|
||||
build; \
|
||||
ninja -C build; \
|
||||
meson install -C build;
|
||||
|
||||
#
|
||||
# Stage 0: Build xorg dependencies.
|
||||
#
|
||||
FROM debian:bookworm-slim as xorg-deps
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y \
|
||||
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
WORKDIR /xorg
|
||||
|
||||
COPY xorg/ /xorg/
|
||||
|
||||
# build xf86-video-dummy v0.3.8 with RandR support
|
||||
RUN set -eux; \
|
||||
cd xf86-video-dummy/v0.3.8; \
|
||||
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
|
||||
autoreconf -v --install; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
# build custom input driver
|
||||
RUN set -eux; \
|
||||
cd xf86-input-neko; \
|
||||
./autogen.sh --prefix=/usr; \
|
||||
./configure; \
|
||||
make -j$(nproc); \
|
||||
make install;
|
||||
|
||||
#
|
||||
# Stage 1: Build.
|
||||
#
|
||||
FROM golang:1.21-bookworm as build
|
||||
WORKDIR /src
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev libxcvt-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
|
||||
#
|
||||
# build server
|
||||
COPY . .
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# Stage 2: Runtime.
|
||||
#
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
|
||||
ARG UBUNTU_RELEASE
|
||||
ARG VIRTUALGL_VERSION
|
||||
|
||||
# Make all NVIDIA GPUs visible by default
|
||||
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
|
||||
|
||||
#
|
||||
# set vgl-display to headless 3d gpu card/// correct values are egl[n] or /dev/dri/card0:if this is passed into container
|
||||
ENV VGL_DISPLAY egl
|
||||
|
||||
#
|
||||
# set custom user
|
||||
ARG USERNAME=neko
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
#
|
||||
# install hardware accleration dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
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"
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates supervisor \
|
||||
pulseaudio xserver-xorg-video-dummy \
|
||||
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 libx264-163 libvo-aacenc0 librtmp1 libxcvt0 \
|
||||
libgtk-3-bin software-properties-common cabextract aptitude vim curl \
|
||||
#
|
||||
# needed for profile upload preStop hook
|
||||
zip curl \
|
||||
#
|
||||
# file chooser handler, clipboard, drop
|
||||
xdotool xclip libgtk-3-0; \
|
||||
#
|
||||
# create a non-root user
|
||||
groupadd --gid $USER_GID $USERNAME; \
|
||||
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
|
||||
adduser $USERNAME audio; \
|
||||
adduser $USERNAME video; \
|
||||
adduser $USERNAME pulse; \
|
||||
#
|
||||
# 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; \
|
||||
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
|
||||
#
|
||||
# install fonts
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Emojis
|
||||
fonts-noto-color-emoji \
|
||||
# Chinese fonts
|
||||
fonts-arphic-ukai fonts-arphic-uming \
|
||||
# Japanese fonts
|
||||
fonts-ipafont-mincho fonts-ipafont-gothic \
|
||||
# Korean fonts
|
||||
fonts-unfonts-core \
|
||||
# Indian fonts
|
||||
fonts-indic; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
|
||||
|
||||
# copy dependencies from previous stage
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
|
||||
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
#
|
||||
# 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\
|
||||
\"library_path\": \"libGLX_nvidia.so.0\",\n\
|
||||
\"api_version\" : \"${VULKAN_API_VERSION}\"\n\
|
||||
}\n\
|
||||
}" > /etc/vulkan/icd.d/nvidia_icd.json
|
||||
|
||||
#
|
||||
# install VirtualGL and make libraries available for preload
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
apt-get install -y --no-install-recommends ./virtualgl_${VIRTUALGL_VERSION}_amd64.deb ./virtualgl32_${VIRTUALGL_VERSION}_amd64.deb; \
|
||||
rm -f "virtualgl_${VIRTUALGL_VERSION}_amd64.deb" "virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
|
||||
chmod u+s /usr/lib/libvglfaker.so; \
|
||||
chmod u+s /usr/lib/libdlfaker.so; \
|
||||
chmod u+s /usr/lib32/libvglfaker.so; \
|
||||
chmod u+s /usr/lib32/libdlfaker.so; \
|
||||
chmod u+s /usr/lib/i386-linux-gnu/libvglfaker.so; \
|
||||
chmod u+s /usr/lib/i386-linux-gnu/libdlfaker.so; \
|
||||
#
|
||||
# clean up
|
||||
apt-get clean -y; \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/*;
|
||||
|
||||
#
|
||||
# copy runtime configs
|
||||
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
|
||||
COPY runtime/default.pa /etc/pulse/default.pa
|
||||
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
|
||||
COPY runtime/xorg.conf /etc/neko/xorg.conf
|
||||
|
||||
#
|
||||
# copy runtime folders
|
||||
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
|
||||
COPY runtime/fontconfig/* /etc/fonts/conf.d/
|
||||
COPY runtime/fonts /usr/local/share/fonts
|
||||
|
||||
#
|
||||
# set default envs
|
||||
ENV USER=$USERNAME
|
||||
ENV DISPLAY=:99.0
|
||||
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
|
||||
ENV NEKO_SERVER_BIND=:8080
|
||||
ENV NEKO_PLUGINS_ENABLED=true
|
||||
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
|
||||
|
||||
#
|
||||
# set gstreamer envs
|
||||
ENV PATH="/usr/share/gstreamer/bin:${PATH}"
|
||||
ENV LD_LIBRARY_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||
ENV PKG_CONFIG_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
|
||||
|
||||
#
|
||||
# copy gstreamer from previous stage
|
||||
COPY --from=gstreamer /usr/share/gstreamer /usr/share/gstreamer
|
||||
|
||||
#
|
||||
# copy plugins from previous stage
|
||||
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
|
||||
|
||||
#
|
||||
# copy executable from previous stage
|
||||
COPY --from=build /src/bin/neko /usr/bin/neko
|
||||
|
||||
#
|
||||
# add healthcheck
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
|
||||
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
|
||||
|
||||
#
|
||||
# run neko
|
||||
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]
|
5
LICENSE
5
LICENSE
|
@ -187,7 +187,8 @@
|
|||
identification within third-party archives.
|
||||
|
||||
Copyright (C) 2020 Nurdism <nurdism.io@gmail.com>
|
||||
Copyright (C) 2020-2023 m1k1o
|
||||
Copyright (C) 2021-2023 m1k1o & Demodesk GmbH
|
||||
Copyright (C) 2024- m1k1o
|
||||
All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -200,4 +201,4 @@
|
|||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
limitations under the License.
|
||||
|
|
87
build
Executable file
87
build
Executable file
|
@ -0,0 +1,87 @@
|
|||
#!/bin/bash
|
||||
|
||||
#
|
||||
# aborting if any command returns a non-zero value
|
||||
set -e
|
||||
|
||||
#
|
||||
# do not build plugins when passing "core" as first argument
|
||||
if [ "$1" = "core" ];
|
||||
then
|
||||
skip_plugins="true"
|
||||
fi
|
||||
|
||||
#
|
||||
# set git build variables if git exists
|
||||
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`
|
||||
fi
|
||||
|
||||
#
|
||||
# load server dependencies
|
||||
go get -v -t -d .
|
||||
|
||||
#
|
||||
# build server
|
||||
go build \
|
||||
-o bin/neko \
|
||||
-ldflags "
|
||||
-s -w
|
||||
-X 'github.com/demodesk/neko.buildDate=`date -u +'%Y-%m-%dT%H:%M:%SZ'`'
|
||||
-X 'github.com/demodesk/neko.gitCommit=${GIT_COMMIT}'
|
||||
-X 'github.com/demodesk/neko.gitBranch=${GIT_BRANCH}'
|
||||
-X 'github.com/demodesk/neko.gitTag=${GIT_TAG}'
|
||||
" \
|
||||
cmd/neko/main.go;
|
||||
|
||||
#
|
||||
# ensure plugins folder exists
|
||||
mkdir -p bin/plugins
|
||||
|
||||
#
|
||||
# if plugins are ignored
|
||||
if [ "$skip_plugins" = "true" ];
|
||||
then
|
||||
echo "Not building plugins..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
#
|
||||
# if plugins directory does not exist
|
||||
if [ ! -d "./plugins" ];
|
||||
then
|
||||
echo "No plugins directory found, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
#
|
||||
# remove old plugins
|
||||
rm -f bin/plugins/*
|
||||
|
||||
#
|
||||
# build plugins
|
||||
for plugPath in ./plugins/*; do
|
||||
if [ ! -d $plugPath ];
|
||||
then
|
||||
continue
|
||||
fi
|
||||
|
||||
pushd $plugPath
|
||||
|
||||
echo "Building plugin: $plugPath"
|
||||
|
||||
if [ ! -f "go.plug.mod" ];
|
||||
then
|
||||
echo "go.plug.mod not found, skipping..."
|
||||
popd
|
||||
continue
|
||||
fi
|
||||
|
||||
# build plugin
|
||||
go build -modfile=go.plug.mod -buildmode=plugin -buildvcs=false -o "../../bin/plugins/${plugPath##*/}.so"
|
||||
|
||||
popd
|
||||
done
|
18
cmd/neko/main.go
Normal file
18
cmd/neko/main.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko"
|
||||
"github.com/demodesk/neko/cmd"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Print(utils.Colorf(neko.Header, "server", neko.Version))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
log.Panic().Err(err).Msg("failed to execute command")
|
||||
}
|
||||
}
|
49
cmd/plugins.go
Normal file
49
cmd/plugins.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/plugins"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "plugins [directory]",
|
||||
Short: "load, verify and list plugins",
|
||||
Long: `load, verify and list plugins`,
|
||||
Run: pluginsCmd,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
}
|
||||
root.AddCommand(command)
|
||||
}
|
||||
|
||||
func pluginsCmd(cmd *cobra.Command, args []string) {
|
||||
pluginDir := "/etc/neko/plugins"
|
||||
if len(args) > 0 {
|
||||
pluginDir = args[0]
|
||||
}
|
||||
log.Info().Str("dir", pluginDir).Msg("plugins directory")
|
||||
|
||||
plugs := plugins.New(&config.Plugins{
|
||||
Enabled: true,
|
||||
Required: true,
|
||||
Dir: pluginDir,
|
||||
})
|
||||
|
||||
meta := plugs.Metadata()
|
||||
if len(meta) == 0 {
|
||||
log.Fatal().Msg("no plugins found")
|
||||
}
|
||||
|
||||
// marshal indent to stdout
|
||||
dec := json.NewEncoder(os.Stdout)
|
||||
dec.SetIndent("", " ")
|
||||
err := dec.Encode(meta)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("unable to marshal metadata")
|
||||
}
|
||||
}
|
165
cmd/root.go
Normal file
165
cmd/root.go
Normal file
|
@ -0,0 +1,165 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/diode"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko"
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
)
|
||||
|
||||
func Execute() error {
|
||||
// properly log unhandled panics
|
||||
defer func() {
|
||||
panicVal := recover()
|
||||
if panicVal != nil {
|
||||
log.Panic().Msgf("%v", panicVal)
|
||||
}
|
||||
}()
|
||||
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
var root = &cobra.Command{
|
||||
Use: "neko",
|
||||
Short: "neko streaming server",
|
||||
Long: `neko streaming server`,
|
||||
Version: neko.Version.String(),
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootConfig := config.Root{}
|
||||
|
||||
cobra.OnInitialize(func() {
|
||||
//////
|
||||
// configs
|
||||
//////
|
||||
|
||||
config := viper.GetString("config") // Use config file from the flag.
|
||||
if config == "" {
|
||||
config = os.Getenv("NEKO_CONFIG") // Use config file from the environment variable.
|
||||
}
|
||||
|
||||
if config != "" {
|
||||
viper.SetConfigFile(config)
|
||||
} else {
|
||||
if runtime.GOOS == "linux" {
|
||||
viper.AddConfigPath("/etc/neko/")
|
||||
}
|
||||
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetConfigName("neko")
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("NEKO")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// read config values
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
_, notFound := err.(viper.ConfigFileNotFoundError)
|
||||
if !notFound {
|
||||
log.Fatal().Err(err).Msg("unable to read config file")
|
||||
}
|
||||
}
|
||||
|
||||
// get full config file path
|
||||
config = viper.ConfigFileUsed()
|
||||
|
||||
// set root config values
|
||||
rootConfig.Set()
|
||||
|
||||
//////
|
||||
// logs
|
||||
//////
|
||||
var logWriter io.Writer
|
||||
|
||||
// log to a directory instead of stderr
|
||||
if rootConfig.LogDir != "" {
|
||||
if _, err := os.Stat(rootConfig.LogDir); os.IsNotExist(err) {
|
||||
_ = os.Mkdir(rootConfig.LogDir, os.ModePerm)
|
||||
}
|
||||
|
||||
latest := filepath.Join(rootConfig.LogDir, "neko-latest.log")
|
||||
if _, err := os.Stat(latest); err == nil {
|
||||
err = os.Rename(latest, filepath.Join(rootConfig.LogDir, "neko."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log"))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to rotate log file")
|
||||
}
|
||||
}
|
||||
|
||||
logf, err := os.OpenFile(latest, os.O_RDWR|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to open log file")
|
||||
}
|
||||
|
||||
logWriter = diode.NewWriter(logf, 1000, 10*time.Millisecond, func(missed int) {
|
||||
fmt.Printf("logger dropped %d messages", missed)
|
||||
})
|
||||
} else {
|
||||
logWriter = os.Stderr
|
||||
}
|
||||
|
||||
// log console output instead of json
|
||||
if !rootConfig.LogJson {
|
||||
logWriter = zerolog.ConsoleWriter{
|
||||
Out: logWriter,
|
||||
NoColor: rootConfig.LogNocolor,
|
||||
}
|
||||
}
|
||||
|
||||
// save new logger output
|
||||
log.Logger = log.Output(logWriter)
|
||||
|
||||
// set custom log level
|
||||
if rootConfig.LogLevel != zerolog.NoLevel {
|
||||
zerolog.SetGlobalLevel(rootConfig.LogLevel)
|
||||
}
|
||||
|
||||
// set custom log tiem format
|
||||
if rootConfig.LogTime != "" {
|
||||
zerolog.TimeFieldFormat = rootConfig.LogTime
|
||||
}
|
||||
|
||||
timeFormat := rootConfig.LogTime
|
||||
if rootConfig.LogTime == zerolog.TimeFormatUnix {
|
||||
timeFormat = "UNIX"
|
||||
}
|
||||
|
||||
logger := log.With().
|
||||
Str("config", config).
|
||||
Str("log-level", zerolog.GlobalLevel().String()).
|
||||
Bool("log-json", rootConfig.LogJson).
|
||||
Str("log-time", timeFormat).
|
||||
Str("log-dir", rootConfig.LogDir).
|
||||
Logger()
|
||||
|
||||
if config == "" {
|
||||
logger.Warn().Msg("preflight complete without config file")
|
||||
} else {
|
||||
if _, err := os.Stat(config); os.IsNotExist(err) {
|
||||
logger.Err(err).Msg("preflight complete with nonexistent config file")
|
||||
} else {
|
||||
logger.Info().Msg("preflight complete with config file")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := rootConfig.Init(root); err != nil {
|
||||
log.Panic().Err(err).Msg("unable to run root command")
|
||||
}
|
||||
|
||||
root.SetVersionTemplate(neko.Version.Details())
|
||||
}
|
212
cmd/serve.go
Normal file
212
cmd/serve.go
Normal file
|
@ -0,0 +1,212 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/demodesk/neko/internal/api"
|
||||
"github.com/demodesk/neko/internal/capture"
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/desktop"
|
||||
"github.com/demodesk/neko/internal/http"
|
||||
"github.com/demodesk/neko/internal/member"
|
||||
"github.com/demodesk/neko/internal/plugins"
|
||||
"github.com/demodesk/neko/internal/session"
|
||||
"github.com/demodesk/neko/internal/webrtc"
|
||||
"github.com/demodesk/neko/internal/websocket"
|
||||
)
|
||||
|
||||
func init() {
|
||||
service := serve{}
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "serve neko streaming server",
|
||||
Long: `serve neko streaming server`,
|
||||
PreRun: service.PreRun,
|
||||
Run: service.Run,
|
||||
}
|
||||
|
||||
if err := service.Init(command); err != nil {
|
||||
log.Panic().Err(err).Msg("unable to initialize configuration")
|
||||
}
|
||||
|
||||
root.AddCommand(command)
|
||||
}
|
||||
|
||||
type serve struct {
|
||||
logger zerolog.Logger
|
||||
|
||||
configs struct {
|
||||
Desktop config.Desktop
|
||||
Capture config.Capture
|
||||
WebRTC config.WebRTC
|
||||
Member config.Member
|
||||
Session config.Session
|
||||
Plugins config.Plugins
|
||||
Server config.Server
|
||||
}
|
||||
|
||||
managers struct {
|
||||
desktop *desktop.DesktopManagerCtx
|
||||
capture *capture.CaptureManagerCtx
|
||||
webRTC *webrtc.WebRTCManagerCtx
|
||||
member *member.MemberManagerCtx
|
||||
session *session.SessionManagerCtx
|
||||
webSocket *websocket.WebSocketManagerCtx
|
||||
plugins *plugins.ManagerCtx
|
||||
api *api.ApiManagerCtx
|
||||
http *http.HttpManagerCtx
|
||||
}
|
||||
}
|
||||
|
||||
func (c *serve) Init(cmd *cobra.Command) error {
|
||||
if err := c.configs.Desktop.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.Capture.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.WebRTC.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.Member.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.Session.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.Plugins.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.configs.Server.Init(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *serve) PreRun(cmd *cobra.Command, args []string) {
|
||||
c.logger = log.With().Str("service", "neko").Logger()
|
||||
|
||||
c.configs.Desktop.Set()
|
||||
c.configs.Capture.Set()
|
||||
c.configs.WebRTC.Set()
|
||||
c.configs.Member.Set()
|
||||
c.configs.Session.Set()
|
||||
c.configs.Plugins.Set()
|
||||
c.configs.Server.Set()
|
||||
}
|
||||
|
||||
func (c *serve) Start(cmd *cobra.Command) {
|
||||
c.managers.session = session.New(
|
||||
&c.configs.Session,
|
||||
)
|
||||
|
||||
c.managers.member = member.New(
|
||||
c.managers.session,
|
||||
&c.configs.Member,
|
||||
)
|
||||
|
||||
if err := c.managers.member.Connect(); err != nil {
|
||||
c.logger.Panic().Err(err).Msg("unable to connect to member manager")
|
||||
}
|
||||
|
||||
c.managers.desktop = desktop.New(
|
||||
&c.configs.Desktop,
|
||||
)
|
||||
c.managers.desktop.Start()
|
||||
|
||||
c.managers.capture = capture.New(
|
||||
c.managers.desktop,
|
||||
&c.configs.Capture,
|
||||
)
|
||||
c.managers.capture.Start()
|
||||
|
||||
c.managers.webRTC = webrtc.New(
|
||||
c.managers.desktop,
|
||||
c.managers.capture,
|
||||
&c.configs.WebRTC,
|
||||
)
|
||||
c.managers.webRTC.Start()
|
||||
|
||||
c.managers.webSocket = websocket.New(
|
||||
c.managers.session,
|
||||
c.managers.desktop,
|
||||
c.managers.capture,
|
||||
c.managers.webRTC,
|
||||
)
|
||||
c.managers.webSocket.Start()
|
||||
|
||||
c.managers.api = api.New(
|
||||
c.managers.session,
|
||||
c.managers.member,
|
||||
c.managers.desktop,
|
||||
c.managers.capture,
|
||||
)
|
||||
|
||||
c.managers.plugins = plugins.New(
|
||||
&c.configs.Plugins,
|
||||
)
|
||||
|
||||
// init and set configuration now
|
||||
// this means it won't be in --help
|
||||
c.managers.plugins.InitConfigs(cmd)
|
||||
c.managers.plugins.SetConfigs()
|
||||
|
||||
c.managers.plugins.Start(
|
||||
c.managers.session,
|
||||
c.managers.webSocket,
|
||||
c.managers.api,
|
||||
)
|
||||
|
||||
c.managers.http = http.New(
|
||||
c.managers.webSocket,
|
||||
c.managers.api,
|
||||
&c.configs.Server,
|
||||
)
|
||||
c.managers.http.Start()
|
||||
}
|
||||
|
||||
func (c *serve) Shutdown() {
|
||||
var err error
|
||||
|
||||
err = c.managers.http.Shutdown()
|
||||
c.logger.Err(err).Msg("http manager shutdown")
|
||||
|
||||
err = c.managers.plugins.Shutdown()
|
||||
c.logger.Err(err).Msg("plugins manager shutdown")
|
||||
|
||||
err = c.managers.webSocket.Shutdown()
|
||||
c.logger.Err(err).Msg("websocket manager shutdown")
|
||||
|
||||
err = c.managers.webRTC.Shutdown()
|
||||
c.logger.Err(err).Msg("webrtc manager shutdown")
|
||||
|
||||
err = c.managers.capture.Shutdown()
|
||||
c.logger.Err(err).Msg("capture manager shutdown")
|
||||
|
||||
err = c.managers.desktop.Shutdown()
|
||||
c.logger.Err(err).Msg("desktop manager shutdown")
|
||||
|
||||
err = c.managers.member.Disconnect()
|
||||
c.logger.Err(err).Msg("member manager disconnect")
|
||||
}
|
||||
|
||||
func (c *serve) Run(cmd *cobra.Command, args []string) {
|
||||
c.logger.Info().Msg("starting neko server")
|
||||
c.Start(cmd)
|
||||
c.logger.Info().Msg("neko ready")
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
sig := <-quit
|
||||
|
||||
c.logger.Warn().Msgf("received %s, attempting graceful shutdown", sig)
|
||||
c.Shutdown()
|
||||
c.logger.Info().Msg("shutdown complete")
|
||||
}
|
23
dev/build
Executable file
23
dev/build
Executable file
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
#
|
||||
# aborting if any command returns a non-zero value
|
||||
set -e
|
||||
|
||||
GIT_COMMIT=`git rev-parse --short HEAD`
|
||||
GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD`
|
||||
|
||||
# if first argument is nvidia, use nvidia dockerfile
|
||||
if [ "$1" = "nvidia" ]; then
|
||||
echo "Building nvidia docker image"
|
||||
DOCKERFILE="Dockerfile.nvidia"
|
||||
else
|
||||
echo "Building default docker image"
|
||||
DOCKERFILE="Dockerfile"
|
||||
fi
|
||||
|
||||
docker build -t neko_server_build --target build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE ..
|
||||
docker build -t neko_server_runtime --target runtime --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE ..
|
||||
|
||||
docker build -t neko_server_app --build-arg "BASE_IMAGE=neko_server_runtime" -f ./runtime/Dockerfile ./runtime
|
3
dev/exec
Executable file
3
dev/exec
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
docker exec -it neko_server_dev /bin/bash
|
12
dev/fmt
Executable file
12
dev/fmt
Executable file
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
|
||||
echo "Image 'neko_server_build' not found. Run ./build first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -it --rm \
|
||||
--entrypoint="go" \
|
||||
-v "${PWD}/../:/src" \
|
||||
neko_server_build fmt ./...
|
25
dev/go
Executable file
25
dev/go
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
|
||||
echo "Image 'neko_server_build' not found. Run ./build first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -it \
|
||||
--name "neko_server_go" \
|
||||
--entrypoint="go" \
|
||||
-v "${PWD}/../:/src" \
|
||||
neko_server_build "$@";
|
||||
#
|
||||
# copy package files
|
||||
docker cp neko_server_go:/src/go.mod "../go.mod"
|
||||
docker cp neko_server_go:/src/go.sum "../go.sum"
|
||||
|
||||
#
|
||||
# commit changes to image
|
||||
docker commit "neko_server_go" "neko_server_build"
|
||||
|
||||
#
|
||||
# remove contianer
|
||||
docker rm "neko_server_go"
|
14
dev/lint
Executable file
14
dev/lint
Executable file
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
|
||||
echo "Image 'neko_server_build' not found. Run ./build first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#
|
||||
# build server
|
||||
docker run --rm -it \
|
||||
-v "${PWD}/../:/src" \
|
||||
--entrypoint="/bin/bash" \
|
||||
neko_server_build -c '[ -f ./bin/golangci-lint ] || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.31.0;./bin/golangci-lint run';
|
32
dev/rebuild
Executable file
32
dev/rebuild
Executable file
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
#
|
||||
# aborting if any command returns a non-zero value
|
||||
set -e
|
||||
|
||||
#
|
||||
# build server
|
||||
docker run --rm -it \
|
||||
-v "${PWD}/../:/src" \
|
||||
--entrypoint="/bin/bash" \
|
||||
neko_server_build "./build" "$@";
|
||||
|
||||
#
|
||||
# remove old plugins
|
||||
docker exec neko_server_dev rm -rf /etc/neko/plugins
|
||||
|
||||
#
|
||||
# replace server binary in container
|
||||
docker cp "${PWD}/../bin/neko" neko_server_dev:/usr/bin/neko
|
||||
|
||||
#
|
||||
# replace plugin binaries in container
|
||||
if [ -d "${PWD}/../bin/plugins" ];
|
||||
then
|
||||
docker cp "${PWD}/../bin/plugins" neko_server_dev:/etc/neko/plugins
|
||||
fi
|
||||
|
||||
#
|
||||
# restart server
|
||||
docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart neko
|
32
dev/rebuild.input
Executable file
32
dev/rebuild.input
Executable file
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
cd ../xorg/xf86-input-neko
|
||||
|
||||
#
|
||||
# aborting if any command returns a non-zero value
|
||||
set -e
|
||||
|
||||
#
|
||||
# check if docker image exists
|
||||
if [ -z "$(docker images -q xf86-input-neko)" ]; then
|
||||
echo "Docker image not found, building it"
|
||||
docker build -t xf86-input-neko .
|
||||
fi
|
||||
|
||||
#
|
||||
# if there is no ./configure script, run autogen.sh and configure
|
||||
if [ ! -f ./configure ]; then
|
||||
docker run -v $PWD/:/app --rm xf86-input-neko bash -c './autogen.sh && ./configure'
|
||||
fi
|
||||
|
||||
#
|
||||
# make install
|
||||
docker run -v $PWD/:/app --rm xf86-input-neko bash -c 'make && make install DESTDIR=/app/build'
|
||||
|
||||
#
|
||||
# replace input driver in container
|
||||
docker cp "${PWD}/build/usr/local/lib/xorg/modules/input/neko_drv.so" neko_server_dev:/usr/lib/xorg/modules/input/neko_drv.so
|
||||
|
||||
#
|
||||
# restart server
|
||||
docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart x-server
|
31
dev/runtime/Dockerfile
Normal file
31
dev/runtime/Dockerfile
Normal file
|
@ -0,0 +1,31 @@
|
|||
ARG BASE_IMAGE=neko_server_runtime:latest
|
||||
FROM $BASE_IMAGE
|
||||
|
||||
ARG SRC_URL="https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US"
|
||||
|
||||
#
|
||||
# install xfce and firefox
|
||||
RUN set -eux; apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
dbus-x11 xfce4 xfce4-terminal sudo \
|
||||
xz-utils bzip2 libgtk-3-0 libdbus-glib-1-2; \
|
||||
#
|
||||
# fetch latest firefox 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; \
|
||||
#
|
||||
# add user to sudoers
|
||||
usermod -aG sudo neko; \
|
||||
echo "neko:neko" | chpasswd; \
|
||||
echo "%sudo ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \
|
||||
# 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.conf /etc/neko/supervisord/xfce.conf
|
101
dev/runtime/config.nvidia.yml
Normal file
101
dev/runtime/config.nvidia.yml
Normal file
|
@ -0,0 +1,101 @@
|
|||
capture:
|
||||
video:
|
||||
codec: h264
|
||||
ids:
|
||||
- nvh264enc
|
||||
- x264enc
|
||||
pipelines:
|
||||
nvh264enc:
|
||||
fps: 25
|
||||
bitrate: 2
|
||||
#gst_prefix: "! cudaupload ! cudaconvert ! video/x-raw(memory:CUDAMemory),format=NV12"
|
||||
gst_prefix: "! video/x-raw,format=NV12"
|
||||
gst_encoder: "nvh264enc"
|
||||
gst_params:
|
||||
bitrate: 3000
|
||||
rc-mode: 5 # Low-Delay CBR, High Quality
|
||||
preset: 5 # Low Latency, High Performance
|
||||
zerolatency: true
|
||||
gop-size: 25
|
||||
gst_suffix: "! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"
|
||||
x264enc:
|
||||
fps: 25
|
||||
bitrate: 1
|
||||
gst_prefix: "! video/x-raw,format=I420"
|
||||
gst_encoder: "x264enc"
|
||||
gst_params:
|
||||
threads: 4
|
||||
bitrate: 4096
|
||||
key-int-max: 25
|
||||
byte-stream: true
|
||||
tune: zerolatency
|
||||
speed-preset: veryfast
|
||||
gst_suffix: "! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"
|
||||
|
||||
server:
|
||||
pprof: true
|
||||
|
||||
desktop:
|
||||
screen: "1920x1080@60"
|
||||
|
||||
member:
|
||||
provider: "object"
|
||||
object:
|
||||
users:
|
||||
- username: "admin"
|
||||
password: "admin"
|
||||
profile:
|
||||
name: "Administrator"
|
||||
is_admin: true
|
||||
can_login: true
|
||||
can_connect: true
|
||||
can_watch: true
|
||||
can_host: true
|
||||
can_share_media: true
|
||||
can_access_clipboard: true
|
||||
sends_inactive_cursor: true
|
||||
can_see_inactive_cursors: true
|
||||
- username: "user"
|
||||
password: "neko"
|
||||
profile:
|
||||
name: "User"
|
||||
is_admin: false
|
||||
can_login: true
|
||||
can_connect: true
|
||||
can_watch: true
|
||||
can_host: true
|
||||
can_share_media: true
|
||||
can_access_clipboard: true
|
||||
sends_inactive_cursor: true
|
||||
can_see_inactive_cursors: false
|
||||
# provider: "file"
|
||||
# file:
|
||||
# path: "/home/neko/members.json"
|
||||
# provider: "multiuser"
|
||||
# multiuser:
|
||||
# admin_password: "admin"
|
||||
# user_password: "neko"
|
||||
# provider: "noauth"
|
||||
|
||||
session:
|
||||
# Allows reconnecting the websocket even if the previous
|
||||
# connection was not closed. Can lead to session hijacking.
|
||||
merciful_reconnect: true
|
||||
# Show inactive cursors on the screen. Can lead to multiple
|
||||
# data sent via WebSockets and additonal rendering cost on
|
||||
# the clients.
|
||||
inactive_cursors: true
|
||||
api_token: "neko123"
|
||||
cookie:
|
||||
# Disabling cookies will result to use Bearer Authentication.
|
||||
# This is less secure, because access token will be sent to
|
||||
# client in playload and accessible via JS app.
|
||||
enabled: false
|
||||
secure: false
|
||||
|
||||
webrtc:
|
||||
icelite: true
|
||||
iceservers:
|
||||
- urls: [ stun:stun.l.google.com:19302 ]
|
||||
# username: foo
|
||||
# credential: bar
|
143
dev/runtime/config.yml
Normal file
143
dev/runtime/config.yml
Normal file
|
@ -0,0 +1,143 @@
|
|||
capture:
|
||||
video:
|
||||
codec: vp8
|
||||
ids: [ hq, lq ]
|
||||
pipelines:
|
||||
hq:
|
||||
fps: 25
|
||||
gst_encoder: vp8enc
|
||||
gst_params:
|
||||
target-bitrate: round(3072 * 650)
|
||||
cpu-used: 4
|
||||
end-usage: cbr
|
||||
threads: 4
|
||||
deadline: 1
|
||||
undershoot: 95
|
||||
buffer-size: (3072 * 4)
|
||||
buffer-initial-size: (3072 * 2)
|
||||
buffer-optimal-size: (3072 * 3)
|
||||
keyframe-max-dist: 25
|
||||
min-quantizer: 4
|
||||
max-quantizer: 20
|
||||
lq:
|
||||
fps: 25
|
||||
gst_encoder: vp8enc
|
||||
gst_params:
|
||||
target-bitrate: round(1024 * 650)
|
||||
cpu-used: 4
|
||||
end-usage: cbr
|
||||
threads: 4
|
||||
deadline: 1
|
||||
undershoot: 95
|
||||
buffer-size: (1024 * 4)
|
||||
buffer-initial-size: (1024 * 2)
|
||||
buffer-optimal-size: (1024 * 3)
|
||||
keyframe-max-dist: 25
|
||||
min-quantizer: 4
|
||||
max-quantizer: 20
|
||||
# video:
|
||||
# codec: h264
|
||||
# ids: [ main ]
|
||||
# pipelines:
|
||||
# main:
|
||||
# width: (width / 3) * 2
|
||||
# height: (height / 3) * 2
|
||||
# fps: 20
|
||||
# gst_prefix: "! video/x-raw,format=I420"
|
||||
# gst_encoder: "x264enc"
|
||||
# gst_params:
|
||||
# threads: 4
|
||||
# bitrate: 4096
|
||||
# key-int-max: 15
|
||||
# byte-stream: true
|
||||
# tune: zerolatency
|
||||
# speed-preset: veryfast
|
||||
# gst_suffix: "! video/x-h264,stream-format=byte-stream"
|
||||
screencast:
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
pprof: true
|
||||
|
||||
desktop:
|
||||
screen: "1920x1080@60"
|
||||
|
||||
member:
|
||||
provider: "object"
|
||||
object:
|
||||
users:
|
||||
- username: "admin"
|
||||
password: "admin"
|
||||
profile:
|
||||
name: "Administrator"
|
||||
is_admin: true
|
||||
can_login: true
|
||||
can_connect: true
|
||||
can_watch: true
|
||||
can_host: true
|
||||
can_share_media: true
|
||||
can_access_clipboard: true
|
||||
sends_inactive_cursor: true
|
||||
can_see_inactive_cursors: true
|
||||
- username: "user"
|
||||
password: "neko"
|
||||
profile:
|
||||
name: "User"
|
||||
is_admin: false
|
||||
can_login: true
|
||||
can_connect: true
|
||||
can_watch: true
|
||||
can_host: true
|
||||
can_share_media: true
|
||||
can_access_clipboard: true
|
||||
sends_inactive_cursor: true
|
||||
can_see_inactive_cursors: false
|
||||
# provider: "file"
|
||||
# file:
|
||||
# path: "/home/neko/members.json"
|
||||
# provider: "multiuser"
|
||||
# multiuser:
|
||||
# admin_password: "admin"
|
||||
# user_password: "neko"
|
||||
# admin_profile: # optional
|
||||
# user_profile: # optional
|
||||
# provider: "noauth"
|
||||
|
||||
session:
|
||||
# Allows reconnecting the websocket even if the previous
|
||||
# connection was not closed. Can lead to session hijacking.
|
||||
merciful_reconnect: true
|
||||
# Show inactive cursors on the screen. Can lead to multiple
|
||||
# data sent via WebSockets and additonal rendering cost on
|
||||
# the clients.
|
||||
inactive_cursors: true
|
||||
api_token: "neko123"
|
||||
cookie:
|
||||
# Disabling cookies will result to use Bearer Authentication.
|
||||
# This is less secure, because access token will be sent to
|
||||
# client in playload and accessible via JS app.
|
||||
enabled: false
|
||||
secure: false
|
||||
|
||||
webrtc:
|
||||
icelite: true
|
||||
iceservers:
|
||||
# Backend servers are ignored if icelite is true.
|
||||
backend:
|
||||
- urls: [ stun:stun.l.google.com:19302 ]
|
||||
frontend:
|
||||
- urls: [ stun:stun.l.google.com:19305 ]
|
||||
#username: foo
|
||||
#credential: bar
|
||||
# estimator:
|
||||
# enabled: true
|
||||
# passive: false
|
||||
# debug: true
|
||||
# initial_bitrate: 1000000
|
||||
# read_interval: 1s
|
||||
# stable_duration: 10s
|
||||
# unstable_duration: 5s
|
||||
# stalled_duration: 20s
|
||||
# downgrade_backoff: 10s
|
||||
# upgrade_backoff: 30s
|
||||
# diff_threshold: 0.5
|
10
dev/runtime/supervisord.conf
Normal file
10
dev/runtime/supervisord.conf
Normal file
|
@ -0,0 +1,10 @@
|
|||
[program:xfce]
|
||||
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
|
||||
command=/usr/bin/startxfce4
|
||||
stopsignal=INT
|
||||
autorestart=true
|
||||
priority=500
|
||||
user=%(ENV_USER)s
|
||||
stdout_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
60
dev/start
Executable file
60
dev/start
Executable file
|
@ -0,0 +1,60 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -z "$(docker images -q neko_server_app 2> /dev/null)" ]; then
|
||||
echo "Image 'neko_server_app' not found. Running ./build first."
|
||||
./build
|
||||
fi
|
||||
|
||||
if [ -z $NEKO_PORT ]; then
|
||||
NEKO_PORT="3000"
|
||||
fi
|
||||
|
||||
if [ -z $NEKO_MUX ]; then
|
||||
NEKO_MUX="52100"
|
||||
fi
|
||||
|
||||
if [ -z $NEKO_NAT1TO1 ]; then
|
||||
for i in $(ifconfig -l 2>/dev/null); do
|
||||
NEKO_NAT1TO1=$(ipconfig getifaddr $i)
|
||||
if [ ! -z $NEKO_NAT1TO1 ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z $NEKO_NAT1TO1 ]; then
|
||||
NEKO_NAT1TO1=$(hostname -i 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
|
||||
# if first argument is nvidia, start with nvidia runtime
|
||||
if [ "$1" = "nvidia" ]; then
|
||||
echo "Starting nvidia docker image"
|
||||
EXTRAOPTS="--gpus all"
|
||||
CONFIG="config.nvidia.yml"
|
||||
else
|
||||
echo "Starting default docker image"
|
||||
EXTRAOPTS=""
|
||||
CONFIG="config.yml"
|
||||
fi
|
||||
|
||||
echo "Using app port: ${NEKO_PORT}"
|
||||
echo "Using mux port: ${NEKO_MUX}"
|
||||
echo "Using IP address: ${NEKO_NAT1TO1}"
|
||||
|
||||
# start server
|
||||
docker run --rm -it \
|
||||
--name "neko_server_dev" \
|
||||
-p "${NEKO_PORT}:8080" \
|
||||
-p "${NEKO_MUX}:${NEKO_MUX}/tcp" \
|
||||
-p "${NEKO_MUX}:${NEKO_MUX}/udp" \
|
||||
-e "NEKO_WEBRTC_UDPMUX=${NEKO_MUX}" \
|
||||
-e "NEKO_WEBRTC_TCPMUX=${NEKO_MUX}" \
|
||||
-e "NEKO_WEBRTC_NAT1TO1=${NEKO_NAT1TO1}" \
|
||||
-e "NEKO_SESSION_FILE=/home/neko/sessions.txt" \
|
||||
-v "${PWD}/runtime/$CONFIG:/etc/neko/neko.yml" \
|
||||
-e "NEKO_DEBUG=1" \
|
||||
--shm-size=2G \
|
||||
--security-opt seccomp=unconfined \
|
||||
$EXTRAOPTS \
|
||||
neko_server_app:latest;
|
68
go.mod
Normal file
68
go.mod
Normal file
|
@ -0,0 +1,68 @@
|
|||
module github.com/demodesk/neko
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/PaesslerAG/gval v1.2.2
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/kataras/go-events v0.0.3
|
||||
github.com/pion/ice/v2 v2.3.12
|
||||
github.com/pion/interceptor v0.1.25
|
||||
github.com/pion/logging v0.2.2
|
||||
github.com/pion/rtcp v1.2.13
|
||||
github.com/pion/webrtc/v3 v3.2.24
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/pion/datachannel v1.5.5 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.9 // indirect
|
||||
github.com/pion/mdns v0.0.9 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtp v1.8.3 // indirect
|
||||
github.com/pion/sctp v1.8.9 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.6 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.18 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/turn/v2 v2.1.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.46.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shopspring/decimal v1.3.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.18.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
305
go.sum
Normal file
305
go.sum
Normal file
|
@ -0,0 +1,305 @@
|
|||
github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E=
|
||||
github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0 h1:gADYeifvlqK3R3i2cR5B4DGgxLXIPb3TRTH1mGi0jPI=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||
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-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kataras/go-events v0.0.3 h1:o5YK53uURXtrlg7qE/vovxd/yKOJcLuFtPQbf1rYMC4=
|
||||
github.com/kataras/go-events v0.0.3/go.mod h1:bFBgtzwwzrag7kQmGuU1ZaVxhK2qseYPQomXoVEMsj4=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
|
||||
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.9 h1:K+D/aVf9/REahQvqk6G5JavdrD8W1PWDKC11UlwN7ts=
|
||||
github.com/pion/dtls/v2 v2.2.9/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/ice/v2 v2.3.11/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E=
|
||||
github.com/pion/ice/v2 v2.3.12 h1:NWKW2b3+oSZS3klbQMIEWQ0i52Kuo0KBg505a5kQv4s=
|
||||
github.com/pion/ice/v2 v2.3.12/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E=
|
||||
github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc=
|
||||
github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.8/go.mod h1:hYE72WX8WDveIhg7fmXgMKivD3Puklk0Ymzog0lSyaI=
|
||||
github.com/pion/mdns v0.0.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4=
|
||||
github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.13 h1:+EQijuisKwm/8VBs8nWllr0bIndR7Lf7cZG200mpbNo=
|
||||
github.com/pion/rtcp v1.2.13/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.8/go.mod h1:igF9nZBrjh5AtmKc7U30jXltsFHicFCXSmWA2GWRaWs=
|
||||
github.com/pion/sctp v1.8.9 h1:TP5ZVxV5J7rz7uZmbyvnUvsn7EJ2x/5q9uhsTtXbI3g=
|
||||
github.com/pion/sctp v1.8.9/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI=
|
||||
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
|
||||
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
|
||||
github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo=
|
||||
github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
|
||||
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.4 h1:2xn8rduI5W6sCZQkEnIUDAkrBQNl2eYIBCHMZ3QMmP8=
|
||||
github.com/pion/turn/v2 v2.1.4/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.2.24 h1:MiFL5DMo2bDaaIFWr0DDpwiV/L4EGbLZb+xoRvfEo1Y=
|
||||
github.com/pion/webrtc/v3 v3.2.24/go.mod h1:1CaT2fcZzZ6VZA+O1i9yK2DU4EOcXVvSbWG9pr5jefs=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
|
||||
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
|
||||
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
84
internal/api/members/bluk.go
Normal file
84
internal/api/members/bluk.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package members
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type MemberBulkUpdatePayload struct {
|
||||
IDs []string `json:"ids"`
|
||||
Profile types.MemberProfile `json:"profile"`
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersBulkUpdate(w http.ResponseWriter, r *http.Request) error {
|
||||
bytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("unable to read post body").WithInternalErr(err)
|
||||
}
|
||||
|
||||
header := &MemberBulkUpdatePayload{}
|
||||
if err := json.Unmarshal(bytes, &header); err != nil {
|
||||
return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err)
|
||||
}
|
||||
|
||||
for _, memberId := range header.IDs {
|
||||
// TODO: Bulk select?
|
||||
profile, err := h.members.Select(memberId)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to select member profile").
|
||||
Msgf("failed to update member %s", memberId)
|
||||
}
|
||||
|
||||
body := &MemberBulkUpdatePayload{
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bytes, &body); err != nil {
|
||||
return utils.HttpBadRequest().
|
||||
WithInternalErr(err).
|
||||
Msgf("unable to unmarshal payload for member %s", memberId)
|
||||
}
|
||||
|
||||
if err := h.members.UpdateProfile(memberId, body.Profile); err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to update member profile").
|
||||
Msgf("failed to update member %s", memberId)
|
||||
}
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
type MemberBulkDeletePayload struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersBulkDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
bytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("unable to read post body").WithInternalErr(err)
|
||||
}
|
||||
|
||||
data := &MemberBulkDeletePayload{}
|
||||
if err := json.Unmarshal(bytes, &data); err != nil {
|
||||
return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err)
|
||||
}
|
||||
|
||||
for _, memberId := range data.IDs {
|
||||
if err := h.members.Delete(memberId); err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to delete member").
|
||||
Msgf("failed to delete member %s", memberId)
|
||||
}
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
144
internal/api/members/controler.go
Normal file
144
internal/api/members/controler.go
Normal file
|
@ -0,0 +1,144 @@
|
|||
package members
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type MemberDataPayload struct {
|
||||
ID string `json:"id"`
|
||||
Profile types.MemberProfile `json:"profile"`
|
||||
}
|
||||
|
||||
type MemberCreatePayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Profile types.MemberProfile `json:"profile"`
|
||||
}
|
||||
|
||||
type MemberPasswordPayload struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersList(w http.ResponseWriter, r *http.Request) error {
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
// TODO: Default zero.
|
||||
limit = 0
|
||||
}
|
||||
|
||||
offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if err != nil {
|
||||
// TODO: Default zero.
|
||||
offset = 0
|
||||
}
|
||||
|
||||
entries, err := h.members.SelectAll(limit, offset)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
members := []MemberDataPayload{}
|
||||
for id, profile := range entries {
|
||||
members = append(members, MemberDataPayload{
|
||||
ID: id,
|
||||
Profile: profile,
|
||||
})
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, members)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersCreate(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &MemberCreatePayload{
|
||||
// default values
|
||||
Profile: types.MemberProfile{
|
||||
IsAdmin: false,
|
||||
CanLogin: true,
|
||||
CanConnect: true,
|
||||
CanWatch: true,
|
||||
CanHost: true,
|
||||
CanShareMedia: true,
|
||||
CanAccessClipboard: true,
|
||||
SendsInactiveCursor: true,
|
||||
CanSeeInactiveCursors: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.Username == "" {
|
||||
return utils.HttpBadRequest("username cannot be empty")
|
||||
}
|
||||
|
||||
if data.Password == "" {
|
||||
return utils.HttpBadRequest("password cannot be empty")
|
||||
}
|
||||
|
||||
id, err := h.members.Insert(data.Username, data.Password, data.Profile)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrMemberAlreadyExists) {
|
||||
return utils.HttpUnprocessableEntity("member already exists")
|
||||
}
|
||||
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, MemberDataPayload{
|
||||
ID: id,
|
||||
Profile: data.Profile,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersRead(w http.ResponseWriter, r *http.Request) error {
|
||||
member := GetMember(r)
|
||||
profile := member.Profile
|
||||
|
||||
return utils.HttpSuccess(w, profile)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersUpdateProfile(w http.ResponseWriter, r *http.Request) error {
|
||||
member := GetMember(r)
|
||||
data := &member.Profile
|
||||
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.members.UpdateProfile(member.ID, *data); err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersUpdatePassword(w http.ResponseWriter, r *http.Request) error {
|
||||
member := GetMember(r)
|
||||
data := &MemberPasswordPayload{}
|
||||
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.members.UpdatePassword(member.ID, data.Password); err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) membersDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
member := GetMember(r)
|
||||
|
||||
if err := h.members.Delete(member.ID); err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
83
internal/api/members/handler.go
Normal file
83
internal/api/members/handler.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package members
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type key int
|
||||
|
||||
const keyMemberCtx key = iota
|
||||
|
||||
type MembersHandler struct {
|
||||
members types.MemberManager
|
||||
}
|
||||
|
||||
func New(
|
||||
members types.MemberManager,
|
||||
) *MembersHandler {
|
||||
// Init
|
||||
|
||||
return &MembersHandler{
|
||||
members: members,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MembersHandler) Route(r types.Router) {
|
||||
r.Get("/", h.membersList)
|
||||
|
||||
r.With(auth.AdminsOnly).Group(func(r types.Router) {
|
||||
r.Post("/", h.membersCreate)
|
||||
r.With(h.ExtractMember).Route("/{memberId}", func(r types.Router) {
|
||||
r.Get("/", h.membersRead)
|
||||
r.Post("/", h.membersUpdateProfile)
|
||||
r.Post("/password", h.membersUpdatePassword)
|
||||
r.Delete("/", h.membersDelete)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MembersHandler) RouteBulk(r types.Router) {
|
||||
r.With(auth.AdminsOnly).Group(func(r types.Router) {
|
||||
r.Post("/update", h.membersBulkUpdate)
|
||||
r.Post("/delete", h.membersBulkDelete)
|
||||
})
|
||||
}
|
||||
|
||||
type MemberData struct {
|
||||
ID string
|
||||
Profile types.MemberProfile
|
||||
}
|
||||
|
||||
func SetMember(r *http.Request, session MemberData) context.Context {
|
||||
return context.WithValue(r.Context(), keyMemberCtx, session)
|
||||
}
|
||||
|
||||
func GetMember(r *http.Request) MemberData {
|
||||
return r.Context().Value(keyMemberCtx).(MemberData)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) ExtractMember(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||
memberId := chi.URLParam(r, "memberId")
|
||||
|
||||
profile, err := h.members.Select(memberId)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrMemberDoesNotExist) {
|
||||
return nil, utils.HttpNotFound("member not found")
|
||||
}
|
||||
|
||||
return nil, utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return SetMember(r, MemberData{
|
||||
ID: memberId,
|
||||
Profile: profile,
|
||||
}), nil
|
||||
}
|
70
internal/api/room/broadcast.go
Normal file
70
internal/api/room/broadcast.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types/event"
|
||||
"github.com/demodesk/neko/pkg/types/message"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type BroadcastStatusPayload struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (h *RoomHandler) broadcastStatus(w http.ResponseWriter, r *http.Request) error {
|
||||
broadcast := h.capture.Broadcast()
|
||||
|
||||
return utils.HttpSuccess(w, BroadcastStatusPayload{
|
||||
IsActive: broadcast.Started(),
|
||||
URL: broadcast.Url(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RoomHandler) boradcastStart(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &BroadcastStatusPayload{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.URL == "" {
|
||||
return utils.HttpBadRequest("missing broadcast URL")
|
||||
}
|
||||
|
||||
broadcast := h.capture.Broadcast()
|
||||
if broadcast.Started() {
|
||||
return utils.HttpUnprocessableEntity("server is already broadcasting")
|
||||
}
|
||||
|
||||
if err := broadcast.Start(data.URL); err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
h.sessions.AdminBroadcast(
|
||||
event.BORADCAST_STATUS,
|
||||
message.BroadcastStatus{
|
||||
IsActive: broadcast.Started(),
|
||||
URL: broadcast.Url(),
|
||||
})
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) boradcastStop(w http.ResponseWriter, r *http.Request) error {
|
||||
broadcast := h.capture.Broadcast()
|
||||
if !broadcast.Started() {
|
||||
return utils.HttpUnprocessableEntity("server is not broadcasting")
|
||||
}
|
||||
|
||||
broadcast.Stop()
|
||||
|
||||
h.sessions.AdminBroadcast(
|
||||
event.BORADCAST_STATUS,
|
||||
message.BroadcastStatus{
|
||||
IsActive: broadcast.Started(),
|
||||
URL: broadcast.Url(),
|
||||
})
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
107
internal/api/room/clipboard.go
Normal file
107
internal/api/room/clipboard.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
// TODO: Unused now.
|
||||
//"bytes"
|
||||
//"strings"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type ClipboardPayload struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
}
|
||||
|
||||
func (h *RoomHandler) clipboardGetText(w http.ResponseWriter, r *http.Request) error {
|
||||
data, err := h.desktop.ClipboardGetText()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, ClipboardPayload{
|
||||
Text: data.Text,
|
||||
HTML: data.HTML,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RoomHandler) clipboardSetText(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &ClipboardPayload{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := h.desktop.ClipboardSetText(types.ClipboardText{
|
||||
Text: data.Text,
|
||||
HTML: data.HTML,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) clipboardGetImage(w http.ResponseWriter, r *http.Request) error {
|
||||
bytes, err := h.desktop.ClipboardGetBinary("image/png")
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
|
||||
_, err = w.Write(bytes)
|
||||
return err
|
||||
}
|
||||
|
||||
/* TODO: Unused now.
|
||||
func (h *RoomHandler) clipboardSetImage(w http.ResponseWriter, r *http.Request) error {
|
||||
err := r.ParseMultipartForm(MAX_UPLOAD_SIZE)
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
|
||||
}
|
||||
|
||||
//nolint
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("no file received").WithInternalErr(err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
mime := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return utils.HttpBadRequest("file must be image")
|
||||
}
|
||||
|
||||
buffer := new(bytes.Buffer)
|
||||
_, err = buffer.ReadFrom(file)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable to read from uploaded file")
|
||||
}
|
||||
|
||||
err = h.desktop.ClipboardSetBinary("image/png", buffer.Bytes())
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable set image to clipboard")
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) clipboardGetTargets(w http.ResponseWriter, r *http.Request) error {
|
||||
targets, err := h.desktop.ClipboardGetTargets()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, targets)
|
||||
}
|
||||
|
||||
*/
|
96
internal/api/room/control.go
Normal file
96
internal/api/room/control.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type ControlStatusPayload struct {
|
||||
HasHost bool `json:"has_host"`
|
||||
HostId string `json:"host_id,omitempty"`
|
||||
}
|
||||
|
||||
type ControlTargetPayload struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlStatus(w http.ResponseWriter, r *http.Request) error {
|
||||
host, hasHost := h.sessions.GetHost()
|
||||
|
||||
var hostId string
|
||||
if hasHost {
|
||||
hostId = host.ID()
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, ControlStatusPayload{
|
||||
HasHost: hasHost,
|
||||
HostId: hostId,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
_, hasHost := h.sessions.GetHost()
|
||||
if hasHost {
|
||||
return utils.HttpUnprocessableEntity("there is already a host")
|
||||
}
|
||||
|
||||
session, _ := auth.GetSession(r)
|
||||
if h.sessions.Settings().LockedControls && !session.Profile().IsAdmin {
|
||||
return utils.HttpForbidden("controls are locked")
|
||||
}
|
||||
|
||||
h.sessions.SetHost(session)
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlRelease(w http.ResponseWriter, r *http.Request) error {
|
||||
session, _ := auth.GetSession(r)
|
||||
if !session.IsHost() {
|
||||
return utils.HttpUnprocessableEntity("session is not the host")
|
||||
}
|
||||
|
||||
h.desktop.ResetKeys()
|
||||
h.sessions.ClearHost()
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlTake(w http.ResponseWriter, r *http.Request) error {
|
||||
session, _ := auth.GetSession(r)
|
||||
h.sessions.SetHost(session)
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlGive(w http.ResponseWriter, r *http.Request) error {
|
||||
sessionId := chi.URLParam(r, "sessionId")
|
||||
|
||||
target, ok := h.sessions.Get(sessionId)
|
||||
if !ok {
|
||||
return utils.HttpNotFound("target session was not found")
|
||||
}
|
||||
|
||||
if !target.Profile().CanHost {
|
||||
return utils.HttpBadRequest("target session is not allowed to host")
|
||||
}
|
||||
|
||||
h.sessions.SetHost(target)
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) controlReset(w http.ResponseWriter, r *http.Request) error {
|
||||
_, hasHost := h.sessions.GetHost()
|
||||
|
||||
if hasHost {
|
||||
h.desktop.ResetKeys()
|
||||
h.sessions.ClearHost()
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
126
internal/api/room/handler.go
Normal file
126
internal/api/room/handler.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type RoomHandler struct {
|
||||
sessions types.SessionManager
|
||||
desktop types.DesktopManager
|
||||
capture types.CaptureManager
|
||||
|
||||
privateModeImage []byte
|
||||
}
|
||||
|
||||
func New(
|
||||
sessions types.SessionManager,
|
||||
desktop types.DesktopManager,
|
||||
capture types.CaptureManager,
|
||||
) *RoomHandler {
|
||||
h := &RoomHandler{
|
||||
sessions: sessions,
|
||||
desktop: desktop,
|
||||
capture: capture,
|
||||
}
|
||||
|
||||
// generate fallback image for private mode when needed
|
||||
sessions.OnSettingsChanged(func(new types.Settings, old types.Settings) {
|
||||
if old.PrivateMode && !new.PrivateMode {
|
||||
log.Debug().Msg("clearing private mode fallback image")
|
||||
h.privateModeImage = nil
|
||||
return
|
||||
}
|
||||
|
||||
if !old.PrivateMode && new.PrivateMode {
|
||||
img := h.desktop.GetScreenshotImage()
|
||||
bytes, err := utils.CreateJPGImage(img, 90)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("could not generate private mode fallback image")
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msg("using private mode fallback image")
|
||||
h.privateModeImage = bytes
|
||||
}
|
||||
})
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *RoomHandler) Route(r types.Router) {
|
||||
r.With(auth.AdminsOnly).Route("/settings", func(r types.Router) {
|
||||
r.Post("/", h.settingsSet)
|
||||
r.Get("/", h.settingsGet)
|
||||
})
|
||||
|
||||
r.With(auth.AdminsOnly).Route("/broadcast", func(r types.Router) {
|
||||
r.Get("/", h.broadcastStatus)
|
||||
r.Post("/start", h.boradcastStart)
|
||||
r.Post("/stop", h.boradcastStop)
|
||||
})
|
||||
|
||||
r.With(auth.CanAccessClipboardOnly).With(auth.HostsOnly).Route("/clipboard", func(r types.Router) {
|
||||
r.Get("/", h.clipboardGetText)
|
||||
r.Post("/", h.clipboardSetText)
|
||||
r.Get("/image.png", h.clipboardGetImage)
|
||||
|
||||
// TODO: Refactor. xclip is failing to set propper target type
|
||||
// and this content is sent back to client as text in another
|
||||
// clipboard update. Therefore endpoint is not usable!
|
||||
//r.Post("/image", h.clipboardSetImage)
|
||||
|
||||
// TODO: Refactor. If there would be implemented custom target
|
||||
// retrieval, this endpoint would be useful.
|
||||
//r.Get("/targets", h.clipboardGetTargets)
|
||||
})
|
||||
|
||||
r.With(auth.CanHostOnly).Route("/keyboard", func(r types.Router) {
|
||||
r.Get("/map", h.keyboardMapGet)
|
||||
r.With(auth.HostsOnly).Post("/map", h.keyboardMapSet)
|
||||
|
||||
r.Get("/modifiers", h.keyboardModifiersGet)
|
||||
r.With(auth.HostsOnly).Post("/modifiers", h.keyboardModifiersSet)
|
||||
})
|
||||
|
||||
r.With(auth.CanHostOnly).Route("/control", func(r types.Router) {
|
||||
r.Get("/", h.controlStatus)
|
||||
r.Post("/request", h.controlRequest)
|
||||
r.Post("/release", h.controlRelease)
|
||||
|
||||
r.With(auth.AdminsOnly).Post("/take", h.controlTake)
|
||||
r.With(auth.AdminsOnly).Post("/give/{sessionId}", h.controlGive)
|
||||
r.With(auth.AdminsOnly).Post("/reset", h.controlReset)
|
||||
})
|
||||
|
||||
r.With(auth.CanWatchOnly).Route("/screen", func(r types.Router) {
|
||||
r.Get("/", h.screenConfiguration)
|
||||
r.With(auth.AdminsOnly).Post("/", h.screenConfigurationChange)
|
||||
r.With(auth.AdminsOnly).Get("/configurations", h.screenConfigurationsList)
|
||||
|
||||
r.Get("/cast.jpg", h.screenCastGet)
|
||||
r.With(auth.AdminsOnly).Get("/shot.jpg", h.screenShotGet)
|
||||
})
|
||||
|
||||
r.With(h.uploadMiddleware).Route("/upload", func(r types.Router) {
|
||||
r.Post("/drop", h.uploadDrop)
|
||||
r.Post("/dialog", h.uploadDialogPost)
|
||||
r.Delete("/dialog", h.uploadDialogClose)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (h *RoomHandler) uploadMiddleware(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||
session, ok := auth.GetSession(r)
|
||||
if !ok || (!session.IsHost() && (!session.Profile().CanHost || !h.sessions.Settings().ImplicitHosting)) {
|
||||
return nil, utils.HttpForbidden("without implicit hosting, only host can upload files")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
57
internal/api/room/keyboard.go
Normal file
57
internal/api/room/keyboard.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type KeyboardMapData struct {
|
||||
types.KeyboardMap
|
||||
}
|
||||
|
||||
type KeyboardModifiersData struct {
|
||||
types.KeyboardModifiers
|
||||
}
|
||||
|
||||
func (h *RoomHandler) keyboardMapSet(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &KeyboardMapData{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := h.desktop.SetKeyboardMap(data.KeyboardMap)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) keyboardMapGet(w http.ResponseWriter, r *http.Request) error {
|
||||
data, err := h.desktop.GetKeyboardMap()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, KeyboardMapData{
|
||||
KeyboardMap: *data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RoomHandler) keyboardModifiersSet(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &KeyboardModifiersData{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.desktop.SetKeyboardModifiers(data.KeyboardModifiers)
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) keyboardModifiersGet(w http.ResponseWriter, r *http.Request) error {
|
||||
return utils.HttpSuccess(w, KeyboardModifiersData{
|
||||
KeyboardModifiers: h.desktop.GetKeyboardModifiers(),
|
||||
})
|
||||
}
|
119
internal/api/room/screen.go
Normal file
119
internal/api/room/screen.go
Normal file
|
@ -0,0 +1,119 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/event"
|
||||
"github.com/demodesk/neko/pkg/types/message"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type ScreenConfigurationPayload struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Rate int16 `json:"rate"`
|
||||
}
|
||||
|
||||
func (h *RoomHandler) screenConfiguration(w http.ResponseWriter, r *http.Request) error {
|
||||
size := h.desktop.GetScreenSize()
|
||||
|
||||
return utils.HttpSuccess(w, ScreenConfigurationPayload{
|
||||
Width: size.Width,
|
||||
Height: size.Height,
|
||||
Rate: size.Rate,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RoomHandler) screenConfigurationChange(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &ScreenConfigurationPayload{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
size, err := h.desktop.SetScreenSize(types.ScreenSize{
|
||||
Width: data.Width,
|
||||
Height: data.Height,
|
||||
Rate: data.Rate,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return utils.HttpUnprocessableEntity("cannot set screen size").WithInternalErr(err)
|
||||
}
|
||||
|
||||
h.sessions.Broadcast(event.SCREEN_UPDATED, message.ScreenSize{
|
||||
Width: size.Width,
|
||||
Height: size.Height,
|
||||
Rate: size.Rate,
|
||||
})
|
||||
|
||||
return utils.HttpSuccess(w, data)
|
||||
}
|
||||
|
||||
// TODO: remove.
|
||||
func (h *RoomHandler) screenConfigurationsList(w http.ResponseWriter, r *http.Request) error {
|
||||
configurations := h.desktop.ScreenConfigurations()
|
||||
|
||||
list := make([]ScreenConfigurationPayload, 0, len(configurations))
|
||||
for _, conf := range configurations {
|
||||
list = append(list, ScreenConfigurationPayload{
|
||||
Width: conf.Width,
|
||||
Height: conf.Height,
|
||||
Rate: conf.Rate,
|
||||
})
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, list)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) screenShotGet(w http.ResponseWriter, r *http.Request) error {
|
||||
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
|
||||
if err != nil {
|
||||
quality = 90
|
||||
}
|
||||
|
||||
img := h.desktop.GetScreenshotImage()
|
||||
bytes, err := utils.CreateJPGImage(img, quality)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
|
||||
_, err = w.Write(bytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *RoomHandler) screenCastGet(w http.ResponseWriter, r *http.Request) error {
|
||||
// display fallback image when private mode is enabled even if screencast is not
|
||||
if session, ok := auth.GetSession(r); ok && session.PrivateModeEnabled() {
|
||||
if h.privateModeImage != nil {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
|
||||
_, err := w.Write(h.privateModeImage)
|
||||
return err
|
||||
}
|
||||
|
||||
return utils.HttpBadRequest("private mode is enabled but no fallback image available")
|
||||
}
|
||||
|
||||
screencast := h.capture.Screencast()
|
||||
if !screencast.Enabled() {
|
||||
return utils.HttpBadRequest("screencast pipeline is not enabled")
|
||||
}
|
||||
|
||||
bytes, err := screencast.Image()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
|
||||
_, err = w.Write(bytes)
|
||||
return err
|
||||
}
|
24
internal/api/room/settings.go
Normal file
24
internal/api/room/settings.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func (h *RoomHandler) settingsGet(w http.ResponseWriter, r *http.Request) error {
|
||||
settings := h.sessions.Settings()
|
||||
return utils.HttpSuccess(w, settings)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) settingsSet(w http.ResponseWriter, r *http.Request) error {
|
||||
settings := h.sessions.Settings()
|
||||
|
||||
if err := utils.HttpJsonRequest(w, r, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.sessions.UpdateSettings(settings)
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
172
internal/api/room/upload.go
Normal file
172
internal/api/room/upload.go
Normal file
|
@ -0,0 +1,172 @@
|
|||
package room
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
// TODO: Extract file uploading to custom utility.
|
||||
|
||||
// maximum upload size of 32 MB
|
||||
const maxUploadSize = 32 << 20
|
||||
|
||||
func (h *RoomHandler) uploadDrop(w http.ResponseWriter, r *http.Request) error {
|
||||
if !h.desktop.IsUploadDropEnabled() {
|
||||
return utils.HttpBadRequest("upload drop is disabled")
|
||||
}
|
||||
|
||||
err := r.ParseMultipartForm(maxUploadSize)
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
|
||||
}
|
||||
|
||||
//nolint
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
X, err := strconv.Atoi(r.FormValue("x"))
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("no X coordinate received").WithInternalErr(err)
|
||||
}
|
||||
|
||||
Y, err := strconv.Atoi(r.FormValue("y"))
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("no Y coordinate received").WithInternalErr(err)
|
||||
}
|
||||
|
||||
req_files := r.MultipartForm.File["files"]
|
||||
if len(req_files) == 0 {
|
||||
return utils.HttpBadRequest("no files received")
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "neko-drop-*")
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to create temporary directory")
|
||||
}
|
||||
|
||||
files := []string{}
|
||||
for _, req_file := range req_files {
|
||||
path := path.Join(dir, req_file.Filename)
|
||||
|
||||
srcFile, err := req_file.Open()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to open uploaded file")
|
||||
}
|
||||
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to open destination file")
|
||||
}
|
||||
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to copy uploaded file to destination file")
|
||||
}
|
||||
|
||||
files = append(files, path)
|
||||
}
|
||||
|
||||
if !h.desktop.DropFiles(X, Y, files) {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalMsg("unable to drop files")
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) uploadDialogPost(w http.ResponseWriter, r *http.Request) error {
|
||||
if !h.desktop.IsFileChooserDialogEnabled() {
|
||||
return utils.HttpBadRequest("file chooser dialog is disabled")
|
||||
}
|
||||
|
||||
err := r.ParseMultipartForm(maxUploadSize)
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
|
||||
}
|
||||
|
||||
//nolint
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
req_files := r.MultipartForm.File["files"]
|
||||
if len(req_files) == 0 {
|
||||
return utils.HttpBadRequest("no files received")
|
||||
}
|
||||
|
||||
if !h.desktop.IsFileChooserDialogOpened() {
|
||||
return utils.HttpUnprocessableEntity("file chooser dialog is not open")
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "neko-dialog-*")
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to create temporary directory")
|
||||
}
|
||||
|
||||
for _, req_file := range req_files {
|
||||
path := path.Join(dir, req_file.Filename)
|
||||
|
||||
srcFile, err := req_file.Open()
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to open uploaded file")
|
||||
}
|
||||
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to open destination file")
|
||||
}
|
||||
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to copy uploaded file to destination file")
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.desktop.HandleFileChooserDialog(dir); err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
WithInternalMsg("unable to handle file chooser dialog")
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
||||
|
||||
func (h *RoomHandler) uploadDialogClose(w http.ResponseWriter, r *http.Request) error {
|
||||
if !h.desktop.IsFileChooserDialogEnabled() {
|
||||
return utils.HttpBadRequest("file chooser dialog is disabled")
|
||||
}
|
||||
|
||||
if !h.desktop.IsFileChooserDialogOpened() {
|
||||
return utils.HttpUnprocessableEntity("file chooser dialog is not open")
|
||||
}
|
||||
|
||||
h.desktop.CloseFileChooserDialog()
|
||||
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
82
internal/api/router.go
Normal file
82
internal/api/router.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/internal/api/members"
|
||||
"github.com/demodesk/neko/internal/api/room"
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type ApiManagerCtx struct {
|
||||
sessions types.SessionManager
|
||||
members types.MemberManager
|
||||
desktop types.DesktopManager
|
||||
capture types.CaptureManager
|
||||
routers map[string]func(types.Router)
|
||||
}
|
||||
|
||||
func New(
|
||||
sessions types.SessionManager,
|
||||
members types.MemberManager,
|
||||
desktop types.DesktopManager,
|
||||
capture types.CaptureManager,
|
||||
) *ApiManagerCtx {
|
||||
|
||||
return &ApiManagerCtx{
|
||||
sessions: sessions,
|
||||
members: members,
|
||||
desktop: desktop,
|
||||
capture: capture,
|
||||
routers: make(map[string]func(types.Router)),
|
||||
}
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Route(r types.Router) {
|
||||
r.Post("/login", api.Login)
|
||||
|
||||
// Authenticated area
|
||||
r.Group(func(r types.Router) {
|
||||
r.Use(api.Authenticate)
|
||||
|
||||
r.Post("/logout", api.Logout)
|
||||
r.Get("/whoami", api.Whoami)
|
||||
r.Get("/sessions", api.Sessions)
|
||||
|
||||
membersHandler := members.New(api.members)
|
||||
r.Route("/members", membersHandler.Route)
|
||||
r.Route("/members_bulk", membersHandler.RouteBulk)
|
||||
|
||||
roomHandler := room.New(api.sessions, api.desktop, api.capture)
|
||||
r.Route("/room", roomHandler.Route)
|
||||
|
||||
for path, router := range api.routers {
|
||||
r.Route(path, router)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||
session, err := api.sessions.Authenticate(r)
|
||||
if err != nil {
|
||||
if api.sessions.CookieEnabled() {
|
||||
api.sessions.CookieClearToken(w, r)
|
||||
}
|
||||
|
||||
if errors.Is(err, types.ErrSessionLoginDisabled) {
|
||||
return nil, utils.HttpForbidden("login is disabled for this session")
|
||||
}
|
||||
|
||||
return nil, utils.HttpUnauthorized().WithInternalErr(err)
|
||||
}
|
||||
|
||||
return auth.SetSession(r, session), nil
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) AddRouter(path string, router func(types.Router)) {
|
||||
api.routers[path] = router
|
||||
}
|
96
internal/api/session.go
Normal file
96
internal/api/session.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type SessionLoginPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SessionDataPayload struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Profile types.MemberProfile `json:"profile"`
|
||||
State types.SessionState `json:"state"`
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
|
||||
data := &SessionLoginPayload{}
|
||||
if err := utils.HttpJsonRequest(w, r, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, token, err := api.members.Login(data.Username, data.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrSessionAlreadyConnected) {
|
||||
return utils.HttpUnprocessableEntity("session already connected")
|
||||
} else if errors.Is(err, types.ErrMemberDoesNotExist) || errors.Is(err, types.ErrMemberInvalidPassword) {
|
||||
return utils.HttpUnauthorized().WithInternalErr(err)
|
||||
} else {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
sessionData := SessionDataPayload{
|
||||
ID: session.ID(),
|
||||
Profile: session.Profile(),
|
||||
State: session.State(),
|
||||
}
|
||||
|
||||
if api.sessions.CookieEnabled() {
|
||||
api.sessions.CookieSetToken(w, token)
|
||||
} else {
|
||||
sessionData.Token = token
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, sessionData)
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error {
|
||||
session, _ := auth.GetSession(r)
|
||||
|
||||
err := api.members.Logout(session.ID())
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrSessionNotFound) {
|
||||
return utils.HttpBadRequest("session is not logged in")
|
||||
} else {
|
||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
if api.sessions.CookieEnabled() {
|
||||
api.sessions.CookieClearToken(w, r)
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, true)
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
|
||||
session, _ := auth.GetSession(r)
|
||||
|
||||
return utils.HttpSuccess(w, SessionDataPayload{
|
||||
ID: session.ID(),
|
||||
Profile: session.Profile(),
|
||||
State: session.State(),
|
||||
})
|
||||
}
|
||||
|
||||
func (api *ApiManagerCtx) Sessions(w http.ResponseWriter, r *http.Request) error {
|
||||
sessions := []SessionDataPayload{}
|
||||
for _, session := range api.sessions.List() {
|
||||
sessions = append(sessions, SessionDataPayload{
|
||||
ID: session.ID(),
|
||||
Profile: session.Profile(),
|
||||
State: session.State(),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, sessions)
|
||||
}
|
156
internal/capture/broadcast.go
Normal file
156
internal/capture/broadcast.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/gst"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type BroacastManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
mu sync.Mutex
|
||||
|
||||
pipeline gst.Pipeline
|
||||
pipelineMu sync.Mutex
|
||||
pipelineFn func(url string) (string, error)
|
||||
|
||||
url string
|
||||
started bool
|
||||
|
||||
// metrics
|
||||
pipelinesCounter prometheus.Counter
|
||||
pipelinesActive prometheus.Gauge
|
||||
}
|
||||
|
||||
func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string) *BroacastManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "broadcast").
|
||||
Logger()
|
||||
|
||||
return &BroacastManagerCtx{
|
||||
logger: logger,
|
||||
pipelineFn: pipelineFn,
|
||||
url: defaultUrl,
|
||||
started: defaultUrl != "",
|
||||
|
||||
// metrics
|
||||
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pipelines_total",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of created pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "broadcast",
|
||||
"video_id": "main",
|
||||
"codec_name": "-",
|
||||
"codec_type": "-",
|
||||
},
|
||||
}),
|
||||
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pipelines_active",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of active pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "broadcast",
|
||||
"video_id": "main",
|
||||
"codec_name": "-",
|
||||
"codec_type": "-",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) shutdown() {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.destroyPipeline()
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) Start(url string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
err := manager.createPipeline()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.url = url
|
||||
manager.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) Stop() {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
manager.started = false
|
||||
manager.destroyPipeline()
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) Started() bool {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
return manager.started
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) Url() string {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
return manager.url
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) createPipeline() error {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline != nil {
|
||||
return types.ErrCapturePipelineAlreadyExists
|
||||
}
|
||||
|
||||
pipelineStr, err := manager.pipelineFn(manager.url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.logger.Info().
|
||||
Str("url", manager.url).
|
||||
Str("src", pipelineStr).
|
||||
Msgf("starting pipeline")
|
||||
|
||||
manager.pipeline, err = gst.CreatePipeline(pipelineStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.pipeline.Play()
|
||||
manager.pipelinesCounter.Inc()
|
||||
manager.pipelinesActive.Set(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *BroacastManagerCtx) destroyPipeline() {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.pipeline.Destroy()
|
||||
manager.logger.Info().Msgf("destroying pipeline")
|
||||
manager.pipeline = nil
|
||||
|
||||
manager.pipelinesActive.Set(0)
|
||||
}
|
269
internal/capture/manager.go
Normal file
269
internal/capture/manager.go
Normal file
|
@ -0,0 +1,269 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
)
|
||||
|
||||
type CaptureManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
desktop types.DesktopManager
|
||||
config *config.Capture
|
||||
|
||||
// sinks
|
||||
broadcast *BroacastManagerCtx
|
||||
screencast *ScreencastManagerCtx
|
||||
audio *StreamSinkManagerCtx
|
||||
video *StreamSelectorManagerCtx
|
||||
|
||||
// sources
|
||||
webcam *StreamSrcManagerCtx
|
||||
microphone *StreamSrcManagerCtx
|
||||
}
|
||||
|
||||
func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCtx {
|
||||
logger := log.With().Str("module", "capture").Logger()
|
||||
|
||||
videos := map[string]types.StreamSinkManager{}
|
||||
for video_id, cnf := range config.VideoPipelines {
|
||||
pipelineConf := cnf
|
||||
|
||||
createPipeline := func() (string, error) {
|
||||
if pipelineConf.GstPipeline != "" {
|
||||
// replace {display} with valid display
|
||||
return strings.Replace(pipelineConf.GstPipeline, "{display}", config.Display, 1), nil
|
||||
}
|
||||
|
||||
screen := desktop.GetScreenSize()
|
||||
pipeline, err := pipelineConf.GetPipeline(screen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"ximagesrc display-name=%s show-pointer=false use-damage=false "+
|
||||
"%s ! appsink name=appsink", config.Display, pipeline,
|
||||
), nil
|
||||
}
|
||||
|
||||
// trigger function to catch evaluation errors at startup
|
||||
pipeline, err := createPipeline()
|
||||
if err != nil {
|
||||
logger.Panic().Err(err).
|
||||
Str("video_id", video_id).
|
||||
Msg("failed to create video pipeline")
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("video_id", video_id).
|
||||
Str("pipeline", pipeline).
|
||||
Msg("syntax check for video stream pipeline passed")
|
||||
|
||||
// append to videos
|
||||
videos[video_id] = streamSinkNew(config.VideoCodec, createPipeline, video_id)
|
||||
}
|
||||
|
||||
return &CaptureManagerCtx{
|
||||
logger: logger,
|
||||
desktop: desktop,
|
||||
config: config,
|
||||
|
||||
// sinks
|
||||
broadcast: broadcastNew(func(url string) (string, error) {
|
||||
if config.BroadcastPipeline != "" {
|
||||
var pipeline = config.BroadcastPipeline
|
||||
// replace {display} with valid display
|
||||
pipeline = strings.Replace(pipeline, "{display}", config.Display, 1)
|
||||
// replace {device} with valid device
|
||||
pipeline = strings.Replace(pipeline, "{device}", config.AudioDevice, 1)
|
||||
// replace {url} with valid URL
|
||||
return strings.Replace(pipeline, "{url}", url, 1), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"flvmux name=mux ! rtmpsink location='%s live=1' "+
|
||||
"pulsesrc device=%s "+
|
||||
"! audio/x-raw,channels=2 "+
|
||||
"! audioconvert "+
|
||||
"! queue "+
|
||||
"! voaacenc bitrate=%d "+
|
||||
"! mux. "+
|
||||
"ximagesrc display-name=%s show-pointer=true use-damage=false "+
|
||||
"! video/x-raw "+
|
||||
"! videoconvert "+
|
||||
"! queue "+
|
||||
"! x264enc threads=4 bitrate=%d key-int-max=15 byte-stream=true tune=zerolatency speed-preset=%s "+
|
||||
"! mux.", url, config.AudioDevice, config.BroadcastAudioBitrate*1000, config.Display, config.BroadcastVideoBitrate, config.BroadcastPreset,
|
||||
), nil
|
||||
}, config.BroadcastUrl),
|
||||
screencast: screencastNew(config.ScreencastEnabled, func() string {
|
||||
if config.ScreencastPipeline != "" {
|
||||
// replace {display} with valid display
|
||||
return strings.Replace(config.ScreencastPipeline, "{display}", config.Display, 1)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"ximagesrc display-name=%s show-pointer=true use-damage=false "+
|
||||
"! video/x-raw,framerate=%s "+
|
||||
"! videoconvert "+
|
||||
"! queue "+
|
||||
"! jpegenc quality=%s "+
|
||||
"! appsink name=appsink", config.Display, config.ScreencastRate, config.ScreencastQuality,
|
||||
)
|
||||
}()),
|
||||
|
||||
audio: streamSinkNew(config.AudioCodec, func() (string, error) {
|
||||
if config.AudioPipeline != "" {
|
||||
// replace {device} with valid device
|
||||
return strings.Replace(config.AudioPipeline, "{device}", config.AudioDevice, 1), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"pulsesrc device=%s "+
|
||||
"! audio/x-raw,channels=2 "+
|
||||
"! audioconvert "+
|
||||
"! queue "+
|
||||
"! %s "+
|
||||
"! appsink name=appsink", config.AudioDevice, config.AudioCodec.Pipeline,
|
||||
), nil
|
||||
}, "audio"),
|
||||
video: streamSelectorNew(config.VideoCodec, videos, config.VideoIDs),
|
||||
|
||||
// sources
|
||||
webcam: streamSrcNew(config.WebcamEnabled, map[string]string{
|
||||
codec.VP8().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
|
||||
fmt.Sprintf("! application/x-rtp, payload=%d, encoding-name=VP8-DRAFT-IETF-01 ", codec.VP8().PayloadType) +
|
||||
"! rtpvp8depay " +
|
||||
"! decodebin " +
|
||||
"! videoconvert " +
|
||||
"! videorate " +
|
||||
"! videoscale " +
|
||||
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
|
||||
"! identity drop-allocation=true " +
|
||||
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
|
||||
// TODO: Test this pipeline.
|
||||
codec.VP9().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
|
||||
"! application/x-rtp " +
|
||||
"! rtpvp9depay " +
|
||||
"! decodebin " +
|
||||
"! videoconvert " +
|
||||
"! videorate " +
|
||||
"! videoscale " +
|
||||
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
|
||||
"! identity drop-allocation=true " +
|
||||
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
|
||||
// TODO: Test this pipeline.
|
||||
codec.H264().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
|
||||
"! application/x-rtp " +
|
||||
"! rtph264depay " +
|
||||
"! decodebin " +
|
||||
"! videoconvert " +
|
||||
"! videorate " +
|
||||
"! videoscale " +
|
||||
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
|
||||
"! identity drop-allocation=true " +
|
||||
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
|
||||
}, "webcam"),
|
||||
microphone: streamSrcNew(config.MicrophoneEnabled, map[string]string{
|
||||
codec.Opus().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
|
||||
fmt.Sprintf("! application/x-rtp, payload=%d, encoding-name=OPUS ", codec.Opus().PayloadType) +
|
||||
"! rtpopusdepay " +
|
||||
"! decodebin " +
|
||||
fmt.Sprintf("! pulsesink device=%s", config.MicrophoneDevice),
|
||||
// TODO: Test this pipeline.
|
||||
codec.G722().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
|
||||
"! application/x-rtp clock-rate=8000 " +
|
||||
"! rtpg722depay " +
|
||||
"! decodebin " +
|
||||
fmt.Sprintf("! pulsesink device=%s", config.MicrophoneDevice),
|
||||
}, "microphone"),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Start() {
|
||||
if manager.broadcast.Started() {
|
||||
if err := manager.broadcast.createPipeline(); err != nil {
|
||||
manager.logger.Panic().Err(err).Msg("unable to create broadcast pipeline")
|
||||
}
|
||||
}
|
||||
|
||||
manager.desktop.OnBeforeScreenSizeChange(func() {
|
||||
manager.video.destroyPipelines()
|
||||
|
||||
if manager.broadcast.Started() {
|
||||
manager.broadcast.destroyPipeline()
|
||||
}
|
||||
|
||||
if manager.screencast.Started() {
|
||||
manager.screencast.destroyPipeline()
|
||||
}
|
||||
})
|
||||
|
||||
manager.desktop.OnAfterScreenSizeChange(func() {
|
||||
err := manager.video.recreatePipelines()
|
||||
if err != nil {
|
||||
manager.logger.Panic().Err(err).Msg("unable to recreate video pipelines")
|
||||
}
|
||||
|
||||
if manager.broadcast.Started() {
|
||||
err := manager.broadcast.createPipeline()
|
||||
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
|
||||
manager.logger.Panic().Err(err).Msg("unable to recreate broadcast pipeline")
|
||||
}
|
||||
}
|
||||
|
||||
if manager.screencast.Started() {
|
||||
err := manager.screencast.createPipeline()
|
||||
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
|
||||
manager.logger.Panic().Err(err).Msg("unable to recreate screencast pipeline")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Shutdown() error {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.broadcast.shutdown()
|
||||
manager.screencast.shutdown()
|
||||
|
||||
manager.audio.shutdown()
|
||||
manager.video.shutdown()
|
||||
|
||||
manager.webcam.shutdown()
|
||||
manager.microphone.shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Broadcast() types.BroadcastManager {
|
||||
return manager.broadcast
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Screencast() types.ScreencastManager {
|
||||
return manager.screencast
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Audio() types.StreamSinkManager {
|
||||
return manager.audio
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Video() types.StreamSelectorManager {
|
||||
return manager.video
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Webcam() types.StreamSrcManager {
|
||||
return manager.webcam
|
||||
}
|
||||
|
||||
func (manager *CaptureManagerCtx) Microphone() types.StreamSrcManager {
|
||||
return manager.microphone
|
||||
}
|
257
internal/capture/screencast.go
Normal file
257
internal/capture/screencast.go
Normal file
|
@ -0,0 +1,257 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/gst"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
// timeout between intervals, when screencast pipeline is checked
|
||||
const screencastTimeout = 5 * time.Second
|
||||
|
||||
type ScreencastManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
|
||||
pipeline gst.Pipeline
|
||||
pipelineStr string
|
||||
pipelineMu sync.Mutex
|
||||
|
||||
image types.Sample
|
||||
imageMu sync.Mutex
|
||||
tickerStop chan struct{}
|
||||
|
||||
enabled bool
|
||||
started bool
|
||||
expired int32
|
||||
|
||||
// metrics
|
||||
imagesCounter prometheus.Counter
|
||||
pipelinesCounter prometheus.Counter
|
||||
pipelinesActive prometheus.Gauge
|
||||
}
|
||||
|
||||
func screencastNew(enabled bool, pipelineStr string) *ScreencastManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "screencast").
|
||||
Logger()
|
||||
|
||||
manager := &ScreencastManagerCtx{
|
||||
logger: logger,
|
||||
pipelineStr: pipelineStr,
|
||||
tickerStop: make(chan struct{}),
|
||||
enabled: enabled,
|
||||
started: false,
|
||||
|
||||
// metrics
|
||||
imagesCounter: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "screencast_images_total",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of created images.",
|
||||
}),
|
||||
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pipelines_total",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of created pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "screencast",
|
||||
"video_id": "main",
|
||||
"codec_name": "-",
|
||||
"codec_type": "-",
|
||||
},
|
||||
}),
|
||||
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pipelines_active",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of active pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "screencast",
|
||||
"video_id": "main",
|
||||
"codec_name": "-",
|
||||
"codec_type": "-",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
manager.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer manager.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(screencastTimeout)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-manager.tickerStop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if manager.Started() && !atomic.CompareAndSwapInt32(&manager.expired, 0, 1) {
|
||||
manager.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) shutdown() {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.destroyPipeline()
|
||||
|
||||
close(manager.tickerStop)
|
||||
manager.wg.Wait()
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) Enabled() bool {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
return manager.enabled
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) Started() bool {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
return manager.started
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) Image() ([]byte, error) {
|
||||
atomic.StoreInt32(&manager.expired, 0)
|
||||
|
||||
err := manager.start()
|
||||
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.imageMu.Lock()
|
||||
defer manager.imageMu.Unlock()
|
||||
|
||||
if manager.image.Data == nil {
|
||||
return nil, errors.New("image data not found")
|
||||
}
|
||||
|
||||
return manager.image.Data, nil
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) start() error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if !manager.enabled {
|
||||
return errors.New("screencast not enabled")
|
||||
}
|
||||
|
||||
err := manager.createPipeline()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) stop() {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
manager.started = false
|
||||
manager.destroyPipeline()
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) createPipeline() error {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline != nil {
|
||||
return types.ErrCapturePipelineAlreadyExists
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
manager.logger.Info().
|
||||
Str("str", manager.pipelineStr).
|
||||
Msgf("creating pipeline")
|
||||
|
||||
manager.pipeline, err = gst.CreatePipeline(manager.pipelineStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.pipeline.AttachAppsink("appsink")
|
||||
manager.pipeline.Play()
|
||||
manager.pipelinesCounter.Inc()
|
||||
manager.pipelinesActive.Set(1)
|
||||
|
||||
// get first image
|
||||
select {
|
||||
case image, ok := <-manager.pipeline.Sample():
|
||||
if !ok {
|
||||
return errors.New("unable to get first image")
|
||||
} else {
|
||||
manager.setImage(image)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
return errors.New("timeouted while waiting for first image")
|
||||
}
|
||||
|
||||
manager.wg.Add(1)
|
||||
pipeline := manager.pipeline
|
||||
|
||||
go func() {
|
||||
manager.logger.Debug().Msg("started receiving images")
|
||||
defer manager.wg.Done()
|
||||
|
||||
for {
|
||||
image, ok := <-pipeline.Sample()
|
||||
if !ok {
|
||||
manager.logger.Debug().Msg("stopped receiving images")
|
||||
return
|
||||
}
|
||||
|
||||
manager.setImage(image)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) setImage(image types.Sample) {
|
||||
manager.imageMu.Lock()
|
||||
manager.image = image
|
||||
manager.imageMu.Unlock()
|
||||
|
||||
manager.imagesCounter.Inc()
|
||||
}
|
||||
|
||||
func (manager *ScreencastManagerCtx) destroyPipeline() {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.pipeline.Destroy()
|
||||
manager.logger.Info().Msgf("destroying pipeline")
|
||||
manager.pipeline = nil
|
||||
|
||||
manager.pipelinesActive.Set(0)
|
||||
}
|
206
internal/capture/streamselector.go
Normal file
206
internal/capture/streamselector.go
Normal file
|
@ -0,0 +1,206 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
)
|
||||
|
||||
type StreamSelectorManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
codec codec.RTPCodec
|
||||
streams map[string]types.StreamSinkManager
|
||||
streamIDs []string
|
||||
}
|
||||
|
||||
func streamSelectorNew(codec codec.RTPCodec, streams map[string]types.StreamSinkManager, streamIDs []string) *StreamSelectorManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "stream-selector").
|
||||
Logger()
|
||||
|
||||
return &StreamSelectorManagerCtx{
|
||||
logger: logger,
|
||||
codec: codec,
|
||||
streams: streams,
|
||||
streamIDs: streamIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) shutdown() {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.destroyPipelines()
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) destroyPipelines() {
|
||||
for _, stream := range manager.streams {
|
||||
if stream.Started() {
|
||||
stream.DestroyPipeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) recreatePipelines() error {
|
||||
for _, stream := range manager.streams {
|
||||
if stream.Started() {
|
||||
err := stream.CreatePipeline()
|
||||
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) IDs() []string {
|
||||
return manager.streamIDs
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) Codec() codec.RTPCodec {
|
||||
return manager.codec
|
||||
}
|
||||
|
||||
func (manager *StreamSelectorManagerCtx) GetStream(selector types.StreamSelector) (types.StreamSinkManager, bool) {
|
||||
// select stream by ID
|
||||
if selector.ID != "" {
|
||||
// select lower stream
|
||||
if selector.Type == types.StreamSelectorTypeLower {
|
||||
var lastStream types.StreamSinkManager
|
||||
for i := len(manager.streamIDs) - 1; i >= 0; i-- {
|
||||
streamID := manager.streamIDs[i]
|
||||
if streamID == selector.ID {
|
||||
return lastStream, lastStream != nil
|
||||
}
|
||||
stream, ok := manager.streams[streamID]
|
||||
if ok {
|
||||
lastStream = stream
|
||||
}
|
||||
}
|
||||
// we couldn't find a lower stream
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// select higher stream
|
||||
if selector.Type == types.StreamSelectorTypeHigher {
|
||||
var lastStream types.StreamSinkManager
|
||||
for _, streamID := range manager.streamIDs {
|
||||
if streamID == selector.ID {
|
||||
return lastStream, lastStream != nil
|
||||
}
|
||||
stream, ok := manager.streams[streamID]
|
||||
if ok {
|
||||
lastStream = stream
|
||||
}
|
||||
}
|
||||
// we couldn't find a higher stream
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// select exact stream
|
||||
stream, ok := manager.streams[selector.ID]
|
||||
return stream, ok
|
||||
}
|
||||
|
||||
// select stream by bitrate
|
||||
if selector.Bitrate != 0 {
|
||||
// select stream by nearest bitrate
|
||||
if selector.Type == types.StreamSelectorTypeNearest {
|
||||
return manager.nearestBitrate(selector.Bitrate), true
|
||||
}
|
||||
|
||||
// select lower stream
|
||||
if selector.Type == types.StreamSelectorTypeLower {
|
||||
// start from the highest stream, and go down, until we find a lower stream
|
||||
for i := len(manager.streamIDs) - 1; i >= 0; i-- {
|
||||
streamID := manager.streamIDs[i]
|
||||
stream := manager.streams[streamID]
|
||||
// if stream should be considered in calculation
|
||||
considered := stream.Bitrate() != 0 && stream.Started()
|
||||
if considered && stream.Bitrate() < selector.Bitrate {
|
||||
return stream, true
|
||||
}
|
||||
}
|
||||
// we couldn't find a lower stream
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// select higher stream
|
||||
if selector.Type == types.StreamSelectorTypeHigher {
|
||||
// start from the lowest stream, and go up, until we find a higher stream
|
||||
for _, streamID := range manager.streamIDs {
|
||||
stream := manager.streams[streamID]
|
||||
// if stream should be considered in calculation
|
||||
considered := stream.Bitrate() != 0 && stream.Started()
|
||||
if considered && stream.Bitrate() > selector.Bitrate {
|
||||
return stream, true
|
||||
}
|
||||
}
|
||||
// we couldn't find a higher stream
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// select stream by exact bitrate
|
||||
for _, stream := range manager.streams {
|
||||
if stream.Bitrate() == selector.Bitrate {
|
||||
return stream, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we couldn't find a stream
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TODO: This is a very naive implementation, we should use a binary search instead.
|
||||
func (manager *StreamSelectorManagerCtx) nearestBitrate(bitrate uint64) types.StreamSinkManager {
|
||||
type streamDiff struct {
|
||||
id string
|
||||
bitrateDiff int
|
||||
}
|
||||
|
||||
sortDiff := func(a, b int) bool {
|
||||
switch {
|
||||
case a < 0 && b < 0:
|
||||
return a > b
|
||||
case a >= 0:
|
||||
if b >= 0 {
|
||||
return a <= b
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var diffs []streamDiff
|
||||
|
||||
for _, stream := range manager.streams {
|
||||
// if stream should be considered in calculation
|
||||
considered := stream.Bitrate() != 0 && stream.Started()
|
||||
if !considered {
|
||||
continue
|
||||
}
|
||||
diffs = append(diffs, streamDiff{
|
||||
id: stream.ID(),
|
||||
bitrateDiff: int(bitrate) - int(stream.Bitrate()),
|
||||
})
|
||||
}
|
||||
|
||||
// no streams available
|
||||
if len(diffs) == 0 {
|
||||
// return first (lowest) stream
|
||||
return manager.streams[manager.streamIDs[0]]
|
||||
}
|
||||
|
||||
sort.Slice(diffs, func(i, j int) bool {
|
||||
return sortDiff(diffs[i].bitrateDiff, diffs[j].bitrateDiff)
|
||||
})
|
||||
|
||||
bestDiff := diffs[0]
|
||||
return manager.streams[bestDiff.id]
|
||||
}
|
414
internal/capture/streamsink.go
Normal file
414
internal/capture/streamsink.go
Normal file
|
@ -0,0 +1,414 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/gst"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
)
|
||||
|
||||
var moveSinkListenerMu = sync.Mutex{}
|
||||
|
||||
type StreamSinkManagerCtx struct {
|
||||
id string
|
||||
|
||||
// wait for a keyframe before sending samples
|
||||
waitForKf bool
|
||||
|
||||
bitrate uint64 // atomic
|
||||
brBuckets map[int]float64
|
||||
|
||||
logger zerolog.Logger
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
|
||||
codec codec.RTPCodec
|
||||
pipeline gst.Pipeline
|
||||
pipelineMu sync.Mutex
|
||||
pipelineFn func() (string, error)
|
||||
|
||||
listeners map[uintptr]types.SampleListener
|
||||
listenersKf map[uintptr]types.SampleListener // keyframe lobby
|
||||
listenersMu sync.Mutex
|
||||
|
||||
// metrics
|
||||
currentListeners prometheus.Gauge
|
||||
totalBytes prometheus.Counter
|
||||
pipelinesCounter prometheus.Counter
|
||||
pipelinesActive prometheus.Gauge
|
||||
}
|
||||
|
||||
func streamSinkNew(codec codec.RTPCodec, pipelineFn func() (string, error), id string) *StreamSinkManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "stream-sink").
|
||||
Str("id", id).Logger()
|
||||
|
||||
manager := &StreamSinkManagerCtx{
|
||||
id: id,
|
||||
|
||||
// only wait for keyframes if the codec is video
|
||||
waitForKf: codec.IsVideo(),
|
||||
|
||||
bitrate: 0,
|
||||
brBuckets: map[int]float64{},
|
||||
|
||||
logger: logger,
|
||||
codec: codec,
|
||||
pipelineFn: pipelineFn,
|
||||
|
||||
listeners: map[uintptr]types.SampleListener{},
|
||||
listenersKf: map[uintptr]types.SampleListener{},
|
||||
|
||||
// metrics
|
||||
currentListeners: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "streamsink_listeners",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Current number of listeners for a pipeline.",
|
||||
ConstLabels: map[string]string{
|
||||
"video_id": id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
}),
|
||||
totalBytes: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "streamsink_bytes",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of bytes created by the pipeline.",
|
||||
ConstLabels: map[string]string{
|
||||
"video_id": id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
}),
|
||||
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pipelines_total",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of created pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "streamsink",
|
||||
"video_id": id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
}),
|
||||
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pipelines_active",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of active pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "streamsink",
|
||||
"video_id": id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) shutdown() {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.listenersMu.Lock()
|
||||
for key := range manager.listeners {
|
||||
delete(manager.listeners, key)
|
||||
}
|
||||
for key := range manager.listenersKf {
|
||||
delete(manager.listenersKf, key)
|
||||
}
|
||||
manager.listenersMu.Unlock()
|
||||
|
||||
manager.DestroyPipeline()
|
||||
manager.wg.Wait()
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) ID() string {
|
||||
return manager.id
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) Bitrate() uint64 {
|
||||
return atomic.LoadUint64(&manager.bitrate)
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) Codec() codec.RTPCodec {
|
||||
return manager.codec
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) start() error {
|
||||
if len(manager.listeners)+len(manager.listenersKf) == 0 {
|
||||
err := manager.CreatePipeline()
|
||||
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.logger.Info().Msgf("first listener, starting")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) stop() {
|
||||
if len(manager.listeners)+len(manager.listenersKf) == 0 {
|
||||
manager.DestroyPipeline()
|
||||
manager.logger.Info().Msgf("last listener, stopping")
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) addListener(listener types.SampleListener) {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
emitKeyframe := false
|
||||
|
||||
manager.listenersMu.Lock()
|
||||
if manager.waitForKf {
|
||||
// if this is the first listener, we need to emit a keyframe
|
||||
emitKeyframe = len(manager.listenersKf) == 0
|
||||
// if we're waiting for a keyframe, add it to the keyframe lobby
|
||||
manager.listenersKf[ptr] = listener
|
||||
} else {
|
||||
// otherwise, add it as a regular listener
|
||||
manager.listeners[ptr] = listener
|
||||
}
|
||||
manager.listenersMu.Unlock()
|
||||
|
||||
manager.logger.Debug().Interface("ptr", ptr).Msgf("adding listener")
|
||||
manager.currentListeners.Set(float64(manager.ListenersCount()))
|
||||
|
||||
// if we will be waiting for a keyframe, emit one now
|
||||
if manager.pipeline != nil && emitKeyframe {
|
||||
manager.pipeline.EmitVideoKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) removeListener(listener types.SampleListener) {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
|
||||
manager.listenersMu.Lock()
|
||||
delete(manager.listeners, ptr)
|
||||
delete(manager.listenersKf, ptr) // if it's a keyframe listener, remove it too
|
||||
manager.listenersMu.Unlock()
|
||||
|
||||
manager.logger.Debug().Interface("ptr", ptr).Msgf("removing listener")
|
||||
manager.currentListeners.Set(float64(manager.ListenersCount()))
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) AddListener(listener types.SampleListener) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if listener == nil {
|
||||
return errors.New("listener cannot be nil")
|
||||
}
|
||||
|
||||
// start if stopped
|
||||
if err := manager.start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add listener
|
||||
manager.addListener(listener)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) RemoveListener(listener types.SampleListener) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if listener == nil {
|
||||
return errors.New("listener cannot be nil")
|
||||
}
|
||||
|
||||
// remove listener
|
||||
manager.removeListener(listener)
|
||||
|
||||
// stop if started
|
||||
manager.stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// moving listeners between streams ensures, that target pipeline is running
|
||||
// before listener is added, and stops source pipeline if there are 0 listeners
|
||||
func (manager *StreamSinkManagerCtx) MoveListenerTo(listener types.SampleListener, stream types.StreamSinkManager) error {
|
||||
if listener == nil {
|
||||
return errors.New("listener cannot be nil")
|
||||
}
|
||||
|
||||
targetStream, ok := stream.(*StreamSinkManagerCtx)
|
||||
if !ok {
|
||||
return errors.New("target stream manager does not support moving listeners")
|
||||
}
|
||||
|
||||
// we need to acquire both mutextes, from source stream and from target stream
|
||||
// in order to do that safely (without possibility of deadlock) we need third
|
||||
// global mutex, that ensures atomic locking
|
||||
|
||||
// lock global mutex
|
||||
moveSinkListenerMu.Lock()
|
||||
|
||||
// lock source stream
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
// lock target stream
|
||||
targetStream.mu.Lock()
|
||||
defer targetStream.mu.Unlock()
|
||||
|
||||
// unlock global mutex
|
||||
moveSinkListenerMu.Unlock()
|
||||
|
||||
// start if stopped
|
||||
if err := targetStream.start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// swap listeners
|
||||
manager.removeListener(listener)
|
||||
targetStream.addListener(listener)
|
||||
|
||||
// stop if started
|
||||
manager.stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) ListenersCount() int {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
return len(manager.listeners) + len(manager.listenersKf)
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) Started() bool {
|
||||
return manager.ListenersCount() > 0
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) CreatePipeline() error {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline != nil {
|
||||
return types.ErrCapturePipelineAlreadyExists
|
||||
}
|
||||
|
||||
pipelineStr, err := manager.pipelineFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.logger.Info().
|
||||
Str("codec", manager.codec.Name).
|
||||
Str("src", pipelineStr).
|
||||
Msgf("creating pipeline")
|
||||
|
||||
manager.pipeline, err = gst.CreatePipeline(pipelineStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.pipeline.AttachAppsink("appsink")
|
||||
manager.pipeline.Play()
|
||||
|
||||
manager.wg.Add(1)
|
||||
pipeline := manager.pipeline
|
||||
|
||||
go func() {
|
||||
manager.logger.Debug().Msg("started emitting samples")
|
||||
defer manager.wg.Done()
|
||||
|
||||
for {
|
||||
sample, ok := <-pipeline.Sample()
|
||||
if !ok {
|
||||
manager.logger.Debug().Msg("stopped emitting samples")
|
||||
return
|
||||
}
|
||||
|
||||
manager.onSample(sample)
|
||||
}
|
||||
}()
|
||||
|
||||
manager.pipelinesCounter.Inc()
|
||||
manager.pipelinesActive.Set(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) saveSampleBitrate(timestamp time.Time, delta float64) {
|
||||
// get unix timestamp in seconds
|
||||
sec := timestamp.Unix()
|
||||
// last bucket is timestamp rounded to 3 seconds - 1 second
|
||||
last := int((sec - 1) % 3)
|
||||
// current bucket is timestamp rounded to 3 seconds
|
||||
curr := int(sec % 3)
|
||||
// next bucket is timestamp rounded to 3 seconds + 1 second
|
||||
next := int((sec + 1) % 3)
|
||||
|
||||
if manager.brBuckets[next] != 0 {
|
||||
// atomic update bitrate
|
||||
atomic.StoreUint64(&manager.bitrate, uint64(manager.brBuckets[last]))
|
||||
// empty next bucket
|
||||
manager.brBuckets[next] = 0
|
||||
}
|
||||
|
||||
// add rate to current bucket
|
||||
manager.brBuckets[curr] += delta
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) onSample(sample types.Sample) {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
// save to metrics
|
||||
length := float64(sample.Length)
|
||||
manager.totalBytes.Add(length)
|
||||
manager.saveSampleBitrate(sample.Timestamp, length)
|
||||
|
||||
// if is not delta unit -> it can be decoded independently -> it is a keyframe
|
||||
if manager.waitForKf && !sample.DeltaUnit && len(manager.listenersKf) > 0 {
|
||||
// if current sample is a keyframe, move listeners from
|
||||
// keyframe lobby to actual listeners map and clear lobby
|
||||
for k, v := range manager.listenersKf {
|
||||
manager.listeners[k] = v
|
||||
}
|
||||
manager.listenersKf = make(map[uintptr]types.SampleListener)
|
||||
}
|
||||
|
||||
for _, l := range manager.listeners {
|
||||
l.WriteSample(sample)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSinkManagerCtx) DestroyPipeline() {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.pipeline.Destroy()
|
||||
manager.logger.Info().Msgf("destroying pipeline")
|
||||
manager.pipeline = nil
|
||||
|
||||
manager.pipelinesActive.Set(0)
|
||||
|
||||
manager.brBuckets = make(map[int]float64)
|
||||
atomic.StoreUint64(&manager.bitrate, 0)
|
||||
}
|
197
internal/capture/streamsrc.go
Normal file
197
internal/capture/streamsrc.go
Normal file
|
@ -0,0 +1,197 @@
|
|||
package capture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/gst"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
)
|
||||
|
||||
type StreamSrcManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
enabled bool
|
||||
codecPipeline map[string]string // codec -> pipeline
|
||||
|
||||
codec codec.RTPCodec
|
||||
pipeline gst.Pipeline
|
||||
pipelineMu sync.Mutex
|
||||
pipelineStr string
|
||||
|
||||
// metrics
|
||||
pushedData map[string]prometheus.Summary
|
||||
pipelinesCounter map[string]prometheus.Counter
|
||||
pipelinesActive map[string]prometheus.Gauge
|
||||
}
|
||||
|
||||
func streamSrcNew(enabled bool, codecPipeline map[string]string, video_id string) *StreamSrcManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "stream-src").
|
||||
Str("video_id", video_id).Logger()
|
||||
|
||||
pushedData := map[string]prometheus.Summary{}
|
||||
pipelinesCounter := map[string]prometheus.Counter{}
|
||||
pipelinesActive := map[string]prometheus.Gauge{}
|
||||
|
||||
for codecName, pipeline := range codecPipeline {
|
||||
codec, ok := codec.ParseStr(codecName)
|
||||
if !ok {
|
||||
logger.Fatal().
|
||||
Str("codec", codecName).
|
||||
Str("pipeline", pipeline).
|
||||
Msg("unknown codec name")
|
||||
}
|
||||
|
||||
pushedData[codecName] = promauto.NewSummary(prometheus.SummaryOpts{
|
||||
Name: "streamsrc_data_bytes",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Data pushed to a pipeline (in bytes).",
|
||||
ConstLabels: map[string]string{
|
||||
"video_id": video_id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
})
|
||||
pipelinesCounter[codecName] = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pipelines_total",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of created pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "streamsrc",
|
||||
"video_id": video_id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
})
|
||||
pipelinesActive[codecName] = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pipelines_active",
|
||||
Namespace: "neko",
|
||||
Subsystem: "capture",
|
||||
Help: "Total number of active pipelines.",
|
||||
ConstLabels: map[string]string{
|
||||
"submodule": "streamsrc",
|
||||
"video_id": video_id,
|
||||
"codec_name": codec.Name,
|
||||
"codec_type": codec.Type.String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &StreamSrcManagerCtx{
|
||||
logger: logger,
|
||||
enabled: enabled,
|
||||
codecPipeline: codecPipeline,
|
||||
|
||||
// metrics
|
||||
pushedData: pushedData,
|
||||
pipelinesCounter: pipelinesCounter,
|
||||
pipelinesActive: pipelinesActive,
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) shutdown() {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
manager.Stop()
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) Codec() codec.RTPCodec {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
return manager.codec
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) Start(codec codec.RTPCodec) error {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline != nil {
|
||||
return types.ErrCapturePipelineAlreadyExists
|
||||
}
|
||||
|
||||
if !manager.enabled {
|
||||
return errors.New("stream-src not enabled")
|
||||
}
|
||||
|
||||
found := false
|
||||
for codecName, pipeline := range manager.codecPipeline {
|
||||
if codecName == codec.Name {
|
||||
manager.pipelineStr = pipeline
|
||||
manager.codec = codec
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("no pipeline found for a codec")
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
manager.logger.Info().
|
||||
Str("codec", manager.codec.Name).
|
||||
Str("src", manager.pipelineStr).
|
||||
Msgf("creating pipeline")
|
||||
|
||||
manager.pipeline, err = gst.CreatePipeline(manager.pipelineStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.pipeline.AttachAppsrc("appsrc")
|
||||
manager.pipeline.Play()
|
||||
|
||||
manager.pipelinesCounter[manager.codec.Name].Inc()
|
||||
manager.pipelinesActive[manager.codec.Name].Set(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) Stop() {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.pipeline.Destroy()
|
||||
manager.pipeline = nil
|
||||
|
||||
manager.logger.Info().
|
||||
Str("codec", manager.codec.Name).
|
||||
Str("src", manager.pipelineStr).
|
||||
Msgf("destroying pipeline")
|
||||
|
||||
manager.pipelinesActive[manager.codec.Name].Set(0)
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) Push(bytes []byte) {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
if manager.pipeline == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.pipeline.Push(bytes)
|
||||
manager.pushedData[manager.codec.Name].Observe(float64(len(bytes)))
|
||||
}
|
||||
|
||||
func (manager *StreamSrcManagerCtx) Started() bool {
|
||||
manager.pipelineMu.Lock()
|
||||
defer manager.pipelineMu.Unlock()
|
||||
|
||||
return manager.pipeline != nil
|
||||
}
|
246
internal/config/capture.go
Normal file
246
internal/config/capture.go
Normal file
|
@ -0,0 +1,246 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type Capture struct {
|
||||
Display string
|
||||
|
||||
VideoCodec codec.RTPCodec
|
||||
VideoIDs []string
|
||||
VideoPipelines map[string]types.VideoConfig
|
||||
|
||||
AudioDevice string
|
||||
AudioCodec codec.RTPCodec
|
||||
AudioPipeline string
|
||||
|
||||
BroadcastAudioBitrate int
|
||||
BroadcastVideoBitrate int
|
||||
BroadcastPreset string
|
||||
BroadcastPipeline string
|
||||
BroadcastUrl string
|
||||
|
||||
ScreencastEnabled bool
|
||||
ScreencastRate string
|
||||
ScreencastQuality string
|
||||
ScreencastPipeline string
|
||||
|
||||
WebcamEnabled bool
|
||||
WebcamDevice string
|
||||
WebcamWidth int
|
||||
WebcamHeight int
|
||||
|
||||
MicrophoneEnabled bool
|
||||
MicrophoneDevice string
|
||||
}
|
||||
|
||||
func (Capture) Init(cmd *cobra.Command) error {
|
||||
// audio
|
||||
cmd.PersistentFlags().String("capture.audio.device", "audio_output.monitor", "pulseaudio device to capture")
|
||||
if err := viper.BindPFlag("capture.audio.device", cmd.PersistentFlags().Lookup("capture.audio.device")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.audio.codec", "opus", "audio codec to be used")
|
||||
if err := viper.BindPFlag("capture.audio.codec", cmd.PersistentFlags().Lookup("capture.audio.codec")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.audio.pipeline", "", "gstreamer pipeline used for audio streaming")
|
||||
if err := viper.BindPFlag("capture.audio.pipeline", cmd.PersistentFlags().Lookup("capture.audio.pipeline")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// videos
|
||||
cmd.PersistentFlags().String("capture.video.codec", "vp8", "video codec to be used")
|
||||
if err := viper.BindPFlag("capture.video.codec", cmd.PersistentFlags().Lookup("capture.video.codec")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringSlice("capture.video.ids", []string{}, "ordered list of video ids")
|
||||
if err := viper.BindPFlag("capture.video.ids", cmd.PersistentFlags().Lookup("capture.video.ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.video.pipelines", "[]", "pipelines config in JSON used for video streaming")
|
||||
if err := viper.BindPFlag("capture.video.pipelines", cmd.PersistentFlags().Lookup("capture.video.pipelines")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// broadcast
|
||||
cmd.PersistentFlags().Int("capture.broadcast.audio_bitrate", 128, "broadcast audio bitrate in KB/s")
|
||||
if err := viper.BindPFlag("capture.broadcast.audio_bitrate", cmd.PersistentFlags().Lookup("capture.broadcast.audio_bitrate")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("capture.broadcast.video_bitrate", 4096, "broadcast video bitrate in KB/s")
|
||||
if err := viper.BindPFlag("capture.broadcast.video_bitrate", cmd.PersistentFlags().Lookup("capture.broadcast.video_bitrate")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.broadcast.preset", "veryfast", "broadcast speed preset for h264 encoding")
|
||||
if err := viper.BindPFlag("capture.broadcast.preset", cmd.PersistentFlags().Lookup("capture.broadcast.preset")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.broadcast.pipeline", "", "gstreamer pipeline used for broadcasting")
|
||||
if err := viper.BindPFlag("capture.broadcast.pipeline", cmd.PersistentFlags().Lookup("capture.broadcast.pipeline")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.broadcast.url", "", "initial URL for broadcasting, setting this value will automatically start broadcasting")
|
||||
if err := viper.BindPFlag("capture.broadcast.url", cmd.PersistentFlags().Lookup("capture.broadcast.url")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// screencast
|
||||
cmd.PersistentFlags().Bool("capture.screencast.enabled", false, "enable screencast")
|
||||
if err := viper.BindPFlag("capture.screencast.enabled", cmd.PersistentFlags().Lookup("capture.screencast.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.screencast.rate", "10/1", "screencast frame rate")
|
||||
if err := viper.BindPFlag("capture.screencast.rate", cmd.PersistentFlags().Lookup("capture.screencast.rate")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.screencast.quality", "60", "screencast JPEG quality")
|
||||
if err := viper.BindPFlag("capture.screencast.quality", cmd.PersistentFlags().Lookup("capture.screencast.quality")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.screencast.pipeline", "", "gstreamer pipeline used for screencasting")
|
||||
if err := viper.BindPFlag("capture.screencast.pipeline", cmd.PersistentFlags().Lookup("capture.screencast.pipeline")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// webcam
|
||||
cmd.PersistentFlags().Bool("capture.webcam.enabled", false, "enable webcam stream")
|
||||
if err := viper.BindPFlag("capture.webcam.enabled", cmd.PersistentFlags().Lookup("capture.webcam.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// sudo apt install v4l2loopback-dkms v4l2loopback-utils
|
||||
// sudo apt-get install linux-headers-`uname -r` linux-modules-extra-`uname -r`
|
||||
// sudo modprobe v4l2loopback exclusive_caps=1
|
||||
cmd.PersistentFlags().String("capture.webcam.device", "/dev/video0", "v4l2sink device used for webcam")
|
||||
if err := viper.BindPFlag("capture.webcam.device", cmd.PersistentFlags().Lookup("capture.webcam.device")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("capture.webcam.width", 1280, "webcam stream width")
|
||||
if err := viper.BindPFlag("capture.webcam.width", cmd.PersistentFlags().Lookup("capture.webcam.width")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("capture.webcam.height", 720, "webcam stream height")
|
||||
if err := viper.BindPFlag("capture.webcam.height", cmd.PersistentFlags().Lookup("capture.webcam.height")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// microphone
|
||||
cmd.PersistentFlags().Bool("capture.microphone.enabled", true, "enable microphone stream")
|
||||
if err := viper.BindPFlag("capture.microphone.enabled", cmd.PersistentFlags().Lookup("capture.microphone.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("capture.microphone.device", "audio_input", "pulseaudio device used for microphone")
|
||||
if err := viper.BindPFlag("capture.microphone.device", cmd.PersistentFlags().Lookup("capture.microphone.device")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Capture) Set() {
|
||||
var ok bool
|
||||
|
||||
// Display is provided by env variable
|
||||
s.Display = os.Getenv("DISPLAY")
|
||||
|
||||
// video
|
||||
videoCodec := viper.GetString("capture.video.codec")
|
||||
s.VideoCodec, ok = codec.ParseStr(videoCodec)
|
||||
if !ok || !s.VideoCodec.IsVideo() {
|
||||
log.Warn().Str("codec", videoCodec).Msgf("unknown video codec, using Vp8")
|
||||
s.VideoCodec = codec.VP8()
|
||||
}
|
||||
|
||||
s.VideoIDs = viper.GetStringSlice("capture.video.ids")
|
||||
if err := viper.UnmarshalKey("capture.video.pipelines", &s.VideoPipelines, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode(s.VideoPipelines),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse video pipelines")
|
||||
}
|
||||
|
||||
// default video
|
||||
if len(s.VideoPipelines) == 0 {
|
||||
log.Warn().Msgf("no video pipelines specified, using defaults")
|
||||
|
||||
s.VideoCodec = codec.VP8()
|
||||
s.VideoPipelines = map[string]types.VideoConfig{
|
||||
"main": {
|
||||
Fps: "25",
|
||||
GstEncoder: "vp8enc",
|
||||
GstParams: map[string]string{
|
||||
"target-bitrate": "round(3072 * 650)",
|
||||
"cpu-used": "4",
|
||||
"end-usage": "cbr",
|
||||
"threads": "4",
|
||||
"deadline": "1",
|
||||
"undershoot": "95",
|
||||
"buffer-size": "(3072 * 4)",
|
||||
"buffer-initial-size": "(3072 * 2)",
|
||||
"buffer-optimal-size": "(3072 * 3)",
|
||||
"keyframe-max-dist": "25",
|
||||
"min-quantizer": "4",
|
||||
"max-quantizer": "20",
|
||||
},
|
||||
},
|
||||
}
|
||||
s.VideoIDs = []string{"main"}
|
||||
}
|
||||
|
||||
// audio
|
||||
s.AudioDevice = viper.GetString("capture.audio.device")
|
||||
s.AudioPipeline = viper.GetString("capture.audio.pipeline")
|
||||
|
||||
audioCodec := viper.GetString("capture.audio.codec")
|
||||
s.AudioCodec, ok = codec.ParseStr(audioCodec)
|
||||
if !ok || !s.AudioCodec.IsAudio() {
|
||||
log.Warn().Str("codec", audioCodec).Msgf("unknown audio codec, using Opus")
|
||||
s.AudioCodec = codec.Opus()
|
||||
}
|
||||
|
||||
// broadcast
|
||||
s.BroadcastAudioBitrate = viper.GetInt("capture.broadcast.audio_bitrate")
|
||||
s.BroadcastVideoBitrate = viper.GetInt("capture.broadcast.video_bitrate")
|
||||
s.BroadcastPreset = viper.GetString("capture.broadcast.preset")
|
||||
s.BroadcastPipeline = viper.GetString("capture.broadcast.pipeline")
|
||||
s.BroadcastUrl = viper.GetString("capture.broadcast.url")
|
||||
|
||||
// screencast
|
||||
s.ScreencastEnabled = viper.GetBool("capture.screencast.enabled")
|
||||
s.ScreencastRate = viper.GetString("capture.screencast.rate")
|
||||
s.ScreencastQuality = viper.GetString("capture.screencast.quality")
|
||||
s.ScreencastPipeline = viper.GetString("capture.screencast.pipeline")
|
||||
|
||||
// webcam
|
||||
s.WebcamEnabled = viper.GetBool("capture.webcam.enabled")
|
||||
s.WebcamDevice = viper.GetString("capture.webcam.device")
|
||||
s.WebcamWidth = viper.GetInt("capture.webcam.width")
|
||||
s.WebcamHeight = viper.GetInt("capture.webcam.height")
|
||||
|
||||
// microphone
|
||||
s.MicrophoneEnabled = viper.GetBool("capture.microphone.enabled")
|
||||
s.MicrophoneDevice = viper.GetString("capture.microphone.device")
|
||||
}
|
8
internal/config/config.go
Normal file
8
internal/config/config.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package config
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
type Config interface {
|
||||
Init(cmd *cobra.Command) error
|
||||
Set()
|
||||
}
|
91
internal/config/desktop.go
Normal file
91
internal/config/desktop.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type Desktop struct {
|
||||
Display string
|
||||
|
||||
ScreenSize types.ScreenSize
|
||||
|
||||
UseInputDriver bool
|
||||
InputSocket string
|
||||
|
||||
Unminimize bool
|
||||
UploadDrop bool
|
||||
FileChooserDialog bool
|
||||
}
|
||||
|
||||
func (Desktop) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().String("desktop.screen", "1280x720@30", "default screen size and framerate")
|
||||
if err := viper.BindPFlag("desktop.screen", cmd.PersistentFlags().Lookup("desktop.screen")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("desktop.input.enabled", true, "whether custom xf86 input driver should be used to handle touchscreen")
|
||||
if err := viper.BindPFlag("desktop.input.enabled", cmd.PersistentFlags().Lookup("desktop.input.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("desktop.input.socket", "/tmp/xf86-input-neko.sock", "socket path for custom xf86 input driver connection")
|
||||
if err := viper.BindPFlag("desktop.input.socket", cmd.PersistentFlags().Lookup("desktop.input.socket")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("desktop.unminimize", true, "automatically unminimize window when it is minimized")
|
||||
if err := viper.BindPFlag("desktop.unminimize", cmd.PersistentFlags().Lookup("desktop.unminimize")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("desktop.upload_drop", true, "whether drop upload is enabled")
|
||||
if err := viper.BindPFlag("desktop.upload_drop", cmd.PersistentFlags().Lookup("desktop.upload_drop")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("desktop.file_chooser_dialog", false, "whether to handle file chooser dialog externally")
|
||||
if err := viper.BindPFlag("desktop.file_chooser_dialog", cmd.PersistentFlags().Lookup("desktop.file_chooser_dialog")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Desktop) Set() {
|
||||
// Display is provided by env variable
|
||||
s.Display = os.Getenv("DISPLAY")
|
||||
|
||||
s.ScreenSize = types.ScreenSize{
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
Rate: 30,
|
||||
}
|
||||
|
||||
r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`)
|
||||
res := r.FindStringSubmatch(viper.GetString("desktop.screen"))
|
||||
|
||||
if len(res) > 0 {
|
||||
width, err1 := strconv.ParseInt(res[1], 10, 64)
|
||||
height, err2 := strconv.ParseInt(res[2], 10, 64)
|
||||
rate, err3 := strconv.ParseInt(res[3], 10, 64)
|
||||
|
||||
if err1 == nil && err2 == nil && err3 == nil {
|
||||
s.ScreenSize.Width = int(width)
|
||||
s.ScreenSize.Height = int(height)
|
||||
s.ScreenSize.Rate = int16(rate)
|
||||
}
|
||||
}
|
||||
|
||||
s.UseInputDriver = viper.GetBool("desktop.input.enabled")
|
||||
s.InputSocket = viper.GetString("desktop.input.socket")
|
||||
s.Unminimize = viper.GetBool("desktop.unminimize")
|
||||
s.UploadDrop = viper.GetBool("desktop.upload_drop")
|
||||
s.FileChooserDialog = viper.GetBool("desktop.file_chooser_dialog")
|
||||
}
|
128
internal/config/member.go
Normal file
128
internal/config/member.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko/internal/member/file"
|
||||
"github.com/demodesk/neko/internal/member/multiuser"
|
||||
"github.com/demodesk/neko/internal/member/object"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type Member struct {
|
||||
Provider string
|
||||
|
||||
// providers
|
||||
File file.Config
|
||||
Object object.Config
|
||||
Multiuser multiuser.Config
|
||||
}
|
||||
|
||||
func (Member) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().String("member.provider", "multiuser", "choose member provider")
|
||||
if err := viper.BindPFlag("member.provider", cmd.PersistentFlags().Lookup("member.provider")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// file provider
|
||||
cmd.PersistentFlags().String("member.file.path", "", "member file provider: storage path")
|
||||
if err := viper.BindPFlag("member.file.path", cmd.PersistentFlags().Lookup("member.file.path")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("member.file.hash", true, "member file provider: whether to hash passwords using sha256 (recommended)")
|
||||
if err := viper.BindPFlag("member.file.hash", cmd.PersistentFlags().Lookup("member.file.hash")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// object provider
|
||||
cmd.PersistentFlags().String("member.object.users", "[]", "member object provider: users in JSON format")
|
||||
if err := viper.BindPFlag("member.object.users", cmd.PersistentFlags().Lookup("member.object.users")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// multiuser provider
|
||||
cmd.PersistentFlags().String("member.multiuser.user_password", "neko", "member multiuser provider: user password")
|
||||
if err := viper.BindPFlag("member.multiuser.user_password", cmd.PersistentFlags().Lookup("member.multiuser.user_password")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("member.multiuser.admin_password", "admin", "member multiuser provider: admin password")
|
||||
if err := viper.BindPFlag("member.multiuser.admin_password", cmd.PersistentFlags().Lookup("member.multiuser.admin_password")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("member.multiuser.user_profile", "{}", "member multiuser provider: user profile in JSON format")
|
||||
if err := viper.BindPFlag("member.multiuser.user_profile", cmd.PersistentFlags().Lookup("member.multiuser.user_profile")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("member.multiuser.admin_profile", "{}", "member multiuser provider: admin profile in JSON format")
|
||||
if err := viper.BindPFlag("member.multiuser.admin_profile", cmd.PersistentFlags().Lookup("member.multiuser.admin_profile")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Member) Set() {
|
||||
s.Provider = viper.GetString("member.provider")
|
||||
|
||||
// file provider
|
||||
s.File.Path = viper.GetString("member.file.path")
|
||||
s.File.Hash = viper.GetBool("member.file.hash")
|
||||
|
||||
// object provider
|
||||
if err := viper.UnmarshalKey("member.object.users", &s.Object.Users, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode(s.Object.Users),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse member object users")
|
||||
}
|
||||
|
||||
// multiuser provider
|
||||
s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password")
|
||||
s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password")
|
||||
|
||||
// default user profile
|
||||
s.Multiuser.UserProfile = types.MemberProfile{
|
||||
IsAdmin: false,
|
||||
CanLogin: true,
|
||||
CanConnect: true,
|
||||
CanWatch: true,
|
||||
CanHost: true,
|
||||
CanShareMedia: true,
|
||||
CanAccessClipboard: true,
|
||||
SendsInactiveCursor: true,
|
||||
CanSeeInactiveCursors: false,
|
||||
}
|
||||
|
||||
// override user profile
|
||||
if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode(s.Multiuser.UserProfile),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse member multiuser user profile")
|
||||
}
|
||||
|
||||
// default admin profile
|
||||
s.Multiuser.AdminProfile = types.MemberProfile{
|
||||
IsAdmin: true,
|
||||
CanLogin: true,
|
||||
CanConnect: true,
|
||||
CanWatch: true,
|
||||
CanHost: true,
|
||||
CanShareMedia: true,
|
||||
CanAccessClipboard: true,
|
||||
SendsInactiveCursor: true,
|
||||
CanSeeInactiveCursors: true,
|
||||
}
|
||||
|
||||
// override admin profile
|
||||
if err := viper.UnmarshalKey("member.multiuser.admin_profile", &s.Multiuser.AdminProfile, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode(s.Multiuser.AdminProfile),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
|
||||
}
|
||||
}
|
37
internal/config/plugins.go
Normal file
37
internal/config/plugins.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Plugins struct {
|
||||
Enabled bool
|
||||
Dir string
|
||||
Required bool
|
||||
}
|
||||
|
||||
func (Plugins) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().Bool("plugins.enabled", false, "load plugins in runtime")
|
||||
if err := viper.BindPFlag("plugins.enabled", cmd.PersistentFlags().Lookup("plugins.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("plugins.dir", "./bin/plugins", "path to neko plugins to load")
|
||||
if err := viper.BindPFlag("plugins.dir", cmd.PersistentFlags().Lookup("plugins.dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("plugins.required", false, "if true, neko will exit if there is an error when loading a plugin")
|
||||
if err := viper.BindPFlag("plugins.required", cmd.PersistentFlags().Lookup("plugins.required")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Plugins) Set() {
|
||||
s.Enabled = viper.GetBool("plugins.enabled")
|
||||
s.Dir = viper.GetString("plugins.dir")
|
||||
s.Required = viper.GetBool("plugins.required")
|
||||
}
|
97
internal/config/root.go
Normal file
97
internal/config/root.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Root struct {
|
||||
Config string
|
||||
|
||||
LogLevel zerolog.Level
|
||||
LogTime string
|
||||
LogJson bool
|
||||
LogNocolor bool
|
||||
LogDir string
|
||||
}
|
||||
|
||||
func (Root) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().StringP("config", "c", "", "configuration file path")
|
||||
if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// just a shortcut
|
||||
cmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode")
|
||||
if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("log.level", "info", "set log level (trace, debug, info, warn, error, fatal, panic, disabled)")
|
||||
if err := viper.BindPFlag("log.level", cmd.PersistentFlags().Lookup("log.level")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("log.time", "unix", "time format used in logs (unix, unixms, unixmicro)")
|
||||
if err := viper.BindPFlag("log.time", cmd.PersistentFlags().Lookup("log.time")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("log.json", false, "logs in JSON format")
|
||||
if err := viper.BindPFlag("log.json", cmd.PersistentFlags().Lookup("log.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("log.nocolor", false, "no ANSI colors in non-JSON output")
|
||||
if err := viper.BindPFlag("log.nocolor", cmd.PersistentFlags().Lookup("log.nocolor")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("log.dir", "", "logging directory to store logs")
|
||||
if err := viper.BindPFlag("log.dir", cmd.PersistentFlags().Lookup("log.dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Root) Set() {
|
||||
s.Config = viper.GetString("config")
|
||||
|
||||
logLevel := viper.GetString("log.level")
|
||||
level, err := zerolog.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("unknown log level %s", logLevel)
|
||||
} else {
|
||||
s.LogLevel = level
|
||||
}
|
||||
|
||||
logTime := viper.GetString("log.time")
|
||||
switch logTime {
|
||||
case "unix":
|
||||
s.LogTime = zerolog.TimeFormatUnix
|
||||
case "unixms":
|
||||
s.LogTime = zerolog.TimeFormatUnixMs
|
||||
case "unixmicro":
|
||||
s.LogTime = zerolog.TimeFormatUnixMicro
|
||||
default:
|
||||
log.Warn().Msgf("unknown log time %s", logTime)
|
||||
}
|
||||
|
||||
s.LogJson = viper.GetBool("log.json")
|
||||
s.LogNocolor = viper.GetBool("log.nocolor")
|
||||
s.LogDir = viper.GetString("log.dir")
|
||||
|
||||
if viper.GetBool("debug") && s.LogLevel != zerolog.TraceLevel {
|
||||
s.LogLevel = zerolog.DebugLevel
|
||||
}
|
||||
|
||||
// support for NO_COLOR env variable: https://no-color.org/
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
s.LogNocolor = true
|
||||
}
|
||||
}
|
103
internal/config/server.go
Normal file
103
internal/config/server.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Cert string
|
||||
Key string
|
||||
Bind string
|
||||
Proxy bool
|
||||
Static string
|
||||
PathPrefix string
|
||||
PProf bool
|
||||
Metrics bool
|
||||
CORS []string
|
||||
}
|
||||
|
||||
func (Server) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().String("server.bind", "127.0.0.1:8080", "address/port/socket to serve neko")
|
||||
if err := viper.BindPFlag("server.bind", cmd.PersistentFlags().Lookup("server.bind")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("server.cert", "", "path to the SSL cert used to secure the neko server")
|
||||
if err := viper.BindPFlag("server.cert", cmd.PersistentFlags().Lookup("server.cert")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("server.key", "", "path to the SSL key used to secure the neko server")
|
||||
if err := viper.BindPFlag("server.key", cmd.PersistentFlags().Lookup("server.key")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("server.proxy", false, "trust reverse proxy headers")
|
||||
if err := viper.BindPFlag("server.proxy", cmd.PersistentFlags().Lookup("server.proxy")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("server.static", "", "path to neko client files to serve")
|
||||
if err := viper.BindPFlag("server.static", cmd.PersistentFlags().Lookup("server.static")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("server.path_prefix", "/", "path prefix for HTTP requests")
|
||||
if err := viper.BindPFlag("server.path_prefix", cmd.PersistentFlags().Lookup("server.path_prefix")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("server.pprof", false, "enable pprof endpoint available at /debug/pprof")
|
||||
if err := viper.BindPFlag("server.pprof", cmd.PersistentFlags().Lookup("server.pprof")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("server.metrics", true, "enable prometheus metrics available at /metrics")
|
||||
if err := viper.BindPFlag("server.metrics", cmd.PersistentFlags().Lookup("server.metrics")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed")
|
||||
if err := viper.BindPFlag("server.cors", cmd.PersistentFlags().Lookup("server.cors")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Set() {
|
||||
s.Cert = viper.GetString("server.cert")
|
||||
s.Key = viper.GetString("server.key")
|
||||
s.Bind = viper.GetString("server.bind")
|
||||
s.Proxy = viper.GetBool("server.proxy")
|
||||
s.Static = viper.GetString("server.static")
|
||||
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("server.path_prefix")))
|
||||
s.PProf = viper.GetBool("server.pprof")
|
||||
s.Metrics = viper.GetBool("server.metrics")
|
||||
|
||||
s.CORS = viper.GetStringSlice("server.cors")
|
||||
in, _ := utils.ArrayIn("*", s.CORS)
|
||||
if len(s.CORS) == 0 || in {
|
||||
s.CORS = []string{"*"}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HasCors() bool {
|
||||
return len(s.CORS) > 0
|
||||
}
|
||||
|
||||
func (s *Server) AllowOrigin(origin string) bool {
|
||||
// if CORS is disabled, allow all origins
|
||||
if len(s.CORS) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// if CORS is enabled, allow only origins in the list
|
||||
in, _ := utils.ArrayIn(origin, s.CORS)
|
||||
return in || s.CORS[0] == "*"
|
||||
}
|
100
internal/config/session.go
Normal file
100
internal/config/session.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
File string
|
||||
|
||||
PrivateMode bool
|
||||
LockedControls bool
|
||||
ImplicitHosting bool
|
||||
InactiveCursors bool
|
||||
MercifulReconnect bool
|
||||
APIToken string
|
||||
|
||||
CookieEnabled bool
|
||||
CookieName string
|
||||
CookieExpiration time.Duration
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
func (Session) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().String("session.file", "", "if sessions should be stored in a file, otherwise they will be stored only in memory")
|
||||
if err := viper.BindPFlag("session.file", cmd.PersistentFlags().Lookup("session.file")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.private_mode", false, "whether private mode should be enabled initially")
|
||||
if err := viper.BindPFlag("session.private_mode", cmd.PersistentFlags().Lookup("session.private_mode")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.locked_controls", false, "whether controls should be locked for users initially")
|
||||
if err := viper.BindPFlag("session.locked_controls", cmd.PersistentFlags().Lookup("session.locked_controls")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.implicit_hosting", true, "allow implicit control switching")
|
||||
if err := viper.BindPFlag("session.implicit_hosting", cmd.PersistentFlags().Lookup("session.implicit_hosting")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.inactive_cursors", false, "show inactive cursors on the screen")
|
||||
if err := viper.BindPFlag("session.inactive_cursors", cmd.PersistentFlags().Lookup("session.inactive_cursors")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.merciful_reconnect", true, "allow reconnecting to websocket even if previous connection was not closed")
|
||||
if err := viper.BindPFlag("session.merciful_reconnect", cmd.PersistentFlags().Lookup("session.merciful_reconnect")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("session.api_token", "", "API token for interacting with external services")
|
||||
if err := viper.BindPFlag("session.api_token", cmd.PersistentFlags().Lookup("session.api_token")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cookie
|
||||
cmd.PersistentFlags().Bool("session.cookie.enabled", true, "whether cookies authentication should be enabled")
|
||||
if err := viper.BindPFlag("session.cookie.enabled", cmd.PersistentFlags().Lookup("session.cookie.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("session.cookie.name", "NEKO_SESSION", "name of the cookie that holds token")
|
||||
if err := viper.BindPFlag("session.cookie.name", cmd.PersistentFlags().Lookup("session.cookie.name")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("session.cookie.expiration", 365*24, "expiration of the cookie in hours")
|
||||
if err := viper.BindPFlag("session.cookie.expiration", cmd.PersistentFlags().Lookup("session.cookie.expiration")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("session.cookie.secure", true, "use secure cookies")
|
||||
if err := viper.BindPFlag("session.cookie.secure", cmd.PersistentFlags().Lookup("session.cookie.secure")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Set() {
|
||||
s.File = viper.GetString("session.file")
|
||||
|
||||
s.PrivateMode = viper.GetBool("session.private_mode")
|
||||
s.LockedControls = viper.GetBool("session.locked_controls")
|
||||
s.ImplicitHosting = viper.GetBool("session.implicit_hosting")
|
||||
s.InactiveCursors = viper.GetBool("session.inactive_cursors")
|
||||
s.MercifulReconnect = viper.GetBool("session.merciful_reconnect")
|
||||
s.APIToken = viper.GetString("session.api_token")
|
||||
|
||||
s.CookieEnabled = viper.GetBool("session.cookie.enabled")
|
||||
s.CookieName = viper.GetString("session.cookie.name")
|
||||
s.CookieExpiration = time.Duration(viper.GetInt("session.cookie.expiration")) * time.Hour
|
||||
s.CookieSecure = viper.GetBool("session.cookie.secure")
|
||||
}
|
273
internal/config/webrtc.go
Normal file
273
internal/config/webrtc.go
Normal file
|
@ -0,0 +1,273 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
// default stun server
|
||||
const defStunSrv = "stun:stun.l.google.com:19302"
|
||||
|
||||
type WebRTCEstimator struct {
|
||||
Enabled bool
|
||||
Passive bool
|
||||
Debug bool
|
||||
InitialBitrate int
|
||||
|
||||
// how often to read and process bandwidth estimation reports
|
||||
ReadInterval time.Duration
|
||||
// how long to wait for stable connection (only neutral or upward trend) before upgrading
|
||||
StableDuration time.Duration
|
||||
// how long to wait for unstable connection (downward trend) before downgrading
|
||||
UnstableDuration time.Duration
|
||||
// how long to wait for stalled connection (neutral trend with low bandwidth) before downgrading
|
||||
StalledDuration time.Duration
|
||||
// how long to wait before downgrading again after previous downgrade
|
||||
DowngradeBackoff time.Duration
|
||||
// how long to wait before upgrading again after previous upgrade
|
||||
UpgradeBackoff time.Duration
|
||||
// how bigger the difference between estimated and stream bitrate must be to trigger upgrade/downgrade
|
||||
DiffThreshold float64
|
||||
}
|
||||
|
||||
type WebRTC struct {
|
||||
ICELite bool
|
||||
ICETrickle bool
|
||||
ICEServersFrontend []types.ICEServer
|
||||
ICEServersBackend []types.ICEServer
|
||||
EphemeralMin uint16
|
||||
EphemeralMax uint16
|
||||
TCPMux int
|
||||
UDPMux int
|
||||
|
||||
NAT1To1IPs []string
|
||||
IpRetrievalUrl string
|
||||
|
||||
Estimator WebRTCEstimator
|
||||
}
|
||||
|
||||
func (WebRTC) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().Bool("webrtc.icelite", false, "configures whether or not the ICE agent should be a lite agent")
|
||||
if err := viper.BindPFlag("webrtc.icelite", cmd.PersistentFlags().Lookup("webrtc.icelite")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("webrtc.icetrickle", true, "configures whether cadidates should be sent asynchronously using Trickle ICE")
|
||||
if err := viper.BindPFlag("webrtc.icetrickle", cmd.PersistentFlags().Lookup("webrtc.icetrickle")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Looks like this is conflicting with the frontend and backend ICE servers since latest versions
|
||||
//cmd.PersistentFlags().String("webrtc.iceservers", "[]", "Global STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
|
||||
//if err := viper.BindPFlag("webrtc.iceservers", cmd.PersistentFlags().Lookup("webrtc.iceservers")); err != nil {
|
||||
// return err
|
||||
//}
|
||||
|
||||
cmd.PersistentFlags().String("webrtc.iceservers.frontend", "[]", "Frontend only STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
|
||||
if err := viper.BindPFlag("webrtc.iceservers.frontend", cmd.PersistentFlags().Lookup("webrtc.iceservers.frontend")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("webrtc.iceservers.backend", "[]", "Backend only STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
|
||||
if err := viper.BindPFlag("webrtc.iceservers.backend", cmd.PersistentFlags().Lookup("webrtc.iceservers.backend")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("webrtc.epr", "", "limits the pool of ephemeral ports that ICE UDP connections can allocate from")
|
||||
if err := viper.BindPFlag("webrtc.epr", cmd.PersistentFlags().Lookup("webrtc.epr")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("webrtc.tcpmux", 0, "single TCP mux port for all peers")
|
||||
if err := viper.BindPFlag("webrtc.tcpmux", cmd.PersistentFlags().Lookup("webrtc.tcpmux")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("webrtc.udpmux", 0, "single UDP mux port for all peers, replaces EPR")
|
||||
if err := viper.BindPFlag("webrtc.udpmux", cmd.PersistentFlags().Lookup("webrtc.udpmux")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringSlice("webrtc.nat1to1", []string{}, "sets a list of external IP addresses of 1:1 (D)NAT and a candidate type for which the external IP address is used")
|
||||
if err := viper.BindPFlag("webrtc.nat1to1", cmd.PersistentFlags().Lookup("webrtc.nat1to1")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("webrtc.ip_retrieval_url", "https://checkip.amazonaws.com", "URL address used for retrieval of the external IP address")
|
||||
if err := viper.BindPFlag("webrtc.ip_retrieval_url", cmd.PersistentFlags().Lookup("webrtc.ip_retrieval_url")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// bandwidth estimator
|
||||
|
||||
cmd.PersistentFlags().Bool("webrtc.estimator.enabled", false, "enables the bandwidth estimator")
|
||||
if err := viper.BindPFlag("webrtc.estimator.enabled", cmd.PersistentFlags().Lookup("webrtc.estimator.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("webrtc.estimator.passive", false, "passive estimator mode, when it does not switch pipelines, only estimates")
|
||||
if err := viper.BindPFlag("webrtc.estimator.passive", cmd.PersistentFlags().Lookup("webrtc.estimator.passive")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("webrtc.estimator.debug", false, "enables debug logging for the bandwidth estimator")
|
||||
if err := viper.BindPFlag("webrtc.estimator.debug", cmd.PersistentFlags().Lookup("webrtc.estimator.debug")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Int("webrtc.estimator.initial_bitrate", 1_000_000, "initial bitrate for the bandwidth estimator")
|
||||
if err := viper.BindPFlag("webrtc.estimator.initial_bitrate", cmd.PersistentFlags().Lookup("webrtc.estimator.initial_bitrate")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.read_interval", 2*time.Second, "how often to read and process bandwidth estimation reports")
|
||||
if err := viper.BindPFlag("webrtc.estimator.read_interval", cmd.PersistentFlags().Lookup("webrtc.estimator.read_interval")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.stable_duration", 12*time.Second, "how long to wait for stable connection (upward or neutral trend) before upgrading")
|
||||
if err := viper.BindPFlag("webrtc.estimator.stable_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.stable_duration")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.unstable_duration", 6*time.Second, "how long to wait for stalled connection (neutral trend with low bandwidth) before downgrading")
|
||||
if err := viper.BindPFlag("webrtc.estimator.unstable_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.unstable_duration")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.stalled_duration", 24*time.Second, "how long to wait for stalled bandwidth estimation before downgrading")
|
||||
if err := viper.BindPFlag("webrtc.estimator.stalled_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.stalled_duration")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.downgrade_backoff", 10*time.Second, "how long to wait before downgrading again after previous downgrade")
|
||||
if err := viper.BindPFlag("webrtc.estimator.downgrade_backoff", cmd.PersistentFlags().Lookup("webrtc.estimator.downgrade_backoff")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("webrtc.estimator.upgrade_backoff", 5*time.Second, "how long to wait before upgrading again after previous upgrade")
|
||||
if err := viper.BindPFlag("webrtc.estimator.upgrade_backoff", cmd.PersistentFlags().Lookup("webrtc.estimator.upgrade_backoff")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Float64("webrtc.estimator.diff_threshold", 0.15, "how bigger the difference between estimated and stream bitrate must be to trigger upgrade/downgrade")
|
||||
if err := viper.BindPFlag("webrtc.estimator.diff_threshold", cmd.PersistentFlags().Lookup("webrtc.estimator.diff_threshold")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebRTC) Set() {
|
||||
s.ICELite = viper.GetBool("webrtc.icelite")
|
||||
s.ICETrickle = viper.GetBool("webrtc.icetrickle")
|
||||
|
||||
// parse frontend ice servers
|
||||
if err := viper.UnmarshalKey("webrtc.iceservers.frontend", &s.ICEServersFrontend, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode([]types.ICEServer{}),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse frontend ICE servers")
|
||||
}
|
||||
|
||||
// parse backend ice servers
|
||||
if err := viper.UnmarshalKey("webrtc.iceservers.backend", &s.ICEServersBackend, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode([]types.ICEServer{}),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse backend ICE servers")
|
||||
}
|
||||
|
||||
if s.ICELite && len(s.ICEServersBackend) > 0 {
|
||||
log.Warn().Msgf("ICE Lite is enabled, but backend ICE servers are configured. Backend ICE servers will be ignored.")
|
||||
}
|
||||
|
||||
// if no frontend or backend ice servers are configured
|
||||
if len(s.ICEServersFrontend) == 0 && len(s.ICEServersBackend) == 0 {
|
||||
// parse global ice servers
|
||||
var iceServers []types.ICEServer
|
||||
if err := viper.UnmarshalKey("webrtc.iceservers", &iceServers, viper.DecodeHook(
|
||||
utils.JsonStringAutoDecode([]types.ICEServer{}),
|
||||
)); err != nil {
|
||||
log.Warn().Err(err).Msgf("unable to parse global ICE servers")
|
||||
}
|
||||
|
||||
// add default stun server if none are configured
|
||||
if len(iceServers) == 0 {
|
||||
iceServers = append(iceServers, types.ICEServer{
|
||||
URLs: []string{defStunSrv},
|
||||
})
|
||||
}
|
||||
|
||||
s.ICEServersFrontend = append(s.ICEServersFrontend, iceServers...)
|
||||
s.ICEServersBackend = append(s.ICEServersBackend, iceServers...)
|
||||
}
|
||||
|
||||
s.TCPMux = viper.GetInt("webrtc.tcpmux")
|
||||
s.UDPMux = viper.GetInt("webrtc.udpmux")
|
||||
|
||||
epr := viper.GetString("webrtc.epr")
|
||||
if epr != "" {
|
||||
ports := strings.SplitN(epr, "-", -1)
|
||||
if len(ports) > 1 {
|
||||
min, err := strconv.ParseUint(ports[0], 10, 16)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msgf("unable to parse ephemeral min port")
|
||||
}
|
||||
|
||||
max, err := strconv.ParseUint(ports[1], 10, 16)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msgf("unable to parse ephemeral max port")
|
||||
}
|
||||
|
||||
s.EphemeralMin = uint16(min)
|
||||
s.EphemeralMax = uint16(max)
|
||||
}
|
||||
|
||||
if s.EphemeralMin > s.EphemeralMax {
|
||||
log.Panic().Msgf("ephemeral min port cannot be bigger than max")
|
||||
}
|
||||
}
|
||||
|
||||
if epr == "" && s.TCPMux == 0 && s.UDPMux == 0 {
|
||||
// using default epr range
|
||||
s.EphemeralMin = 59000
|
||||
s.EphemeralMax = 59100
|
||||
|
||||
log.Warn().
|
||||
Uint16("min", s.EphemeralMin).
|
||||
Uint16("max", s.EphemeralMax).
|
||||
Msgf("no TCP, UDP mux or epr specified, using default epr range")
|
||||
}
|
||||
|
||||
s.NAT1To1IPs = viper.GetStringSlice("webrtc.nat1to1")
|
||||
s.IpRetrievalUrl = viper.GetString("webrtc.ip_retrieval_url")
|
||||
if s.IpRetrievalUrl != "" && len(s.NAT1To1IPs) == 0 {
|
||||
ip, err := utils.HttpRequestGET(s.IpRetrievalUrl)
|
||||
if err == nil {
|
||||
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
|
||||
} else {
|
||||
log.Warn().Err(err).Msgf("IP retrieval failed")
|
||||
}
|
||||
}
|
||||
|
||||
// bandwidth estimator
|
||||
|
||||
s.Estimator.Enabled = viper.GetBool("webrtc.estimator.enabled")
|
||||
s.Estimator.Passive = viper.GetBool("webrtc.estimator.passive")
|
||||
s.Estimator.Debug = viper.GetBool("webrtc.estimator.debug")
|
||||
s.Estimator.InitialBitrate = viper.GetInt("webrtc.estimator.initial_bitrate")
|
||||
s.Estimator.ReadInterval = viper.GetDuration("webrtc.estimator.read_interval")
|
||||
s.Estimator.StableDuration = viper.GetDuration("webrtc.estimator.stable_duration")
|
||||
s.Estimator.UnstableDuration = viper.GetDuration("webrtc.estimator.unstable_duration")
|
||||
s.Estimator.StalledDuration = viper.GetDuration("webrtc.estimator.stalled_duration")
|
||||
s.Estimator.DowngradeBackoff = viper.GetDuration("webrtc.estimator.downgrade_backoff")
|
||||
s.Estimator.UpgradeBackoff = viper.GetDuration("webrtc.estimator.upgrade_backoff")
|
||||
s.Estimator.DiffThreshold = viper.GetFloat64("webrtc.estimator.diff_threshold")
|
||||
}
|
122
internal/desktop/clipboard.go
Normal file
122
internal/desktop/clipboard.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/xevent"
|
||||
)
|
||||
|
||||
func (manager *DesktopManagerCtx) ClipboardGetText() (*types.ClipboardText, error) {
|
||||
text, err := manager.ClipboardGetBinary("STRING")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Rich text must not always be available, can fail silently.
|
||||
html, _ := manager.ClipboardGetBinary("text/html")
|
||||
|
||||
return &types.ClipboardText{
|
||||
Text: string(text),
|
||||
HTML: string(html),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ClipboardSetText(data types.ClipboardText) error {
|
||||
// TODO: Refactor.
|
||||
// Current implementation is unable to set multiple targets. HTML
|
||||
// is set, if available. Otherwise plain text.
|
||||
|
||||
if data.HTML != "" {
|
||||
return manager.ClipboardSetBinary("text/html", []byte(data.HTML))
|
||||
}
|
||||
|
||||
return manager.ClipboardSetBinary("STRING", []byte(data.Text))
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ClipboardGetBinary(mime string) ([]byte, error) {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", mime)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ClipboardSetBinary(mime string, data []byte) error {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-in", "-target", mime)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Refactor.
|
||||
// We need to wait until the data came to the clipboard.
|
||||
wait := make(chan struct{})
|
||||
xevent.Emmiter.Once("clipboard-updated", func(payload ...any) {
|
||||
wait <- struct{}{}
|
||||
})
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
_, err = stdin.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdin.Close()
|
||||
|
||||
// TODO: Refactor.
|
||||
// cmd.Wait()
|
||||
<-wait
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ClipboardGetTargets() ([]string, error) {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", "TARGETS")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
var response []string
|
||||
targets := strings.Split(stdout.String(), "\n")
|
||||
for _, target := range targets {
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(target, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
response = append(response, target)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
68
internal/desktop/drop.go
Normal file
68
internal/desktop/drop.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/pkg/drop"
|
||||
)
|
||||
|
||||
// repeat move event multiple times
|
||||
const dropMoveRepeat = 4
|
||||
|
||||
// wait after each repeated move event
|
||||
const dropMoveDelay = 100 * time.Millisecond
|
||||
|
||||
func (manager *DesktopManagerCtx) DropFiles(x int, y int, files []string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
drop.Emmiter.Clear()
|
||||
|
||||
drop.Emmiter.Once("create", func(payload ...any) {
|
||||
manager.Move(0, 0)
|
||||
})
|
||||
|
||||
drop.Emmiter.Once("cursor-enter", func(payload ...any) {
|
||||
//nolint
|
||||
manager.ButtonDown(1)
|
||||
})
|
||||
|
||||
drop.Emmiter.Once("button-press", func(payload ...any) {
|
||||
manager.Move(x, y)
|
||||
})
|
||||
|
||||
drop.Emmiter.Once("begin", func(payload ...any) {
|
||||
for i := 0; i < dropMoveRepeat; i++ {
|
||||
manager.Move(x, y)
|
||||
time.Sleep(dropMoveDelay)
|
||||
}
|
||||
|
||||
//nolint
|
||||
manager.ButtonUp(1)
|
||||
})
|
||||
|
||||
finished := make(chan bool)
|
||||
drop.Emmiter.Once("finish", func(payload ...any) {
|
||||
b, ok := payload[0].(bool)
|
||||
// workaround until https://github.com/kataras/go-events/pull/8 is merged
|
||||
if !ok {
|
||||
b = (payload[0].([]any))[0].(bool)
|
||||
}
|
||||
finished <- b
|
||||
})
|
||||
|
||||
manager.ResetKeys()
|
||||
go drop.OpenWindow(files)
|
||||
|
||||
select {
|
||||
case succeeded := <-finished:
|
||||
return succeeded
|
||||
case <-time.After(1 * time.Second):
|
||||
drop.CloseWindow()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) IsUploadDropEnabled() bool {
|
||||
return manager.config.UploadDrop
|
||||
}
|
102
internal/desktop/filechooserdialog.go
Normal file
102
internal/desktop/filechooserdialog.go
Normal file
|
@ -0,0 +1,102 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
|
||||
"github.com/demodesk/neko/pkg/xorg"
|
||||
)
|
||||
|
||||
// name of the window that is being controlled
|
||||
const fileChooserDialogName = "Open File"
|
||||
|
||||
// short sleep value between fake user interactions
|
||||
const fileChooserDialogShortSleep = "0.2"
|
||||
|
||||
// long sleep value between fake user interactions
|
||||
const fileChooserDialogLongSleep = "0.4"
|
||||
|
||||
func (manager *DesktopManagerCtx) HandleFileChooserDialog(uri string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// TODO: Use native API.
|
||||
err1 := exec.Command(
|
||||
"xdotool",
|
||||
"search", "--name", fileChooserDialogName, "windowfocus",
|
||||
"sleep", fileChooserDialogShortSleep,
|
||||
"key", "--clearmodifiers", "ctrl+l",
|
||||
"type", "--args", "1", uri+"//",
|
||||
"sleep", fileChooserDialogShortSleep,
|
||||
"key", "Delete", // remove autocomplete results
|
||||
"sleep", fileChooserDialogShortSleep,
|
||||
"key", "Return",
|
||||
"sleep", fileChooserDialogLongSleep,
|
||||
"key", "Down",
|
||||
"key", "--clearmodifiers", "ctrl+a",
|
||||
"key", "Return",
|
||||
"sleep", fileChooserDialogLongSleep,
|
||||
).Run()
|
||||
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
|
||||
// TODO: Use native API.
|
||||
err2 := exec.Command(
|
||||
"xdotool",
|
||||
"search", "--name", fileChooserDialogName,
|
||||
).Run()
|
||||
|
||||
// if last command didn't return error, consider dialog as still open
|
||||
if err2 == nil {
|
||||
return errors.New("unable to select files in dialog")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) CloseFileChooserDialog() {
|
||||
for i := 0; i < 5; i++ {
|
||||
mu.Lock()
|
||||
|
||||
manager.logger.Debug().Msg("attempting to close file chooser dialog")
|
||||
|
||||
// TODO: Use native API.
|
||||
err := exec.Command(
|
||||
"xdotool",
|
||||
"search", "--name", fileChooserDialogName, "windowfocus",
|
||||
).Run()
|
||||
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
manager.logger.Info().Msg("file chooser dialog is closed")
|
||||
return
|
||||
}
|
||||
|
||||
// custom press Alt + F4
|
||||
// because xdotool is failing to send proper Alt+F4
|
||||
|
||||
//nolint
|
||||
manager.KeyPress(xorg.XK_Alt_L, xorg.XK_F4)
|
||||
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) IsFileChooserDialogEnabled() bool {
|
||||
return manager.config.FileChooserDialog
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) IsFileChooserDialogOpened() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// TODO: Use native API.
|
||||
err := exec.Command(
|
||||
"xdotool",
|
||||
"search", "--name", fileChooserDialogName,
|
||||
).Run()
|
||||
|
||||
return err == nil
|
||||
}
|
138
internal/desktop/manager.go
Normal file
138
internal/desktop/manager.go
Normal file
|
@ -0,0 +1,138 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kataras/go-events"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/xevent"
|
||||
"github.com/demodesk/neko/pkg/xinput"
|
||||
"github.com/demodesk/neko/pkg/xorg"
|
||||
)
|
||||
|
||||
var mu = sync.Mutex{}
|
||||
|
||||
type DesktopManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
wg sync.WaitGroup
|
||||
shutdown chan struct{}
|
||||
emmiter events.EventEmmiter
|
||||
config *config.Desktop
|
||||
screenSize types.ScreenSize // cached screen size
|
||||
input xinput.Driver
|
||||
}
|
||||
|
||||
func New(config *config.Desktop) *DesktopManagerCtx {
|
||||
var input xinput.Driver
|
||||
if config.UseInputDriver {
|
||||
input = xinput.NewDriver(config.InputSocket)
|
||||
} else {
|
||||
input = xinput.NewDummy()
|
||||
}
|
||||
|
||||
return &DesktopManagerCtx{
|
||||
logger: log.With().Str("module", "desktop").Logger(),
|
||||
shutdown: make(chan struct{}),
|
||||
emmiter: events.New(),
|
||||
config: config,
|
||||
screenSize: config.ScreenSize,
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) Start() {
|
||||
if xorg.DisplayOpen(manager.config.Display) {
|
||||
manager.logger.Panic().Str("display", manager.config.Display).Msg("unable to open display")
|
||||
}
|
||||
|
||||
// X11 can throw errors below, and the default error handler exits
|
||||
xevent.SetupErrorHandler()
|
||||
|
||||
xorg.GetScreenConfigurations()
|
||||
|
||||
screenSize, err := xorg.ChangeScreenSize(manager.config.ScreenSize)
|
||||
if err != nil {
|
||||
manager.logger.Err(err).
|
||||
Str("screen_size", screenSize.String()).
|
||||
Msgf("unable to set initial screen size")
|
||||
} else {
|
||||
// cache screen size
|
||||
manager.screenSize = screenSize
|
||||
manager.logger.Info().
|
||||
Str("screen_size", screenSize.String()).
|
||||
Msgf("setting initial screen size")
|
||||
}
|
||||
|
||||
err = manager.input.Connect()
|
||||
if err != nil {
|
||||
// TODO: fail silently to dummy driver?
|
||||
manager.logger.Panic().Err(err).Msg("unable to connect to input driver")
|
||||
}
|
||||
|
||||
// set up event listeners
|
||||
xevent.Unminimize = manager.config.Unminimize
|
||||
xevent.FileChooserDialog = manager.config.FileChooserDialog
|
||||
go xevent.EventLoop(manager.config.Display)
|
||||
|
||||
// in case it was opened
|
||||
if manager.config.FileChooserDialog {
|
||||
go manager.CloseFileChooserDialog()
|
||||
}
|
||||
|
||||
manager.OnEventError(func(error_code uint8, message string, request_code uint8, minor_code uint8) {
|
||||
manager.logger.Warn().
|
||||
Uint8("error_code", error_code).
|
||||
Str("message", message).
|
||||
Uint8("request_code", request_code).
|
||||
Uint8("minor_code", minor_code).
|
||||
Msg("X event error occured")
|
||||
})
|
||||
|
||||
manager.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer manager.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
const debounceDuration = 10 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-manager.shutdown:
|
||||
return
|
||||
case <-ticker.C:
|
||||
xorg.CheckKeys(debounceDuration)
|
||||
manager.input.Debounce(debounceDuration)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnBeforeScreenSizeChange(listener func()) {
|
||||
manager.emmiter.On("before_screen_size_change", func(payload ...any) {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnAfterScreenSizeChange(listener func()) {
|
||||
manager.emmiter.On("after_screen_size_change", func(payload ...any) {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) Shutdown() error {
|
||||
manager.logger.Info().Msgf("shutdown")
|
||||
|
||||
close(manager.shutdown)
|
||||
manager.wg.Wait()
|
||||
|
||||
xorg.DisplayClose()
|
||||
return nil
|
||||
}
|
35
internal/desktop/xevent.go
Normal file
35
internal/desktop/xevent.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/xevent"
|
||||
)
|
||||
|
||||
func (manager *DesktopManagerCtx) OnCursorChanged(listener func(serial uint64)) {
|
||||
xevent.Emmiter.On("cursor-changed", func(payload ...any) {
|
||||
listener(payload[0].(uint64))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnClipboardUpdated(listener func()) {
|
||||
xevent.Emmiter.On("clipboard-updated", func(payload ...any) {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnFileChooserDialogOpened(listener func()) {
|
||||
xevent.Emmiter.On("file-chooser-dialog-opened", func(payload ...any) {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnFileChooserDialogClosed(listener func()) {
|
||||
xevent.Emmiter.On("file-chooser-dialog-closed", func(payload ...any) {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) OnEventError(listener func(error_code uint8, message string, request_code uint8, minor_code uint8)) {
|
||||
xevent.Emmiter.On("event-error", func(payload ...any) {
|
||||
listener(payload[0].(uint8), payload[1].(string), payload[2].(uint8), payload[3].(uint8))
|
||||
})
|
||||
}
|
36
internal/desktop/xinput.go
Normal file
36
internal/desktop/xinput.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package desktop
|
||||
|
||||
import "github.com/demodesk/neko/pkg/xinput"
|
||||
|
||||
func (manager *DesktopManagerCtx) inputRelToAbs(x, y int) (int, int) {
|
||||
return (x * xinput.AbsX) / manager.screenSize.Width, (y * xinput.AbsY) / manager.screenSize.Height
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) HasTouchSupport() bool {
|
||||
// we assume now, that if the input driver is enabled, we have touch support
|
||||
return manager.config.UseInputDriver
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) TouchBegin(touchId uint32, x, y int, pressure uint8) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
x, y = manager.inputRelToAbs(x, y)
|
||||
return manager.input.TouchBegin(touchId, x, y, pressure)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) TouchUpdate(touchId uint32, x, y int, pressure uint8) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
x, y = manager.inputRelToAbs(x, y)
|
||||
return manager.input.TouchUpdate(touchId, x, y, pressure)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) TouchEnd(touchId uint32, x, y int, pressure uint8) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
x, y = manager.inputRelToAbs(x, y)
|
||||
return manager.input.TouchEnd(touchId, x, y, pressure)
|
||||
}
|
202
internal/desktop/xorg.go
Normal file
202
internal/desktop/xorg.go
Normal file
|
@ -0,0 +1,202 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"image"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/xorg"
|
||||
)
|
||||
|
||||
func (manager *DesktopManagerCtx) Move(x, y int) {
|
||||
xorg.Move(x, y)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
|
||||
return xorg.GetCursorPosition()
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
|
||||
xorg.Scroll(deltaX, deltaY, controlKey)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {
|
||||
return xorg.ButtonDown(code)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) KeyDown(code uint32) error {
|
||||
return xorg.KeyDown(code)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ButtonUp(code uint32) error {
|
||||
return xorg.ButtonUp(code)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) KeyUp(code uint32) error {
|
||||
return xorg.KeyUp(code)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ButtonPress(code uint32) error {
|
||||
xorg.ResetKeys()
|
||||
defer xorg.ResetKeys()
|
||||
|
||||
return xorg.ButtonDown(code)
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) KeyPress(codes ...uint32) error {
|
||||
xorg.ResetKeys()
|
||||
defer xorg.ResetKeys()
|
||||
|
||||
for _, code := range codes {
|
||||
if err := xorg.KeyDown(code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(codes) > 1 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ResetKeys() {
|
||||
xorg.ResetKeys()
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) ScreenConfigurations() []types.ScreenSize {
|
||||
var configs []types.ScreenSize
|
||||
for _, size := range xorg.ScreenConfigurations {
|
||||
for _, fps := range size.Rates {
|
||||
// filter out all irrelevant rates
|
||||
if fps > 60 || (fps > 30 && fps%10 != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
configs = append(configs, types.ScreenSize{
|
||||
Width: size.Width,
|
||||
Height: size.Height,
|
||||
Rate: fps,
|
||||
})
|
||||
}
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (types.ScreenSize, error) {
|
||||
mu.Lock()
|
||||
manager.emmiter.Emit("before_screen_size_change")
|
||||
|
||||
defer func() {
|
||||
manager.emmiter.Emit("after_screen_size_change")
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
screenSize, err := xorg.ChangeScreenSize(screenSize)
|
||||
if err == nil {
|
||||
// cache the new screen size
|
||||
manager.screenSize = screenSize
|
||||
}
|
||||
|
||||
return screenSize, err
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetScreenSize() types.ScreenSize {
|
||||
return xorg.GetScreenSize()
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) SetKeyboardMap(kbd types.KeyboardMap) error {
|
||||
// TOOD: Use native API.
|
||||
cmd := exec.Command("setxkbmap", "-layout", kbd.Layout, "-variant", kbd.Variant)
|
||||
_, err := cmd.Output()
|
||||
return err
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetKeyboardMap() (*types.KeyboardMap, error) {
|
||||
// TOOD: Use native API.
|
||||
cmd := exec.Command("setxkbmap", "-query")
|
||||
res, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kbd := types.KeyboardMap{}
|
||||
|
||||
re := regexp.MustCompile(`layout:\s+(.*)\n`)
|
||||
arr := re.FindStringSubmatch(string(res))
|
||||
if len(arr) > 1 {
|
||||
kbd.Layout = arr[1]
|
||||
}
|
||||
|
||||
re = regexp.MustCompile(`variant:\s+(.*)\n`)
|
||||
arr = re.FindStringSubmatch(string(res))
|
||||
if len(arr) > 1 {
|
||||
kbd.Variant = arr[1]
|
||||
}
|
||||
|
||||
return &kbd, nil
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) SetKeyboardModifiers(mod types.KeyboardModifiers) {
|
||||
if mod.Shift != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModShift, *mod.Shift)
|
||||
}
|
||||
|
||||
if mod.CapsLock != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModCapsLock, *mod.CapsLock)
|
||||
}
|
||||
|
||||
if mod.Control != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModControl, *mod.Control)
|
||||
}
|
||||
|
||||
if mod.Alt != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModAlt, *mod.Alt)
|
||||
}
|
||||
|
||||
if mod.NumLock != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModNumLock, *mod.NumLock)
|
||||
}
|
||||
|
||||
if mod.Meta != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModMeta, *mod.Meta)
|
||||
}
|
||||
|
||||
if mod.Super != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModSuper, *mod.Super)
|
||||
}
|
||||
|
||||
if mod.AltGr != nil {
|
||||
xorg.SetKeyboardModifier(xorg.KbdModAltGr, *mod.AltGr)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetKeyboardModifiers() types.KeyboardModifiers {
|
||||
modifiers := xorg.GetKeyboardModifiers()
|
||||
|
||||
isset := func(mod xorg.KbdMod) *bool {
|
||||
x := modifiers&mod != 0
|
||||
return &x
|
||||
}
|
||||
|
||||
return types.KeyboardModifiers{
|
||||
Shift: isset(xorg.KbdModShift),
|
||||
CapsLock: isset(xorg.KbdModCapsLock),
|
||||
Control: isset(xorg.KbdModControl),
|
||||
Alt: isset(xorg.KbdModAlt),
|
||||
NumLock: isset(xorg.KbdModNumLock),
|
||||
Meta: isset(xorg.KbdModMeta),
|
||||
Super: isset(xorg.KbdModSuper),
|
||||
AltGr: isset(xorg.KbdModAltGr),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetCursorImage() *types.CursorImage {
|
||||
return xorg.GetCursorImage()
|
||||
}
|
||||
|
||||
func (manager *DesktopManagerCtx) GetScreenshotImage() *image.RGBA {
|
||||
return xorg.GetScreenshotImage()
|
||||
}
|
123
internal/http/batch.go
Normal file
123
internal/http/batch.go
Normal file
|
@ -0,0 +1,123 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type BatchRequest struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResponse struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func (b *BatchResponse) Error(httpErr *utils.HTTPError) (err error) {
|
||||
b.Body, err = json.Marshal(httpErr)
|
||||
b.Status = httpErr.Code
|
||||
return
|
||||
}
|
||||
|
||||
type batchHandler struct {
|
||||
Router types.Router
|
||||
PathPrefix string
|
||||
Excluded []string
|
||||
}
|
||||
|
||||
func (b *batchHandler) Handle(w http.ResponseWriter, r *http.Request) error {
|
||||
var requests []BatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&requests); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responses := make([]BatchResponse, len(requests))
|
||||
for i, request := range requests {
|
||||
res := BatchResponse{
|
||||
Path: request.Path,
|
||||
Method: request.Method,
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(request.Path, b.PathPrefix) {
|
||||
res.Error(utils.HttpBadRequest("this path is not allowed in batch requests"))
|
||||
responses[i] = res
|
||||
continue
|
||||
}
|
||||
|
||||
if exists, _ := utils.ArrayIn(request.Path, b.Excluded); exists {
|
||||
res.Error(utils.HttpBadRequest("this path is excluded from batch requests"))
|
||||
responses[i] = res
|
||||
continue
|
||||
}
|
||||
|
||||
// prepare request
|
||||
req, err := http.NewRequest(request.Method, request.Path, bytes.NewBuffer(request.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy headers
|
||||
for k, vv := range r.Header {
|
||||
for _, v := range vv {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// execute request
|
||||
rr := newResponseRecorder()
|
||||
b.Router.ServeHTTP(rr, req)
|
||||
|
||||
// read response
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write response
|
||||
responses[i] = BatchResponse{
|
||||
Path: request.Path,
|
||||
Method: request.Method,
|
||||
Body: body,
|
||||
Status: rr.Code,
|
||||
}
|
||||
}
|
||||
|
||||
return utils.HttpSuccess(w, responses)
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
Code int
|
||||
HeaderMap http.Header
|
||||
Body *bytes.Buffer
|
||||
}
|
||||
|
||||
func newResponseRecorder() *responseRecorder {
|
||||
return &responseRecorder{
|
||||
Code: http.StatusOK,
|
||||
HeaderMap: make(http.Header),
|
||||
Body: new(bytes.Buffer),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *responseRecorder) Header() http.Header {
|
||||
return w.HeaderMap
|
||||
}
|
||||
|
||||
func (w *responseRecorder) Write(b []byte) (int, error) {
|
||||
return w.Body.Write(b)
|
||||
}
|
||||
|
||||
func (w *responseRecorder) WriteHeader(code int) {
|
||||
w.Code = code
|
||||
}
|
36
internal/http/debug.go
Normal file
36
internal/http/debug.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func pprofHandler(r types.Router) {
|
||||
r.Get("/debug/pprof/", func(w http.ResponseWriter, r *http.Request) error {
|
||||
pprof.Index(w, r)
|
||||
return nil
|
||||
})
|
||||
|
||||
r.Get("/debug/pprof/{action}", func(w http.ResponseWriter, r *http.Request) error {
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
switch action {
|
||||
case "cmdline":
|
||||
pprof.Cmdline(w, r)
|
||||
case "profile":
|
||||
pprof.Profile(w, r)
|
||||
case "symbol":
|
||||
pprof.Symbol(w, r)
|
||||
case "trace":
|
||||
pprof.Trace(w, r)
|
||||
default:
|
||||
pprof.Handler(action).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
135
internal/http/logger.go
Normal file
135
internal/http/logger.go
Normal file
|
@ -0,0 +1,135 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type logFormatter struct {
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (l *logFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
|
||||
// exclude health & metrics from logs
|
||||
if r.RequestURI == "/health" || r.RequestURI == "/metrics" {
|
||||
return &nulllog{}
|
||||
}
|
||||
|
||||
req := map[string]any{}
|
||||
|
||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||
req["id"] = reqID
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
req["scheme"] = scheme
|
||||
req["proto"] = r.Proto
|
||||
req["method"] = r.Method
|
||||
req["remote"] = r.RemoteAddr
|
||||
req["agent"] = r.UserAgent()
|
||||
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
|
||||
|
||||
return &logEntry{
|
||||
logger: l.logger.With().Interface("req", req).Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
logger zerolog.Logger
|
||||
err error
|
||||
panic *logPanic
|
||||
session types.Session
|
||||
}
|
||||
|
||||
type logPanic struct {
|
||||
message string
|
||||
stack string
|
||||
}
|
||||
|
||||
func (e *logEntry) Panic(v any, stack []byte) {
|
||||
e.panic = &logPanic{
|
||||
message: fmt.Sprintf("%+v", v),
|
||||
stack: string(stack),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *logEntry) Error(err error) {
|
||||
e.err = err
|
||||
}
|
||||
|
||||
func (e *logEntry) SetSession(session types.Session) {
|
||||
e.session = session
|
||||
}
|
||||
|
||||
func (e *logEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
|
||||
res := map[string]any{}
|
||||
res["time"] = time.Now().UTC().Format(time.RFC1123)
|
||||
res["status"] = status
|
||||
res["bytes"] = bytes
|
||||
res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0
|
||||
|
||||
logger := e.logger.With().Interface("res", res).Logger()
|
||||
|
||||
// add session ID to logs (if exists)
|
||||
if e.session != nil {
|
||||
logger = logger.With().Str("session_id", e.session.ID()).Logger()
|
||||
}
|
||||
|
||||
// handle panic error message
|
||||
if e.panic != nil {
|
||||
logger.WithLevel(zerolog.PanicLevel).
|
||||
Err(e.err).
|
||||
Str("stack", e.panic.stack).
|
||||
Msgf("request failed (%d): %s", status, e.panic.message)
|
||||
return
|
||||
}
|
||||
|
||||
// handle panic error message
|
||||
if e.err != nil {
|
||||
httpErr, ok := e.err.(*utils.HTTPError)
|
||||
if !ok {
|
||||
logger.Err(e.err).Msgf("request failed (%d)", status)
|
||||
return
|
||||
}
|
||||
|
||||
if httpErr.Message == "" {
|
||||
httpErr.Message = http.StatusText(httpErr.Code)
|
||||
}
|
||||
|
||||
var logLevel zerolog.Level
|
||||
if httpErr.Code < 500 {
|
||||
logLevel = zerolog.WarnLevel
|
||||
} else {
|
||||
logLevel = zerolog.ErrorLevel
|
||||
}
|
||||
|
||||
message := httpErr.Message
|
||||
if httpErr.InternalMsg != "" {
|
||||
message = httpErr.InternalMsg
|
||||
}
|
||||
|
||||
logger.WithLevel(logLevel).Err(httpErr.InternalErr).Msgf("request failed (%d): %s", status, message)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Msgf("request complete (%d)", status)
|
||||
}
|
||||
|
||||
type nulllog struct{}
|
||||
|
||||
func (e *nulllog) Panic(v any, stack []byte) {}
|
||||
func (e *nulllog) Error(err error) {}
|
||||
func (e *nulllog) SetSession(session types.Session) {}
|
||||
func (e *nulllog) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
|
||||
}
|
132
internal/http/manager.go
Normal file
132
internal/http/manager.go
Normal file
|
@ -0,0 +1,132 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type HttpManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
config *config.Server
|
||||
router types.Router
|
||||
http *http.Server
|
||||
}
|
||||
|
||||
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx {
|
||||
logger := log.With().Str("module", "http").Logger()
|
||||
|
||||
opts := []RouterOption{
|
||||
WithRequestID(), // create a request id for each request
|
||||
}
|
||||
|
||||
// use real ip if behind proxy
|
||||
// before logger so it can log the real ip
|
||||
if config.Proxy {
|
||||
opts = append(opts, WithRealIP())
|
||||
}
|
||||
|
||||
opts = append(opts,
|
||||
WithLogger(logger),
|
||||
WithRecoverer(), // recover from panics without crashing server
|
||||
)
|
||||
|
||||
if config.HasCors() {
|
||||
opts = append(opts, WithCORS(config.AllowOrigin))
|
||||
}
|
||||
|
||||
if config.PathPrefix != "/" {
|
||||
opts = append(opts, WithPathPrefix(config.PathPrefix))
|
||||
}
|
||||
|
||||
router := newRouter(opts...)
|
||||
|
||||
router.Route("/api", ApiManager.Route)
|
||||
|
||||
router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool {
|
||||
return config.AllowOrigin(r.Header.Get("Origin"))
|
||||
}))
|
||||
|
||||
batch := batchHandler{
|
||||
Router: router,
|
||||
PathPrefix: "/api",
|
||||
Excluded: []string{
|
||||
"/api/batch", // do not allow batchception
|
||||
"/api/ws",
|
||||
},
|
||||
}
|
||||
router.Post("/api/batch", batch.Handle)
|
||||
|
||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
|
||||
_, err := w.Write([]byte("true"))
|
||||
return err
|
||||
})
|
||||
|
||||
if config.Metrics {
|
||||
router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) error {
|
||||
promhttp.Handler().ServeHTTP(w, r)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if config.Static != "" {
|
||||
fs := http.FileServer(http.Dir(config.Static))
|
||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) error {
|
||||
_, err := os.Stat(config.Static + r.URL.Path)
|
||||
if err == nil {
|
||||
fs.ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if config.PProf {
|
||||
pprofHandler(router)
|
||||
}
|
||||
|
||||
return &HttpManagerCtx{
|
||||
logger: logger,
|
||||
config: config,
|
||||
router: router,
|
||||
http: &http.Server{
|
||||
Addr: config.Bind,
|
||||
Handler: router,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *HttpManagerCtx) Start() {
|
||||
if manager.config.Cert != "" && manager.config.Key != "" {
|
||||
go func() {
|
||||
if err := manager.http.ListenAndServeTLS(manager.config.Cert, manager.config.Key); err != http.ErrServerClosed {
|
||||
manager.logger.Panic().Err(err).Msg("unable to start https server")
|
||||
}
|
||||
}()
|
||||
manager.logger.Info().Msgf("https listening on %s", manager.http.Addr)
|
||||
} else {
|
||||
go func() {
|
||||
if err := manager.http.ListenAndServe(); err != http.ErrServerClosed {
|
||||
manager.logger.Panic().Err(err).Msg("unable to start http server")
|
||||
}
|
||||
}()
|
||||
manager.logger.Info().Msgf("http listening on %s", manager.http.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *HttpManagerCtx) Shutdown() error {
|
||||
manager.logger.Info().Msg("shutdown")
|
||||
|
||||
return manager.http.Shutdown(context.Background())
|
||||
}
|
172
internal/http/router.go
Normal file
172
internal/http/router.go
Normal file
|
@ -0,0 +1,172 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type RouterOption func(*router)
|
||||
|
||||
func WithRequestID() RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(middleware.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger zerolog.Logger) RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(middleware.RequestLogger(&logFormatter{logger}))
|
||||
}
|
||||
}
|
||||
|
||||
func WithRecoverer() RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(middleware.Recoverer)
|
||||
}
|
||||
}
|
||||
|
||||
func WithCORS(allowOrigin func(origin string) bool) RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(cors.Handler(cors.Options{
|
||||
AllowOriginFunc: func(r *http.Request, origin string) bool {
|
||||
return allowOrigin(origin)
|
||||
},
|
||||
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func WithPathPrefix(prefix string) RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(func(h http.Handler) http.Handler {
|
||||
return http.StripPrefix(prefix, h)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func WithRealIP() RouterOption {
|
||||
return func(r *router) {
|
||||
r.chi.Use(middleware.RealIP)
|
||||
}
|
||||
}
|
||||
|
||||
type router struct {
|
||||
chi chi.Router
|
||||
}
|
||||
|
||||
func newRouter(opts ...RouterOption) types.Router {
|
||||
r := &router{chi.NewRouter()}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *router) Group(fn func(types.Router)) {
|
||||
r.chi.Group(func(c chi.Router) {
|
||||
fn(&router{c})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) Route(pattern string, fn func(types.Router)) {
|
||||
r.chi.Route(pattern, func(c chi.Router) {
|
||||
fn(&router{c})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) Get(pattern string, fn types.RouterHandler) {
|
||||
r.chi.Get(pattern, routeHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) Post(pattern string, fn types.RouterHandler) {
|
||||
r.chi.Post(pattern, routeHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) Put(pattern string, fn types.RouterHandler) {
|
||||
r.chi.Put(pattern, routeHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) Patch(pattern string, fn types.RouterHandler) {
|
||||
r.chi.Patch(pattern, routeHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) Delete(pattern string, fn types.RouterHandler) {
|
||||
r.chi.Delete(pattern, routeHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) With(fn types.MiddlewareHandler) types.Router {
|
||||
c := r.chi.With(middlewareHandler(fn))
|
||||
return &router{c}
|
||||
}
|
||||
|
||||
func (r *router) Use(fn types.MiddlewareHandler) {
|
||||
r.chi.Use(middlewareHandler(fn))
|
||||
}
|
||||
|
||||
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.chi.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func errorHandler(err error, w http.ResponseWriter, r *http.Request) {
|
||||
httpErr, ok := err.(*utils.HTTPError)
|
||||
if !ok {
|
||||
httpErr = utils.HttpInternalServerError().WithInternalErr(err)
|
||||
}
|
||||
|
||||
utils.HttpJsonResponse(w, httpErr.Code, httpErr)
|
||||
}
|
||||
|
||||
func routeHandler(fn types.RouterHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// get custom log entry pointer from context
|
||||
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
|
||||
|
||||
if err := fn(w, r); err != nil {
|
||||
logEntry.Error(err)
|
||||
errorHandler(err, w, r)
|
||||
}
|
||||
|
||||
// set session if exits
|
||||
if session, ok := auth.GetSession(r); ok {
|
||||
logEntry.SetSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func middlewareHandler(fn types.MiddlewareHandler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// get custom log entry pointer from context
|
||||
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
|
||||
|
||||
ctx, err := fn(w, r)
|
||||
if err != nil {
|
||||
logEntry.Error(err)
|
||||
errorHandler(err, w, r)
|
||||
|
||||
// set session if exits
|
||||
if session, ok := auth.GetSession(r); ok {
|
||||
logEntry.SetSession(session)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if ctx != nil {
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
204
internal/member/file/provider.go
Normal file
204
internal/member/file/provider.go
Normal file
|
@ -0,0 +1,204 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func New(config Config) types.MemberProvider {
|
||||
return &MemberProviderCtx{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
type MemberProviderCtx struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) hash(password string) string {
|
||||
// if hash is disabled, return password as plain text
|
||||
if !provider.config.Hash {
|
||||
return password
|
||||
}
|
||||
|
||||
sha256 := sha256.New()
|
||||
sha256.Write([]byte(password))
|
||||
hashedPassword := sha256.Sum(nil)
|
||||
return base64.StdEncoding.EncodeToString(hashedPassword)
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||
// id will be also username
|
||||
id := username
|
||||
|
||||
entry, err := provider.getEntry(id)
|
||||
if err != nil {
|
||||
return "", types.MemberProfile{}, err
|
||||
}
|
||||
|
||||
if entry.Password != provider.hash(password) {
|
||||
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
|
||||
}
|
||||
|
||||
return id, entry.Profile, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||
// id will be also username
|
||||
id := username
|
||||
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, ok := entries[id]
|
||||
if ok {
|
||||
return "", types.ErrMemberAlreadyExists
|
||||
}
|
||||
|
||||
entries[id] = MemberEntry{
|
||||
Password: provider.hash(password),
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
return id, provider.serialize(entries)
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry, ok := entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
entry.Profile = profile
|
||||
entries[id] = entry
|
||||
|
||||
return provider.serialize(entries)
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry, ok := entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
entry.Password = provider.hash(password)
|
||||
entries[id] = entry
|
||||
|
||||
return provider.serialize(entries)
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
|
||||
entry, err := provider.getEntry(id)
|
||||
if err != nil {
|
||||
return types.MemberProfile{}, err
|
||||
}
|
||||
|
||||
return entry.Profile, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||
profiles := map[string]types.MemberProfile{}
|
||||
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return profiles, err
|
||||
}
|
||||
|
||||
i := 0
|
||||
for id, entry := range entries {
|
||||
if i >= offset && (limit == 0 || i < offset+limit) {
|
||||
profiles[id] = entry.Profile
|
||||
}
|
||||
|
||||
i = i + 1
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, ok := entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
delete(entries, id)
|
||||
|
||||
return provider.serialize(entries)
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) deserialize() (map[string]MemberEntry, error) {
|
||||
file, err := os.OpenFile(provider.config.Path, os.O_RDONLY|os.O_CREATE, os.ModePerm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(raw) == 0 {
|
||||
return map[string]MemberEntry{}, nil
|
||||
}
|
||||
|
||||
var entries map[string]MemberEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) getEntry(id string) (MemberEntry, error) {
|
||||
entries, err := provider.deserialize()
|
||||
if err != nil {
|
||||
return MemberEntry{}, err
|
||||
}
|
||||
|
||||
entry, ok := entries[id]
|
||||
if !ok {
|
||||
return MemberEntry{}, types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) serialize(data map[string]MemberEntry) error {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(provider.config.Path, raw, os.ModePerm)
|
||||
}
|
48
internal/member/file/provider_test.go
Normal file
48
internal/member/file/provider_test.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
// Ensure that hashes are the same after encoding and decoding using json
|
||||
func TestMemberProviderCtx_hash(t *testing.T) {
|
||||
provider := &MemberProviderCtx{
|
||||
config: Config{
|
||||
Hash: true,
|
||||
},
|
||||
}
|
||||
|
||||
// generate random strings
|
||||
passwords := []string{}
|
||||
for i := 0; i < 10; i++ {
|
||||
password, err := utils.NewUID(32)
|
||||
if err != nil {
|
||||
t.Errorf("utils.NewUID() returned error: %s", err)
|
||||
}
|
||||
passwords = append(passwords, password)
|
||||
}
|
||||
|
||||
for _, password := range passwords {
|
||||
hashedPassword := provider.hash(password)
|
||||
|
||||
// json encode password hash
|
||||
hashedPasswordJSON, err := json.Marshal(hashedPassword)
|
||||
if err != nil {
|
||||
t.Errorf("json.Marshal() returned error: %s", err)
|
||||
}
|
||||
|
||||
// json decode password hash json
|
||||
var hashedPasswordStr string
|
||||
err = json.Unmarshal(hashedPasswordJSON, &hashedPasswordStr)
|
||||
if err != nil {
|
||||
t.Errorf("json.Unmarshal() returned error: %s", err)
|
||||
}
|
||||
|
||||
if hashedPasswordStr != hashedPassword {
|
||||
t.Errorf("hashedPasswordStr: %s != hashedPassword: %s", hashedPasswordStr, hashedPassword)
|
||||
}
|
||||
}
|
||||
}
|
15
internal/member/file/types.go
Normal file
15
internal/member/file/types.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type MemberEntry struct {
|
||||
Password string `json:"password"`
|
||||
Profile types.MemberProfile `json:"profile"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Path string
|
||||
Hash bool
|
||||
}
|
164
internal/member/manager.go
Normal file
164
internal/member/manager.go
Normal file
|
@ -0,0 +1,164 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/member/file"
|
||||
"github.com/demodesk/neko/internal/member/multiuser"
|
||||
"github.com/demodesk/neko/internal/member/noauth"
|
||||
"github.com/demodesk/neko/internal/member/object"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx {
|
||||
manager := &MemberManagerCtx{
|
||||
logger: log.With().Str("module", "member").Logger(),
|
||||
sessions: sessions,
|
||||
config: config,
|
||||
}
|
||||
|
||||
switch config.Provider {
|
||||
case "file":
|
||||
manager.provider = file.New(config.File)
|
||||
case "object":
|
||||
manager.provider = object.New(config.Object)
|
||||
case "multiuser":
|
||||
manager.provider = multiuser.New(config.Multiuser)
|
||||
case "noauth":
|
||||
fallthrough
|
||||
default:
|
||||
manager.provider = noauth.New()
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
type MemberManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
sessions types.SessionManager
|
||||
config *config.Member
|
||||
providerMu sync.Mutex
|
||||
provider types.MemberProvider
|
||||
loginMu sync.Mutex
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Connect() error {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.Connect()
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Disconnect() error {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.Disconnect()
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.Authenticate(username, password)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.Insert(username, password, profile)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Select(id string) (types.MemberProfile, error) {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
// get primarily from corresponding session, if exists
|
||||
session, ok := manager.sessions.Get(id)
|
||||
if ok {
|
||||
return session.Profile(), nil
|
||||
}
|
||||
|
||||
return manager.provider.Select(id)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.SelectAll(limit, offset)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
// update corresponding session, if exists
|
||||
err := manager.sessions.Update(id, profile)
|
||||
if err != nil && !errors.Is(err, types.ErrSessionNotFound) {
|
||||
manager.logger.Err(err).Msg("error while updating session")
|
||||
}
|
||||
|
||||
return manager.provider.UpdateProfile(id, profile)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) UpdatePassword(id string, password string) error {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
return manager.provider.UpdatePassword(id, password)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Delete(id string) error {
|
||||
manager.providerMu.Lock()
|
||||
defer manager.providerMu.Unlock()
|
||||
|
||||
// destroy corresponding session, if exists
|
||||
err := manager.sessions.Delete(id)
|
||||
if err != nil && !errors.Is(err, types.ErrSessionNotFound) {
|
||||
manager.logger.Err(err).Msg("error while deleting session")
|
||||
}
|
||||
|
||||
return manager.provider.Delete(id)
|
||||
}
|
||||
|
||||
//
|
||||
// member -> session
|
||||
//
|
||||
|
||||
func (manager *MemberManagerCtx) Login(username string, password string) (types.Session, string, error) {
|
||||
manager.loginMu.Lock()
|
||||
defer manager.loginMu.Unlock()
|
||||
|
||||
id, profile, err := manager.provider.Authenticate(username, password)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
session, ok := manager.sessions.Get(id)
|
||||
if ok {
|
||||
if session.State().IsConnected {
|
||||
return nil, "", types.ErrSessionAlreadyConnected
|
||||
}
|
||||
|
||||
// TODO: Replace session.
|
||||
if err := manager.sessions.Delete(id); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
return manager.sessions.Create(id, profile)
|
||||
}
|
||||
|
||||
func (manager *MemberManagerCtx) Logout(id string) error {
|
||||
manager.loginMu.Lock()
|
||||
defer manager.loginMu.Unlock()
|
||||
|
||||
return manager.sessions.Delete(id)
|
||||
}
|
82
internal/member/multiuser/provider.go
Normal file
82
internal/member/multiuser/provider.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package multiuser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func New(config Config) types.MemberProvider {
|
||||
return &MemberProviderCtx{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
type MemberProviderCtx struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||
// generate random token
|
||||
token, err := utils.NewUID(5)
|
||||
if err != nil {
|
||||
return "", types.MemberProfile{}, err
|
||||
}
|
||||
|
||||
// id is username with token
|
||||
id := fmt.Sprintf("%s-%s", username, token)
|
||||
|
||||
// if logged in as administrator
|
||||
if provider.config.AdminPassword == password {
|
||||
profile := provider.config.AdminProfile
|
||||
if profile.Name == "" {
|
||||
profile.Name = username
|
||||
}
|
||||
return id, profile, nil
|
||||
}
|
||||
|
||||
// if logged in as user
|
||||
if provider.config.UserPassword == password {
|
||||
profile := provider.config.UserProfile
|
||||
if profile.Name == "" {
|
||||
profile.Name = username
|
||||
}
|
||||
return id, profile, nil
|
||||
}
|
||||
|
||||
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||
return "", errors.New("new user is created on first login in multiuser mode")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
|
||||
return errors.New("password can only be modified in config while in multiuser mode")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
|
||||
return types.MemberProfile{}, errors.New("cannot select user in multiuser mode")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||
return map[string]types.MemberProfile{}, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||
return errors.New("cannot delete user in multiuser mode")
|
||||
}
|
10
internal/member/multiuser/types.go
Normal file
10
internal/member/multiuser/types.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package multiuser
|
||||
|
||||
import "github.com/demodesk/neko/pkg/types"
|
||||
|
||||
type Config struct {
|
||||
AdminPassword string
|
||||
UserPassword string
|
||||
AdminProfile types.MemberProfile
|
||||
UserProfile types.MemberProfile
|
||||
}
|
75
internal/member/noauth/provider.go
Normal file
75
internal/member/noauth/provider.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
package noauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func New() types.MemberProvider {
|
||||
return &MemberProviderCtx{
|
||||
profile: types.MemberProfile{
|
||||
IsAdmin: true,
|
||||
CanLogin: true,
|
||||
CanConnect: true,
|
||||
CanWatch: true,
|
||||
CanHost: true,
|
||||
CanShareMedia: true,
|
||||
CanAccessClipboard: true,
|
||||
SendsInactiveCursor: true,
|
||||
CanSeeInactiveCursors: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type MemberProviderCtx struct {
|
||||
profile types.MemberProfile
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||
// generate random token
|
||||
token, err := utils.NewUID(5)
|
||||
if err != nil {
|
||||
return "", types.MemberProfile{}, err
|
||||
}
|
||||
|
||||
// id is username with token
|
||||
id := fmt.Sprintf("%s-%s", username, token)
|
||||
|
||||
provider.profile.Name = username
|
||||
return id, provider.profile, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||
return "", errors.New("new user is created on first login in noauth mode")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
|
||||
return errors.New("noauth mode does not have password")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
|
||||
return types.MemberProfile{}, errors.New("cannot select user in noauth mode")
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||
return map[string]types.MemberProfile{}, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||
return errors.New("cannot delete user in noauth mode")
|
||||
}
|
124
internal/member/object/provider.go
Normal file
124
internal/member/object/provider.go
Normal file
|
@ -0,0 +1,124 @@
|
|||
package object
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func New(config Config) types.MemberProvider {
|
||||
return &MemberProviderCtx{
|
||||
config: config,
|
||||
entries: make(map[string]*memberEntry),
|
||||
}
|
||||
}
|
||||
|
||||
type MemberProviderCtx struct {
|
||||
config Config
|
||||
entries map[string]*memberEntry
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Connect() error {
|
||||
var err error
|
||||
|
||||
for _, entry := range provider.config.Users {
|
||||
_, err = provider.Insert(entry.Username, entry.Password, entry.Profile)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||
// id will be also username
|
||||
id := username
|
||||
|
||||
entry, ok := provider.entries[id]
|
||||
if !ok {
|
||||
return "", types.MemberProfile{}, types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
// TODO: Use hash function.
|
||||
if !entry.CheckPassword(password) {
|
||||
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
|
||||
}
|
||||
|
||||
return id, entry.profile, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||
// id will be also username
|
||||
id := username
|
||||
|
||||
_, ok := provider.entries[id]
|
||||
if ok {
|
||||
return "", types.ErrMemberAlreadyExists
|
||||
}
|
||||
|
||||
provider.entries[id] = &memberEntry{
|
||||
// TODO: Use hash function.
|
||||
password: password,
|
||||
profile: profile,
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||
entry, ok := provider.entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
entry.profile = profile
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
|
||||
entry, ok := provider.entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
// TODO: Use hash function.
|
||||
entry.password = password
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
|
||||
entry, ok := provider.entries[id]
|
||||
if !ok {
|
||||
return types.MemberProfile{}, types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
return entry.profile, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||
profiles := make(map[string]types.MemberProfile)
|
||||
|
||||
i := 0
|
||||
for id, entry := range provider.entries {
|
||||
if i >= offset && (limit == 0 || i < offset+limit) {
|
||||
profiles[id] = entry.profile
|
||||
}
|
||||
|
||||
i = i + 1
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||
_, ok := provider.entries[id]
|
||||
if !ok {
|
||||
return types.ErrMemberDoesNotExist
|
||||
}
|
||||
|
||||
delete(provider.entries, id)
|
||||
|
||||
return nil
|
||||
}
|
24
internal/member/object/types.go
Normal file
24
internal/member/object/types.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package object
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type memberEntry struct {
|
||||
password string
|
||||
profile types.MemberProfile
|
||||
}
|
||||
|
||||
func (m *memberEntry) CheckPassword(password string) bool {
|
||||
return m.password == password
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Username string
|
||||
Password string
|
||||
Profile types.MemberProfile
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Users []User
|
||||
}
|
133
internal/plugins/dependency.go
Normal file
133
internal/plugins/dependency.go
Normal file
|
@ -0,0 +1,133 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type dependency struct {
|
||||
plugin types.Plugin
|
||||
dependsOn []*dependency
|
||||
invoked bool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (a *dependency) findPlugin(name string) (*dependency, bool) {
|
||||
if a == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if a.plugin.Name() == name {
|
||||
return a, true
|
||||
}
|
||||
|
||||
for _, dep := range a.dependsOn {
|
||||
plug, ok := dep.findPlugin(name)
|
||||
if ok {
|
||||
return plug, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (a *dependency) startPlugin(pm types.PluginManagers) error {
|
||||
if a.invoked {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.invoked = true
|
||||
|
||||
for _, do := range a.dependsOn {
|
||||
if err := do.startPlugin(pm); err != nil {
|
||||
return fmt.Errorf("plugin's '%s' dependency: %w", a.plugin.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
err := a.plugin.Start(pm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin '%s' failed to start: %w", a.plugin.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info().Str("plugin", a.plugin.Name()).Msg("plugin started")
|
||||
return nil
|
||||
}
|
||||
|
||||
type dependiencies struct {
|
||||
deps map[string]*dependency
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (d *dependiencies) addPlugin(plugin types.Plugin) error {
|
||||
pluginName := plugin.Name()
|
||||
|
||||
plug, ok := d.deps[pluginName]
|
||||
if !ok {
|
||||
plug = &dependency{}
|
||||
} else if plug.plugin != nil {
|
||||
return fmt.Errorf("plugin '%s' already added", pluginName)
|
||||
}
|
||||
|
||||
plug.plugin = plugin
|
||||
plug.logger = d.logger
|
||||
d.deps[pluginName] = plug
|
||||
|
||||
dplug, ok := plugin.(types.DependablePlugin)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, depName := range dplug.DependsOn() {
|
||||
dependsOn, ok := d.deps[depName]
|
||||
if !ok {
|
||||
dependsOn = &dependency{}
|
||||
} else if dependsOn.plugin != nil {
|
||||
// if there is a cyclical dependency, break it and return error
|
||||
if tdep, ok := dependsOn.findPlugin(pluginName); ok {
|
||||
dependsOn.dependsOn = nil
|
||||
delete(d.deps, pluginName)
|
||||
return fmt.Errorf("cyclical dependency detected: '%s' <-> '%s'", pluginName, tdep.plugin.Name())
|
||||
}
|
||||
}
|
||||
|
||||
plug.dependsOn = append(plug.dependsOn, dependsOn)
|
||||
d.deps[depName] = dependsOn
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) findPlugin(name string) (*dependency, bool) {
|
||||
for _, dep := range d.deps {
|
||||
plug, ok := dep.findPlugin(name)
|
||||
if ok {
|
||||
return plug, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (d *dependiencies) start(pm types.PluginManagers) error {
|
||||
for _, dep := range d.deps {
|
||||
if err := dep.startPlugin(pm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) forEach(f func(*dependency) error) error {
|
||||
for _, dep := range d.deps {
|
||||
if err := f(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) len() int {
|
||||
return len(d.deps)
|
||||
}
|
630
internal/plugins/dependency_test.go
Normal file
630
internal/plugins/dependency_test.go
Normal file
|
@ -0,0 +1,630 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func Test_deps_addPlugin(t *testing.T) {
|
||||
type args struct {
|
||||
p []types.Plugin
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want map[string]*dependency
|
||||
skipRun bool
|
||||
wantErr1 bool
|
||||
wantErr2 bool
|
||||
}{
|
||||
{
|
||||
name: "three plugins - no dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
&dummyPlugin{name: "third"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - one dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - one double dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - two dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first"}},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skipRun: true,
|
||||
}, {
|
||||
name: "three plugins - three dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "four plugins - added in reverse order, with dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skipRun: true,
|
||||
}, {
|
||||
name: "four plugins - two double dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"first", "third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
|
||||
&dummyPlugin{name: "second"},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"first", "third"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
|
||||
// resolving their direct dependiencies first:
|
||||
//
|
||||
// Can be C->D->A->B if D depends on A
|
||||
//
|
||||
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
|
||||
//
|
||||
// i.e. A->B->C D F->G
|
||||
//
|
||||
// and then join these lists in any order.
|
||||
name: "add indirect dependency CDAB",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "A", dep: []string{"B"}},
|
||||
&dummyPlugin{name: "C", dep: []string{"D"}},
|
||||
&dummyPlugin{name: "B"},
|
||||
&dummyPlugin{name: "D", dep: []string{"A"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"B": {
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"A": {
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"D": {
|
||||
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"C": {
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 3},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
|
||||
// resolving their direct dependiencies first:
|
||||
//
|
||||
// Can be A->B->C->D (in this test) if B depends on C
|
||||
//
|
||||
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
|
||||
//
|
||||
// i.e. A->B->C D F->G
|
||||
//
|
||||
// and then join these lists in any order.
|
||||
name: "add indirect dependency ABCD",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "C", dep: []string{"D"}},
|
||||
&dummyPlugin{name: "D"},
|
||||
&dummyPlugin{name: "B", dep: []string{"C"}},
|
||||
&dummyPlugin{name: "A", dep: []string{"B"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"D": {
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"C": {
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"B": {
|
||||
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"A": {
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 3},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "add duplicate plugin",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {plugin: &dummyPlugin{name: "first", idx: 0}, invoked: true},
|
||||
},
|
||||
wantErr1: true,
|
||||
}, {
|
||||
name: "cyclical dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"second"}, idx: 1},
|
||||
invoked: true,
|
||||
},
|
||||
},
|
||||
wantErr1: true,
|
||||
}, {
|
||||
name: "four plugins - cyclical transitive dependencies in reverse order",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first", dep: []string{"forth"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
wantErr1: true,
|
||||
skipRun: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := &dependiencies{deps: make(map[string]*dependency)}
|
||||
|
||||
var (
|
||||
err error
|
||||
counter int
|
||||
)
|
||||
for _, p := range tt.args.p {
|
||||
if !tt.skipRun {
|
||||
p.(*dummyPlugin).counter = &counter
|
||||
}
|
||||
if err = d.addPlugin(p); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil != tt.wantErr1 {
|
||||
t.Errorf("dependiencies.addPlugin() error = %v, wantErr1 %v", err, tt.wantErr1)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.skipRun {
|
||||
if err := d.start(types.PluginManagers{}); (err != nil) != tt.wantErr2 {
|
||||
t.Errorf("dependiencies.start() error = %v, wantErr1 %v", err, tt.wantErr2)
|
||||
}
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(d.deps, tt.want) {
|
||||
t.Errorf("deps = %v, want %v", d.deps, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type dummyPlugin struct {
|
||||
name string
|
||||
dep []string
|
||||
idx int
|
||||
counter *int
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Name() string {
|
||||
return d.name
|
||||
}
|
||||
|
||||
func (d dummyPlugin) DependsOn() []string {
|
||||
return d.dep
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Config() types.PluginConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dummyPlugin) Start(types.PluginManagers) error {
|
||||
if len(d.dep) > 0 {
|
||||
*d.counter++
|
||||
d.idx = *d.counter
|
||||
}
|
||||
d.counter = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Shutdown() error {
|
||||
return nil
|
||||
}
|
177
internal/plugins/manager.go
Normal file
177
internal/plugins/manager.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type ManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
config *config.Plugins
|
||||
plugins dependiencies
|
||||
}
|
||||
|
||||
func New(config *config.Plugins) *ManagerCtx {
|
||||
manager := &ManagerCtx{
|
||||
logger: log.With().Str("module", "plugins").Logger(),
|
||||
config: config,
|
||||
plugins: dependiencies{
|
||||
deps: make(map[string]*dependency),
|
||||
},
|
||||
}
|
||||
|
||||
manager.plugins.logger = manager.logger
|
||||
|
||||
if config.Enabled {
|
||||
err := manager.loadDir(config.Dir)
|
||||
|
||||
// only log error if plugin is not required
|
||||
if err != nil && config.Required {
|
||||
manager.logger.Fatal().Err(err).Msg("error loading plugins")
|
||||
}
|
||||
|
||||
manager.logger.Info().Msgf("loading finished, total %d plugins", manager.plugins.len())
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) loadDir(dir string) error {
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = manager.load(path)
|
||||
|
||||
// return error if plugin is required
|
||||
if err != nil && manager.config.Required {
|
||||
return err
|
||||
}
|
||||
|
||||
// otherwise only log error if plugin is not required
|
||||
manager.logger.Err(err).Str("plugin", path).Msg("loading a plugin")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) load(path string) error {
|
||||
pl, err := plugin.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sym, err := pl.Lookup("Plugin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, ok := sym.(types.Plugin)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a valid plugin")
|
||||
}
|
||||
|
||||
if err = manager.plugins.addPlugin(p); err != nil {
|
||||
return fmt.Errorf("failed to add plugin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) InitConfigs(cmd *cobra.Command) {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
if err := d.plugin.Config().Init(cmd); err != nil {
|
||||
log.Err(err).Str("plugin", d.plugin.Name()).Msg("unable to initialize configuration")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) SetConfigs() {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
d.plugin.Config().Set()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Start(
|
||||
sessionManager types.SessionManager,
|
||||
webSocketManager types.WebSocketManager,
|
||||
apiManager types.ApiManager,
|
||||
) {
|
||||
err := manager.plugins.start(types.PluginManagers{
|
||||
SessionManager: sessionManager,
|
||||
WebSocketManager: webSocketManager,
|
||||
ApiManager: apiManager,
|
||||
LoadServiceFromPlugin: manager.LookupService,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if manager.config.Required {
|
||||
manager.logger.Fatal().Err(err).Msg("failed to start plugins, exiting...")
|
||||
} else {
|
||||
manager.logger.Err(err).Msg("failed to start plugins, skipping...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Shutdown() error {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
err := d.plugin.Shutdown()
|
||||
manager.logger.Err(err).Str("plugin", d.plugin.Name()).Msg("plugin shutdown")
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) LookupService(pluginName string) (any, error) {
|
||||
plug, ok := manager.plugins.findPlugin(pluginName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin '%s' not found", pluginName)
|
||||
}
|
||||
|
||||
expPlug, ok := plug.plugin.(types.ExposablePlugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin '%s' is not exposable", pluginName)
|
||||
}
|
||||
|
||||
return expPlug.ExposeService(), nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Metadata() []types.PluginMetadata {
|
||||
var plugins []types.PluginMetadata
|
||||
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
dependsOn := make([]string, 0)
|
||||
deps, isDependalbe := d.plugin.(types.DependablePlugin)
|
||||
if isDependalbe {
|
||||
dependsOn = deps.DependsOn()
|
||||
}
|
||||
|
||||
_, isExposable := d.plugin.(types.ExposablePlugin)
|
||||
|
||||
plugins = append(plugins, types.PluginMetadata{
|
||||
Name: d.plugin.Name(),
|
||||
IsDependable: isDependalbe,
|
||||
IsExposable: isExposable,
|
||||
DependsOn: dependsOn,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return plugins
|
||||
}
|
80
internal/session/auth.go
Normal file
80
internal/session/auth.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token string) {
|
||||
sameSite := http.SameSiteDefaultMode
|
||||
if manager.config.CookieSecure {
|
||||
sameSite = http.SameSiteNoneMode
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: manager.config.CookieName,
|
||||
Value: token,
|
||||
Expires: time.Now().Add(manager.config.CookieExpiration),
|
||||
Secure: manager.config.CookieSecure,
|
||||
SameSite: sameSite,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) CookieClearToken(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(manager.config.CookieName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cookie.Value = ""
|
||||
cookie.Expires = time.Unix(0, 0)
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) {
|
||||
token, ok := manager.getToken(r)
|
||||
if !ok {
|
||||
return nil, errors.New("no authentication provided")
|
||||
}
|
||||
|
||||
session, ok := manager.GetByToken(token)
|
||||
if !ok {
|
||||
return nil, types.ErrSessionNotFound
|
||||
}
|
||||
|
||||
if !session.Profile().CanLogin {
|
||||
return nil, types.ErrSessionLoginDisabled
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) getToken(r *http.Request) (string, bool) {
|
||||
if manager.CookieEnabled() {
|
||||
// get from Cookie
|
||||
cookie, err := r.Cookie(manager.config.CookieName)
|
||||
if err == nil {
|
||||
return cookie.Value, true
|
||||
}
|
||||
}
|
||||
|
||||
// get from Header
|
||||
reqToken := r.Header.Get("Authorization")
|
||||
splitToken := strings.Split(reqToken, "Bearer ")
|
||||
if len(splitToken) == 2 {
|
||||
return strings.TrimSpace(splitToken[1]), true
|
||||
}
|
||||
|
||||
// get from URL
|
||||
token := r.URL.Query().Get("token")
|
||||
if token != "" {
|
||||
return token, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
412
internal/session/manager.go
Normal file
412
internal/session/manager.go
Normal file
|
@ -0,0 +1,412 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kataras/go-events"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func New(config *config.Session) *SessionManagerCtx {
|
||||
manager := &SessionManagerCtx{
|
||||
logger: log.With().Str("module", "session").Logger(),
|
||||
config: config,
|
||||
settings: types.Settings{
|
||||
PrivateMode: config.PrivateMode,
|
||||
LockedControls: config.LockedControls,
|
||||
ImplicitHosting: config.ImplicitHosting,
|
||||
InactiveCursors: config.InactiveCursors,
|
||||
MercifulReconnect: config.MercifulReconnect,
|
||||
},
|
||||
tokens: make(map[string]string),
|
||||
sessions: make(map[string]*SessionCtx),
|
||||
cursors: make(map[types.Session][]types.Cursor),
|
||||
emmiter: events.New(),
|
||||
}
|
||||
|
||||
// create API session
|
||||
if config.APIToken != "" {
|
||||
manager.apiSession = &SessionCtx{
|
||||
id: "API",
|
||||
token: config.APIToken,
|
||||
manager: manager,
|
||||
logger: manager.logger.With().Str("session_id", "API").Logger(),
|
||||
profile: types.MemberProfile{
|
||||
Name: "API Session",
|
||||
IsAdmin: true,
|
||||
CanLogin: true,
|
||||
CanConnect: false,
|
||||
CanWatch: true,
|
||||
CanHost: true,
|
||||
CanAccessClipboard: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// try to load sessions from file
|
||||
manager.load()
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
type SessionManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
config *config.Session
|
||||
|
||||
settings types.Settings
|
||||
settingsMu sync.Mutex
|
||||
|
||||
tokens map[string]string
|
||||
sessions map[string]*SessionCtx
|
||||
sessionsMu sync.Mutex
|
||||
|
||||
hostId atomic.Value
|
||||
|
||||
cursors map[types.Session][]types.Cursor
|
||||
cursorsMu sync.Mutex
|
||||
|
||||
emmiter events.EventEmmiter
|
||||
apiSession *SessionCtx
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Create(id string, profile types.MemberProfile) (types.Session, string, error) {
|
||||
token, err := utils.NewUID(64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
manager.sessionsMu.Lock()
|
||||
if _, ok := manager.sessions[id]; ok {
|
||||
manager.sessionsMu.Unlock()
|
||||
return nil, "", types.ErrSessionAlreadyExists
|
||||
}
|
||||
|
||||
if _, ok := manager.tokens[token]; ok {
|
||||
manager.sessionsMu.Unlock()
|
||||
return nil, "", errors.New("session token already exists")
|
||||
}
|
||||
|
||||
session := &SessionCtx{
|
||||
id: id,
|
||||
token: token,
|
||||
manager: manager,
|
||||
logger: manager.logger.With().Str("session_id", id).Logger(),
|
||||
profile: profile,
|
||||
}
|
||||
|
||||
manager.tokens[token] = id
|
||||
manager.sessions[id] = session
|
||||
manager.sessionsMu.Unlock()
|
||||
|
||||
manager.emmiter.Emit("created", session)
|
||||
manager.save()
|
||||
|
||||
return session, token, nil
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Update(id string, profile types.MemberProfile) error {
|
||||
manager.sessionsMu.Lock()
|
||||
|
||||
session, ok := manager.sessions[id]
|
||||
if !ok {
|
||||
manager.sessionsMu.Unlock()
|
||||
return types.ErrSessionNotFound
|
||||
}
|
||||
|
||||
session.profile = profile
|
||||
manager.sessionsMu.Unlock()
|
||||
|
||||
manager.emmiter.Emit("profile_changed", session)
|
||||
manager.save()
|
||||
|
||||
session.profileChanged()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Delete(id string) error {
|
||||
manager.sessionsMu.Lock()
|
||||
session, ok := manager.sessions[id]
|
||||
if !ok {
|
||||
manager.sessionsMu.Unlock()
|
||||
return types.ErrSessionNotFound
|
||||
}
|
||||
|
||||
delete(manager.tokens, session.token)
|
||||
delete(manager.sessions, id)
|
||||
manager.sessionsMu.Unlock()
|
||||
|
||||
if session.State().IsConnected {
|
||||
session.DestroyWebSocketPeer("session deleted")
|
||||
}
|
||||
|
||||
if session.State().IsWatching {
|
||||
session.GetWebRTCPeer().Destroy()
|
||||
}
|
||||
|
||||
manager.emmiter.Emit("deleted", session)
|
||||
manager.save()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Get(id string) (types.Session, bool) {
|
||||
manager.sessionsMu.Lock()
|
||||
defer manager.sessionsMu.Unlock()
|
||||
|
||||
session, ok := manager.sessions[id]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) GetByToken(token string) (types.Session, bool) {
|
||||
manager.sessionsMu.Lock()
|
||||
id, ok := manager.tokens[token]
|
||||
manager.sessionsMu.Unlock()
|
||||
|
||||
if ok {
|
||||
return manager.Get(id)
|
||||
}
|
||||
|
||||
// is API session
|
||||
if manager.apiSession != nil && manager.apiSession.token == token {
|
||||
return manager.apiSession, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) List() []types.Session {
|
||||
manager.sessionsMu.Lock()
|
||||
defer manager.sessionsMu.Unlock()
|
||||
|
||||
var sessions []types.Session
|
||||
for _, session := range manager.sessions {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
// ---
|
||||
// host
|
||||
// ---
|
||||
|
||||
func (manager *SessionManagerCtx) SetHost(host types.Session) {
|
||||
var hostId string
|
||||
if host != nil {
|
||||
hostId = host.ID()
|
||||
}
|
||||
|
||||
manager.hostId.Store(hostId)
|
||||
manager.emmiter.Emit("host_changed", host)
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) GetHost() (types.Session, bool) {
|
||||
hostId, ok := manager.hostId.Load().(string)
|
||||
if !ok || hostId == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return manager.Get(hostId)
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) ClearHost() {
|
||||
manager.SetHost(nil)
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) isHost(host types.Session) bool {
|
||||
hostId, ok := manager.hostId.Load().(string)
|
||||
return ok && hostId == host.ID()
|
||||
}
|
||||
|
||||
// ---
|
||||
// cursors
|
||||
// ---
|
||||
|
||||
func (manager *SessionManagerCtx) SetCursor(cursor types.Cursor, session types.Session) {
|
||||
manager.cursorsMu.Lock()
|
||||
defer manager.cursorsMu.Unlock()
|
||||
|
||||
list, ok := manager.cursors[session]
|
||||
if !ok {
|
||||
list = []types.Cursor{}
|
||||
}
|
||||
|
||||
list = append(list, cursor)
|
||||
manager.cursors[session] = list
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) PopCursors() map[types.Session][]types.Cursor {
|
||||
manager.cursorsMu.Lock()
|
||||
defer manager.cursorsMu.Unlock()
|
||||
|
||||
cursors := manager.cursors
|
||||
manager.cursors = make(map[types.Session][]types.Cursor)
|
||||
|
||||
return cursors
|
||||
}
|
||||
|
||||
// ---
|
||||
// broadcasts
|
||||
// ---
|
||||
|
||||
func (manager *SessionManagerCtx) Broadcast(event string, payload any, exclude ...string) {
|
||||
for _, session := range manager.List() {
|
||||
if !session.State().IsConnected {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(exclude) > 0 {
|
||||
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
session.Send(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) AdminBroadcast(event string, payload any, exclude ...string) {
|
||||
for _, session := range manager.List() {
|
||||
if !session.State().IsConnected || !session.Profile().IsAdmin {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(exclude) > 0 {
|
||||
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
session.Send(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) InactiveCursorsBroadcast(event string, payload any, exclude ...string) {
|
||||
for _, session := range manager.List() {
|
||||
if !session.State().IsConnected || !session.Profile().CanSeeInactiveCursors {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(exclude) > 0 {
|
||||
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
session.Send(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// events
|
||||
// ---
|
||||
|
||||
func (manager *SessionManagerCtx) OnCreated(listener func(session types.Session)) {
|
||||
manager.emmiter.On("created", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnDeleted(listener func(session types.Session)) {
|
||||
manager.emmiter.On("deleted", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnConnected(listener func(session types.Session)) {
|
||||
manager.emmiter.On("connected", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnDisconnected(listener func(session types.Session)) {
|
||||
manager.emmiter.On("disconnected", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnProfileChanged(listener func(session types.Session)) {
|
||||
manager.emmiter.On("profile_changed", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnStateChanged(listener func(session types.Session)) {
|
||||
manager.emmiter.On("state_changed", func(payload ...any) {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnHostChanged(listener func(session types.Session)) {
|
||||
manager.emmiter.On("host_changed", func(payload ...any) {
|
||||
if payload[0] == nil {
|
||||
listener(nil)
|
||||
} else {
|
||||
listener(payload[0].(*SessionCtx))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) OnSettingsChanged(listener func(new types.Settings, old types.Settings)) {
|
||||
manager.emmiter.On("settings_changed", func(payload ...any) {
|
||||
listener(payload[0].(types.Settings), payload[1].(types.Settings))
|
||||
})
|
||||
}
|
||||
|
||||
// ---
|
||||
// settings
|
||||
// ---
|
||||
|
||||
func (manager *SessionManagerCtx) UpdateSettings(new types.Settings) {
|
||||
manager.settingsMu.Lock()
|
||||
old := manager.settings
|
||||
manager.settings = new
|
||||
manager.settingsMu.Unlock()
|
||||
|
||||
// if private mode changed
|
||||
if old.PrivateMode != new.PrivateMode {
|
||||
// update webrtc paused state for all sessions
|
||||
for _, session := range manager.List() {
|
||||
enabled := session.PrivateModeEnabled()
|
||||
|
||||
// if session had control, it must release it
|
||||
if enabled && session.IsHost() {
|
||||
manager.ClearHost()
|
||||
}
|
||||
|
||||
// its webrtc connection will be paused or unpaused
|
||||
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
|
||||
webrtcPeer.SetPaused(enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if contols have been locked
|
||||
if old.LockedControls != new.LockedControls && new.LockedControls {
|
||||
// if the host is not admin, it must release controls
|
||||
host, hasHost := manager.GetHost()
|
||||
if hasHost && !host.Profile().IsAdmin {
|
||||
manager.ClearHost()
|
||||
}
|
||||
}
|
||||
|
||||
manager.emmiter.Emit("settings_changed", new, old)
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) Settings() types.Settings {
|
||||
manager.settingsMu.Lock()
|
||||
defer manager.settingsMu.Unlock()
|
||||
|
||||
return manager.settings
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) CookieEnabled() bool {
|
||||
return manager.config.CookieEnabled
|
||||
}
|
97
internal/session/serialize.go
Normal file
97
internal/session/serialize.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func (manager *SessionManagerCtx) save() {
|
||||
if manager.config.File == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// serialize sessions
|
||||
sessions := make([]types.SessionProfile, 0, len(manager.sessions))
|
||||
for _, session := range manager.sessions {
|
||||
sessions = append(sessions, types.SessionProfile{
|
||||
Id: session.id,
|
||||
Token: session.token,
|
||||
Profile: session.profile,
|
||||
})
|
||||
}
|
||||
|
||||
// convert to json
|
||||
data, err := json.Marshal(sessions)
|
||||
if err != nil {
|
||||
manager.logger.Error().Err(err).Msg("failed to marshal sessions")
|
||||
return
|
||||
}
|
||||
|
||||
// write to file
|
||||
err = os.WriteFile(manager.config.File, data, 0644)
|
||||
if err != nil {
|
||||
manager.logger.Error().Err(err).
|
||||
Str("file", manager.config.File).
|
||||
Msg("failed to write sessions to a file")
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManagerCtx) load() {
|
||||
if manager.config.File == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// read file
|
||||
data, err := os.ReadFile(manager.config.File)
|
||||
if err != nil {
|
||||
// if file does not exist
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
manager.logger.Info().
|
||||
Str("file", manager.config.File).
|
||||
Msg("sessions file does not exist")
|
||||
return
|
||||
}
|
||||
manager.logger.Error().Err(err).
|
||||
Str("file", manager.config.File).
|
||||
Msg("failed to read sessions from a file")
|
||||
return
|
||||
}
|
||||
|
||||
// if file is empty
|
||||
if len(data) == 0 {
|
||||
manager.logger.Info().
|
||||
Str("file", manager.config.File).
|
||||
Msg("sessions file is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// deserialize sessions
|
||||
sessions := make([]types.SessionProfile, 0)
|
||||
err = json.Unmarshal(data, &sessions)
|
||||
if err != nil {
|
||||
manager.logger.Error().Err(err).Msg("failed to unmarshal sessions")
|
||||
return
|
||||
}
|
||||
|
||||
// create sessions
|
||||
manager.sessionsMu.Lock()
|
||||
for _, session := range sessions {
|
||||
manager.tokens[session.Token] = session.Id
|
||||
manager.sessions[session.Id] = &SessionCtx{
|
||||
id: session.Id,
|
||||
token: session.Token,
|
||||
manager: manager,
|
||||
logger: manager.logger.With().Str("session_id", session.Id).Logger(),
|
||||
profile: session.Profile,
|
||||
}
|
||||
}
|
||||
manager.sessionsMu.Unlock()
|
||||
|
||||
manager.logger.Info().
|
||||
Int("sessions", len(sessions)).
|
||||
Str("file", manager.config.File).
|
||||
Msg("loaded sessions from a file")
|
||||
}
|
285
internal/session/session.go
Normal file
285
internal/session/session.go
Normal file
|
@ -0,0 +1,285 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/event"
|
||||
)
|
||||
|
||||
// client is expected to reconnect within 5 second
|
||||
// if some unexpected websocket disconnect happens
|
||||
const WS_DELAYED_DURATION = 5 * time.Second
|
||||
|
||||
type SessionCtx struct {
|
||||
id string
|
||||
token string
|
||||
logger zerolog.Logger
|
||||
manager *SessionManagerCtx
|
||||
profile types.MemberProfile
|
||||
state types.SessionState
|
||||
|
||||
websocketPeer types.WebSocketPeer
|
||||
websocketMu sync.Mutex
|
||||
|
||||
// websocket delayed set connected events
|
||||
wsDelayedMu sync.Mutex
|
||||
wsDelayedTimer *time.Timer
|
||||
|
||||
webrtcPeer types.WebRTCPeer
|
||||
webrtcMu sync.Mutex
|
||||
}
|
||||
|
||||
func (session *SessionCtx) ID() string {
|
||||
return session.id
|
||||
}
|
||||
|
||||
func (session *SessionCtx) Profile() types.MemberProfile {
|
||||
return session.profile
|
||||
}
|
||||
|
||||
func (session *SessionCtx) profileChanged() {
|
||||
if !session.profile.CanHost && session.IsHost() {
|
||||
session.manager.ClearHost()
|
||||
}
|
||||
|
||||
if (!session.profile.CanConnect || !session.profile.CanLogin || !session.profile.CanWatch) && session.state.IsWatching {
|
||||
session.GetWebRTCPeer().Destroy()
|
||||
}
|
||||
|
||||
if (!session.profile.CanConnect || !session.profile.CanLogin) && session.state.IsConnected {
|
||||
session.DestroyWebSocketPeer("profile changed")
|
||||
}
|
||||
|
||||
// update webrtc paused state
|
||||
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
|
||||
webrtcPeer.SetPaused(session.PrivateModeEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
func (session *SessionCtx) State() types.SessionState {
|
||||
return session.state
|
||||
}
|
||||
|
||||
func (session *SessionCtx) IsHost() bool {
|
||||
return session.manager.isHost(session)
|
||||
}
|
||||
|
||||
func (session *SessionCtx) PrivateModeEnabled() bool {
|
||||
return session.manager.Settings().PrivateMode && !session.profile.IsAdmin
|
||||
}
|
||||
|
||||
func (session *SessionCtx) SetCursor(cursor types.Cursor) {
|
||||
if session.manager.Settings().InactiveCursors && session.profile.SendsInactiveCursor {
|
||||
session.manager.SetCursor(cursor, session)
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// websocket
|
||||
// ---
|
||||
|
||||
//
|
||||
// Connect WebSocket peer sets current peer and emits connected event. It also destroys the
|
||||
// previous peer, if there was one. If the peer is already set, it will be ignored.
|
||||
//
|
||||
func (session *SessionCtx) ConnectWebSocketPeer(websocketPeer types.WebSocketPeer) {
|
||||
session.websocketMu.Lock()
|
||||
isCurrentPeer := websocketPeer == session.websocketPeer
|
||||
session.websocketPeer, websocketPeer = websocketPeer, session.websocketPeer
|
||||
session.websocketMu.Unlock()
|
||||
|
||||
// ignore if already set
|
||||
if isCurrentPeer {
|
||||
return
|
||||
}
|
||||
|
||||
session.logger.Info().Msg("set websocket connected")
|
||||
|
||||
// update state
|
||||
now := time.Now()
|
||||
session.state.IsConnected = true
|
||||
session.state.ConnectedSince = &now
|
||||
session.state.NotConnectedSince = nil
|
||||
|
||||
session.manager.emmiter.Emit("connected", session)
|
||||
|
||||
// if there is a previous peer, destroy it
|
||||
if websocketPeer != nil {
|
||||
websocketPeer.Destroy("connection replaced")
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Disconnect WebSocket peer sets current peer to nil and emits disconnected event. It also
|
||||
// allows for a delayed disconnect. That means, the peer will not be disconnected immediately,
|
||||
// but after a delay. If the peer is connected again before the delay, the disconnect will be
|
||||
// cancelled.
|
||||
//
|
||||
// If the peer is not the current peer or the peer is nil, it will be ignored.
|
||||
//
|
||||
func (session *SessionCtx) DisconnectWebSocketPeer(websocketPeer types.WebSocketPeer, delayed bool) {
|
||||
session.websocketMu.Lock()
|
||||
isCurrentPeer := websocketPeer == session.websocketPeer && websocketPeer != nil
|
||||
session.websocketMu.Unlock()
|
||||
|
||||
// ignore if not current peer
|
||||
if !isCurrentPeer {
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// ws delayed
|
||||
//
|
||||
|
||||
var wsDelayedTimer *time.Timer
|
||||
|
||||
if delayed {
|
||||
wsDelayedTimer = time.AfterFunc(WS_DELAYED_DURATION, func() {
|
||||
session.DisconnectWebSocketPeer(websocketPeer, false)
|
||||
})
|
||||
}
|
||||
|
||||
session.wsDelayedMu.Lock()
|
||||
if session.wsDelayedTimer != nil {
|
||||
session.wsDelayedTimer.Stop()
|
||||
}
|
||||
session.wsDelayedTimer = wsDelayedTimer
|
||||
session.wsDelayedMu.Unlock()
|
||||
|
||||
if delayed {
|
||||
session.logger.Info().Msg("delayed websocket disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// not delayed
|
||||
//
|
||||
|
||||
session.logger.Info().Msg("set websocket disconnected")
|
||||
|
||||
now := time.Now()
|
||||
session.state.IsConnected = false
|
||||
session.state.ConnectedSince = nil
|
||||
session.state.NotConnectedSince = &now
|
||||
|
||||
session.manager.emmiter.Emit("disconnected", session)
|
||||
|
||||
session.websocketMu.Lock()
|
||||
if websocketPeer == session.websocketPeer {
|
||||
session.websocketPeer = nil
|
||||
}
|
||||
session.websocketMu.Unlock()
|
||||
}
|
||||
|
||||
//
|
||||
// Destroy WebSocket peer disconnects the peer and destroys it. It ensures that the peer is
|
||||
// disconnected immediately even though normal flow would be to disconnect it delayed.
|
||||
//
|
||||
func (session *SessionCtx) DestroyWebSocketPeer(reason string) {
|
||||
session.websocketMu.Lock()
|
||||
peer := session.websocketPeer
|
||||
session.websocketMu.Unlock()
|
||||
|
||||
if peer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// disconnect peer first, so that it is not used anymore
|
||||
session.DisconnectWebSocketPeer(peer, false)
|
||||
|
||||
// destroy it afterwards
|
||||
peer.Destroy(reason)
|
||||
}
|
||||
|
||||
//
|
||||
// Send event to websocket peer.
|
||||
//
|
||||
func (session *SessionCtx) Send(event string, payload any) {
|
||||
session.websocketMu.Lock()
|
||||
peer := session.websocketPeer
|
||||
session.websocketMu.Unlock()
|
||||
|
||||
if peer != nil {
|
||||
peer.Send(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// webrtc
|
||||
// ---
|
||||
|
||||
//
|
||||
// Set webrtc peer and destroy the old one, if there is old one.
|
||||
//
|
||||
func (session *SessionCtx) SetWebRTCPeer(webrtcPeer types.WebRTCPeer) {
|
||||
session.webrtcMu.Lock()
|
||||
session.webrtcPeer, webrtcPeer = webrtcPeer, session.webrtcPeer
|
||||
session.webrtcMu.Unlock()
|
||||
|
||||
if webrtcPeer != nil && webrtcPeer != session.webrtcPeer {
|
||||
webrtcPeer.Destroy()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Set if current webrtc peer is connected or not. Since there might be lefover calls from
|
||||
// webrtc peer, that are not used anymore, we need to check if the webrtc peer is still the
|
||||
// same as the one we are setting the connected state for.
|
||||
//
|
||||
// If webrtc peer is disconnected, we don't expect it to be reconnected, so we set it to nil
|
||||
// and send a signal close to the client. New connection is expected to use a new webrtc peer.
|
||||
//
|
||||
func (session *SessionCtx) SetWebRTCConnected(webrtcPeer types.WebRTCPeer, connected bool) {
|
||||
session.webrtcMu.Lock()
|
||||
isCurrentPeer := webrtcPeer == session.webrtcPeer
|
||||
session.webrtcMu.Unlock()
|
||||
|
||||
if !isCurrentPeer {
|
||||
return
|
||||
}
|
||||
|
||||
session.logger.Info().
|
||||
Bool("connected", connected).
|
||||
Msg("set webrtc connected")
|
||||
|
||||
// update state
|
||||
session.state.IsWatching = connected
|
||||
if now := time.Now(); connected {
|
||||
session.state.WatchingSince = &now
|
||||
session.state.NotWatchingSince = nil
|
||||
} else {
|
||||
session.state.WatchingSince = nil
|
||||
session.state.NotWatchingSince = &now
|
||||
}
|
||||
|
||||
session.manager.emmiter.Emit("state_changed", session)
|
||||
|
||||
if connected {
|
||||
return
|
||||
}
|
||||
|
||||
session.webrtcMu.Lock()
|
||||
isCurrentPeer = webrtcPeer == session.webrtcPeer
|
||||
if isCurrentPeer {
|
||||
session.webrtcPeer = nil
|
||||
}
|
||||
session.webrtcMu.Unlock()
|
||||
|
||||
if isCurrentPeer {
|
||||
session.Send(event.SIGNAL_CLOSE, nil)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Get current WebRTC peer. Nil if not connected.
|
||||
//
|
||||
func (session *SessionCtx) GetWebRTCPeer() types.WebRTCPeer {
|
||||
session.webrtcMu.Lock()
|
||||
defer session.webrtcMu.Unlock()
|
||||
|
||||
return session.webrtcPeer
|
||||
}
|
168
internal/webrtc/cursor/image.go
Normal file
168
internal/webrtc/cursor/image.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type ImageListener interface {
|
||||
SendCursorImage(cur *types.CursorImage, img []byte) error
|
||||
}
|
||||
|
||||
type Image interface {
|
||||
Start()
|
||||
Shutdown()
|
||||
GetCurrent() (cur *types.CursorImage, img []byte, err error)
|
||||
AddListener(listener ImageListener)
|
||||
RemoveListener(listener ImageListener)
|
||||
}
|
||||
|
||||
type imageEntry struct {
|
||||
*types.CursorImage
|
||||
ImagePNG []byte
|
||||
}
|
||||
|
||||
type image struct {
|
||||
logger zerolog.Logger
|
||||
desktop types.DesktopManager
|
||||
|
||||
listeners map[uintptr]ImageListener
|
||||
listenersMu sync.RWMutex
|
||||
|
||||
cache map[uint64]*imageEntry
|
||||
cacheMu sync.RWMutex
|
||||
current *imageEntry
|
||||
maxSerial uint64
|
||||
}
|
||||
|
||||
func NewImage(logger zerolog.Logger, desktop types.DesktopManager) *image {
|
||||
return &image{
|
||||
logger: logger.With().Str("submodule", "cursor-image").Logger(),
|
||||
desktop: desktop,
|
||||
listeners: map[uintptr]ImageListener{},
|
||||
cache: map[uint64]*imageEntry{},
|
||||
maxSerial: 300, // TODO: Cleanup?
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *image) Start() {
|
||||
manager.desktop.OnCursorChanged(func(serial uint64) {
|
||||
entry, err := manager.getCached(serial)
|
||||
if err != nil {
|
||||
manager.logger.Err(err).Msg("failed to get cursor image")
|
||||
return
|
||||
}
|
||||
|
||||
manager.current = entry
|
||||
|
||||
manager.listenersMu.RLock()
|
||||
for _, l := range manager.listeners {
|
||||
if err := l.SendCursorImage(entry.CursorImage, entry.ImagePNG); err != nil {
|
||||
manager.logger.Err(err).Msg("failed to set cursor image")
|
||||
}
|
||||
}
|
||||
manager.listenersMu.RUnlock()
|
||||
})
|
||||
|
||||
manager.logger.Info().Msg("starting")
|
||||
}
|
||||
|
||||
func (manager *image) Shutdown() {
|
||||
manager.logger.Info().Msg("shutdown")
|
||||
|
||||
manager.listenersMu.Lock()
|
||||
for key := range manager.listeners {
|
||||
delete(manager.listeners, key)
|
||||
}
|
||||
manager.listenersMu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *image) getCached(serial uint64) (*imageEntry, error) {
|
||||
// zero means no serial available
|
||||
if serial == 0 || serial > manager.maxSerial {
|
||||
manager.logger.Debug().Uint64("serial", serial).Msg("cache bypass")
|
||||
return manager.fetchEntry()
|
||||
}
|
||||
|
||||
manager.cacheMu.RLock()
|
||||
entry, ok := manager.cache[serial]
|
||||
manager.cacheMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
manager.logger.Debug().Uint64("serial", serial).Msg("cache miss")
|
||||
|
||||
entry, err := manager.fetchEntry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.cacheMu.Lock()
|
||||
manager.cache[entry.Serial] = entry
|
||||
manager.cacheMu.Unlock()
|
||||
|
||||
if entry.Serial != serial {
|
||||
manager.logger.Warn().
|
||||
Uint64("expected-serial", serial).
|
||||
Uint64("received-serial", entry.Serial).
|
||||
Msg("serial mismatch")
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (manager *image) GetCurrent() (cur *types.CursorImage, img []byte, err error) {
|
||||
if manager.current != nil {
|
||||
return manager.current.CursorImage, manager.current.ImagePNG, nil
|
||||
}
|
||||
|
||||
entry, err := manager.fetchEntry()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
manager.current = entry
|
||||
return entry.CursorImage, entry.ImagePNG, nil
|
||||
}
|
||||
|
||||
func (manager *image) AddListener(listener ImageListener) {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
manager.listeners[ptr] = listener
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *image) RemoveListener(listener ImageListener) {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
delete(manager.listeners, ptr)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *image) fetchEntry() (*imageEntry, error) {
|
||||
cur := manager.desktop.GetCursorImage()
|
||||
|
||||
img, err := utils.CreatePNGImage(cur.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur.Image = nil // free memory
|
||||
|
||||
return &imageEntry{
|
||||
CursorImage: cur,
|
||||
ImagePNG: img,
|
||||
}, nil
|
||||
}
|
74
internal/webrtc/cursor/position.go
Normal file
74
internal/webrtc/cursor/position.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type PositionListener interface {
|
||||
SendCursorPosition(x, y int) error
|
||||
}
|
||||
|
||||
type Position interface {
|
||||
Shutdown()
|
||||
Set(x, y int)
|
||||
AddListener(listener PositionListener)
|
||||
RemoveListener(listener PositionListener)
|
||||
}
|
||||
|
||||
type position struct {
|
||||
logger zerolog.Logger
|
||||
|
||||
listeners map[uintptr]PositionListener
|
||||
listenersMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewPosition(logger zerolog.Logger) *position {
|
||||
return &position{
|
||||
logger: logger.With().Str("submodule", "cursor-position").Logger(),
|
||||
listeners: map[uintptr]PositionListener{},
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *position) Shutdown() {
|
||||
manager.logger.Info().Msg("shutdown")
|
||||
|
||||
manager.listenersMu.Lock()
|
||||
for key := range manager.listeners {
|
||||
delete(manager.listeners, key)
|
||||
}
|
||||
manager.listenersMu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *position) Set(x, y int) {
|
||||
manager.listenersMu.RLock()
|
||||
defer manager.listenersMu.RUnlock()
|
||||
|
||||
for _, l := range manager.listeners {
|
||||
if err := l.SendCursorPosition(x, y); err != nil {
|
||||
manager.logger.Err(err).Msg("failed to set cursor position")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *position) AddListener(listener PositionListener) {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
manager.listeners[ptr] = listener
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *position) RemoveListener(listener PositionListener) {
|
||||
manager.listenersMu.Lock()
|
||||
defer manager.listenersMu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
ptr := reflect.ValueOf(listener).Pointer()
|
||||
delete(manager.listeners, ptr)
|
||||
}
|
||||
}
|
205
internal/webrtc/handler.go
Normal file
205
internal/webrtc/handler.go
Normal file
|
@ -0,0 +1,205 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/internal/webrtc/payload"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func (manager *WebRTCManagerCtx) handle(
|
||||
logger zerolog.Logger, data []byte,
|
||||
dataChannel *webrtc.DataChannel,
|
||||
session types.Session,
|
||||
) error {
|
||||
isHost := session.IsHost()
|
||||
|
||||
//
|
||||
// parse header
|
||||
//
|
||||
|
||||
buffer := bytes.NewBuffer(data)
|
||||
|
||||
header := &payload.Header{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
// parse body
|
||||
//
|
||||
|
||||
// handle cursor move event
|
||||
if header.Event == payload.OP_MOVE {
|
||||
payload := &payload.Move{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
x, y := int(payload.X), int(payload.Y)
|
||||
if isHost {
|
||||
// handle active cursor movement
|
||||
manager.desktop.Move(x, y)
|
||||
manager.curPosition.Set(x, y)
|
||||
} else {
|
||||
// handle inactive cursor movement
|
||||
session.SetCursor(types.Cursor{
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
} else if header.Event == payload.OP_PING {
|
||||
ping := &payload.Ping{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, ping); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create pong header
|
||||
header := payload.Header{
|
||||
Event: payload.OP_PONG,
|
||||
Length: 19,
|
||||
}
|
||||
|
||||
// generate server timestamp
|
||||
serverTs := uint64(time.Now().UnixMilli())
|
||||
|
||||
// generate pong payload
|
||||
pong := payload.Pong{
|
||||
Ping: *ping,
|
||||
ServerTs1: uint32(serverTs / math.MaxUint32),
|
||||
ServerTs2: uint32(serverTs % math.MaxUint32),
|
||||
}
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, pong); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dataChannel.Send(buffer.Bytes())
|
||||
}
|
||||
|
||||
// continue only if session is host
|
||||
if !isHost {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch header.Event {
|
||||
case payload.OP_SCROLL:
|
||||
// TODO: remove this once the client is fixed
|
||||
if header.Length == 4 {
|
||||
payload := &payload.Scroll_Old{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.desktop.Scroll(int(payload.X), int(payload.Y), false)
|
||||
logger.Trace().
|
||||
Int16("x", payload.X).
|
||||
Int16("y", payload.Y).
|
||||
Msg("scroll")
|
||||
} else {
|
||||
payload := &payload.Scroll{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.desktop.Scroll(int(payload.DeltaX), int(payload.DeltaY), payload.ControlKey)
|
||||
logger.Trace().
|
||||
Int16("deltaX", payload.DeltaX).
|
||||
Int16("deltaY", payload.DeltaY).
|
||||
Bool("controlKey", payload.ControlKey).
|
||||
Msg("scroll")
|
||||
}
|
||||
case payload.OP_KEY_DOWN:
|
||||
payload := &payload.Key{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.KeyDown(payload.Key); err != nil {
|
||||
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("key down failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("key", payload.Key).Msg("key down")
|
||||
}
|
||||
case payload.OP_KEY_UP:
|
||||
payload := &payload.Key{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.KeyUp(payload.Key); err != nil {
|
||||
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("key up failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("key", payload.Key).Msg("key up")
|
||||
}
|
||||
case payload.OP_BTN_DOWN:
|
||||
payload := &payload.Key{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.ButtonDown(payload.Key); err != nil {
|
||||
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("button down failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("key", payload.Key).Msg("button down")
|
||||
}
|
||||
case payload.OP_BTN_UP:
|
||||
payload := &payload.Key{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.ButtonUp(payload.Key); err != nil {
|
||||
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("button up failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("key", payload.Key).Msg("button up")
|
||||
}
|
||||
case payload.OP_TOUCH_BEGIN:
|
||||
payload := &payload.Touch{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.TouchBegin(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
|
||||
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch begin failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch begin")
|
||||
}
|
||||
case payload.OP_TOUCH_UPDATE:
|
||||
payload := &payload.Touch{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.TouchUpdate(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
|
||||
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch update failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch update")
|
||||
}
|
||||
case payload.OP_TOUCH_END:
|
||||
payload := &payload.Touch{}
|
||||
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manager.desktop.TouchEnd(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
|
||||
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch end failed")
|
||||
} else {
|
||||
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch end")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
576
internal/webrtc/manager.go
Normal file
576
internal/webrtc/manager.go
Normal file
|
@ -0,0 +1,576 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/cc"
|
||||
"github.com/pion/interceptor/pkg/gcc"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/webrtc/cursor"
|
||||
"github.com/demodesk/neko/internal/webrtc/pionlog"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/codec"
|
||||
"github.com/demodesk/neko/pkg/types/event"
|
||||
"github.com/demodesk/neko/pkg/types/message"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
// size of receiving channel used to buffer incoming TCP packets
|
||||
tcpReadChanBufferSize = 50
|
||||
|
||||
// size of buffer used to buffer outgoing TCP packets. Default is 4MB
|
||||
tcpWriteBufferSizeInBytes = 4 * 1024 * 1024
|
||||
|
||||
// the duration without network activity before a Agent is considered disconnected. Default is 5 Seconds
|
||||
disconnectedTimeout = 4 * time.Second
|
||||
|
||||
// the duration without network activity before a Agent is considered failed after disconnected. Default is 25 Seconds
|
||||
failedTimeout = 6 * time.Second
|
||||
|
||||
// how often the ICE Agent sends extra traffic if there is no activity, if media is flowing no traffic will be sent. Default is 2 seconds
|
||||
keepAliveInterval = 2 * time.Second
|
||||
|
||||
// send a PLI on an interval so that the publisher is pushing a keyframe every rtcpPLIInterval
|
||||
rtcpPLIInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
func New(desktop types.DesktopManager, capture types.CaptureManager, config *config.WebRTC) *WebRTCManagerCtx {
|
||||
logger := log.With().Str("module", "webrtc").Logger()
|
||||
|
||||
configuration := webrtc.Configuration{
|
||||
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
|
||||
}
|
||||
|
||||
if !config.ICELite {
|
||||
ICEServers := []webrtc.ICEServer{}
|
||||
for _, server := range config.ICEServersBackend {
|
||||
var credential any
|
||||
if server.Credential != "" {
|
||||
credential = server.Credential
|
||||
} else {
|
||||
credential = false
|
||||
}
|
||||
|
||||
ICEServers = append(ICEServers, webrtc.ICEServer{
|
||||
URLs: server.URLs,
|
||||
Username: server.Username,
|
||||
Credential: credential,
|
||||
})
|
||||
}
|
||||
|
||||
configuration.ICEServers = ICEServers
|
||||
}
|
||||
|
||||
return &WebRTCManagerCtx{
|
||||
logger: logger,
|
||||
config: config,
|
||||
metrics: newMetricsManager(),
|
||||
|
||||
webrtcConfiguration: configuration,
|
||||
|
||||
desktop: desktop,
|
||||
capture: capture,
|
||||
curImage: cursor.NewImage(logger, desktop),
|
||||
curPosition: cursor.NewPosition(logger),
|
||||
}
|
||||
}
|
||||
|
||||
type WebRTCManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
config *config.WebRTC
|
||||
metrics *metricsManager
|
||||
peerId int32
|
||||
|
||||
desktop types.DesktopManager
|
||||
capture types.CaptureManager
|
||||
curImage cursor.Image
|
||||
curPosition cursor.Position
|
||||
|
||||
webrtcConfiguration webrtc.Configuration
|
||||
|
||||
tcpMux ice.TCPMux
|
||||
udpMux ice.UDPMux
|
||||
|
||||
camStop, micStop *func()
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) Start() {
|
||||
manager.curImage.Start()
|
||||
|
||||
logger := pionlog.New(manager.logger)
|
||||
|
||||
// add TCP Mux listener
|
||||
if manager.config.TCPMux > 0 {
|
||||
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{
|
||||
IP: net.IP{0, 0, 0, 0},
|
||||
Port: manager.config.TCPMux,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
manager.logger.Fatal().Err(err).Msg("unable to setup ice TCP mux")
|
||||
}
|
||||
|
||||
manager.tcpMux = ice.NewTCPMuxDefault(ice.TCPMuxParams{
|
||||
Listener: tcpListener,
|
||||
Logger: logger.NewLogger("ice-tcp"),
|
||||
ReadBufferSize: tcpReadChanBufferSize,
|
||||
WriteBufferSize: tcpWriteBufferSizeInBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// add UDP Mux listener
|
||||
if manager.config.UDPMux > 0 {
|
||||
var err error
|
||||
manager.udpMux, err = ice.NewMultiUDPMuxFromPort(manager.config.UDPMux,
|
||||
ice.UDPMuxFromPortWithLogger(logger.NewLogger("ice-udp")),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
manager.logger.Fatal().Err(err).Msg("unable to setup ice UDP mux")
|
||||
}
|
||||
}
|
||||
|
||||
manager.logger.Info().
|
||||
Bool("icelite", manager.config.ICELite).
|
||||
Bool("icetrickle", manager.config.ICETrickle).
|
||||
Interface("iceservers-frontend", manager.config.ICEServersFrontend).
|
||||
Interface("iceservers-backend", manager.config.ICEServersBackend).
|
||||
Str("nat1to1", strings.Join(manager.config.NAT1To1IPs, ",")).
|
||||
Str("epr", fmt.Sprintf("%d-%d", manager.config.EphemeralMin, manager.config.EphemeralMax)).
|
||||
Int("tcpmux", manager.config.TCPMux).
|
||||
Int("udpmux", manager.config.UDPMux).
|
||||
Msg("webrtc starting")
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) Shutdown() error {
|
||||
manager.logger.Info().Msg("shutdown")
|
||||
|
||||
manager.curImage.Shutdown()
|
||||
manager.curPosition.Shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) ICEServers() []types.ICEServer {
|
||||
return manager.config.ICEServersFrontend
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) newPeerConnection(logger zerolog.Logger, codecs []codec.RTPCodec) (*webrtc.PeerConnection, cc.BandwidthEstimator, error) {
|
||||
// create media engine
|
||||
engine := &webrtc.MediaEngine{}
|
||||
for _, codec := range codecs {
|
||||
if err := codec.Register(engine); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// create setting engine
|
||||
settings := webrtc.SettingEngine{
|
||||
LoggerFactory: pionlog.New(logger),
|
||||
}
|
||||
|
||||
settings.DisableMediaEngineCopy(true)
|
||||
settings.SetICETimeouts(disconnectedTimeout, failedTimeout, keepAliveInterval)
|
||||
settings.SetNAT1To1IPs(manager.config.NAT1To1IPs, webrtc.ICECandidateTypeHost)
|
||||
settings.SetLite(manager.config.ICELite)
|
||||
// make sure server answer sdp setup as passive, to not force DTLS renegotiation
|
||||
// otherwise iOS renegotiation fails with: Failed to set SSL role for the transport.
|
||||
settings.SetAnsweringDTLSRole(webrtc.DTLSRoleServer)
|
||||
|
||||
var networkType []webrtc.NetworkType
|
||||
|
||||
// udp candidates
|
||||
if manager.udpMux != nil {
|
||||
settings.SetICEUDPMux(manager.udpMux)
|
||||
networkType = append(networkType,
|
||||
webrtc.NetworkTypeUDP4,
|
||||
webrtc.NetworkTypeUDP6,
|
||||
)
|
||||
} else if manager.config.EphemeralMax != 0 {
|
||||
_ = settings.SetEphemeralUDPPortRange(manager.config.EphemeralMin, manager.config.EphemeralMax)
|
||||
networkType = append(networkType,
|
||||
webrtc.NetworkTypeUDP4,
|
||||
webrtc.NetworkTypeUDP6,
|
||||
)
|
||||
}
|
||||
|
||||
// tcp candidates
|
||||
if manager.tcpMux != nil {
|
||||
settings.SetICETCPMux(manager.tcpMux)
|
||||
networkType = append(networkType,
|
||||
webrtc.NetworkTypeTCP4,
|
||||
webrtc.NetworkTypeTCP6,
|
||||
)
|
||||
}
|
||||
|
||||
// enable support for TCP and UDP ICE candidates
|
||||
settings.SetNetworkTypes(networkType)
|
||||
|
||||
// create interceptor registry
|
||||
registry := &interceptor.Registry{}
|
||||
|
||||
// create bandwidth estimator
|
||||
estimatorChan := make(chan cc.BandwidthEstimator, 1)
|
||||
if manager.config.Estimator.Enabled {
|
||||
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
|
||||
return gcc.NewSendSideBWE(
|
||||
gcc.SendSideBWEInitialBitrate(manager.config.Estimator.InitialBitrate),
|
||||
gcc.SendSideBWEPacer(gcc.NewNoOpPacer()),
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) {
|
||||
estimatorChan <- estimator
|
||||
})
|
||||
|
||||
registry.Add(congestionController)
|
||||
if err = webrtc.ConfigureTWCCHeaderExtensionSender(engine, registry); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
} else {
|
||||
// no estimator, send nil
|
||||
estimatorChan <- nil
|
||||
}
|
||||
|
||||
if err := webrtc.RegisterDefaultInterceptors(engine, registry); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// create new API
|
||||
api := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(engine),
|
||||
webrtc.WithSettingEngine(settings),
|
||||
webrtc.WithInterceptorRegistry(registry),
|
||||
)
|
||||
|
||||
// create new peer connection
|
||||
configuration := manager.webrtcConfiguration
|
||||
connection, err := api.NewPeerConnection(configuration)
|
||||
return connection, <-estimatorChan, err
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.SessionDescription, types.WebRTCPeer, error) {
|
||||
id := atomic.AddInt32(&manager.peerId, 1)
|
||||
|
||||
// get metrics for session
|
||||
metrics := manager.metrics.getBySession(session)
|
||||
metrics.NewConnection()
|
||||
|
||||
// add session id to logger context
|
||||
logger := manager.logger.With().Str("session_id", session.ID()).Int32("peer_id", id).Logger()
|
||||
logger.Info().Msg("creating webrtc peer")
|
||||
|
||||
// all audios must have the same codec
|
||||
audio := manager.capture.Audio()
|
||||
audioCodec := audio.Codec()
|
||||
|
||||
// all videos must have the same codec
|
||||
video := manager.capture.Video()
|
||||
videoCodec := video.Codec()
|
||||
|
||||
connection, estimator, err := manager.newPeerConnection(
|
||||
logger, []codec.RTPCodec{audioCodec, videoCodec})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// asynchronously send local ICE Candidates
|
||||
if manager.config.ICETrickle {
|
||||
connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
logger.Debug().Msg("all local ice candidates sent")
|
||||
return
|
||||
}
|
||||
|
||||
session.Send(
|
||||
event.SIGNAL_CANDIDATE,
|
||||
message.SignalCandidate{
|
||||
ICECandidateInit: candidate.ToJSON(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// audio track
|
||||
audioTrack, err := NewTrack(logger, audioCodec, connection)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// we disable audio by default manually
|
||||
audioTrack.SetPaused(true)
|
||||
|
||||
// set stream for audio track
|
||||
_, err = audioTrack.SetStream(audio)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// video track
|
||||
videoRtcp := make(chan []rtcp.Packet, 1)
|
||||
videoTrack, err := NewTrack(logger, videoCodec, connection, WithRtcpChan(videoRtcp))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// stream for video track will be set later
|
||||
//
|
||||
|
||||
// data channel
|
||||
|
||||
dataChannel, err := connection.CreateDataChannel("data", nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
peer := &WebRTCPeerCtx{
|
||||
logger: logger,
|
||||
session: session,
|
||||
metrics: metrics,
|
||||
connection: connection,
|
||||
// bandwidth estimator
|
||||
estimator: estimator,
|
||||
estimateTrend: utils.NewTrendDetector(
|
||||
utils.TrendDetectorParams{
|
||||
// Probing
|
||||
//RequiredSamples: 3,
|
||||
//DownwardTrendThreshold: 0.0,
|
||||
//CollapseValues: false,
|
||||
// Non-Probing
|
||||
RequiredSamples: 8,
|
||||
DownwardTrendThreshold: -0.5,
|
||||
CollapseValues: true,
|
||||
}),
|
||||
// stream selectors
|
||||
video: video,
|
||||
audio: audio,
|
||||
// tracks & channels
|
||||
audioTrack: audioTrack,
|
||||
videoTrack: videoTrack,
|
||||
dataChannel: dataChannel,
|
||||
rtcpChannel: videoRtcp,
|
||||
// config
|
||||
iceTrickle: manager.config.ICETrickle,
|
||||
estimatorConfig: manager.config.Estimator,
|
||||
audioDisabled: true, // we disable audio by default manually
|
||||
}
|
||||
|
||||
connection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
logger := logger.With().
|
||||
Str("kind", track.Kind().String()).
|
||||
Str("mime", track.Codec().RTPCodecCapability.MimeType).
|
||||
Logger()
|
||||
|
||||
logger.Info().Msgf("received new remote track")
|
||||
|
||||
if !session.Profile().CanShareMedia {
|
||||
err := receiver.Stop()
|
||||
logger.Warn().Err(err).Msg("media sharing is disabled for this session")
|
||||
return
|
||||
}
|
||||
|
||||
// parse codec from remote track
|
||||
codec, ok := codec.ParseRTC(track.Codec())
|
||||
if !ok {
|
||||
err := receiver.Stop()
|
||||
logger.Warn().Err(err).Msg("remote track with unknown codec")
|
||||
return
|
||||
}
|
||||
|
||||
var srcManager types.StreamSrcManager
|
||||
|
||||
stopped := false
|
||||
stopFn := func() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
stopped = true
|
||||
err := receiver.Stop()
|
||||
srcManager.Stop()
|
||||
logger.Err(err).Msg("remote track stopped")
|
||||
}
|
||||
|
||||
if track.Kind() == webrtc.RTPCodecTypeAudio {
|
||||
// audio -> microphone
|
||||
srcManager = manager.capture.Microphone()
|
||||
defer stopFn()
|
||||
|
||||
if manager.micStop != nil {
|
||||
(*manager.micStop)()
|
||||
}
|
||||
manager.micStop = &stopFn
|
||||
} else if track.Kind() == webrtc.RTPCodecTypeVideo {
|
||||
// video -> webcam
|
||||
srcManager = manager.capture.Webcam()
|
||||
defer stopFn()
|
||||
|
||||
if manager.camStop != nil {
|
||||
(*manager.camStop)()
|
||||
}
|
||||
manager.camStop = &stopFn
|
||||
} else {
|
||||
err := receiver.Stop()
|
||||
logger.Warn().Err(err).Msg("remote track with unsupported codec type")
|
||||
return
|
||||
}
|
||||
|
||||
err := srcManager.Start(codec)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("failed to start pipeline")
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(rtcpPLIInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
err := connection.WriteRTCP([]rtcp.Packet{
|
||||
&rtcp.PictureLossIndication{
|
||||
MediaSSRC: uint32(track.SSRC()),
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("remote track rtcp send err")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, 1400)
|
||||
for {
|
||||
i, _, err := track.Read(buf)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed read from remote track")
|
||||
break
|
||||
}
|
||||
|
||||
srcManager.Push(buf[:i])
|
||||
}
|
||||
|
||||
logger.Info().Msg("remote track data finished")
|
||||
})
|
||||
|
||||
connection.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
logger.Info().Interface("data_channel", dc).Msg("got remote data channel")
|
||||
})
|
||||
|
||||
var once sync.Once
|
||||
connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
session.SetWebRTCConnected(peer, true)
|
||||
case webrtc.PeerConnectionStateDisconnected,
|
||||
webrtc.PeerConnectionStateFailed:
|
||||
peer.Destroy()
|
||||
case webrtc.PeerConnectionStateClosed:
|
||||
// ensure we only run this once
|
||||
once.Do(func() {
|
||||
session.SetWebRTCConnected(peer, false)
|
||||
//
|
||||
// TODO: Shutdown peer?
|
||||
//
|
||||
audioTrack.Shutdown()
|
||||
videoTrack.Shutdown()
|
||||
close(videoRtcp)
|
||||
})
|
||||
}
|
||||
|
||||
metrics.SetState(state)
|
||||
})
|
||||
|
||||
dataChannel.OnOpen(func() {
|
||||
manager.curImage.AddListener(peer)
|
||||
manager.curPosition.AddListener(peer)
|
||||
|
||||
// send initial cursor image
|
||||
cur, img, err := manager.curImage.GetCurrent()
|
||||
if err == nil {
|
||||
err := peer.SendCursorImage(cur, img)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("failed to set cursor image")
|
||||
}
|
||||
} else {
|
||||
logger.Err(err).Msg("failed to get cursor image")
|
||||
}
|
||||
|
||||
// send initial cursor position
|
||||
x, y := manager.desktop.GetCursorPosition()
|
||||
err = peer.SendCursorPosition(x, y)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("failed to set cursor position")
|
||||
}
|
||||
})
|
||||
|
||||
dataChannel.OnClose(func() {
|
||||
manager.curImage.RemoveListener(peer)
|
||||
manager.curPosition.RemoveListener(peer)
|
||||
})
|
||||
|
||||
dataChannel.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||
if err := manager.handle(logger, message.Data, dataChannel, session); err != nil {
|
||||
logger.Err(err).Msg("data handle failed")
|
||||
}
|
||||
})
|
||||
|
||||
session.SetWebRTCPeer(peer)
|
||||
|
||||
offer, err := peer.CreateOffer(false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// on negotiation needed handler must be registered after creating initial
|
||||
// offer, otherwise it can fire and intercept sucessful negotiation
|
||||
|
||||
connection.OnNegotiationNeeded(func() {
|
||||
logger.Warn().Msg("negotiation is needed")
|
||||
|
||||
if connection.SignalingState() != webrtc.SignalingStateStable {
|
||||
logger.Warn().Msg("connection isn't stable yet; postponing...")
|
||||
return
|
||||
}
|
||||
|
||||
offer, err := peer.CreateOffer(false)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("sdp offer failed")
|
||||
return
|
||||
}
|
||||
|
||||
session.Send(
|
||||
event.SIGNAL_OFFER,
|
||||
message.SignalDescription{
|
||||
SDP: offer.SDP,
|
||||
})
|
||||
})
|
||||
|
||||
// start metrics collectors
|
||||
go metrics.rtcpReceiver(videoRtcp)
|
||||
go metrics.connectionStats(connection)
|
||||
|
||||
// start estimator reader
|
||||
go peer.estimatorReader()
|
||||
|
||||
return offer, peer, nil
|
||||
}
|
||||
|
||||
func (manager *WebRTCManagerCtx) SetCursorPosition(x, y int) {
|
||||
manager.curPosition.Set(x, y)
|
||||
}
|
458
internal/webrtc/metrics.go
Normal file
458
internal/webrtc/metrics.go
Normal file
|
@ -0,0 +1,458 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
const (
|
||||
// how often to read and process webrtc connection stats
|
||||
connectionStatsInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
type metricsManager struct {
|
||||
mu sync.Mutex
|
||||
|
||||
sessions map[string]*metrics
|
||||
}
|
||||
|
||||
func newMetricsManager() *metricsManager {
|
||||
return &metricsManager{
|
||||
sessions: map[string]*metrics{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *metricsManager) getBySession(session types.Session) *metrics {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sessionId := session.ID()
|
||||
|
||||
met, ok := m.sessions[sessionId]
|
||||
if ok {
|
||||
return met
|
||||
}
|
||||
|
||||
met = &metrics{
|
||||
sessionId: sessionId,
|
||||
|
||||
connectionState: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "connection_state",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Connection state of session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
connectionStateCount: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "connection_state_count",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Count of connection state changes for a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
connectionCount: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "connection_count",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Connection count of a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
|
||||
iceCandidates: map[string]struct{}{},
|
||||
iceCandidatesMu: &sync.Mutex{},
|
||||
iceCandidatesUdpCount: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ice_candidates_count",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Count of ICE candidates sent by a remote client.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"protocol": "udp",
|
||||
},
|
||||
}),
|
||||
iceCandidatesTcpCount: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ice_candidates_count",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Count of ICE candidates sent by a remote client.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"protocol": "tcp",
|
||||
},
|
||||
}),
|
||||
|
||||
iceCandidatesUsedUdp: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ice_candidates_used",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Used ICE candidates that are currently in use.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"protocol": "udp",
|
||||
},
|
||||
}),
|
||||
iceCandidatesUsedTcp: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ice_candidates_used",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Used ICE candidates that are currently in use.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"protocol": "tcp",
|
||||
},
|
||||
}),
|
||||
|
||||
videoIds: map[string]prometheus.Gauge{},
|
||||
videoIdsMu: &sync.Mutex{},
|
||||
|
||||
receiverEstimatedMaximumBitrate: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "receiver_estimated_maximum_bitrate",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Receiver Estimated Maximum Bitrate from RTCP.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
receiverEstimatedTargetBitrate: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "receiver_estimated_target_bitrate",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Receiver Estimated Target Bitrate using Google's congestion control.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
|
||||
receiverReportDelay: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "receiver_report_delay",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Receiver Report Delay from RTCP, expressed in units of 1/65536 seconds.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
receiverReportJitter: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "receiver_report_jitter",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Receiver Report Jitter from RTCP.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
receiverReportTotalLost: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "receiver_report_total_lost",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Receiver Report Total Lost from RTCP.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
|
||||
transportLayerNacks: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "transport_layer_nacks",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Transport Layer NACKs from RTCP.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
},
|
||||
}),
|
||||
|
||||
iceBytesSent: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "bytes_sent",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Sent bytes to a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"transport": "ice",
|
||||
},
|
||||
}),
|
||||
iceBytesReceived: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "bytes_received",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Received bytes from a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"transport": "ice",
|
||||
},
|
||||
}),
|
||||
|
||||
sctpBytesSent: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "bytes_sent",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Sent bytes to a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"transport": "sctp",
|
||||
},
|
||||
}),
|
||||
sctpBytesReceived: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "bytes_received",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Received bytes from a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": sessionId,
|
||||
"transport": "sctp",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
m.sessions[sessionId] = met
|
||||
return met
|
||||
}
|
||||
|
||||
type metrics struct {
|
||||
sessionId string
|
||||
|
||||
connectionState prometheus.Gauge
|
||||
connectionStateCount prometheus.Counter
|
||||
connectionCount prometheus.Counter
|
||||
|
||||
iceCandidates map[string]struct{}
|
||||
iceCandidatesMu *sync.Mutex
|
||||
iceCandidatesUdpCount prometheus.Counter
|
||||
iceCandidatesTcpCount prometheus.Counter
|
||||
|
||||
iceCandidatesUsedUdp prometheus.Gauge
|
||||
iceCandidatesUsedTcp prometheus.Gauge
|
||||
|
||||
videoIds map[string]prometheus.Gauge
|
||||
videoIdsMu *sync.Mutex
|
||||
|
||||
receiverEstimatedMaximumBitrate prometheus.Gauge
|
||||
receiverEstimatedTargetBitrate prometheus.Gauge
|
||||
|
||||
receiverReportDelay prometheus.Gauge
|
||||
receiverReportJitter prometheus.Gauge
|
||||
receiverReportTotalLost prometheus.Gauge
|
||||
|
||||
transportLayerNacks prometheus.Counter
|
||||
|
||||
iceBytesSent prometheus.Gauge
|
||||
iceBytesReceived prometheus.Gauge
|
||||
sctpBytesSent prometheus.Gauge
|
||||
sctpBytesReceived prometheus.Gauge
|
||||
}
|
||||
|
||||
func (met *metrics) reset() {
|
||||
met.videoIdsMu.Lock()
|
||||
for _, entry := range met.videoIds {
|
||||
entry.Set(0)
|
||||
}
|
||||
met.videoIdsMu.Unlock()
|
||||
|
||||
met.iceCandidatesUsedUdp.Set(float64(0))
|
||||
met.iceCandidatesUsedTcp.Set(float64(0))
|
||||
|
||||
met.receiverEstimatedMaximumBitrate.Set(0)
|
||||
|
||||
met.receiverReportDelay.Set(0)
|
||||
met.receiverReportJitter.Set(0)
|
||||
}
|
||||
|
||||
func (met *metrics) NewConnection() {
|
||||
met.connectionCount.Add(1)
|
||||
}
|
||||
|
||||
func (met *metrics) NewICECandidate(candidate webrtc.ICECandidateStats) {
|
||||
met.iceCandidatesMu.Lock()
|
||||
defer met.iceCandidatesMu.Unlock()
|
||||
|
||||
if _, found := met.iceCandidates[candidate.ID]; found {
|
||||
return
|
||||
}
|
||||
|
||||
met.iceCandidates[candidate.ID] = struct{}{}
|
||||
if candidate.Protocol == "udp" {
|
||||
met.iceCandidatesUdpCount.Add(1)
|
||||
} else if candidate.Protocol == "tcp" {
|
||||
met.iceCandidatesTcpCount.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (met *metrics) SetICECandidatesUsed(candidates []webrtc.ICECandidateStats) {
|
||||
udp, tcp := 0, 0
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Protocol == "udp" {
|
||||
udp++
|
||||
} else if candidate.Protocol == "tcp" {
|
||||
tcp++
|
||||
}
|
||||
}
|
||||
|
||||
met.iceCandidatesUsedUdp.Set(float64(udp))
|
||||
met.iceCandidatesUsedTcp.Set(float64(tcp))
|
||||
}
|
||||
|
||||
func (met *metrics) SetState(state webrtc.PeerConnectionState) {
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateNew:
|
||||
met.connectionState.Set(0)
|
||||
case webrtc.PeerConnectionStateConnecting:
|
||||
met.connectionState.Set(4)
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
met.connectionState.Set(5)
|
||||
case webrtc.PeerConnectionStateDisconnected:
|
||||
met.connectionState.Set(3)
|
||||
case webrtc.PeerConnectionStateFailed:
|
||||
met.connectionState.Set(2)
|
||||
case webrtc.PeerConnectionStateClosed:
|
||||
met.connectionState.Set(1)
|
||||
met.reset()
|
||||
default:
|
||||
met.connectionState.Set(-1)
|
||||
}
|
||||
|
||||
met.connectionStateCount.Add(1)
|
||||
}
|
||||
|
||||
func (met *metrics) SetVideoID(videoId string) {
|
||||
met.videoIdsMu.Lock()
|
||||
defer met.videoIdsMu.Unlock()
|
||||
|
||||
if _, found := met.videoIds[videoId]; !found {
|
||||
met.videoIds[videoId] = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "video_listeners",
|
||||
Namespace: "neko",
|
||||
Subsystem: "webrtc",
|
||||
Help: "Listeners for Video pipelines by a session.",
|
||||
ConstLabels: map[string]string{
|
||||
"session_id": met.sessionId,
|
||||
"video_id": videoId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for id, entry := range met.videoIds {
|
||||
if id == videoId {
|
||||
entry.Set(1)
|
||||
} else {
|
||||
entry.Set(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (met *metrics) SetReceiverEstimatedMaximumBitrate(bitrate float32) {
|
||||
met.receiverEstimatedMaximumBitrate.Set(float64(bitrate))
|
||||
}
|
||||
|
||||
func (met *metrics) SetReceiverEstimatedTargetBitrate(bitrate float64) {
|
||||
met.receiverEstimatedTargetBitrate.Set(bitrate)
|
||||
}
|
||||
|
||||
func (met *metrics) SetReceiverReport(report rtcp.ReceptionReport) {
|
||||
met.receiverReportDelay.Set(float64(report.Delay))
|
||||
met.receiverReportJitter.Set(float64(report.Jitter))
|
||||
met.receiverReportTotalLost.Set(float64(report.TotalLost))
|
||||
}
|
||||
|
||||
func (met *metrics) SetIceTransportStats(data webrtc.TransportStats) {
|
||||
met.iceBytesSent.Set(float64(data.BytesSent))
|
||||
met.iceBytesReceived.Set(float64(data.BytesReceived))
|
||||
}
|
||||
|
||||
func (met *metrics) SetSctpTransportStats(data webrtc.TransportStats) {
|
||||
met.sctpBytesSent.Set(float64(data.BytesSent))
|
||||
met.sctpBytesReceived.Set(float64(data.BytesReceived))
|
||||
}
|
||||
|
||||
//
|
||||
// collectors
|
||||
//
|
||||
|
||||
func (met *metrics) rtcpReceiver(rtcpCh chan []rtcp.Packet) {
|
||||
for {
|
||||
packets, ok := <-rtcpCh
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
for _, p := range packets {
|
||||
switch rtcpPacket := p.(type) {
|
||||
case *rtcp.ReceiverEstimatedMaximumBitrate: // TODO: Deprecated.
|
||||
met.SetReceiverEstimatedMaximumBitrate(rtcpPacket.Bitrate)
|
||||
|
||||
case *rtcp.ReceiverReport:
|
||||
l := len(rtcpPacket.Reports)
|
||||
if l > 0 {
|
||||
// use only last report
|
||||
met.SetReceiverReport(rtcpPacket.Reports[l-1])
|
||||
}
|
||||
case *rtcp.TransportLayerNack:
|
||||
for _, pair := range rtcpPacket.Nacks {
|
||||
packetList := pair.PacketList()
|
||||
met.transportLayerNacks.Add(float64(len(packetList)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (met *metrics) connectionStats(connection *webrtc.PeerConnection) {
|
||||
ticker := time.NewTicker(connectionStatsInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if connection.ConnectionState() == webrtc.PeerConnectionStateClosed {
|
||||
break
|
||||
}
|
||||
|
||||
stats := connection.GetStats()
|
||||
|
||||
data, ok := stats["iceTransport"].(webrtc.TransportStats)
|
||||
if ok {
|
||||
met.SetIceTransportStats(data)
|
||||
}
|
||||
|
||||
data, ok = stats["sctpTransport"].(webrtc.TransportStats)
|
||||
if ok {
|
||||
met.SetSctpTransportStats(data)
|
||||
}
|
||||
|
||||
remoteCandidates := map[string]webrtc.ICECandidateStats{}
|
||||
nominatedRemoteCandidates := map[string]struct{}{}
|
||||
for _, entry := range stats {
|
||||
// only remote ice candidate stats
|
||||
candidate, ok := entry.(webrtc.ICECandidateStats)
|
||||
if ok && candidate.Type == webrtc.StatsTypeRemoteCandidate {
|
||||
met.NewICECandidate(candidate)
|
||||
remoteCandidates[candidate.ID] = candidate
|
||||
}
|
||||
|
||||
// only nominated ice candidate pair stats
|
||||
pair, ok := entry.(webrtc.ICECandidatePairStats)
|
||||
if ok && pair.Nominated {
|
||||
nominatedRemoteCandidates[pair.RemoteCandidateID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
iceCandidatesUsed := []webrtc.ICECandidateStats{}
|
||||
for id := range nominatedRemoteCandidates {
|
||||
if candidate, ok := remoteCandidates[id]; ok {
|
||||
iceCandidatesUsed = append(iceCandidatesUsed, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
met.SetICECandidatesUsed(iceCandidatesUsed)
|
||||
}
|
||||
}
|
55
internal/webrtc/payload/receive.go
Normal file
55
internal/webrtc/payload/receive.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package payload
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
OP_MOVE = 0x01
|
||||
OP_SCROLL = 0x02
|
||||
OP_KEY_DOWN = 0x03
|
||||
OP_KEY_UP = 0x04
|
||||
OP_BTN_DOWN = 0x05
|
||||
OP_BTN_UP = 0x06
|
||||
OP_PING = 0x07
|
||||
// touch events
|
||||
OP_TOUCH_BEGIN = 0x08
|
||||
OP_TOUCH_UPDATE = 0x09
|
||||
OP_TOUCH_END = 0x0a
|
||||
)
|
||||
|
||||
type Move struct {
|
||||
X uint16
|
||||
Y uint16
|
||||
}
|
||||
|
||||
// TODO: remove this once the client is fixed
|
||||
type Scroll_Old struct {
|
||||
X int16
|
||||
Y int16
|
||||
}
|
||||
|
||||
type Scroll struct {
|
||||
DeltaX int16
|
||||
DeltaY int16
|
||||
ControlKey bool
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
Key uint32
|
||||
}
|
||||
|
||||
type Ping struct {
|
||||
// client's timestamp split into two uint32
|
||||
ClientTs1 uint32
|
||||
ClientTs2 uint32
|
||||
}
|
||||
|
||||
func (p Ping) ClientTs() uint64 {
|
||||
return (uint64(p.ClientTs1) * uint64(math.MaxUint32)) + uint64(p.ClientTs2)
|
||||
}
|
||||
|
||||
type Touch struct {
|
||||
TouchId uint32
|
||||
X int32
|
||||
Y int32
|
||||
Pressure uint8
|
||||
}
|
33
internal/webrtc/payload/send.go
Normal file
33
internal/webrtc/payload/send.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package payload
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
OP_CURSOR_POSITION = 0x01
|
||||
OP_CURSOR_IMAGE = 0x02
|
||||
OP_PONG = 0x03
|
||||
)
|
||||
|
||||
type CursorPosition struct {
|
||||
X uint16
|
||||
Y uint16
|
||||
}
|
||||
|
||||
type CursorImage struct {
|
||||
Width uint16
|
||||
Height uint16
|
||||
Xhot uint16
|
||||
Yhot uint16
|
||||
}
|
||||
|
||||
type Pong struct {
|
||||
Ping
|
||||
|
||||
// server's timestamp split into two uint32
|
||||
ServerTs1 uint32
|
||||
ServerTs2 uint32
|
||||
}
|
||||
|
||||
func (p Pong) ServerTs() uint64 {
|
||||
return (uint64(p.ServerTs1) * uint64(math.MaxUint32)) + uint64(p.ServerTs2)
|
||||
}
|
6
internal/webrtc/payload/types.go
Normal file
6
internal/webrtc/payload/types.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package payload
|
||||
|
||||
type Header struct {
|
||||
Event uint8
|
||||
Length uint16
|
||||
}
|
543
internal/webrtc/peer.go
Normal file
543
internal/webrtc/peer.go
Normal file
|
@ -0,0 +1,543 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor/pkg/cc"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/webrtc/payload"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/types/event"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
type WebRTCPeerCtx struct {
|
||||
mu sync.Mutex
|
||||
logger zerolog.Logger
|
||||
session types.Session
|
||||
metrics *metrics
|
||||
connection *webrtc.PeerConnection
|
||||
// bandwidth estimator
|
||||
estimator cc.BandwidthEstimator
|
||||
estimateTrend *utils.TrendDetector
|
||||
// stream selectors
|
||||
video types.StreamSelectorManager
|
||||
audio types.StreamSinkManager
|
||||
// tracks & channels
|
||||
audioTrack *Track
|
||||
videoTrack *Track
|
||||
dataChannel *webrtc.DataChannel
|
||||
rtcpChannel chan []rtcp.Packet
|
||||
// config
|
||||
iceTrickle bool
|
||||
estimatorConfig config.WebRTCEstimator
|
||||
paused bool
|
||||
videoAuto bool
|
||||
videoDisabled bool
|
||||
audioDisabled bool
|
||||
}
|
||||
|
||||
//
|
||||
// connection
|
||||
//
|
||||
|
||||
func (peer *WebRTCPeerCtx) CreateOffer(ICERestart bool) (*webrtc.SessionDescription, error) {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
offer, err := peer.connection.CreateOffer(&webrtc.OfferOptions{
|
||||
ICERestart: ICERestart,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return peer.setLocalDescription(offer)
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) CreateAnswer() (*webrtc.SessionDescription, error) {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
answer, err := peer.connection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return peer.setLocalDescription(answer)
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) setLocalDescription(description webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
|
||||
if !peer.iceTrickle {
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peer.connection)
|
||||
|
||||
if err := peer.connection.SetLocalDescription(description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
<-gatherComplete
|
||||
} else {
|
||||
if err := peer.connection.SetLocalDescription(description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return peer.connection.LocalDescription(), nil
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) SetRemoteDescription(desc webrtc.SessionDescription) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
return peer.connection.SetRemoteDescription(desc)
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) SetCandidate(candidate webrtc.ICECandidateInit) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
return peer.connection.AddICECandidate(candidate)
|
||||
}
|
||||
|
||||
// TODO: Add shutdown function?
|
||||
func (peer *WebRTCPeerCtx) Destroy() {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
var err error
|
||||
|
||||
// if peer connection is not closed, close it
|
||||
if peer.connection.ConnectionState() != webrtc.PeerConnectionStateClosed {
|
||||
err = peer.connection.Close()
|
||||
}
|
||||
|
||||
peer.logger.Err(err).Msg("peer connection destroyed")
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) estimatorReader() {
|
||||
conf := peer.estimatorConfig
|
||||
|
||||
// if estimator is not in debug mode, use a nop logger
|
||||
var debugLogger zerolog.Logger
|
||||
if conf.Debug {
|
||||
debugLogger = peer.logger.With().Str("component", "estimator").Logger().Level(zerolog.DebugLevel)
|
||||
} else {
|
||||
debugLogger = zerolog.Nop()
|
||||
}
|
||||
|
||||
// if estimator is disabled, do nothing
|
||||
if peer.estimator == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// use a ticker to get current client target bitrate
|
||||
ticker := time.NewTicker(conf.ReadInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// since when is the estimate stable/unstable
|
||||
stableSince := time.Now() // we asume stable at start
|
||||
unstableSince := time.Time{}
|
||||
// since when are we neutral but cannot accomodate current bitrate
|
||||
// we migt be stalled or estimator just reached zer (very bad connection)
|
||||
stalledSince := time.Time{}
|
||||
// when was the last upgrade/downgrade
|
||||
lastUpgradeTime := time.Time{}
|
||||
lastDowngradeTime := time.Time{}
|
||||
|
||||
for range ticker.C {
|
||||
targetBitrate := peer.estimator.GetTargetBitrate()
|
||||
peer.metrics.SetReceiverEstimatedTargetBitrate(float64(targetBitrate))
|
||||
|
||||
// if peer connection is closed, stop reading
|
||||
if peer.connection.ConnectionState() == webrtc.PeerConnectionStateClosed {
|
||||
break
|
||||
}
|
||||
|
||||
// if estimation or video is disabled, do nothing
|
||||
if !peer.videoAuto || peer.videoDisabled || peer.paused || conf.Passive {
|
||||
continue
|
||||
}
|
||||
|
||||
// get trend direction to decide if we should upgrade or downgrade
|
||||
peer.estimateTrend.AddValue(int64(targetBitrate))
|
||||
direction := peer.estimateTrend.GetDirection()
|
||||
|
||||
// get current stream bitrate
|
||||
stream, ok := peer.videoTrack.Stream()
|
||||
if !ok {
|
||||
debugLogger.Warn().Msg("looks like we don't have a stream yet, skipping bitrate estimation")
|
||||
continue
|
||||
}
|
||||
|
||||
// if stream bitrate is 0, we need to wait for some time until we get a valid value
|
||||
streamId, streamBitrate := stream.ID(), stream.Bitrate()
|
||||
if streamBitrate == 0 {
|
||||
debugLogger.Warn().Msg("looks like stream bitrate is 0, we need to wait for some time")
|
||||
continue
|
||||
}
|
||||
|
||||
// check whats the difference between target and stream bitrate
|
||||
diff := float64(targetBitrate) / float64(streamBitrate)
|
||||
|
||||
debugLogger.Info().
|
||||
Float64("diff", diff).
|
||||
Int("target_bitrate", targetBitrate).
|
||||
Uint64("stream_bitrate", streamBitrate).
|
||||
Str("direction", direction.String()).
|
||||
Msg("got bitrate from estimator")
|
||||
|
||||
// if we can accomodate current stream or we are not netural anymore,
|
||||
// we are not stalled so we reset the stalled time
|
||||
if direction != utils.TrendDirectionNeutral || diff > 1+conf.DiffThreshold {
|
||||
stalledSince = time.Now()
|
||||
}
|
||||
|
||||
// if we are neutral and stalled for too long, we might be congesting
|
||||
stalled := direction == utils.TrendDirectionNeutral && time.Since(stalledSince) > conf.StalledDuration
|
||||
if stalled {
|
||||
debugLogger.Warn().
|
||||
Time("stalled_since", stalledSince).
|
||||
Msgf("it looks like we are stalled")
|
||||
}
|
||||
|
||||
// if we have an downward trend or are stalled, we might be congesting
|
||||
if direction == utils.TrendDirectionDownward || stalled {
|
||||
// we reset the stable time because we are congesting
|
||||
stableSince = time.Now()
|
||||
|
||||
// if we downgraded recently, we wait for some more time
|
||||
if time.Since(lastDowngradeTime) < conf.DowngradeBackoff {
|
||||
debugLogger.Debug().
|
||||
Time("last_downgrade", lastDowngradeTime).
|
||||
Msgf("downgraded recently, waiting for at least %v", conf.DowngradeBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// if we are not unstable but we fluctuate we should wait for some more time
|
||||
if time.Since(unstableSince) < conf.UnstableDuration {
|
||||
debugLogger.Debug().
|
||||
Time("unstable_since", unstableSince).
|
||||
Msgf("we are not unstable long enough, waiting for at least %v", conf.UnstableDuration)
|
||||
continue
|
||||
}
|
||||
|
||||
// if we still have a big difference between target and stream bitrate, we wait for some more time
|
||||
if conf.DiffThreshold >= 0 && diff > 1+conf.DiffThreshold {
|
||||
debugLogger.Debug().
|
||||
Float64("diff", diff).
|
||||
Float64("threshold", conf.DiffThreshold).
|
||||
Msgf("we still have a big difference between target and stream bitrate, " +
|
||||
"therefore we still should be able to accomodate current stream")
|
||||
continue
|
||||
}
|
||||
|
||||
err := peer.SetVideo(types.PeerVideoRequest{
|
||||
Selector: &types.StreamSelector{
|
||||
ID: streamId,
|
||||
Type: types.StreamSelectorTypeLower,
|
||||
},
|
||||
})
|
||||
if err != nil && err != types.ErrWebRTCStreamNotFound {
|
||||
peer.logger.Warn().Err(err).Msg("failed to downgrade video stream")
|
||||
}
|
||||
lastDowngradeTime = time.Now()
|
||||
|
||||
if err == types.ErrWebRTCStreamNotFound {
|
||||
debugLogger.Info().Msg("looks like we are already on the lowest stream")
|
||||
} else {
|
||||
debugLogger.Info().Msg("downgraded video stream")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// we reset the unstable time because we are not congesting
|
||||
unstableSince = time.Now()
|
||||
|
||||
// if we have a neutral or upward trend, that means our estimate is stable
|
||||
// if we are on the highest stream, we don't need to do anything
|
||||
// but if there is a higher stream, we should try to upgrade and see if it works
|
||||
|
||||
// if we upgraded recently, we wait for some more time
|
||||
if time.Since(lastUpgradeTime) < conf.UpgradeBackoff {
|
||||
debugLogger.Debug().
|
||||
Time("last_upgrade", lastUpgradeTime).
|
||||
Msgf("upgraded recently, waiting for at least %v", conf.UpgradeBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// if we are not stable for long enough, we wait for some more time
|
||||
// because bandwidth estimation might fluctuate
|
||||
if time.Since(stableSince) < conf.StableDuration {
|
||||
debugLogger.Debug().
|
||||
Time("stable_since", stableSince).
|
||||
Msgf("we are not stable long enough, waiting for at least %v", conf.StableDuration)
|
||||
continue
|
||||
}
|
||||
|
||||
// upgrade only if estimated bitrate passed the threshold
|
||||
if conf.DiffThreshold >= 0 && diff < 1+conf.DiffThreshold {
|
||||
debugLogger.Debug().
|
||||
Float64("diff", diff).
|
||||
Float64("threshold", conf.DiffThreshold).
|
||||
Msgf("looks like we don't have enough bitrate to accomodate higher stream, " +
|
||||
"therefore we should wait for some more time")
|
||||
continue
|
||||
}
|
||||
|
||||
err := peer.SetVideo(types.PeerVideoRequest{
|
||||
Selector: &types.StreamSelector{
|
||||
ID: streamId,
|
||||
Type: types.StreamSelectorTypeHigher,
|
||||
},
|
||||
})
|
||||
if err != nil && err != types.ErrWebRTCStreamNotFound {
|
||||
peer.logger.Warn().Err(err).Msg("failed to upgrade video stream")
|
||||
}
|
||||
lastUpgradeTime = time.Now()
|
||||
|
||||
if err == types.ErrWebRTCStreamNotFound {
|
||||
debugLogger.Info().Msg("looks like we are already on the highest stream")
|
||||
} else {
|
||||
debugLogger.Info().Msg("upgraded video stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) SetPaused(isPaused bool) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
peer.videoTrack.SetPaused(isPaused || peer.videoDisabled)
|
||||
peer.audioTrack.SetPaused(isPaused || peer.audioDisabled)
|
||||
|
||||
peer.logger.Info().Bool("is_paused", isPaused).Msg("set paused")
|
||||
peer.paused = isPaused
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) Paused() bool {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
return peer.paused
|
||||
}
|
||||
|
||||
//
|
||||
// video
|
||||
//
|
||||
|
||||
func (peer *WebRTCPeerCtx) SetVideo(r types.PeerVideoRequest) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
modified := false
|
||||
|
||||
// video disabled
|
||||
if r.Disabled != nil {
|
||||
disabled := *r.Disabled
|
||||
|
||||
// update only if changed
|
||||
if peer.videoDisabled != disabled {
|
||||
peer.videoDisabled = disabled
|
||||
peer.videoTrack.SetPaused(disabled || peer.paused)
|
||||
|
||||
peer.logger.Info().Bool("disabled", disabled).Msg("set video disabled")
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
// video selector
|
||||
if r.Selector != nil {
|
||||
selector := *r.Selector
|
||||
|
||||
// get requested video stream from selector
|
||||
stream, ok := peer.video.GetStream(selector)
|
||||
if !ok {
|
||||
return types.ErrWebRTCStreamNotFound
|
||||
}
|
||||
|
||||
// set video stream to track
|
||||
changed, err := peer.videoTrack.SetStream(stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update only if stream changed
|
||||
if changed {
|
||||
videoID := stream.ID()
|
||||
peer.metrics.SetVideoID(videoID)
|
||||
|
||||
peer.logger.Info().Str("video_id", videoID).Msg("set video")
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
// video auto
|
||||
if r.Auto != nil {
|
||||
videoAuto := *r.Auto
|
||||
|
||||
if peer.estimator == nil || peer.estimatorConfig.Passive {
|
||||
peer.logger.Warn().Msg("estimator is disabled or in passive mode, cannot change video auto")
|
||||
videoAuto = false // ensure video auto is disabled
|
||||
}
|
||||
|
||||
// update only if video auto changed
|
||||
if peer.videoAuto != videoAuto {
|
||||
peer.videoAuto = videoAuto
|
||||
|
||||
peer.logger.Info().Bool("video_auto", videoAuto).Msg("set video auto")
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
// send video signal if modified
|
||||
if modified {
|
||||
go func() {
|
||||
// in goroutine because of mutex and we don't want to block
|
||||
peer.session.Send(event.SIGNAL_VIDEO, peer.Video())
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) Video() types.PeerVideo {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
// get current video stream ID
|
||||
ID := ""
|
||||
stream, ok := peer.videoTrack.Stream()
|
||||
if ok {
|
||||
ID = stream.ID()
|
||||
}
|
||||
|
||||
return types.PeerVideo{
|
||||
Disabled: peer.videoDisabled,
|
||||
ID: ID,
|
||||
Video: ID, // TODO: Remove, used for backward compatibility
|
||||
Auto: peer.videoAuto,
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// audio
|
||||
//
|
||||
|
||||
func (peer *WebRTCPeerCtx) SetAudio(r types.PeerAudioRequest) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
modified := false
|
||||
|
||||
// audio disabled
|
||||
if r.Disabled != nil {
|
||||
disabled := *r.Disabled
|
||||
|
||||
// update only if changed
|
||||
if peer.audioDisabled != disabled {
|
||||
peer.audioDisabled = disabled
|
||||
peer.audioTrack.SetPaused(disabled || peer.paused)
|
||||
|
||||
peer.logger.Info().Bool("disabled", disabled).Msg("set audio disabled")
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
// send video signal if modified
|
||||
if modified {
|
||||
go func() {
|
||||
// in goroutine because of mutex and we don't want to block
|
||||
peer.session.Send(event.SIGNAL_AUDIO, peer.Audio())
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) Audio() types.PeerAudio {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
return types.PeerAudio{
|
||||
Disabled: peer.audioDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// data channel
|
||||
//
|
||||
|
||||
func (peer *WebRTCPeerCtx) SendCursorPosition(x, y int) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
// do not send cursor position to host
|
||||
if peer.session.IsHost() {
|
||||
return nil
|
||||
}
|
||||
|
||||
header := payload.Header{
|
||||
Event: payload.OP_CURSOR_POSITION,
|
||||
Length: 7,
|
||||
}
|
||||
|
||||
data := payload.CursorPosition{
|
||||
X: uint16(x),
|
||||
Y: uint16(y),
|
||||
}
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return peer.dataChannel.Send(buffer.Bytes())
|
||||
}
|
||||
|
||||
func (peer *WebRTCPeerCtx) SendCursorImage(cur *types.CursorImage, img []byte) error {
|
||||
peer.mu.Lock()
|
||||
defer peer.mu.Unlock()
|
||||
|
||||
header := payload.Header{
|
||||
Event: payload.OP_CURSOR_IMAGE,
|
||||
Length: uint16(11 + len(img)),
|
||||
}
|
||||
|
||||
data := payload.CursorImage{
|
||||
Width: cur.Width,
|
||||
Height: cur.Height,
|
||||
Xhot: cur.Xhot,
|
||||
Yhot: cur.Yhot,
|
||||
}
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := binary.Write(buffer, binary.BigEndian, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return peer.dataChannel.Send(buffer.Bytes())
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue