├── plugins └── .gitkeep ├── runtime ├── fonts │ └── .gitkeep ├── icon-theme │ └── .gitkeep ├── .Xresources ├── supervisord.dbus.conf ├── dbus ├── default.pa └── supervisord.conf ├── .gitattributes ├── xorg ├── xf86-input-neko │ ├── m4 │ │ └── .gitkeep │ ├── xorg-neko.pc.in │ ├── 80-neko.conf │ ├── autogen-clean.sh │ ├── autogen.sh │ ├── Dockerfile │ ├── README.md │ ├── .gitignore │ ├── COPYING │ ├── Makefile.am │ └── src │ │ └── Makefile.am └── xf86-video-dummy │ ├── v0.3.8 │ ├── COPYING │ ├── README │ ├── Makefile.am │ ├── src │ │ ├── Makefile.am │ │ ├── dummy.h │ │ ├── dummy_cursor.c │ │ └── compat-api.h │ ├── config.h.in │ └── configure.ac │ └── README.md ├── dev ├── exec ├── runtime │ ├── supervisord.conf │ ├── Dockerfile │ └── config.nvidia.yml ├── fmt ├── lint ├── go ├── rebuild ├── build ├── rebuild.input └── start ├── internal ├── webrtc │ ├── payload │ │ ├── types.go │ │ ├── send.go │ │ └── receive.go │ ├── pionlog │ │ ├── factory.go │ │ ├── nullog.go │ │ └── logger.go │ └── cursor │ │ └── position.go ├── config │ ├── config.go │ ├── plugins.go │ ├── root.go │ ├── desktop.go │ └── server.go ├── member │ ├── multiuser │ │ ├── types.go │ │ └── provider.go │ ├── file │ │ ├── types.go │ │ └── provider_test.go │ ├── object │ │ ├── types.go │ │ └── provider.go │ └── noauth │ │ └── provider.go ├── websocket │ ├── handler │ │ ├── clipboard.go │ │ ├── keyboard.go │ │ ├── screen.go │ │ ├── send.go │ │ ├── session.go │ │ └── system.go │ ├── filechooserdialog.go │ └── peer.go ├── api │ ├── room │ │ ├── settings.go │ │ ├── keyboard.go │ │ ├── broadcast.go │ │ ├── control.go │ │ ├── clipboard.go │ │ └── screen.go │ ├── members │ │ ├── handler.go │ │ ├── bluk.go │ │ └── controler.go │ ├── router.go │ └── session.go ├── http │ ├── debug.go │ ├── batch.go │ ├── logger.go │ └── manager.go ├── desktop │ ├── xevent.go │ ├── xinput.go │ ├── drop.go │ ├── filechooserdialog.go │ ├── clipboard.go │ └── manager.go ├── session │ ├── auth.go │ └── serialize.go ├── plugins │ └── dependency.go └── capture │ └── broadcast.go ├── pkg ├── types │ ├── api.go │ ├── websocket.go │ ├── http.go │ ├── plugins.go │ ├── member.go │ ├── webrtc.go │ ├── event │ │ └── events.go │ ├── desktop.go │ └── session.go ├── utils │ ├── array.go │ ├── request.go │ ├── json.go │ ├── color.go │ ├── image.go │ ├── zip.go │ ├── uid.go │ ├── http.go │ └── trenddetector.go ├── xorg │ ├── keysymdef.sh │ └── xorg.h ├── xinput │ ├── dummy.go │ ├── types.go │ └── xinput.go ├── xevent │ ├── xevent.h │ └── xevent.go ├── drop │ ├── drop.h │ ├── drop.go │ └── drop.c ├── gst │ └── gst.h └── auth │ └── auth.go ├── .editorconfig ├── .gitignore ├── .vscode ├── settings.json └── launch.json ├── cmd ├── neko │ └── main.go └── plugins.go ├── .github └── workflows │ ├── pull_requests.yml │ ├── build.yml │ └── build_variants.yml ├── .devcontainer ├── README.md └── devcontainer.json ├── neko.go ├── README.md └── go.mod /plugins/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime/icon-theme/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/m4/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime/.Xresources: -------------------------------------------------------------------------------- 1 | Xcursor.size: 16 2 | -------------------------------------------------------------------------------- /dev/exec: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker exec -it neko_server_dev /bin/bash 4 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/COPYING: -------------------------------------------------------------------------------- 1 | Copyright 2002, SuSE Linux AG, Author: Egbert Eich 2 | 3 | -------------------------------------------------------------------------------- /internal/webrtc/payload/types.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | type Header struct { 4 | Event uint8 5 | Length uint16 6 | } 7 | -------------------------------------------------------------------------------- /pkg/types/api.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ApiManager interface { 4 | Route(r Router) 5 | AddRouter(path string, router func(Router)) 6 | } 7 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/README.md: -------------------------------------------------------------------------------- 1 | From: https://salsa.debian.org/xorg-team/driver/xserver-xorg-video-dummy 2 | Branch: xserver-xorg-video-dummy-1_0.3.8-2 3 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | type Config interface { 6 | Init(cmd *cobra.Command) error 7 | Set() 8 | } 9 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/xorg-neko.pc.in: -------------------------------------------------------------------------------- 1 | Name: xorg-neko 2 | Description: X.Org neko input driver. 3 | Version: @PACKAGE_VERSION@ 4 | Libs: -L${libdir} 5 | Cflags: -I${includedir} 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | .idea 3 | .env.development 4 | 5 | runtime/fonts/* 6 | !runtime/fonts/.gitkeep 7 | 8 | runtime/icon-theme/* 9 | !runtime/icon-theme/.gitkeep 10 | 11 | plugins/* 12 | !plugins/.gitkeep 13 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/80-neko.conf: -------------------------------------------------------------------------------- 1 | # 2 | # Create new Xorg input device for Neko 3 | # 4 | 5 | Section "InputDevice" 6 | Identifier "dummy_touchscreen" 7 | Option "SocketName" "/tmp/xf86-input-neko.sock" 8 | Driver "neko" 9 | EndSection 10 | -------------------------------------------------------------------------------- /runtime/supervisord.dbus.conf: -------------------------------------------------------------------------------- 1 | [program:dbus] 2 | environment=HOME="/root",USER="root" 3 | command=/usr/bin/dbus 4 | autorestart=true 5 | priority=100 6 | user=root 7 | stdout_logfile=/dev/stderr 8 | stdout_logfile_maxbytes=0 9 | redirect_stderr=true 10 | -------------------------------------------------------------------------------- /internal/member/multiuser/types.go: -------------------------------------------------------------------------------- 1 | package multiuser 2 | 3 | import "github.com/demodesk/neko/pkg/types" 4 | 5 | type Config struct { 6 | AdminPassword string 7 | UserPassword string 8 | AdminProfile types.MemberProfile 9 | UserProfile types.MemberProfile 10 | } 11 | -------------------------------------------------------------------------------- /runtime/dbus: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! -d /var/run/dbus ]; then 4 | mkdir -p /var/run/dbus 5 | fi 6 | 7 | if [ -f /var/run/dbus/pid ]; then 8 | rm -f /var/run/dbus/pid 9 | fi 10 | 11 | /usr/bin/dbus-daemon --nofork --print-pid --config-file=/usr/share/dbus-1/system.conf 12 | -------------------------------------------------------------------------------- /pkg/utils/array.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func ArrayIn[T comparable](val T, array []T) (exists bool, index int) { 4 | exists, index = false, -1 5 | 6 | for i, a := range array { 7 | if a == val { 8 | exists, index = true, i 9 | return 10 | } 11 | } 12 | 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /pkg/xorg/keysymdef.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | wget https://cgit.freedesktop.org/xorg/proto/x11proto/plain/keysymdef.h 4 | sed -i -E 's/\#define (XK_[a-zA-Z_0-9]+\s+)(0x[0-9a-f]+)/const \1 = \2/g' keysymdef.h 5 | sed -i -E 's/^\#/\/\//g' keysymdef.h 6 | echo "package xorg" | cat - keysymdef.h > keysymdef.go && rm keysymdef.h 7 | -------------------------------------------------------------------------------- /dev/runtime/supervisord.conf: -------------------------------------------------------------------------------- 1 | [program:xfce] 2 | environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" 3 | command=/usr/bin/startxfce4 4 | stopsignal=INT 5 | autorestart=true 6 | priority=500 7 | user=%(ENV_USER)s 8 | stdout_logfile=/dev/stderr 9 | stdout_logfile_maxbytes=0 10 | redirect_stderr=true 11 | -------------------------------------------------------------------------------- /internal/member/file/types.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/types" 5 | ) 6 | 7 | type MemberEntry struct { 8 | Password string `json:"password"` 9 | Profile types.MemberProfile `json:"profile"` 10 | } 11 | 12 | type Config struct { 13 | Path string 14 | Hash bool 15 | } 16 | -------------------------------------------------------------------------------- /dev/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then 5 | echo "Image 'neko_server_build' not found. Run ./build first." 6 | exit 1 7 | fi 8 | 9 | docker run -it --rm \ 10 | --entrypoint="go" \ 11 | -v "${PWD}/../:/src" \ 12 | neko_server_build fmt ./... 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.formatTool": "goformat", 3 | "go.inferGopath": false, 4 | "go.autocompleteUnimportedPackages": true, 5 | "go.delveConfig": { 6 | "useApiV1": false 7 | }, 8 | "[go]": { 9 | "editor.formatOnSave": true, 10 | "editor.codeActionsOnSave": { 11 | "source.organizeImports": "explicit" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/autogen-clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if [ -f Makefile ]; then 3 | echo "Making make distclean..." 4 | make distclean 5 | fi 6 | echo "Removing autogenned files..." 7 | rm -f config.guess config.sub configure install-sh missing mkinstalldirs Makefile.in ltmain.sh stamp-h.in */Makefile.in ltconfig stamp-h config.h.in* aclocal.m4 compile depcomp 8 | echo "Done." 9 | -------------------------------------------------------------------------------- /pkg/utils/request.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func HttpRequestGET(url string) (string, error) { 10 | rsp, err := http.Get(url) 11 | if err != nil { 12 | return "", err 13 | } 14 | defer rsp.Body.Close() 15 | 16 | buf, err := io.ReadAll(rsp.Body) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | return string(bytes.TrimSpace(buf)), nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/neko/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rs/zerolog/log" 7 | 8 | "github.com/demodesk/neko" 9 | "github.com/demodesk/neko/cmd" 10 | "github.com/demodesk/neko/pkg/utils" 11 | ) 12 | 13 | func main() { 14 | fmt.Print(utils.Colorf(neko.Header, "server", neko.Version)) 15 | if err := cmd.Execute(); err != nil { 16 | log.Panic().Err(err).Msg("failed to execute command") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/member/object/types.go: -------------------------------------------------------------------------------- 1 | package object 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/types" 5 | ) 6 | 7 | type memberEntry struct { 8 | password string 9 | profile types.MemberProfile 10 | } 11 | 12 | func (m *memberEntry) CheckPassword(password string) bool { 13 | return m.password == password 14 | } 15 | 16 | type User struct { 17 | Username string 18 | Password string 19 | Profile types.MemberProfile 20 | } 21 | 22 | type Config struct { 23 | Users []User 24 | } 25 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/autogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | autoreconf -f -i -I $(pwd)/m4 4 | exit $? 5 | 6 | echo -n "Libtoolize..." 7 | libtoolize --force --copy 8 | echo "Done." 9 | echo -n "Aclocal..." 10 | aclocal 11 | echo "Done." 12 | echo -n "Autoheader..." 13 | autoheader 14 | echo "Done." 15 | echo -n "Automake..." 16 | automake --add-missing --copy 17 | echo "Done." 18 | echo -n "Autoconf..." 19 | autoconf 20 | echo "Done." 21 | #./configure $* 22 | echo "Now you can do ./configure, make, make install." 23 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: Build a Docker image 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-image: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Build Docker image 19 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 20 | with: 21 | context: . 22 | -------------------------------------------------------------------------------- /dev/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then 5 | echo "Image 'neko_server_build' not found. Run ./build first." 6 | exit 1 7 | fi 8 | 9 | # 10 | # build server 11 | docker run --rm -it \ 12 | -v "${PWD}/../:/src" \ 13 | --entrypoint="/bin/bash" \ 14 | 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'; 15 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | # dev container 2 | 3 | You need to run all dependencies with `deps` command before you start debugging. 4 | 5 | Create `.env.development` in repository root. Make sure your local IP is correct. 6 | 7 | ```sh 8 | NEKO_WEBRTC_NAT1TO1=10.0.0.8 9 | ``` 10 | 11 | # without container 12 | 13 | - Make sure `pulseaudio` contains correct configuration. 14 | - Specify `DISPLAY` that is being used by xorg. 15 | 16 | ```sh 17 | DISPLAY=:0 18 | NEKO_WEBRTC_NAT1TO1=10.0.0.8 19 | NEKO_SERVER_BIND=:3000 20 | ``` 21 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN set -eux; \ 6 | apt-get update; \ 7 | apt-get install -y \ 8 | gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \ 9 | && rm -rf /var/lib/apt/lists/*; 10 | 11 | WORKDIR /app 12 | 13 | COPY ./ /app/ 14 | 15 | RUN set -eux; \ 16 | ./autogen.sh --prefix=/usr; \ 17 | ./configure; \ 18 | make -j$(nproc); \ 19 | make install; 20 | 21 | # docker build -t xf86-input-neko . 22 | # docker run -v $PWD/build:/app/build --rm xf86-input-neko make install DESTDIR=/app/build 23 | -------------------------------------------------------------------------------- /internal/webrtc/pionlog/factory.go: -------------------------------------------------------------------------------- 1 | package pionlog 2 | 3 | import ( 4 | "github.com/pion/logging" 5 | "github.com/rs/zerolog" 6 | ) 7 | 8 | func New(logger zerolog.Logger) Factory { 9 | return Factory{ 10 | Logger: logger.With().Str("submodule", "pion").Logger(), 11 | } 12 | } 13 | 14 | type Factory struct { 15 | Logger zerolog.Logger 16 | } 17 | 18 | func (l Factory) NewLogger(subsystem string) logging.LeveledLogger { 19 | if subsystem == "sctp" { 20 | return nulllog{} 21 | } 22 | 23 | return logger{ 24 | subsystem: subsystem, 25 | logger: l.Logger.With().Str("subsystem", subsystem).Logger(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/websocket/handler/clipboard.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/demodesk/neko/pkg/types" 7 | "github.com/demodesk/neko/pkg/types/message" 8 | ) 9 | 10 | func (h *MessageHandlerCtx) clipboardSet(session types.Session, payload *message.ClipboardData) error { 11 | if !session.Profile().CanAccessClipboard { 12 | return errors.New("cannot access clipboard") 13 | } 14 | 15 | if !session.IsHost() { 16 | return errors.New("is not the host") 17 | } 18 | 19 | return h.desktop.ClipboardSetText(types.ClipboardText{ 20 | Text: payload.Text, 21 | // TODO: Send HTML? 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /internal/api/room/settings.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/demodesk/neko/pkg/utils" 7 | ) 8 | 9 | func (h *RoomHandler) settingsGet(w http.ResponseWriter, r *http.Request) error { 10 | settings := h.sessions.Settings() 11 | return utils.HttpSuccess(w, settings) 12 | } 13 | 14 | func (h *RoomHandler) settingsSet(w http.ResponseWriter, r *http.Request) error { 15 | settings := h.sessions.Settings() 16 | 17 | if err := utils.HttpJsonRequest(w, r, &settings); err != nil { 18 | return err 19 | } 20 | 21 | h.sessions.UpdateSettings(settings) 22 | 23 | return utils.HttpSuccess(w) 24 | } 25 | -------------------------------------------------------------------------------- /internal/webrtc/payload/send.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "math" 4 | 5 | const ( 6 | OP_CURSOR_POSITION = 0x01 7 | OP_CURSOR_IMAGE = 0x02 8 | OP_PONG = 0x03 9 | ) 10 | 11 | type CursorPosition struct { 12 | X uint16 13 | Y uint16 14 | } 15 | 16 | type CursorImage struct { 17 | Width uint16 18 | Height uint16 19 | Xhot uint16 20 | Yhot uint16 21 | } 22 | 23 | type Pong struct { 24 | Ping 25 | 26 | // server's timestamp split into two uint32 27 | ServerTs1 uint32 28 | ServerTs2 uint32 29 | } 30 | 31 | func (p Pong) ServerTs() uint64 { 32 | return (uint64(p.ServerTs1) * uint64(math.MaxUint32)) + uint64(p.ServerTs2) 33 | } 34 | -------------------------------------------------------------------------------- /dev/go: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then 5 | echo "Image 'neko_server_build' not found. Run ./build first." 6 | exit 1 7 | fi 8 | 9 | docker run -it \ 10 | --name "neko_server_go" \ 11 | --entrypoint="go" \ 12 | -v "${PWD}/../:/src" \ 13 | neko_server_build "$@"; 14 | # 15 | # copy package files 16 | docker cp neko_server_go:/src/go.mod "../go.mod" 17 | docker cp neko_server_go:/src/go.sum "../go.sum" 18 | 19 | # 20 | # commit changes to image 21 | docker commit "neko_server_go" "neko_server_build" 22 | 23 | # 24 | # remove contianer 25 | docker rm "neko_server_go" 26 | -------------------------------------------------------------------------------- /internal/webrtc/pionlog/nullog.go: -------------------------------------------------------------------------------- 1 | package pionlog 2 | 3 | type nulllog struct{} 4 | 5 | func (l nulllog) Trace(msg string) {} 6 | func (l nulllog) Tracef(format string, args ...any) {} 7 | func (l nulllog) Debug(msg string) {} 8 | func (l nulllog) Debugf(format string, args ...any) {} 9 | func (l nulllog) Info(msg string) {} 10 | func (l nulllog) Infof(format string, args ...any) {} 11 | func (l nulllog) Warn(msg string) {} 12 | func (l nulllog) Warnf(format string, args ...any) {} 13 | func (l nulllog) Error(msg string) {} 14 | func (l nulllog) Errorf(format string, args ...any) {} 15 | -------------------------------------------------------------------------------- /pkg/xinput/dummy.go: -------------------------------------------------------------------------------- 1 | package xinput 2 | 3 | import "time" 4 | 5 | type dummy struct{} 6 | 7 | func NewDummy() Driver { 8 | return &dummy{} 9 | } 10 | 11 | func (d *dummy) Connect() error { 12 | return nil 13 | } 14 | 15 | func (d *dummy) Close() error { 16 | return nil 17 | } 18 | 19 | func (d *dummy) Debounce(duration time.Duration) {} 20 | 21 | func (d *dummy) TouchBegin(touchId uint32, x, y int, pressure uint8) error { 22 | return nil 23 | } 24 | 25 | func (d *dummy) TouchUpdate(touchId uint32, x, y int, pressure uint8) error { 26 | return nil 27 | } 28 | 29 | func (d *dummy) TouchEnd(touchId uint32, x, y int, pressure uint8) error { 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/types/websocket.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type WebSocketMessage struct { 9 | Event string `json:"event"` 10 | Payload json.RawMessage `json:"payload,omitempty"` 11 | } 12 | 13 | type WebSocketHandler func(Session, WebSocketMessage) bool 14 | 15 | type CheckOrigin func(r *http.Request) bool 16 | 17 | type WebSocketPeer interface { 18 | Send(event string, payload any) 19 | Ping() error 20 | Destroy(reason string) 21 | } 22 | 23 | type WebSocketManager interface { 24 | Start() 25 | Shutdown() error 26 | AddHandler(handler WebSocketHandler) 27 | Upgrade(checkOrigin CheckOrigin) RouterHandler 28 | } 29 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/README.md: -------------------------------------------------------------------------------- 1 | # xf86-input-neko 2 | [X.org](https://x.org/) [neko](http://github.com/demodesk/neko) input driver 3 | 4 | ### how to use 5 | xf86-input-neko assumes you have only one virtual touchscreen device available, see 6 | `80-neko.conf`. If there are multiple in your system, please specify one config 7 | section for each. 8 | xf86-input-neko aims to make [neko](http://github.com/demodesk/neko) easy to use and doesn't 9 | offer special configuration options. 10 | 11 | * `./configure --prefix=/usr` 12 | * `make` 13 | * `sudo make install` 14 | 15 | Done. 16 | 17 | To _uninstall_, again go inside the extracted directory, and do 18 | 19 | sudo make uninstall 20 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/README: -------------------------------------------------------------------------------- 1 | xf86-video-dummy - virtual/offscreen frame buffer driver for the Xorg X server 2 | 3 | Please submit bugs & patches to the Xorg bugzilla: 4 | 5 | https://bugs.freedesktop.org/enter_bug.cgi?product=xorg 6 | 7 | All questions regarding this software should be directed at the 8 | Xorg mailing list: 9 | 10 | http://lists.freedesktop.org/mailman/listinfo/xorg 11 | 12 | The master development code repository can be found at: 13 | 14 | git://anongit.freedesktop.org/git/xorg/driver/xf86-video-dummy 15 | 16 | http://cgit.freedesktop.org/xorg/driver/xf86-video-dummy 17 | 18 | For more information on the git code manager, see: 19 | 20 | http://wiki.x.org/wiki/GitPage 21 | -------------------------------------------------------------------------------- /internal/websocket/handler/keyboard.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/demodesk/neko/pkg/types" 7 | "github.com/demodesk/neko/pkg/types/message" 8 | ) 9 | 10 | func (h *MessageHandlerCtx) keyboardMap(session types.Session, payload *message.KeyboardMap) error { 11 | if !session.IsHost() { 12 | return errors.New("is not the host") 13 | } 14 | 15 | return h.desktop.SetKeyboardMap(payload.KeyboardMap) 16 | } 17 | 18 | func (h *MessageHandlerCtx) keyboardModifiers(session types.Session, payload *message.KeyboardModifiers) error { 19 | if !session.IsHost() { 20 | return errors.New("is not the host") 21 | } 22 | 23 | h.desktop.SetKeyboardModifiers(payload.KeyboardModifiers) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | ) 7 | 8 | func Unmarshal(in any, raw []byte, callback func() error) error { 9 | if err := json.Unmarshal(raw, &in); err != nil { 10 | return err 11 | } 12 | return callback() 13 | } 14 | 15 | func JsonStringAutoDecode(m any) func(rf reflect.Kind, rt reflect.Kind, data any) (any, error) { 16 | return func(rf reflect.Kind, rt reflect.Kind, data any) (any, error) { 17 | if rf != reflect.String || rt == reflect.String { 18 | return data, nil 19 | } 20 | 21 | raw := data.(string) 22 | if raw != "" && (raw[0:1] == "{" || raw[0:1] == "[") { 23 | err := json.Unmarshal([]byte(raw), &m) 24 | return m, err 25 | } 26 | 27 | return data, nil 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/types/http.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type RouterHandler func(w http.ResponseWriter, r *http.Request) error 9 | type MiddlewareHandler func(w http.ResponseWriter, r *http.Request) (context.Context, error) 10 | 11 | type Router interface { 12 | Group(fn func(Router)) 13 | Route(pattern string, fn func(Router)) 14 | Get(pattern string, fn RouterHandler) 15 | Post(pattern string, fn RouterHandler) 16 | Put(pattern string, fn RouterHandler) 17 | Patch(pattern string, fn RouterHandler) 18 | Delete(pattern string, fn RouterHandler) 19 | With(fn MiddlewareHandler) Router 20 | Use(fn MiddlewareHandler) 21 | ServeHTTP(w http.ResponseWriter, req *http.Request) 22 | } 23 | 24 | type HttpManager interface { 25 | Start() 26 | Shutdown() error 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "launch", 9 | "type": "go", 10 | "debugAdapter": "dlv-dap", 11 | "request": "launch", 12 | "mode": "debug", 13 | "program": "${workspaceFolder}/cmd/neko", 14 | "output": "${workspaceFolder}/bin/debug/neko", 15 | "cwd": "${workspaceFolder}/", 16 | "args": ["serve", "-d", "-c", "dev/runtime/config.yml"], 17 | "envFile": "${workspaceFolder}/.env.development" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /dev/rebuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | # 5 | # aborting if any command returns a non-zero value 6 | set -e 7 | 8 | # 9 | # build server 10 | docker run --rm -it \ 11 | -v "${PWD}/../:/src" \ 12 | --entrypoint="/bin/bash" \ 13 | neko_server_build "./build" "$@"; 14 | 15 | # 16 | # remove old plugins 17 | docker exec neko_server_dev rm -rf /etc/neko/plugins 18 | 19 | # 20 | # replace server binary in container 21 | docker cp "${PWD}/../bin/neko" neko_server_dev:/usr/bin/neko 22 | 23 | # 24 | # replace plugin binaries in container 25 | if [ -d "${PWD}/../bin/plugins" ]; 26 | then 27 | docker cp "${PWD}/../bin/plugins" neko_server_dev:/etc/neko/plugins 28 | fi 29 | 30 | # 31 | # restart server 32 | docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart neko 33 | -------------------------------------------------------------------------------- /internal/http/debug.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "net/http/pprof" 6 | 7 | "github.com/go-chi/chi" 8 | 9 | "github.com/demodesk/neko/pkg/types" 10 | ) 11 | 12 | func pprofHandler(r types.Router) { 13 | r.Get("/debug/pprof/", func(w http.ResponseWriter, r *http.Request) error { 14 | pprof.Index(w, r) 15 | return nil 16 | }) 17 | 18 | r.Get("/debug/pprof/{action}", func(w http.ResponseWriter, r *http.Request) error { 19 | action := chi.URLParam(r, "action") 20 | 21 | switch action { 22 | case "cmdline": 23 | pprof.Cmdline(w, r) 24 | case "profile": 25 | pprof.Profile(w, r) 26 | case "symbol": 27 | pprof.Symbol(w, r) 28 | case "trace": 29 | pprof.Trace(w, r) 30 | default: 31 | pprof.Handler(action).ServeHTTP(w, r) 32 | } 33 | 34 | return nil 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /internal/websocket/handler/screen.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/demodesk/neko/pkg/types" 7 | "github.com/demodesk/neko/pkg/types/event" 8 | "github.com/demodesk/neko/pkg/types/message" 9 | ) 10 | 11 | func (h *MessageHandlerCtx) screenSet(session types.Session, payload *message.ScreenSize) error { 12 | if !session.Profile().IsAdmin { 13 | return errors.New("is not the admin") 14 | } 15 | 16 | size, err := h.desktop.SetScreenSize(types.ScreenSize{ 17 | Width: payload.Width, 18 | Height: payload.Height, 19 | Rate: payload.Rate, 20 | }) 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | h.sessions.Broadcast(event.SCREEN_UPDATED, message.ScreenSize{ 27 | Width: size.Width, 28 | Height: size.Height, 29 | Rate: size.Rate, 30 | }) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/utils/color.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | const ( 9 | char = "&" 10 | ) 11 | 12 | // Colors: http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html 13 | var re = regexp.MustCompile(char + `(?m)([0-9]{1,2};[0-9]{1,2}|[0-9]{1,2})`) 14 | 15 | func Color(str string) string { 16 | result := "" 17 | lastIndex := 0 18 | 19 | for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { 20 | groups := []string{} 21 | for i := 0; i < len(v); i += 2 { 22 | groups = append(groups, str[v[i]:v[i+1]]) 23 | } 24 | 25 | result += str[lastIndex:v[0]] + "\033[" + groups[1] + "m" 26 | lastIndex = v[1] 27 | } 28 | 29 | return result + str[lastIndex:] 30 | } 31 | 32 | func Colorf(format string, a ...any) string { 33 | return fmt.Sprintf(Color(format), a...) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/utils/image.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "image" 7 | "image/jpeg" 8 | "image/png" 9 | ) 10 | 11 | func CreatePNGImage(img *image.RGBA) ([]byte, error) { 12 | out := new(bytes.Buffer) 13 | err := png.Encode(out, img) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | return out.Bytes(), nil 19 | } 20 | 21 | func CreateJPGImage(img *image.RGBA, quality int) ([]byte, error) { 22 | out := new(bytes.Buffer) 23 | err := jpeg.Encode(out, img, &jpeg.Options{Quality: quality}) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return out.Bytes(), nil 29 | } 30 | 31 | func CreatePNGImageURI(img *image.RGBA) (string, error) { 32 | data, err := CreatePNGImage(img) 33 | if err != nil { 34 | return "", err 35 | } 36 | 37 | uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) 38 | return uri, nil 39 | } 40 | -------------------------------------------------------------------------------- /runtime/default.pa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/pulseaudio -nF 2 | 3 | ### Create virtual output device sink 4 | load-module module-null-sink sink_name=audio_output sink_properties=device.description="Virtual_Audio_Output" 5 | 6 | ### Create virtual input device sink 7 | load-module module-null-sink sink_name=audio_input sink_properties=device.description="Virtual_Audio_Input" 8 | 9 | ### Create a virtual audio source linked up to the virtual input device 10 | load-module module-virtual-source source_name=microphone master=audio_input.monitor source_properties=device.description="Virtual_Microphone" 11 | 12 | ### Allow pulse audio to be accessed via TCP (from localhost only), to allow other users to access the virtual devices 13 | load-module module-native-protocol-unix socket=/tmp/pulseaudio.socket auth-anonymous=1 14 | 15 | ### Make sure we always have a sink around, even if it is a null sink. 16 | load-module module-always-sink 17 | -------------------------------------------------------------------------------- /dev/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | # 5 | # aborting if any command returns a non-zero value 6 | set -e 7 | 8 | GIT_COMMIT=`git rev-parse --short HEAD` 9 | GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD` 10 | 11 | # if first argument is nvidia, use nvidia dockerfile 12 | if [ "$1" = "nvidia" ]; then 13 | echo "Building nvidia docker image" 14 | DOCKERFILE="Dockerfile.nvidia" 15 | else 16 | echo "Building default docker image" 17 | DOCKERFILE="Dockerfile" 18 | fi 19 | 20 | docker build -t neko_server_build --target build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE .. 21 | docker build -t neko_server_runtime --target runtime --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE .. 22 | 23 | docker build -t neko_server_app --build-arg "BASE_IMAGE=neko_server_runtime" -f ./runtime/Dockerfile ./runtime 24 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/.gitignore: -------------------------------------------------------------------------------- 1 | # Object files 2 | *.o 3 | *.ko 4 | *.obj 5 | *.elf 6 | 7 | # Precompiled Headers 8 | *.gch 9 | *.pch 10 | 11 | # Libraries 12 | *.lib 13 | *.a 14 | *.la 15 | *.lo 16 | 17 | # Shared objects (inc. Windows DLLs) 18 | *.dll 19 | *.so 20 | *.so.* 21 | *.dylib 22 | 23 | # Executables 24 | *.exe 25 | *.out 26 | *.app 27 | *.i*86 28 | *.x86_64 29 | *.hex 30 | 31 | # Debug files 32 | *.dSYM/ 33 | *.su 34 | 35 | # generated files 36 | aclocal.m4 37 | config.h.in 38 | config.guess 39 | configure 40 | config.sub 41 | depcomp 42 | install-sh 43 | ltmain.sh 44 | Makefile 45 | Makefile.in 46 | src/Makefile 47 | src/Makefile.in 48 | missing 49 | autom4te.cache/ 50 | compile 51 | config.h 52 | config.h.in~ 53 | config.log 54 | config.status 55 | libtool 56 | m4/libtool.m4 57 | m4/ltoptions.m4 58 | m4/ltsugar.m4 59 | m4/ltversion.m4 60 | m4/lt~obsolete.m4 61 | src/.deps/ 62 | stamp-h1 63 | src/.libs/ 64 | *.pc 65 | 66 | # build folder 67 | build/ 68 | -------------------------------------------------------------------------------- /pkg/xevent/xevent.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | extern void goXEventCursorChanged(XFixesCursorNotifyEvent event); 12 | extern void goXEventClipboardUpdated(); 13 | extern void goXEventConfigureNotify(Display *display, Window window, char *name, char *role); 14 | extern void goXEventUnmapNotify(Window window); 15 | extern void goXEventWMChangeState(Display *display, Window window, ulong state); 16 | extern void goXEventError(XErrorEvent *event, char *message); 17 | extern int goXEventActive(); 18 | 19 | static int XEventError(Display *display, XErrorEvent *event); 20 | void XSetupErrorHandler(); 21 | void XEventLoop(char *display); 22 | 23 | static void XWindowManagerStateEvent(Display *display, Window window, ulong action, ulong first, ulong second); 24 | void XFileChooserHide(Display *display, Window window); 25 | -------------------------------------------------------------------------------- /dev/rebuild.input: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | cd ../xorg/xf86-input-neko 4 | 5 | # 6 | # aborting if any command returns a non-zero value 7 | set -e 8 | 9 | # 10 | # check if docker image exists 11 | if [ -z "$(docker images -q xf86-input-neko)" ]; then 12 | echo "Docker image not found, building it" 13 | docker build -t xf86-input-neko . 14 | fi 15 | 16 | # 17 | # if there is no ./configure script, run autogen.sh and configure 18 | if [ ! -f ./configure ]; then 19 | docker run -v $PWD/:/app --rm xf86-input-neko bash -c './autogen.sh && ./configure' 20 | fi 21 | 22 | # 23 | # make install 24 | docker run -v $PWD/:/app --rm xf86-input-neko bash -c 'make && make install DESTDIR=/app/build' 25 | 26 | # 27 | # replace input driver in container 28 | docker cp "${PWD}/build/usr/local/lib/xorg/modules/input/neko_drv.so" neko_server_dev:/usr/lib/xorg/modules/input/neko_drv.so 29 | 30 | # 31 | # restart server 32 | docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart x-server 33 | -------------------------------------------------------------------------------- /internal/websocket/handler/send.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/demodesk/neko/pkg/types" 7 | "github.com/demodesk/neko/pkg/types/event" 8 | "github.com/demodesk/neko/pkg/types/message" 9 | ) 10 | 11 | func (h *MessageHandlerCtx) sendUnicast(session types.Session, payload *message.SendUnicast) error { 12 | receiver, ok := h.sessions.Get(payload.Receiver) 13 | if !ok { 14 | return errors.New("receiver session ID not found") 15 | } 16 | 17 | receiver.Send( 18 | event.SEND_UNICAST, 19 | message.SendUnicast{ 20 | Sender: session.ID(), 21 | Receiver: receiver.ID(), 22 | Subject: payload.Subject, 23 | Body: payload.Body, 24 | }) 25 | 26 | return nil 27 | } 28 | 29 | func (h *MessageHandlerCtx) sendBroadcast(session types.Session, payload *message.SendBroadcast) error { 30 | h.sessions.Broadcast( 31 | event.SEND_BROADCAST, 32 | message.SendBroadcast{ 33 | Sender: session.ID(), 34 | Subject: payload.Subject, 35 | Body: payload.Body, 36 | }, session.ID()) 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/drop/drop.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | enum { 6 | DRAG_TARGET_TYPE_TEXT, 7 | DRAG_TARGET_TYPE_URI 8 | }; 9 | 10 | extern void goDragCreate(GtkWidget *widget, GdkEvent *event, gpointer user_data); 11 | extern void goDragCursorEnter(GtkWidget *widget, GdkEvent *event, gpointer user_data); 12 | extern void goDragButtonPress(GtkWidget *widget, GdkEvent *event, gpointer user_data); 13 | extern void goDragBegin(GtkWidget *widget, GdkDragContext *context, gpointer user_data); 14 | extern void goDragFinish(gboolean succeeded); 15 | 16 | static void dragDataGet( 17 | GtkWidget *widget, 18 | GdkDragContext *context, 19 | GtkSelectionData *data, 20 | guint target_type, 21 | guint time, 22 | gpointer user_data 23 | ); 24 | 25 | static void dragEnd( 26 | GtkWidget *widget, 27 | GdkDragContext *context, 28 | gpointer user_data 29 | ); 30 | 31 | void dragWindowOpen(char **uris); 32 | void dragWindowClose(); 33 | 34 | char **dragUrisMake(int size); 35 | void dragUrisSetFile(char **uris, char *file, int n); 36 | void dragUrisFree(char **uris, int size); 37 | -------------------------------------------------------------------------------- /internal/webrtc/payload/receive.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "math" 4 | 5 | const ( 6 | OP_MOVE = 0x01 7 | OP_SCROLL = 0x02 8 | OP_KEY_DOWN = 0x03 9 | OP_KEY_UP = 0x04 10 | OP_BTN_DOWN = 0x05 11 | OP_BTN_UP = 0x06 12 | OP_PING = 0x07 13 | // touch events 14 | OP_TOUCH_BEGIN = 0x08 15 | OP_TOUCH_UPDATE = 0x09 16 | OP_TOUCH_END = 0x0a 17 | ) 18 | 19 | type Move struct { 20 | X uint16 21 | Y uint16 22 | } 23 | 24 | // TODO: remove this once the client is fixed 25 | type Scroll_Old struct { 26 | X int16 27 | Y int16 28 | } 29 | 30 | type Scroll struct { 31 | DeltaX int16 32 | DeltaY int16 33 | ControlKey bool 34 | } 35 | 36 | type Key struct { 37 | Key uint32 38 | } 39 | 40 | type Ping struct { 41 | // client's timestamp split into two uint32 42 | ClientTs1 uint32 43 | ClientTs2 uint32 44 | } 45 | 46 | func (p Ping) ClientTs() uint64 { 47 | return (uint64(p.ClientTs1) * uint64(math.MaxUint32)) + uint64(p.ClientTs2) 48 | } 49 | 50 | type Touch struct { 51 | TouchId uint32 52 | X int32 53 | Y int32 54 | Pressure uint8 55 | } 56 | -------------------------------------------------------------------------------- /dev/runtime/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=neko_server_runtime:latest 2 | FROM $BASE_IMAGE 3 | 4 | ARG SRC_URL="https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US" 5 | 6 | # 7 | # install xfce and firefox 8 | RUN set -eux; apt-get update; \ 9 | apt-get install -y --no-install-recommends \ 10 | dbus-x11 xfce4 xfce4-terminal sudo \ 11 | xz-utils bzip2 libgtk-3-0 libdbus-glib-1-2; \ 12 | # 13 | # fetch latest firefox release 14 | wget -O /tmp/firefox-setup.tar.bz2 "${SRC_URL}"; \ 15 | mkdir /usr/lib/firefox; \ 16 | tar -xjf /tmp/firefox-setup.tar.bz2 -C /usr/lib; \ 17 | rm -f /tmp/firefox-setup.tar.bz2; \ 18 | ln -s /usr/lib/firefox/firefox /usr/bin/firefox; \ 19 | # 20 | # add user to sudoers 21 | usermod -aG sudo neko; \ 22 | echo "neko:neko" | chpasswd; \ 23 | echo "%sudo ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \ 24 | # clean up 25 | apt-get --purge autoremove -y xz-utils bzip2; \ 26 | apt-get clean -y; \ 27 | rm -rf /var/lib/apt/lists/* /var/cache/apt/* 28 | 29 | # 30 | # copy configuation files 31 | COPY supervisord.conf /etc/neko/supervisord/xfce.conf 32 | -------------------------------------------------------------------------------- /internal/desktop/xevent.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/xevent" 5 | ) 6 | 7 | func (manager *DesktopManagerCtx) OnCursorChanged(listener func(serial uint64)) { 8 | xevent.Emmiter.On("cursor-changed", func(payload ...any) { 9 | listener(payload[0].(uint64)) 10 | }) 11 | } 12 | 13 | func (manager *DesktopManagerCtx) OnClipboardUpdated(listener func()) { 14 | xevent.Emmiter.On("clipboard-updated", func(payload ...any) { 15 | listener() 16 | }) 17 | } 18 | 19 | func (manager *DesktopManagerCtx) OnFileChooserDialogOpened(listener func()) { 20 | xevent.Emmiter.On("file-chooser-dialog-opened", func(payload ...any) { 21 | listener() 22 | }) 23 | } 24 | 25 | func (manager *DesktopManagerCtx) OnFileChooserDialogClosed(listener func()) { 26 | xevent.Emmiter.On("file-chooser-dialog-closed", func(payload ...any) { 27 | listener() 28 | }) 29 | } 30 | 31 | func (manager *DesktopManagerCtx) OnEventError(listener func(error_code uint8, message string, request_code uint8, minor_code uint8)) { 32 | xevent.Emmiter.On("event-error", func(payload ...any) { 33 | listener(payload[0].(uint8), payload[1].(string), payload[2].(uint8), payload[3].(uint8)) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/config/plugins.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | type Plugins struct { 9 | Enabled bool 10 | Dir string 11 | Required bool 12 | } 13 | 14 | func (Plugins) Init(cmd *cobra.Command) error { 15 | cmd.PersistentFlags().Bool("plugins.enabled", false, "load plugins in runtime") 16 | if err := viper.BindPFlag("plugins.enabled", cmd.PersistentFlags().Lookup("plugins.enabled")); err != nil { 17 | return err 18 | } 19 | 20 | cmd.PersistentFlags().String("plugins.dir", "./bin/plugins", "path to neko plugins to load") 21 | if err := viper.BindPFlag("plugins.dir", cmd.PersistentFlags().Lookup("plugins.dir")); err != nil { 22 | return err 23 | } 24 | 25 | cmd.PersistentFlags().Bool("plugins.required", false, "if true, neko will exit if there is an error when loading a plugin") 26 | if err := viper.BindPFlag("plugins.required", cmd.PersistentFlags().Lookup("plugins.required")); err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (s *Plugins) Set() { 34 | s.Enabled = viper.GetBool("plugins.enabled") 35 | s.Dir = viper.GetString("plugins.dir") 36 | s.Required = viper.GetBool("plugins.required") 37 | } 38 | -------------------------------------------------------------------------------- /cmd/plugins.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/demodesk/neko/internal/config" 8 | "github.com/demodesk/neko/internal/plugins" 9 | "github.com/rs/zerolog/log" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func init() { 14 | command := &cobra.Command{ 15 | Use: "plugins [directory]", 16 | Short: "load, verify and list plugins", 17 | Long: `load, verify and list plugins`, 18 | Run: pluginsCmd, 19 | Args: cobra.MaximumNArgs(1), 20 | } 21 | root.AddCommand(command) 22 | } 23 | 24 | func pluginsCmd(cmd *cobra.Command, args []string) { 25 | pluginDir := "/etc/neko/plugins" 26 | if len(args) > 0 { 27 | pluginDir = args[0] 28 | } 29 | log.Info().Str("dir", pluginDir).Msg("plugins directory") 30 | 31 | plugs := plugins.New(&config.Plugins{ 32 | Enabled: true, 33 | Required: true, 34 | Dir: pluginDir, 35 | }) 36 | 37 | meta := plugs.Metadata() 38 | if len(meta) == 0 { 39 | log.Fatal().Msg("no plugins found") 40 | } 41 | 42 | // marshal indent to stdout 43 | dec := json.NewEncoder(os.Stdout) 44 | dec.SetIndent("", " ") 45 | err := dec.Encode(meta) 46 | if err != nil { 47 | log.Fatal().Err(err).Msg("unable to marshal metadata") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/desktop/xinput.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import "github.com/demodesk/neko/pkg/xinput" 4 | 5 | func (manager *DesktopManagerCtx) inputRelToAbs(x, y int) (int, int) { 6 | return (x * xinput.AbsX) / manager.screenSize.Width, (y * xinput.AbsY) / manager.screenSize.Height 7 | } 8 | 9 | func (manager *DesktopManagerCtx) HasTouchSupport() bool { 10 | // we assume now, that if the input driver is enabled, we have touch support 11 | return manager.config.UseInputDriver 12 | } 13 | 14 | func (manager *DesktopManagerCtx) TouchBegin(touchId uint32, x, y int, pressure uint8) error { 15 | mu.Lock() 16 | defer mu.Unlock() 17 | 18 | x, y = manager.inputRelToAbs(x, y) 19 | return manager.input.TouchBegin(touchId, x, y, pressure) 20 | } 21 | 22 | func (manager *DesktopManagerCtx) TouchUpdate(touchId uint32, x, y int, pressure uint8) error { 23 | mu.Lock() 24 | defer mu.Unlock() 25 | 26 | x, y = manager.inputRelToAbs(x, y) 27 | return manager.input.TouchUpdate(touchId, x, y, pressure) 28 | } 29 | 30 | func (manager *DesktopManagerCtx) TouchEnd(touchId uint32, x, y int, pressure uint8) error { 31 | mu.Lock() 32 | defer mu.Unlock() 33 | 34 | x, y = manager.inputRelToAbs(x, y) 35 | return manager.input.TouchEnd(touchId, x, y, pressure) 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 40 | with: 41 | context: . 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} 45 | -------------------------------------------------------------------------------- /internal/member/file/provider_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/demodesk/neko/pkg/utils" 8 | ) 9 | 10 | // Ensure that hashes are the same after encoding and decoding using json 11 | func TestMemberProviderCtx_hash(t *testing.T) { 12 | provider := &MemberProviderCtx{ 13 | config: Config{ 14 | Hash: true, 15 | }, 16 | } 17 | 18 | // generate random strings 19 | passwords := []string{} 20 | for i := 0; i < 10; i++ { 21 | password, err := utils.NewUID(32) 22 | if err != nil { 23 | t.Errorf("utils.NewUID() returned error: %s", err) 24 | } 25 | passwords = append(passwords, password) 26 | } 27 | 28 | for _, password := range passwords { 29 | hashedPassword := provider.hash(password) 30 | 31 | // json encode password hash 32 | hashedPasswordJSON, err := json.Marshal(hashedPassword) 33 | if err != nil { 34 | t.Errorf("json.Marshal() returned error: %s", err) 35 | } 36 | 37 | // json decode password hash json 38 | var hashedPasswordStr string 39 | err = json.Unmarshal(hashedPasswordJSON, &hashedPasswordStr) 40 | if err != nil { 41 | t.Errorf("json.Unmarshal() returned error: %s", err) 42 | } 43 | 44 | if hashedPasswordStr != hashedPassword { 45 | t.Errorf("hashedPasswordStr: %s != hashedPassword: %s", hashedPasswordStr, hashedPassword) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/types/plugins.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | type Plugin interface { 10 | Name() string 11 | Config() PluginConfig 12 | Start(PluginManagers) error 13 | Shutdown() error 14 | } 15 | 16 | type DependablePlugin interface { 17 | Plugin 18 | DependsOn() []string 19 | } 20 | 21 | type ExposablePlugin interface { 22 | Plugin 23 | ExposeService() any 24 | } 25 | 26 | type PluginConfig interface { 27 | Init(cmd *cobra.Command) error 28 | Set() 29 | } 30 | 31 | type PluginMetadata struct { 32 | Name string 33 | IsDependable bool 34 | IsExposable bool 35 | DependsOn []string `json:",omitempty"` 36 | } 37 | 38 | type PluginManagers struct { 39 | SessionManager SessionManager 40 | WebSocketManager WebSocketManager 41 | ApiManager ApiManager 42 | LoadServiceFromPlugin func(string) (any, error) 43 | } 44 | 45 | func (p *PluginManagers) Validate() error { 46 | if p.SessionManager == nil { 47 | return errors.New("SessionManager is nil") 48 | } 49 | 50 | if p.WebSocketManager == nil { 51 | return errors.New("WebSocketManager is nil") 52 | } 53 | 54 | if p.ApiManager == nil { 55 | return errors.New("ApiManager is nil") 56 | } 57 | 58 | if p.LoadServiceFromPlugin == nil { 59 | return errors.New("LoadServiceFromPlugin is nil") 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/Makefile.am: -------------------------------------------------------------------------------- 1 | # Copyright 2005 Adam Jackson. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # on the rights to use, copy, modify, merge, publish, distribute, sub 7 | # license, and/or sell copies of the Software, and to permit persons to whom 8 | # the Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice (including the next 11 | # paragraph) shall be included in all copies or substantial portions of the 12 | # Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 17 | # ADAM JACKSON BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | SUBDIRS = src 22 | MAINTAINERCLEANFILES = ChangeLog 23 | 24 | .PHONY: ChangeLog 25 | 26 | ChangeLog: 27 | $(CHANGELOG_CMD) 28 | 29 | dist-hook: ChangeLog 30 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/COPYING: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 1999 Frederic Lepied, France 4 | Copyright 2005 Adam Jackson 5 | Copyright 2005 Sun Microsystems, Inc. 6 | Copyright 2006 Sascha Hauer, Pengutronix 7 | Copyright 2007 Clement Chauplannaz, Thales e-Transactions 8 | Copyright 2017 Martin Kepplinger 9 | Copyright 2023 Miroslav Sedivy 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/Makefile.am: -------------------------------------------------------------------------------- 1 | # Copyright 2005 Adam Jackson. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # on the rights to use, copy, modify, merge, publish, distribute, sub 7 | # license, and/or sell copies of the Software, and to permit persons to whom 8 | # the Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice (including the next 11 | # paragraph) shall be included in all copies or substantial portions of the 12 | # Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 17 | # ADAM JACKSON BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | AUTOMAKE_OPTIONS = foreign 22 | SUBDIRS = src 23 | ACLOCAL_AMFLAGS = -I m4 24 | 25 | pkgconfigdir = $(libdir)/pkgconfig 26 | pkgconfig_DATA = xorg-neko.pc 27 | 28 | dist_xorgconf_DATA = 80-neko.conf 29 | 30 | EXTRA_DIST = README.md 31 | -------------------------------------------------------------------------------- /runtime/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | pidfile=/var/run/supervisord.pid 5 | logfile=/dev/stderr 6 | logfile_maxbytes=0 7 | 8 | [include] 9 | files=/etc/neko/supervisord/*.conf 10 | 11 | [program:x-server] 12 | environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s" 13 | command=/usr/bin/X %(ENV_DISPLAY)s -config /etc/neko/xorg.conf -noreset -nolisten tcp 14 | autorestart=true 15 | priority=300 16 | user=%(ENV_USER)s 17 | stdout_logfile=/dev/stderr 18 | stdout_logfile_maxbytes=0 19 | redirect_stderr=true 20 | 21 | [program:pulseaudio] 22 | environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" 23 | command=/usr/bin/pulseaudio --log-level=error --disallow-module-loading --disallow-exit --exit-idle-time=-1 24 | autorestart=true 25 | priority=300 26 | user=%(ENV_USER)s 27 | stdout_logfile=/dev/stderr 28 | stdout_logfile_maxbytes=0 29 | redirect_stderr=true 30 | 31 | [program:neko] 32 | environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" 33 | command=/usr/bin/neko serve 34 | stopsignal=INT 35 | stopwaitsecs=3 36 | autorestart=true 37 | priority=800 38 | user=%(ENV_USER)s 39 | stdout_logfile=/dev/stdout 40 | stdout_logfile_maxbytes=0 41 | redirect_stderr=true 42 | 43 | [unix_http_server] 44 | file=/var/run/supervisor.sock 45 | chmod=0770 46 | chown=root:neko 47 | 48 | [supervisorctl] 49 | serverurl=unix:///var/run/supervisor.sock 50 | 51 | [rpcinterface:supervisor] 52 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 53 | -------------------------------------------------------------------------------- /pkg/drop/drop.go: -------------------------------------------------------------------------------- 1 | package drop 2 | 3 | /* 4 | #cgo pkg-config: gtk+-3.0 5 | 6 | #include "drop.h" 7 | */ 8 | import "C" 9 | 10 | import ( 11 | "sync" 12 | 13 | "github.com/kataras/go-events" 14 | ) 15 | 16 | var Emmiter events.EventEmmiter 17 | var mu = sync.Mutex{} 18 | 19 | func init() { 20 | Emmiter = events.New() 21 | } 22 | 23 | func OpenWindow(files []string) { 24 | mu.Lock() 25 | defer mu.Unlock() 26 | 27 | size := C.int(len(files)) 28 | urisUnsafe := C.dragUrisMake(size) 29 | defer C.dragUrisFree(urisUnsafe, size) 30 | 31 | for i, file := range files { 32 | C.dragUrisSetFile(urisUnsafe, C.CString(file), C.int(i)) 33 | } 34 | 35 | C.dragWindowOpen(urisUnsafe) 36 | } 37 | 38 | func CloseWindow() { 39 | C.dragWindowClose() 40 | } 41 | 42 | //export goDragCreate 43 | func goDragCreate(widget *C.GtkWidget, event *C.GdkEvent, user_data C.gpointer) { 44 | go Emmiter.Emit("create") 45 | } 46 | 47 | //export goDragCursorEnter 48 | func goDragCursorEnter(widget *C.GtkWidget, event *C.GdkEvent, user_data C.gpointer) { 49 | go Emmiter.Emit("cursor-enter") 50 | } 51 | 52 | //export goDragButtonPress 53 | func goDragButtonPress(widget *C.GtkWidget, event *C.GdkEvent, user_data C.gpointer) { 54 | go Emmiter.Emit("button-press") 55 | } 56 | 57 | //export goDragBegin 58 | func goDragBegin(widget *C.GtkWidget, context *C.GdkDragContext, user_data C.gpointer) { 59 | go Emmiter.Emit("begin") 60 | } 61 | 62 | //export goDragFinish 63 | func goDragFinish(succeeded C.gboolean) { 64 | go Emmiter.Emit("finish", bool(succeeded == C.int(1))) 65 | } 66 | -------------------------------------------------------------------------------- /internal/api/room/keyboard.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/demodesk/neko/pkg/types" 7 | "github.com/demodesk/neko/pkg/utils" 8 | ) 9 | 10 | type KeyboardMapData struct { 11 | types.KeyboardMap 12 | } 13 | 14 | type KeyboardModifiersData struct { 15 | types.KeyboardModifiers 16 | } 17 | 18 | func (h *RoomHandler) keyboardMapSet(w http.ResponseWriter, r *http.Request) error { 19 | data := &KeyboardMapData{} 20 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 21 | return err 22 | } 23 | 24 | err := h.desktop.SetKeyboardMap(data.KeyboardMap) 25 | if err != nil { 26 | return utils.HttpInternalServerError().WithInternalErr(err) 27 | } 28 | 29 | return utils.HttpSuccess(w) 30 | } 31 | 32 | func (h *RoomHandler) keyboardMapGet(w http.ResponseWriter, r *http.Request) error { 33 | data, err := h.desktop.GetKeyboardMap() 34 | if err != nil { 35 | return utils.HttpInternalServerError().WithInternalErr(err) 36 | } 37 | 38 | return utils.HttpSuccess(w, KeyboardMapData{ 39 | KeyboardMap: *data, 40 | }) 41 | } 42 | 43 | func (h *RoomHandler) keyboardModifiersSet(w http.ResponseWriter, r *http.Request) error { 44 | data := &KeyboardModifiersData{} 45 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 46 | return err 47 | } 48 | 49 | h.desktop.SetKeyboardModifiers(data.KeyboardModifiers) 50 | return utils.HttpSuccess(w) 51 | } 52 | 53 | func (h *RoomHandler) keyboardModifiersGet(w http.ResponseWriter, r *http.Request) error { 54 | return utils.HttpSuccess(w, KeyboardModifiersData{ 55 | KeyboardModifiers: h.desktop.GetKeyboardModifiers(), 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /internal/desktop/drop.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/demodesk/neko/pkg/drop" 7 | ) 8 | 9 | // repeat move event multiple times 10 | const dropMoveRepeat = 4 11 | 12 | // wait after each repeated move event 13 | const dropMoveDelay = 100 * time.Millisecond 14 | 15 | func (manager *DesktopManagerCtx) DropFiles(x int, y int, files []string) bool { 16 | mu.Lock() 17 | defer mu.Unlock() 18 | 19 | drop.Emmiter.Clear() 20 | 21 | drop.Emmiter.Once("create", func(payload ...any) { 22 | manager.Move(0, 0) 23 | }) 24 | 25 | drop.Emmiter.Once("cursor-enter", func(payload ...any) { 26 | //nolint 27 | manager.ButtonDown(1) 28 | }) 29 | 30 | drop.Emmiter.Once("button-press", func(payload ...any) { 31 | manager.Move(x, y) 32 | }) 33 | 34 | drop.Emmiter.Once("begin", func(payload ...any) { 35 | for i := 0; i < dropMoveRepeat; i++ { 36 | manager.Move(x, y) 37 | time.Sleep(dropMoveDelay) 38 | } 39 | 40 | //nolint 41 | manager.ButtonUp(1) 42 | }) 43 | 44 | finished := make(chan bool) 45 | drop.Emmiter.Once("finish", func(payload ...any) { 46 | b, ok := payload[0].(bool) 47 | // workaround until https://github.com/kataras/go-events/pull/8 is merged 48 | if !ok { 49 | b = (payload[0].([]any))[0].(bool) 50 | } 51 | finished <- b 52 | }) 53 | 54 | manager.ResetKeys() 55 | go drop.OpenWindow(files) 56 | 57 | select { 58 | case succeeded := <-finished: 59 | return succeeded 60 | case <-time.After(1 * time.Second): 61 | drop.CloseWindow() 62 | return false 63 | } 64 | } 65 | 66 | func (manager *DesktopManagerCtx) IsUploadDropEnabled() bool { 67 | return manager.config.UploadDrop 68 | } 69 | -------------------------------------------------------------------------------- /neko.go: -------------------------------------------------------------------------------- 1 | package neko 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | const Header = `&34 10 | _ __ __ 11 | / | / /__ / /______ \ /\ 12 | / |/ / _ \/ //_/ __ \ ) ( ') 13 | / /| / __/ ,< / /_/ / ( / ) 14 | /_/ |_/\___/_/|_|\____/ \(__)| 15 | &1&37 nurdism/m1k1o &33%s %s&0 16 | ` 17 | 18 | var ( 19 | // 20 | buildDate = "dev" 21 | // 22 | gitCommit = "dev" 23 | // 24 | gitBranch = "dev" 25 | // 26 | gitTag = "dev" 27 | ) 28 | 29 | var Version = &version{ 30 | GitCommit: gitCommit, 31 | GitBranch: gitBranch, 32 | GitTag: gitTag, 33 | BuildDate: buildDate, 34 | GoVersion: runtime.Version(), 35 | Compiler: runtime.Compiler, 36 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 37 | } 38 | 39 | type version struct { 40 | GitCommit string 41 | GitBranch string 42 | GitTag string 43 | BuildDate string 44 | GoVersion string 45 | Compiler string 46 | Platform string 47 | } 48 | 49 | func (i *version) String() string { 50 | version := i.GitTag 51 | if version == "" || version == "dev" { 52 | version = i.GitBranch 53 | } 54 | 55 | return fmt.Sprintf("%s@%s", version, i.GitCommit) 56 | } 57 | 58 | func (i *version) Details() string { 59 | return "\n" + strings.Join([]string{ 60 | fmt.Sprintf("Version %s", i.String()), 61 | fmt.Sprintf("GitCommit %s", i.GitCommit), 62 | fmt.Sprintf("GitBranch %s", i.GitBranch), 63 | fmt.Sprintf("GitTag %s", i.GitTag), 64 | fmt.Sprintf("BuildDate %s", i.BuildDate), 65 | fmt.Sprintf("GoVersion %s", i.GoVersion), 66 | fmt.Sprintf("Compiler %s", i.Compiler), 67 | fmt.Sprintf("Platform %s", i.Platform), 68 | }, "\n") + "\n" 69 | } 70 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.166.0/containers/go 3 | { 4 | "name": "Go", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "context": "../", 8 | "args": { 9 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.15 10 | "VARIANT": "1.20", 11 | // Options 12 | "INSTALL_NODE": "false", 13 | "NODE_VERSION": "lts/*" 14 | } 15 | }, 16 | "runArgs": [ "--cap-add=SYS_PTRACE", "--cap-add=SYS_ADMIN", "--shm-size=2G", "--security-opt", "seccomp=unconfined" ], 17 | 18 | "customizations": { 19 | "vscode": { 20 | // Set *default* container specific settings.json values on container create. 21 | "settings": { 22 | "terminal.integrated.shell.linux": "/bin/bash", 23 | "go.toolsManagement.checkForUpdates": "local", 24 | "go.useLanguageServer": true, 25 | "go.gopath": "/go", 26 | "go.goroot": "/usr/local/go" 27 | }, 28 | 29 | // Add the IDs of extensions you want installed when the container is created. 30 | "extensions": [ 31 | "golang.Go" 32 | ] 33 | } 34 | }, 35 | 36 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 37 | "appPort": ["3000:3000", "3001:3001/udp", "3002:3002/udp", "3003:3003/udp", "3004:3004/udp"], 38 | 39 | // Use 'postCreateCommand' to run commands after the container is created. 40 | // "postCreateCommand": "go version", 41 | 42 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 43 | "remoteUser": "neko" 44 | } 45 | -------------------------------------------------------------------------------- /internal/webrtc/pionlog/logger.go: -------------------------------------------------------------------------------- 1 | package pionlog 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type logger struct { 11 | logger zerolog.Logger 12 | subsystem string 13 | } 14 | 15 | func (l logger) Trace(msg string) { 16 | l.logger.Trace().Msg(strings.TrimSpace(msg)) 17 | } 18 | 19 | func (l logger) Tracef(format string, args ...any) { 20 | msg := fmt.Sprintf(format, args...) 21 | l.logger.Trace().Msg(strings.TrimSpace(msg)) 22 | } 23 | 24 | func (l logger) Debug(msg string) { 25 | l.logger.Debug().Msg(strings.TrimSpace(msg)) 26 | } 27 | 28 | func (l logger) Debugf(format string, args ...any) { 29 | msg := fmt.Sprintf(format, args...) 30 | l.logger.Debug().Msg(strings.TrimSpace(msg)) 31 | } 32 | 33 | func (l logger) Info(msg string) { 34 | if strings.Contains(msg, "duplicated packet") { 35 | return 36 | } 37 | 38 | l.logger.Info().Msg(strings.TrimSpace(msg)) 39 | } 40 | 41 | func (l logger) Infof(format string, args ...any) { 42 | msg := fmt.Sprintf(format, args...) 43 | if strings.Contains(msg, "duplicated packet") { 44 | return 45 | } 46 | 47 | l.logger.Info().Msg(strings.TrimSpace(msg)) 48 | } 49 | 50 | func (l logger) Warn(msg string) { 51 | l.logger.Warn().Msg(strings.TrimSpace(msg)) 52 | } 53 | 54 | func (l logger) Warnf(format string, args ...any) { 55 | msg := fmt.Sprintf(format, args...) 56 | l.logger.Warn().Msg(strings.TrimSpace(msg)) 57 | } 58 | 59 | func (l logger) Error(msg string) { 60 | l.logger.Error().Msg(strings.TrimSpace(msg)) 61 | } 62 | 63 | func (l logger) Errorf(format string, args ...any) { 64 | msg := fmt.Sprintf(format, args...) 65 | l.logger.Error().Msg(strings.TrimSpace(msg)) 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/build_variants.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image variant 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image-variant: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | strategy: 20 | matrix: 21 | include: 22 | - variant: bookworm 23 | dockerfile: Dockerfile.bookworm 24 | - variant: nvidia 25 | dockerfile: Dockerfile.nvidia 26 | - variant: nvidia_bookworm 27 | dockerfile: Dockerfile.nvidia.bookworm 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | 33 | - name: Log in to the Container registry 34 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Extract metadata (tags, labels) for Docker 41 | id: meta 42 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.variant }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 48 | with: 49 | context: . 50 | file: ${{ matrix.dockerfile }} 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | -------------------------------------------------------------------------------- /pkg/xorg/xorg.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | // for computing xrandr modelines at runtime 14 | #include 15 | 16 | extern void goCreateScreenSize(int index, int width, int height, int mwidth, int mheight); 17 | extern void goSetScreenRates(int index, int rate_index, short rate); 18 | 19 | Display *getXDisplay(void); 20 | int XDisplayOpen(char *input); 21 | void XDisplayClose(void); 22 | 23 | void XMove(int x, int y); 24 | void XCursorPosition(int *x, int *y); 25 | void XScroll(int deltaX, int deltaY); 26 | void XButton(unsigned int button, int down); 27 | 28 | typedef struct xkeyentry_t { 29 | KeySym keysym; 30 | KeyCode keycode; 31 | struct xkeyentry_t *next; 32 | } xkeyentry_t; 33 | 34 | static void XKeyEntryAdd(KeySym keysym, KeyCode keycode); 35 | static KeyCode XKeyEntryGet(KeySym keysym); 36 | static KeyCode XkbKeysymToKeycode(Display *dpy, KeySym keysym); 37 | void XKey(KeySym keysym, int down); 38 | 39 | Status XSetScreenConfiguration(int width, int height, short rate); 40 | void XGetScreenConfiguration(int *width, int *height, short *rate); 41 | void XGetScreenConfigurations(); 42 | void XCreateScreenMode(int width, int height, short rate); 43 | XRRModeInfo *XCreateScreenModeInfo(int hdisplay, int vdisplay, short vrefresh); 44 | 45 | void XSetKeyboardModifier(unsigned char mod, int on); 46 | unsigned char XGetKeyboardModifiers(); 47 | XFixesCursorImage *XGetCursorImage(void); 48 | 49 | char *XGetScreenshot(int *w, int *h); 50 | -------------------------------------------------------------------------------- /xorg/xf86-input-neko/src/Makefile.am: -------------------------------------------------------------------------------- 1 | # Copyright 2005 Adam Jackson. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # on the rights to use, copy, modify, merge, publish, distribute, sub 7 | # license, and/or sell copies of the Software, and to permit persons to whom 8 | # the Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice (including the next 11 | # paragraph) shall be included in all copies or substantial portions of the 12 | # Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 17 | # ADAM JACKSON BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | # this is obnoxious: 23 | # -module lets us name the module exactly how we want 24 | # -avoid-version prevents gratuitous .0.0.0 version numbers on the end 25 | # _ladir passes a dummy rpath to libtool so the thing will actually link 26 | # TODO: -nostdlib/-Bstatic/-lgcc platform magic, not installing the .a, etc. 27 | @DRIVER_NAME@_drv_la_LTLIBRARIES = @DRIVER_NAME@_drv.la 28 | @DRIVER_NAME@_drv_la_LDFLAGS = -module -avoid-version 29 | @DRIVER_NAME@_drv_ladir = @inputdir@ 30 | 31 | @DRIVER_NAME@_drv_la_SOURCES = @DRIVER_NAME@.c 32 | -------------------------------------------------------------------------------- /dev/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | if [ -z "$(docker images -q neko_server_app 2> /dev/null)" ]; then 5 | echo "Image 'neko_server_app' not found. Running ./build first." 6 | ./build 7 | fi 8 | 9 | if [ -z $NEKO_PORT ]; then 10 | NEKO_PORT="3000" 11 | fi 12 | 13 | if [ -z $NEKO_MUX ]; then 14 | NEKO_MUX="52100" 15 | fi 16 | 17 | if [ -z $NEKO_NAT1TO1 ]; then 18 | for i in $(ifconfig -l 2>/dev/null); do 19 | NEKO_NAT1TO1=$(ipconfig getifaddr $i) 20 | if [ ! -z $NEKO_NAT1TO1 ]; then 21 | break 22 | fi 23 | done 24 | 25 | if [ -z $NEKO_NAT1TO1 ]; then 26 | NEKO_NAT1TO1=$(hostname -i 2>/dev/null) 27 | fi 28 | fi 29 | 30 | # if first argument is nvidia, start with nvidia runtime 31 | if [ "$1" = "nvidia" ]; then 32 | echo "Starting nvidia docker image" 33 | EXTRAOPTS="--gpus all" 34 | CONFIG="config.nvidia.yml" 35 | else 36 | echo "Starting default docker image" 37 | EXTRAOPTS="" 38 | CONFIG="config.yml" 39 | fi 40 | 41 | echo "Using app port: ${NEKO_PORT}" 42 | echo "Using mux port: ${NEKO_MUX}" 43 | echo "Using IP address: ${NEKO_NAT1TO1}" 44 | 45 | # start server 46 | docker run --rm -it \ 47 | --name "neko_server_dev" \ 48 | -p "${NEKO_PORT}:8080" \ 49 | -p "${NEKO_MUX}:${NEKO_MUX}/tcp" \ 50 | -p "${NEKO_MUX}:${NEKO_MUX}/udp" \ 51 | -e "NEKO_WEBRTC_UDPMUX=${NEKO_MUX}" \ 52 | -e "NEKO_WEBRTC_TCPMUX=${NEKO_MUX}" \ 53 | -e "NEKO_WEBRTC_NAT1TO1=${NEKO_NAT1TO1}" \ 54 | -e "NEKO_SESSION_FILE=/home/neko/sessions.txt" \ 55 | -v "${PWD}/runtime/$CONFIG:/etc/neko/neko.yml" \ 56 | -e "NEKO_DEBUG=1" \ 57 | --shm-size=2G \ 58 | --security-opt seccomp=unconfined \ 59 | $EXTRAOPTS \ 60 | neko_server_app:latest; 61 | -------------------------------------------------------------------------------- /pkg/xinput/types.go: -------------------------------------------------------------------------------- 1 | package xinput 2 | 3 | import "time" 4 | 5 | const ( 6 | // absolute coordinates used in driver 7 | AbsX = 0xffff 8 | AbsY = 0xffff 9 | ) 10 | 11 | const ( 12 | XI_TouchBegin = 18 13 | XI_TouchUpdate = 19 14 | XI_TouchEnd = 20 15 | ) 16 | 17 | type Message struct { 18 | _type uint16 19 | touchId uint32 20 | x int32 // can be negative? 21 | y int32 // can be negative? 22 | pressure uint8 23 | } 24 | 25 | func (msg *Message) Unpack(buffer []byte) { 26 | msg._type = uint16(buffer[0]) 27 | msg.touchId = uint32(buffer[1]) | (uint32(buffer[2]) << 8) 28 | msg.x = int32(buffer[3]) | (int32(buffer[4]) << 8) | (int32(buffer[5]) << 16) | (int32(buffer[6]) << 24) 29 | msg.y = int32(buffer[7]) | (int32(buffer[8]) << 8) | (int32(buffer[9]) << 16) | (int32(buffer[10]) << 24) 30 | msg.pressure = uint8(buffer[11]) 31 | } 32 | 33 | func (msg *Message) Pack() []byte { 34 | var buffer [12]byte 35 | 36 | buffer[0] = byte(msg._type) 37 | buffer[1] = byte(msg.touchId) 38 | buffer[2] = byte(msg.touchId >> 8) 39 | buffer[3] = byte(msg.x) 40 | buffer[4] = byte(msg.x >> 8) 41 | buffer[5] = byte(msg.x >> 16) 42 | buffer[6] = byte(msg.x >> 24) 43 | buffer[7] = byte(msg.y) 44 | buffer[8] = byte(msg.y >> 8) 45 | buffer[9] = byte(msg.y >> 16) 46 | buffer[10] = byte(msg.y >> 24) 47 | buffer[11] = byte(msg.pressure) 48 | 49 | return buffer[:] 50 | } 51 | 52 | type Driver interface { 53 | Connect() error 54 | Close() error 55 | // release touches, that were not updated for duration 56 | Debounce(duration time.Duration) 57 | // touch events 58 | TouchBegin(touchId uint32, x, y int, pressure uint8) error 59 | TouchUpdate(touchId uint32, x, y int, pressure uint8) error 60 | TouchEnd(touchId uint32, x, y int, pressure uint8) error 61 | } 62 | -------------------------------------------------------------------------------- /internal/webrtc/cursor/position.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "reflect" 5 | "sync" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type PositionListener interface { 11 | SendCursorPosition(x, y int) error 12 | } 13 | 14 | type Position interface { 15 | Shutdown() 16 | Set(x, y int) 17 | AddListener(listener PositionListener) 18 | RemoveListener(listener PositionListener) 19 | } 20 | 21 | type position struct { 22 | logger zerolog.Logger 23 | 24 | listeners map[uintptr]PositionListener 25 | listenersMu sync.RWMutex 26 | } 27 | 28 | func NewPosition(logger zerolog.Logger) *position { 29 | return &position{ 30 | logger: logger.With().Str("submodule", "cursor-position").Logger(), 31 | listeners: map[uintptr]PositionListener{}, 32 | } 33 | } 34 | 35 | func (manager *position) Shutdown() { 36 | manager.logger.Info().Msg("shutdown") 37 | 38 | manager.listenersMu.Lock() 39 | for key := range manager.listeners { 40 | delete(manager.listeners, key) 41 | } 42 | manager.listenersMu.Unlock() 43 | } 44 | 45 | func (manager *position) Set(x, y int) { 46 | manager.listenersMu.RLock() 47 | defer manager.listenersMu.RUnlock() 48 | 49 | for _, l := range manager.listeners { 50 | if err := l.SendCursorPosition(x, y); err != nil { 51 | manager.logger.Err(err).Msg("failed to set cursor position") 52 | } 53 | } 54 | } 55 | 56 | func (manager *position) AddListener(listener PositionListener) { 57 | manager.listenersMu.Lock() 58 | defer manager.listenersMu.Unlock() 59 | 60 | if listener != nil { 61 | ptr := reflect.ValueOf(listener).Pointer() 62 | manager.listeners[ptr] = listener 63 | } 64 | } 65 | 66 | func (manager *position) RemoveListener(listener PositionListener) { 67 | manager.listenersMu.Lock() 68 | defer manager.listenersMu.Unlock() 69 | 70 | if listener != nil { 71 | ptr := reflect.ValueOf(listener).Pointer() 72 | delete(manager.listeners, ptr) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/gst/gst.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #define GLIB_CHECK_VERSION(major,minor,micro) \ 9 | (GLIB_MAJOR_VERSION > (major) || \ 10 | (GLIB_MAJOR_VERSION == (major) && GLIB_MINOR_VERSION > (minor)) || \ 11 | (GLIB_MAJOR_VERSION == (major) && GLIB_MINOR_VERSION == (minor) && \ 12 | GLIB_MICRO_VERSION >= (micro))) 13 | 14 | // g_memdup2 was added in glib 2.67.4, maintain compatibility with older versions 15 | #if !GLIB_CHECK_VERSION(2, 67, 4) 16 | #define g_memdup2 g_memdup 17 | #endif 18 | 19 | typedef struct GstPipelineCtx { 20 | int pipelineId; 21 | GstElement *pipeline; 22 | GstElement *appsink; 23 | GstElement *appsrc; 24 | } GstPipelineCtx; 25 | 26 | extern void goHandlePipelineBuffer(int pipelineId, void *buffer, int bufferLen, guint64 duration, gboolean deltaUnit); 27 | extern void goPipelineLog(int pipelineId, char *level, char *msg); 28 | 29 | GstPipelineCtx *gstreamer_pipeline_create(char *pipelineStr, int pipelineId, GError **error); 30 | void gstreamer_pipeline_attach_appsink(GstPipelineCtx *ctx, char *sinkName); 31 | void gstreamer_pipeline_attach_appsrc(GstPipelineCtx *ctx, char *srcName); 32 | void gstreamer_pipeline_play(GstPipelineCtx *ctx); 33 | void gstreamer_pipeline_pause(GstPipelineCtx *ctx); 34 | void gstreamer_pipeline_destory(GstPipelineCtx *ctx); 35 | void gstreamer_pipeline_push(GstPipelineCtx *ctx, void *buffer, int bufferLen); 36 | 37 | gboolean gstreamer_pipeline_set_prop_int(GstPipelineCtx *ctx, char *binName, char *prop, gint value); 38 | gboolean gstreamer_pipeline_set_caps_framerate(GstPipelineCtx *ctx, const gchar* binName, gint numerator, gint denominator); 39 | gboolean gstreamer_pipeline_set_caps_resolution(GstPipelineCtx *ctx, const gchar* binName, gint width, gint height); 40 | gboolean gstreamer_pipeline_emit_video_keyframe(GstPipelineCtx *ctx); 41 | -------------------------------------------------------------------------------- /internal/websocket/handler/session.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/types" 5 | "github.com/demodesk/neko/pkg/types/event" 6 | "github.com/demodesk/neko/pkg/types/message" 7 | ) 8 | 9 | func (h *MessageHandlerCtx) SessionCreated(session types.Session) error { 10 | h.sessions.Broadcast( 11 | event.SESSION_CREATED, 12 | message.SessionData{ 13 | ID: session.ID(), 14 | Profile: session.Profile(), 15 | State: session.State(), 16 | }) 17 | 18 | return nil 19 | } 20 | 21 | func (h *MessageHandlerCtx) SessionDeleted(session types.Session) error { 22 | h.sessions.Broadcast( 23 | event.SESSION_DELETED, 24 | message.SessionID{ 25 | ID: session.ID(), 26 | }) 27 | 28 | return nil 29 | } 30 | 31 | func (h *MessageHandlerCtx) SessionConnected(session types.Session) error { 32 | if err := h.systemInit(session); err != nil { 33 | return err 34 | } 35 | 36 | if session.Profile().IsAdmin { 37 | if err := h.systemAdmin(session); err != nil { 38 | return err 39 | } 40 | } 41 | 42 | return h.SessionStateChanged(session) 43 | } 44 | 45 | func (h *MessageHandlerCtx) SessionDisconnected(session types.Session) error { 46 | // clear host if exists 47 | if session.IsHost() { 48 | h.desktop.ResetKeys() 49 | h.sessions.ClearHost() 50 | } 51 | 52 | return h.SessionStateChanged(session) 53 | } 54 | 55 | func (h *MessageHandlerCtx) SessionProfileChanged(session types.Session) error { 56 | h.sessions.Broadcast( 57 | event.SESSION_PROFILE, 58 | message.MemberProfile{ 59 | ID: session.ID(), 60 | MemberProfile: session.Profile(), 61 | }) 62 | 63 | return nil 64 | } 65 | 66 | func (h *MessageHandlerCtx) SessionStateChanged(session types.Session) error { 67 | h.sessions.Broadcast( 68 | event.SESSION_STATE, 69 | message.SessionState{ 70 | ID: session.ID(), 71 | SessionState: session.State(), 72 | }) 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/api/room/broadcast.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/demodesk/neko/pkg/types/event" 7 | "github.com/demodesk/neko/pkg/types/message" 8 | "github.com/demodesk/neko/pkg/utils" 9 | ) 10 | 11 | type BroadcastStatusPayload struct { 12 | URL string `json:"url,omitempty"` 13 | IsActive bool `json:"is_active"` 14 | } 15 | 16 | func (h *RoomHandler) broadcastStatus(w http.ResponseWriter, r *http.Request) error { 17 | broadcast := h.capture.Broadcast() 18 | 19 | return utils.HttpSuccess(w, BroadcastStatusPayload{ 20 | IsActive: broadcast.Started(), 21 | URL: broadcast.Url(), 22 | }) 23 | } 24 | 25 | func (h *RoomHandler) boradcastStart(w http.ResponseWriter, r *http.Request) error { 26 | data := &BroadcastStatusPayload{} 27 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 28 | return err 29 | } 30 | 31 | if data.URL == "" { 32 | return utils.HttpBadRequest("missing broadcast URL") 33 | } 34 | 35 | broadcast := h.capture.Broadcast() 36 | if broadcast.Started() { 37 | return utils.HttpUnprocessableEntity("server is already broadcasting") 38 | } 39 | 40 | if err := broadcast.Start(data.URL); err != nil { 41 | return utils.HttpInternalServerError().WithInternalErr(err) 42 | } 43 | 44 | h.sessions.AdminBroadcast( 45 | event.BORADCAST_STATUS, 46 | message.BroadcastStatus{ 47 | IsActive: broadcast.Started(), 48 | URL: broadcast.Url(), 49 | }) 50 | 51 | return utils.HttpSuccess(w) 52 | } 53 | 54 | func (h *RoomHandler) boradcastStop(w http.ResponseWriter, r *http.Request) error { 55 | broadcast := h.capture.Broadcast() 56 | if !broadcast.Started() { 57 | return utils.HttpUnprocessableEntity("server is not broadcasting") 58 | } 59 | 60 | broadcast.Stop() 61 | 62 | h.sessions.AdminBroadcast( 63 | event.BORADCAST_STATUS, 64 | message.BroadcastStatus{ 65 | IsActive: broadcast.Started(), 66 | URL: broadcast.Url(), 67 | }) 68 | 69 | return utils.HttpSuccess(w) 70 | } 71 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/src/Makefile.am: -------------------------------------------------------------------------------- 1 | # Copyright 2005 Adam Jackson. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # on the rights to use, copy, modify, merge, publish, distribute, sub 7 | # license, and/or sell copies of the Software, and to permit persons to whom 8 | # the Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice (including the next 11 | # paragraph) shall be included in all copies or substantial portions of the 12 | # Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 17 | # ADAM JACKSON BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | # this is obnoxious: 23 | # -module lets us name the module exactly how we want 24 | # -avoid-version prevents gratuitous .0.0.0 version numbers on the end 25 | # _ladir passes a dummy rpath to libtool so the thing will actually link 26 | # TODO: -nostdlib/-Bstatic/-lgcc platform magic, not installing the .a, etc. 27 | 28 | AM_CFLAGS = $(XORG_CFLAGS) $(PCIACCESS_CFLAGS) 29 | 30 | dummy_drv_la_LTLIBRARIES = dummy_drv.la 31 | dummy_drv_la_LDFLAGS = -module -avoid-version 32 | dummy_drv_la_LIBADD = $(XORG_LIBS) 33 | dummy_drv_ladir = @moduledir@/drivers 34 | 35 | dummy_drv_la_SOURCES = \ 36 | compat-api.h \ 37 | dummy_cursor.c \ 38 | dummy_driver.c \ 39 | dummy.h 40 | 41 | if DGA 42 | dummy_drv_la_SOURCES += \ 43 | dummy_dga.c 44 | endif 45 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/src/dummy.h: -------------------------------------------------------------------------------- 1 | 2 | /* All drivers should typically include these */ 3 | #include "xf86.h" 4 | #include "xf86_OSproc.h" 5 | 6 | #include "xf86Cursor.h" 7 | 8 | #ifdef XvExtension 9 | #include "xf86xv.h" 10 | #include 11 | #endif 12 | #include 13 | 14 | #include "compat-api.h" 15 | 16 | /* Supported chipsets */ 17 | typedef enum { 18 | DUMMY_CHIP 19 | } DUMMYType; 20 | 21 | /* function prototypes */ 22 | 23 | extern Bool DUMMYSwitchMode(SWITCH_MODE_ARGS_DECL); 24 | extern void DUMMYAdjustFrame(ADJUST_FRAME_ARGS_DECL); 25 | 26 | /* in dummy_cursor.c */ 27 | extern Bool DUMMYCursorInit(ScreenPtr pScrn); 28 | extern void DUMMYShowCursor(ScrnInfoPtr pScrn); 29 | extern void DUMMYHideCursor(ScrnInfoPtr pScrn); 30 | 31 | /* in dummy_dga.c */ 32 | Bool DUMMYDGAInit(ScreenPtr pScreen); 33 | 34 | /* in dummy_video.c */ 35 | extern void DUMMYInitVideo(ScreenPtr pScreen); 36 | 37 | /* globals */ 38 | typedef struct _color 39 | { 40 | int red; 41 | int green; 42 | int blue; 43 | } dummy_colors; 44 | 45 | typedef struct dummyRec 46 | { 47 | DGAModePtr DGAModes; 48 | int numDGAModes; 49 | Bool DGAactive; 50 | int DGAViewportStatus; 51 | /* options */ 52 | OptionInfoPtr Options; 53 | Bool swCursor; 54 | /* proc pointer */ 55 | CloseScreenProcPtr CloseScreen; 56 | xf86CursorInfoPtr CursorInfo; 57 | 58 | Bool DummyHWCursorShown; 59 | int cursorX, cursorY; 60 | int cursorFG, cursorBG; 61 | 62 | Bool screenSaver; 63 | Bool video; 64 | #ifdef XvExtension 65 | XF86VideoAdaptorPtr overlayAdaptor; 66 | #endif 67 | int overlay; 68 | int overlay_offset; 69 | int videoKey; 70 | int interlace; 71 | dummy_colors colors[256]; 72 | pointer* FBBase; 73 | Bool (*CreateWindow)() ; /* wrapped CreateWindow */ 74 | Bool prop; 75 | } DUMMYRec, *DUMMYPtr; 76 | 77 | /* The privates of the DUMMY driver */ 78 | #define DUMMYPTR(p) ((DUMMYPtr)((p)->driverPrivate)) 79 | 80 | -------------------------------------------------------------------------------- /pkg/types/member.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrMemberAlreadyExists = errors.New("member already exists") 7 | ErrMemberDoesNotExist = errors.New("member does not exist") 8 | ErrMemberInvalidPassword = errors.New("invalid password") 9 | ) 10 | 11 | type MemberProfile struct { 12 | Name string `json:"name"` 13 | 14 | // permissions 15 | IsAdmin bool `json:"is_admin" mapstructure:"is_admin"` 16 | CanLogin bool `json:"can_login" mapstructure:"can_login"` 17 | CanConnect bool `json:"can_connect" mapstructure:"can_connect"` 18 | CanWatch bool `json:"can_watch" mapstructure:"can_watch"` 19 | CanHost bool `json:"can_host" mapstructure:"can_host"` 20 | CanShareMedia bool `json:"can_share_media" mapstructure:"can_share_media"` 21 | CanAccessClipboard bool `json:"can_access_clipboard" mapstructure:"can_access_clipboard"` 22 | SendsInactiveCursor bool `json:"sends_inactive_cursor" mapstructure:"sends_inactive_cursor"` 23 | CanSeeInactiveCursors bool `json:"can_see_inactive_cursors" mapstructure:"can_see_inactive_cursors"` 24 | 25 | // plugin scope 26 | Plugins map[string]any `json:"plugins"` 27 | } 28 | 29 | type MemberProvider interface { 30 | Connect() error 31 | Disconnect() error 32 | 33 | Authenticate(username string, password string) (id string, profile MemberProfile, err error) 34 | 35 | Insert(username string, password string, profile MemberProfile) (id string, err error) 36 | Select(id string) (profile MemberProfile, err error) 37 | SelectAll(limit int, offset int) (profiles map[string]MemberProfile, err error) 38 | UpdateProfile(id string, profile MemberProfile) error 39 | UpdatePassword(id string, password string) error 40 | Delete(id string) error 41 | } 42 | 43 | type MemberManager interface { 44 | MemberProvider 45 | 46 | Login(username string, password string) (Session, string, error) 47 | Logout(id string) error 48 | } 49 | -------------------------------------------------------------------------------- /pkg/types/webrtc.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/pion/webrtc/v3" 7 | ) 8 | 9 | var ( 10 | ErrWebRTCDataChannelNotFound = errors.New("webrtc data channel not found") 11 | ErrWebRTCConnectionNotFound = errors.New("webrtc connection not found") 12 | ErrWebRTCStreamNotFound = errors.New("webrtc stream not found") 13 | ) 14 | 15 | type ICEServer struct { 16 | URLs []string `mapstructure:"urls" json:"urls"` 17 | Username string `mapstructure:"username" json:"username,omitempty"` 18 | Credential string `mapstructure:"credential" json:"credential,omitempty"` 19 | } 20 | 21 | type PeerVideo struct { 22 | Disabled bool `json:"disabled"` 23 | ID string `json:"id"` 24 | Video string `json:"video"` // TODO: Remove this, used for compatibility with old clients. 25 | Auto bool `json:"auto"` 26 | } 27 | 28 | type PeerVideoRequest struct { 29 | Disabled *bool `json:"disabled,omitempty"` 30 | Selector *StreamSelector `json:"selector,omitempty"` 31 | Auto *bool `json:"auto,omitempty"` 32 | } 33 | 34 | type PeerAudio struct { 35 | Disabled bool `json:"disabled"` 36 | } 37 | 38 | type PeerAudioRequest struct { 39 | Disabled *bool `json:"disabled,omitempty"` 40 | } 41 | 42 | type WebRTCPeer interface { 43 | CreateOffer(ICERestart bool) (*webrtc.SessionDescription, error) 44 | CreateAnswer() (*webrtc.SessionDescription, error) 45 | SetRemoteDescription(webrtc.SessionDescription) error 46 | SetCandidate(webrtc.ICECandidateInit) error 47 | 48 | SetPaused(isPaused bool) error 49 | Paused() bool 50 | 51 | SetVideo(PeerVideoRequest) error 52 | Video() PeerVideo 53 | SetAudio(PeerAudioRequest) error 54 | Audio() PeerAudio 55 | 56 | SendCursorPosition(x, y int) error 57 | SendCursorImage(cur *CursorImage, img []byte) error 58 | 59 | Destroy() 60 | } 61 | 62 | type WebRTCManager interface { 63 | Start() 64 | Shutdown() error 65 | 66 | ICEServers() []ICEServer 67 | 68 | CreatePeer(session Session) (*webrtc.SessionDescription, WebRTCPeer, error) 69 | SetCursorPosition(x, y int) 70 | } 71 | -------------------------------------------------------------------------------- /internal/session/auth.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/demodesk/neko/pkg/types" 10 | ) 11 | 12 | func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token string) { 13 | sameSite := http.SameSiteDefaultMode 14 | if manager.config.CookieSecure { 15 | sameSite = http.SameSiteNoneMode 16 | } 17 | 18 | http.SetCookie(w, &http.Cookie{ 19 | Name: manager.config.CookieName, 20 | Value: token, 21 | Expires: time.Now().Add(manager.config.CookieExpiration), 22 | Secure: manager.config.CookieSecure, 23 | SameSite: sameSite, 24 | HttpOnly: true, 25 | }) 26 | } 27 | 28 | func (manager *SessionManagerCtx) CookieClearToken(w http.ResponseWriter, r *http.Request) { 29 | cookie, err := r.Cookie(manager.config.CookieName) 30 | if err != nil { 31 | return 32 | } 33 | 34 | cookie.Value = "" 35 | cookie.Expires = time.Unix(0, 0) 36 | http.SetCookie(w, cookie) 37 | } 38 | 39 | func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) { 40 | token, ok := manager.getToken(r) 41 | if !ok { 42 | return nil, errors.New("no authentication provided") 43 | } 44 | 45 | session, ok := manager.GetByToken(token) 46 | if !ok { 47 | return nil, types.ErrSessionNotFound 48 | } 49 | 50 | if !session.Profile().CanLogin { 51 | return nil, types.ErrSessionLoginDisabled 52 | } 53 | 54 | return session, nil 55 | } 56 | 57 | func (manager *SessionManagerCtx) getToken(r *http.Request) (string, bool) { 58 | if manager.CookieEnabled() { 59 | // get from Cookie 60 | cookie, err := r.Cookie(manager.config.CookieName) 61 | if err == nil { 62 | return cookie.Value, true 63 | } 64 | } 65 | 66 | // get from Header 67 | reqToken := r.Header.Get("Authorization") 68 | splitToken := strings.Split(reqToken, "Bearer ") 69 | if len(splitToken) == 2 { 70 | return strings.TrimSpace(splitToken[1]), true 71 | } 72 | 73 | // get from URL 74 | token := r.URL.Query().Get("token") 75 | if token != "" { 76 | return token, true 77 | } 78 | 79 | return "", false 80 | } 81 | -------------------------------------------------------------------------------- /internal/websocket/filechooserdialog.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/types" 5 | "github.com/demodesk/neko/pkg/types/event" 6 | "github.com/demodesk/neko/pkg/types/message" 7 | ) 8 | 9 | func (manager *WebSocketManagerCtx) fileChooserDialogEvents() { 10 | var activeSession types.Session 11 | 12 | // when dialog opens, everyone should be notified. 13 | manager.desktop.OnFileChooserDialogOpened(func() { 14 | manager.logger.Info().Msg("file chooser dialog opened") 15 | 16 | host, hasHost := manager.sessions.GetHost() 17 | if !hasHost { 18 | manager.logger.Warn().Msg("no host for file chooser dialog found, closing") 19 | go manager.desktop.CloseFileChooserDialog() 20 | return 21 | } 22 | 23 | activeSession = host 24 | 25 | go manager.sessions.Broadcast( 26 | event.FILE_CHOOSER_DIALOG_OPENED, 27 | message.SessionID{ 28 | ID: host.ID(), 29 | }) 30 | }) 31 | 32 | // when dialog closes, everyone should be notified. 33 | manager.desktop.OnFileChooserDialogClosed(func() { 34 | manager.logger.Info().Msg("file chooser dialog closed") 35 | 36 | activeSession = nil 37 | 38 | go manager.sessions.Broadcast( 39 | event.FILE_CHOOSER_DIALOG_CLOSED, 40 | message.SessionID{}) 41 | }) 42 | 43 | // when new user joins, and someone holds dialog, he shouldd be notified about it. 44 | manager.sessions.OnConnected(func(session types.Session) { 45 | if activeSession == nil { 46 | return 47 | } 48 | 49 | manager.logger.Debug().Str("session_id", session.ID()).Msg("sending file chooser dialog status to a new session") 50 | 51 | session.Send( 52 | event.FILE_CHOOSER_DIALOG_OPENED, 53 | message.SessionID{ 54 | ID: activeSession.ID(), 55 | }) 56 | }) 57 | 58 | // when user, that holds dialog, disconnects, it should be closed. 59 | manager.sessions.OnDisconnected(func(session types.Session) { 60 | if activeSession == nil || activeSession != session { 61 | return 62 | } 63 | 64 | manager.logger.Info().Msg("file chooser dialog owner left, closing") 65 | manager.desktop.CloseFileChooserDialog() 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neko 2 | This app uses WebRTC to stream a desktop inside of a docker container. Client can be found here: [demodesk/neko-client](https://github.com/demodesk/neko-client). 3 | 4 | For **community edition** neko with GUI and _plug & play_ deployment visit [m1k1o/neko](https://github.com/m1k1o/neko). 5 | 6 | ### **m1k1o/neko** vs **demodesk/neko**, why do we have two of them? 7 | 8 | This project started as a fork of [m1k1o/neko](https://github.com/m1k1o/neko). But over time, development went way ahead of the original one in terms of features, updates and refactoring. The goal is to rebase [m1k1o/neko](https://github.com/m1k1o/neko) repository onto this one and move all extra features (such as chat and emotes) to a standalone plugin. 9 | 10 | - This project is aimed to be the engine providing foundation for all applications that are streaming desktop environment using WebRTC to the browser. 11 | - [m1k1o/neko](https://github.com/m1k1o/neko) is meant to be self-hosted replacement for [rabb.it](https://en.wikipedia.org/wiki/Rabb.it): Community edition with well-known GUI, all the social functions (such as chat and emotes) and easy deployment. 12 | 13 | Notable differences to the [m1k1o/neko](https://github.com/m1k1o/neko) are: 14 | 15 | - Go plugin support. 16 | - Multiple encoding qualities simulcast. 17 | - Bandwidth estimation and adaptive quality. 18 | - Custom screen size (with automatic sync). 19 | - Single cursor for host - cursor image proxying. 20 | - Custom cursor style/badge for participants. 21 | - Inactive cursors (participants that are not hosting). 22 | - Fallback mode and reconnection improvements: 23 | - Watching using screencasting. 24 | - Controlling using websockets. 25 | - Members handling: 26 | - Access control (view, interactivity, clipboard). 27 | - Posibility to add external members providers. 28 | - Persistent login (using cookies). 29 | - Drag and drop passthrough. 30 | - File upload passthrough (experimental). 31 | - Microphone passthrough. 32 | - Webcam passthrough (experimental). 33 | - Bi-directional text/html clipboard. 34 | - Keyboard layouts/variants. 35 | - Metrics and REST API. 36 | 37 | ## Docs 38 | 39 | *TBD.* 40 | -------------------------------------------------------------------------------- /internal/api/members/handler.go: -------------------------------------------------------------------------------- 1 | package members 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | 10 | "github.com/demodesk/neko/pkg/auth" 11 | "github.com/demodesk/neko/pkg/types" 12 | "github.com/demodesk/neko/pkg/utils" 13 | ) 14 | 15 | type key int 16 | 17 | const keyMemberCtx key = iota 18 | 19 | type MembersHandler struct { 20 | members types.MemberManager 21 | } 22 | 23 | func New( 24 | members types.MemberManager, 25 | ) *MembersHandler { 26 | // Init 27 | 28 | return &MembersHandler{ 29 | members: members, 30 | } 31 | } 32 | 33 | func (h *MembersHandler) Route(r types.Router) { 34 | r.Get("/", h.membersList) 35 | 36 | r.With(auth.AdminsOnly).Group(func(r types.Router) { 37 | r.Post("/", h.membersCreate) 38 | r.With(h.ExtractMember).Route("/{memberId}", func(r types.Router) { 39 | r.Get("/", h.membersRead) 40 | r.Post("/", h.membersUpdateProfile) 41 | r.Post("/password", h.membersUpdatePassword) 42 | r.Delete("/", h.membersDelete) 43 | }) 44 | }) 45 | } 46 | 47 | func (h *MembersHandler) RouteBulk(r types.Router) { 48 | r.With(auth.AdminsOnly).Group(func(r types.Router) { 49 | r.Post("/update", h.membersBulkUpdate) 50 | r.Post("/delete", h.membersBulkDelete) 51 | }) 52 | } 53 | 54 | type MemberData struct { 55 | ID string 56 | Profile types.MemberProfile 57 | } 58 | 59 | func SetMember(r *http.Request, session MemberData) context.Context { 60 | return context.WithValue(r.Context(), keyMemberCtx, session) 61 | } 62 | 63 | func GetMember(r *http.Request) MemberData { 64 | return r.Context().Value(keyMemberCtx).(MemberData) 65 | } 66 | 67 | func (h *MembersHandler) ExtractMember(w http.ResponseWriter, r *http.Request) (context.Context, error) { 68 | memberId := chi.URLParam(r, "memberId") 69 | 70 | profile, err := h.members.Select(memberId) 71 | if err != nil { 72 | if errors.Is(err, types.ErrMemberDoesNotExist) { 73 | return nil, utils.HttpNotFound("member not found") 74 | } 75 | 76 | return nil, utils.HttpInternalServerError().WithInternalErr(err) 77 | } 78 | 79 | return SetMember(r, MemberData{ 80 | ID: memberId, 81 | Profile: profile, 82 | }), nil 83 | } 84 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/config.h.in: -------------------------------------------------------------------------------- 1 | /* config.h.in. Generated from configure.ac by autoheader. */ 2 | 3 | #include "xorg-server.h" 4 | 5 | /* Define to 1 if you have the header file. */ 6 | #undef HAVE_DLFCN_H 7 | 8 | /* Define to 1 if you have the header file. */ 9 | #undef HAVE_INTTYPES_H 10 | 11 | /* Define to 1 if you have the header file. */ 12 | #undef HAVE_MEMORY_H 13 | 14 | /* Define to 1 if you have the header file. */ 15 | #undef HAVE_STDINT_H 16 | 17 | /* Define to 1 if you have the header file. */ 18 | #undef HAVE_STDLIB_H 19 | 20 | /* Define to 1 if you have the header file. */ 21 | #undef HAVE_STRINGS_H 22 | 23 | /* Define to 1 if you have the header file. */ 24 | #undef HAVE_STRING_H 25 | 26 | /* Define to 1 if you have the header file. */ 27 | #undef HAVE_SYS_STAT_H 28 | 29 | /* Define to 1 if you have the header file. */ 30 | #undef HAVE_SYS_TYPES_H 31 | 32 | /* Define to 1 if you have the header file. */ 33 | #undef HAVE_UNISTD_H 34 | 35 | /* Define to the sub-directory where libtool stores uninstalled libraries. */ 36 | #undef LT_OBJDIR 37 | 38 | /* Name of package */ 39 | #undef PACKAGE 40 | 41 | /* Define to the address where bug reports for this package should be sent. */ 42 | #undef PACKAGE_BUGREPORT 43 | 44 | /* Define to the full name of this package. */ 45 | #undef PACKAGE_NAME 46 | 47 | /* Define to the full name and version of this package. */ 48 | #undef PACKAGE_STRING 49 | 50 | /* Define to the one symbol short name of this package. */ 51 | #undef PACKAGE_TARNAME 52 | 53 | /* Define to the home page for this package. */ 54 | #undef PACKAGE_URL 55 | 56 | /* Define to the version of this package. */ 57 | #undef PACKAGE_VERSION 58 | 59 | /* Major version of this package */ 60 | #undef PACKAGE_VERSION_MAJOR 61 | 62 | /* Minor version of this package */ 63 | #undef PACKAGE_VERSION_MINOR 64 | 65 | /* Patch version of this package */ 66 | #undef PACKAGE_VERSION_PATCHLEVEL 67 | 68 | /* Define to 1 if you have the ANSI C header files. */ 69 | #undef STDC_HEADERS 70 | 71 | /* Support DGA extension */ 72 | #undef USE_DGA 73 | 74 | /* Version number of package */ 75 | #undef VERSION 76 | -------------------------------------------------------------------------------- /internal/api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/demodesk/neko/internal/api/members" 9 | "github.com/demodesk/neko/internal/api/room" 10 | "github.com/demodesk/neko/pkg/auth" 11 | "github.com/demodesk/neko/pkg/types" 12 | "github.com/demodesk/neko/pkg/utils" 13 | ) 14 | 15 | type ApiManagerCtx struct { 16 | sessions types.SessionManager 17 | members types.MemberManager 18 | desktop types.DesktopManager 19 | capture types.CaptureManager 20 | routers map[string]func(types.Router) 21 | } 22 | 23 | func New( 24 | sessions types.SessionManager, 25 | members types.MemberManager, 26 | desktop types.DesktopManager, 27 | capture types.CaptureManager, 28 | ) *ApiManagerCtx { 29 | 30 | return &ApiManagerCtx{ 31 | sessions: sessions, 32 | members: members, 33 | desktop: desktop, 34 | capture: capture, 35 | routers: make(map[string]func(types.Router)), 36 | } 37 | } 38 | 39 | func (api *ApiManagerCtx) Route(r types.Router) { 40 | r.Post("/login", api.Login) 41 | 42 | // Authenticated area 43 | r.Group(func(r types.Router) { 44 | r.Use(api.Authenticate) 45 | 46 | r.Post("/logout", api.Logout) 47 | r.Get("/whoami", api.Whoami) 48 | r.Get("/sessions", api.Sessions) 49 | 50 | membersHandler := members.New(api.members) 51 | r.Route("/members", membersHandler.Route) 52 | r.Route("/members_bulk", membersHandler.RouteBulk) 53 | 54 | roomHandler := room.New(api.sessions, api.desktop, api.capture) 55 | r.Route("/room", roomHandler.Route) 56 | 57 | for path, router := range api.routers { 58 | r.Route(path, router) 59 | } 60 | }) 61 | } 62 | 63 | func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { 64 | session, err := api.sessions.Authenticate(r) 65 | if err != nil { 66 | if api.sessions.CookieEnabled() { 67 | api.sessions.CookieClearToken(w, r) 68 | } 69 | 70 | if errors.Is(err, types.ErrSessionLoginDisabled) { 71 | return nil, utils.HttpForbidden("login is disabled for this session") 72 | } 73 | 74 | return nil, utils.HttpUnauthorized().WithInternalErr(err) 75 | } 76 | 77 | return auth.SetSession(r, session), nil 78 | } 79 | 80 | func (api *ApiManagerCtx) AddRouter(path string, router func(types.Router)) { 81 | api.routers[path] = router 82 | } 83 | -------------------------------------------------------------------------------- /internal/member/noauth/provider.go: -------------------------------------------------------------------------------- 1 | package noauth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/demodesk/neko/pkg/types" 8 | "github.com/demodesk/neko/pkg/utils" 9 | ) 10 | 11 | func New() types.MemberProvider { 12 | return &MemberProviderCtx{ 13 | profile: types.MemberProfile{ 14 | IsAdmin: true, 15 | CanLogin: true, 16 | CanConnect: true, 17 | CanWatch: true, 18 | CanHost: true, 19 | CanShareMedia: true, 20 | CanAccessClipboard: true, 21 | SendsInactiveCursor: true, 22 | CanSeeInactiveCursors: true, 23 | }, 24 | } 25 | } 26 | 27 | type MemberProviderCtx struct { 28 | profile types.MemberProfile 29 | } 30 | 31 | func (provider *MemberProviderCtx) Connect() error { 32 | return nil 33 | } 34 | 35 | func (provider *MemberProviderCtx) Disconnect() error { 36 | return nil 37 | } 38 | 39 | func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) { 40 | // generate random token 41 | token, err := utils.NewUID(5) 42 | if err != nil { 43 | return "", types.MemberProfile{}, err 44 | } 45 | 46 | // id is username with token 47 | id := fmt.Sprintf("%s-%s", username, token) 48 | 49 | provider.profile.Name = username 50 | return id, provider.profile, nil 51 | } 52 | 53 | func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) { 54 | return "", errors.New("new user is created on first login in noauth mode") 55 | } 56 | 57 | func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error { 58 | return nil 59 | } 60 | 61 | func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error { 62 | return errors.New("noauth mode does not have password") 63 | } 64 | 65 | func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) { 66 | return types.MemberProfile{}, errors.New("cannot select user in noauth mode") 67 | } 68 | 69 | func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) { 70 | return map[string]types.MemberProfile{}, nil 71 | } 72 | 73 | func (provider *MemberProviderCtx) Delete(id string) error { 74 | return errors.New("cannot delete user in noauth mode") 75 | } 76 | -------------------------------------------------------------------------------- /pkg/types/event/events.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | const ( 4 | SYSTEM_INIT = "system/init" 5 | SYSTEM_ADMIN = "system/admin" 6 | SYSTEM_SETTINGS = "system/settings" 7 | SYSTEM_LOGS = "system/logs" 8 | SYSTEM_DISCONNECT = "system/disconnect" 9 | SYSTEM_HEARTBEAT = "system/heartbeat" 10 | ) 11 | 12 | const ( 13 | SIGNAL_REQUEST = "signal/request" 14 | SIGNAL_RESTART = "signal/restart" 15 | SIGNAL_OFFER = "signal/offer" 16 | SIGNAL_ANSWER = "signal/answer" 17 | SIGNAL_PROVIDE = "signal/provide" 18 | SIGNAL_CANDIDATE = "signal/candidate" 19 | SIGNAL_VIDEO = "signal/video" 20 | SIGNAL_AUDIO = "signal/audio" 21 | SIGNAL_CLOSE = "signal/close" 22 | ) 23 | 24 | const ( 25 | SESSION_CREATED = "session/created" 26 | SESSION_DELETED = "session/deleted" 27 | SESSION_PROFILE = "session/profile" 28 | SESSION_STATE = "session/state" 29 | SESSION_CURSORS = "session/cursors" 30 | ) 31 | 32 | const ( 33 | CONTROL_HOST = "control/host" 34 | CONTROL_RELEASE = "control/release" 35 | CONTROL_REQUEST = "control/request" 36 | // mouse 37 | CONTROL_MOVE = "control/move" 38 | CONTROL_SCROLL = "control/scroll" 39 | CONTROL_BUTTONPRESS = "control/buttonpress" 40 | CONTROL_BUTTONDOWN = "control/buttondown" 41 | CONTROL_BUTTONUP = "control/buttonup" 42 | // keyboard 43 | CONTROL_KEYPRESS = "control/keypress" 44 | CONTROL_KEYDOWN = "control/keydown" 45 | CONTROL_KEYUP = "control/keyup" 46 | // touch 47 | CONTROL_TOUCHBEGIN = "control/touchbegin" 48 | CONTROL_TOUCHUPDATE = "control/touchupdate" 49 | CONTROL_TOUCHEND = "control/touchend" 50 | // actions 51 | CONTROL_CUT = "control/cut" 52 | CONTROL_COPY = "control/copy" 53 | CONTROL_PASTE = "control/paste" 54 | CONTROL_SELECT_ALL = "control/select_all" 55 | ) 56 | 57 | const ( 58 | SCREEN_UPDATED = "screen/updated" 59 | SCREEN_SET = "screen/set" 60 | ) 61 | 62 | const ( 63 | CLIPBOARD_UPDATED = "clipboard/updated" 64 | CLIPBOARD_SET = "clipboard/set" 65 | ) 66 | 67 | const ( 68 | KEYBOARD_MODIFIERS = "keyboard/modifiers" 69 | KEYBOARD_MAP = "keyboard/map" 70 | ) 71 | 72 | const ( 73 | BORADCAST_STATUS = "broadcast/status" 74 | ) 75 | 76 | const ( 77 | SEND_UNICAST = "send/unicast" 78 | SEND_BROADCAST = "send/broadcast" 79 | ) 80 | 81 | const ( 82 | FILE_CHOOSER_DIALOG_OPENED = "file_chooser_dialog/opened" 83 | FILE_CHOOSER_DIALOG_CLOSED = "file_chooser_dialog/closed" 84 | ) 85 | -------------------------------------------------------------------------------- /internal/websocket/peer.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "sync" 7 | 8 | "github.com/gorilla/websocket" 9 | "github.com/rs/zerolog" 10 | 11 | "github.com/demodesk/neko/pkg/types" 12 | "github.com/demodesk/neko/pkg/types/event" 13 | "github.com/demodesk/neko/pkg/types/message" 14 | "github.com/demodesk/neko/pkg/utils" 15 | ) 16 | 17 | type WebSocketPeerCtx struct { 18 | mu sync.Mutex 19 | logger zerolog.Logger 20 | connection *websocket.Conn 21 | } 22 | 23 | func newPeer(logger zerolog.Logger, connection *websocket.Conn) *WebSocketPeerCtx { 24 | return &WebSocketPeerCtx{ 25 | logger: logger.With().Str("submodule", "peer").Logger(), 26 | connection: connection, 27 | } 28 | } 29 | 30 | func (peer *WebSocketPeerCtx) Send(event string, payload any) { 31 | peer.mu.Lock() 32 | defer peer.mu.Unlock() 33 | 34 | raw, err := json.Marshal(payload) 35 | if err != nil { 36 | peer.logger.Err(err).Str("event", event).Msg("message marshalling has failed") 37 | return 38 | } 39 | 40 | err = peer.connection.WriteJSON(types.WebSocketMessage{ 41 | Event: event, 42 | Payload: raw, 43 | }) 44 | 45 | if err != nil { 46 | err = errors.Unwrap(err) // unwrap if possible 47 | peer.logger.Warn().Err(err).Str("event", event).Msg("send message error") 48 | return 49 | } 50 | 51 | // log events if not ignored 52 | if ok, _ := utils.ArrayIn(event, nologEvents); !ok { 53 | if len(raw) > maxPayloadLogLength { 54 | raw = []byte("") 55 | } 56 | 57 | peer.logger.Debug(). 58 | Str("address", peer.connection.RemoteAddr().String()). 59 | Str("event", event). 60 | Str("payload", string(raw)). 61 | Msg("sending message to client") 62 | } 63 | } 64 | 65 | func (peer *WebSocketPeerCtx) Ping() error { 66 | peer.mu.Lock() 67 | defer peer.mu.Unlock() 68 | 69 | // application level heartbeat 70 | if err := peer.connection.WriteJSON(types.WebSocketMessage{ 71 | Event: event.SYSTEM_HEARTBEAT, 72 | }); err != nil { 73 | return err 74 | } 75 | 76 | return peer.connection.WriteMessage(websocket.PingMessage, nil) 77 | } 78 | 79 | func (peer *WebSocketPeerCtx) Destroy(reason string) { 80 | peer.Send( 81 | event.SYSTEM_DISCONNECT, 82 | message.SystemDisconnect{ 83 | Message: reason, 84 | }) 85 | 86 | peer.mu.Lock() 87 | defer peer.mu.Unlock() 88 | 89 | err := peer.connection.Close() 90 | peer.logger.Err(err).Msg("peer connection destroyed") 91 | } 92 | -------------------------------------------------------------------------------- /pkg/utils/zip.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "archive/zip" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func Zip(source, zipPath string) error { 12 | archiveFile, err := os.Create(zipPath) 13 | if err != nil { 14 | return err 15 | } 16 | defer archiveFile.Close() 17 | 18 | archive := zip.NewWriter(archiveFile) 19 | defer archive.Close() 20 | 21 | return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if !info.IsDir() && !info.Mode().IsRegular() { 27 | return nil 28 | } 29 | 30 | header, err := zip.FileInfoHeader(info) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | header.Name = strings.TrimPrefix(path, source) 36 | 37 | if info.IsDir() { 38 | header.Name += "/" 39 | } else { 40 | header.Method = zip.Deflate 41 | } 42 | 43 | writer, err := archive.CreateHeader(header) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if info.IsDir() { 49 | return nil 50 | } 51 | 52 | file, err := os.Open(path) 53 | if err != nil { 54 | return err 55 | } 56 | defer file.Close() 57 | 58 | _, err = io.Copy(writer, file) 59 | return err 60 | }) 61 | } 62 | 63 | func Unzip(zipPath, target string) error { 64 | reader, err := zip.OpenReader(zipPath) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if err := os.MkdirAll(target, 0755); err != nil { 70 | return err 71 | } 72 | 73 | for _, file := range reader.File { 74 | path := filepath.Join(target, file.Name) 75 | if file.FileInfo().IsDir() { 76 | if err := os.MkdirAll(path, file.Mode()); err != nil { 77 | return err 78 | } 79 | continue 80 | } 81 | 82 | fileReader, err := file.Open() 83 | if err != nil { 84 | if fileReader != nil { 85 | fileReader.Close() 86 | } 87 | 88 | return err 89 | } 90 | 91 | targetFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) 92 | if err != nil { 93 | fileReader.Close() 94 | 95 | if targetFile != nil { 96 | targetFile.Close() 97 | } 98 | 99 | return err 100 | } 101 | 102 | if _, err := io.Copy(targetFile, fileReader); err != nil { 103 | fileReader.Close() 104 | targetFile.Close() 105 | 106 | return err 107 | } 108 | 109 | fileReader.Close() 110 | targetFile.Close() 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /internal/member/multiuser/provider.go: -------------------------------------------------------------------------------- 1 | package multiuser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/demodesk/neko/pkg/types" 8 | "github.com/demodesk/neko/pkg/utils" 9 | ) 10 | 11 | func New(config Config) types.MemberProvider { 12 | return &MemberProviderCtx{ 13 | config: config, 14 | } 15 | } 16 | 17 | type MemberProviderCtx struct { 18 | config Config 19 | } 20 | 21 | func (provider *MemberProviderCtx) Connect() error { 22 | return nil 23 | } 24 | 25 | func (provider *MemberProviderCtx) Disconnect() error { 26 | return nil 27 | } 28 | 29 | func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) { 30 | // generate random token 31 | token, err := utils.NewUID(5) 32 | if err != nil { 33 | return "", types.MemberProfile{}, err 34 | } 35 | 36 | // id is username with token 37 | id := fmt.Sprintf("%s-%s", username, token) 38 | 39 | // if logged in as administrator 40 | if provider.config.AdminPassword == password { 41 | profile := provider.config.AdminProfile 42 | if profile.Name == "" { 43 | profile.Name = username 44 | } 45 | return id, profile, nil 46 | } 47 | 48 | // if logged in as user 49 | if provider.config.UserPassword == password { 50 | profile := provider.config.UserProfile 51 | if profile.Name == "" { 52 | profile.Name = username 53 | } 54 | return id, profile, nil 55 | } 56 | 57 | return "", types.MemberProfile{}, types.ErrMemberInvalidPassword 58 | } 59 | 60 | func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) { 61 | return "", errors.New("new user is created on first login in multiuser mode") 62 | } 63 | 64 | func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error { 65 | return nil 66 | } 67 | 68 | func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error { 69 | return errors.New("password can only be modified in config while in multiuser mode") 70 | } 71 | 72 | func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) { 73 | return types.MemberProfile{}, errors.New("cannot select user in multiuser mode") 74 | } 75 | 76 | func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) { 77 | return map[string]types.MemberProfile{}, nil 78 | } 79 | 80 | func (provider *MemberProviderCtx) Delete(id string) error { 81 | return errors.New("cannot delete user in multiuser mode") 82 | } 83 | -------------------------------------------------------------------------------- /pkg/utils/uid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | ) 8 | 9 | const ( 10 | defaultAlphabet = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // len=64 11 | defaultSize = 21 12 | defaultMaskSize = 5 13 | ) 14 | 15 | // Generator function 16 | type Generator func([]byte) (int, error) 17 | 18 | // BytesGenerator is the default bytes generator 19 | var BytesGenerator Generator = rand.Read 20 | 21 | func initMasks(params ...int) []uint { 22 | var size int 23 | if len(params) == 0 { 24 | size = defaultMaskSize 25 | } else { 26 | size = params[0] 27 | } 28 | masks := make([]uint, size) 29 | for i := 0; i < size; i++ { 30 | shift := 3 + i 31 | masks[i] = (2 << uint(shift)) - 1 32 | } 33 | return masks 34 | } 35 | 36 | func getMask(alphabet string, masks []uint) int { 37 | for i := 0; i < len(masks); i++ { 38 | curr := int(masks[i]) 39 | if curr >= len(alphabet)-1 { 40 | return curr 41 | } 42 | } 43 | return 0 44 | } 45 | 46 | // GenerateUID is a low-level function to change alphabet and ID size. 47 | func GenerateUID(alphabet string, size int) (string, error) { 48 | if len(alphabet) == 0 || len(alphabet) > 255 { 49 | return "", fmt.Errorf("alphabet must not empty and contain no more than 255 chars. Current len is %d", len(alphabet)) 50 | } 51 | if size <= 0 { 52 | return "", fmt.Errorf("size must be positive integer") 53 | } 54 | 55 | masks := initMasks(size) 56 | mask := getMask(alphabet, masks) 57 | ceilArg := 1.6 * float64(mask*size) / float64(len(alphabet)) 58 | step := int(math.Ceil(ceilArg)) 59 | 60 | id := make([]byte, size) 61 | bytes := make([]byte, step) 62 | for j := 0; ; { 63 | _, err := BytesGenerator(bytes) 64 | if err != nil { 65 | return "", err 66 | } 67 | for i := 0; i < step; i++ { 68 | currByte := bytes[i] & byte(mask) 69 | if currByte < byte(len(alphabet)) { 70 | id[j] = alphabet[currByte] 71 | j++ 72 | if j == size { 73 | return string(id[:size]), nil 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | // NewUID generates secure URL-friendly unique ID. 81 | func NewUID(param ...int) (string, error) { 82 | var size int 83 | if len(param) == 0 { 84 | size = defaultSize 85 | } else { 86 | size = param[0] 87 | } 88 | bytes := make([]byte, size) 89 | _, err := BytesGenerator(bytes) 90 | if err != nil { 91 | return "", err 92 | } 93 | id := make([]byte, size) 94 | for i := 0; i < size; i++ { 95 | id[i] = defaultAlphabet[bytes[i]&63] 96 | } 97 | return string(id[:size]), nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/api/room/control.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | 8 | "github.com/demodesk/neko/pkg/auth" 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type ControlStatusPayload struct { 13 | HasHost bool `json:"has_host"` 14 | HostId string `json:"host_id,omitempty"` 15 | } 16 | 17 | type ControlTargetPayload struct { 18 | ID string `json:"id"` 19 | } 20 | 21 | func (h *RoomHandler) controlStatus(w http.ResponseWriter, r *http.Request) error { 22 | host, hasHost := h.sessions.GetHost() 23 | 24 | var hostId string 25 | if hasHost { 26 | hostId = host.ID() 27 | } 28 | 29 | return utils.HttpSuccess(w, ControlStatusPayload{ 30 | HasHost: hasHost, 31 | HostId: hostId, 32 | }) 33 | } 34 | 35 | func (h *RoomHandler) controlRequest(w http.ResponseWriter, r *http.Request) error { 36 | _, hasHost := h.sessions.GetHost() 37 | if hasHost { 38 | return utils.HttpUnprocessableEntity("there is already a host") 39 | } 40 | 41 | session, _ := auth.GetSession(r) 42 | if h.sessions.Settings().LockedControls && !session.Profile().IsAdmin { 43 | return utils.HttpForbidden("controls are locked") 44 | } 45 | 46 | h.sessions.SetHost(session) 47 | 48 | return utils.HttpSuccess(w) 49 | } 50 | 51 | func (h *RoomHandler) controlRelease(w http.ResponseWriter, r *http.Request) error { 52 | session, _ := auth.GetSession(r) 53 | if !session.IsHost() { 54 | return utils.HttpUnprocessableEntity("session is not the host") 55 | } 56 | 57 | h.desktop.ResetKeys() 58 | h.sessions.ClearHost() 59 | 60 | return utils.HttpSuccess(w) 61 | } 62 | 63 | func (h *RoomHandler) controlTake(w http.ResponseWriter, r *http.Request) error { 64 | session, _ := auth.GetSession(r) 65 | h.sessions.SetHost(session) 66 | 67 | return utils.HttpSuccess(w) 68 | } 69 | 70 | func (h *RoomHandler) controlGive(w http.ResponseWriter, r *http.Request) error { 71 | sessionId := chi.URLParam(r, "sessionId") 72 | 73 | target, ok := h.sessions.Get(sessionId) 74 | if !ok { 75 | return utils.HttpNotFound("target session was not found") 76 | } 77 | 78 | if !target.Profile().CanHost { 79 | return utils.HttpBadRequest("target session is not allowed to host") 80 | } 81 | 82 | h.sessions.SetHost(target) 83 | 84 | return utils.HttpSuccess(w) 85 | } 86 | 87 | func (h *RoomHandler) controlReset(w http.ResponseWriter, r *http.Request) error { 88 | _, hasHost := h.sessions.GetHost() 89 | 90 | if hasHost { 91 | h.desktop.ResetKeys() 92 | h.sessions.ClearHost() 93 | } 94 | 95 | return utils.HttpSuccess(w) 96 | } 97 | -------------------------------------------------------------------------------- /internal/session/serialize.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | 8 | "github.com/demodesk/neko/pkg/types" 9 | ) 10 | 11 | func (manager *SessionManagerCtx) save() { 12 | if manager.config.File == "" { 13 | return 14 | } 15 | 16 | // serialize sessions 17 | sessions := make([]types.SessionProfile, 0, len(manager.sessions)) 18 | for _, session := range manager.sessions { 19 | sessions = append(sessions, types.SessionProfile{ 20 | Id: session.id, 21 | Token: session.token, 22 | Profile: session.profile, 23 | }) 24 | } 25 | 26 | // convert to json 27 | data, err := json.Marshal(sessions) 28 | if err != nil { 29 | manager.logger.Error().Err(err).Msg("failed to marshal sessions") 30 | return 31 | } 32 | 33 | // write to file 34 | err = os.WriteFile(manager.config.File, data, 0644) 35 | if err != nil { 36 | manager.logger.Error().Err(err). 37 | Str("file", manager.config.File). 38 | Msg("failed to write sessions to a file") 39 | } 40 | } 41 | 42 | func (manager *SessionManagerCtx) load() { 43 | if manager.config.File == "" { 44 | return 45 | } 46 | 47 | // read file 48 | data, err := os.ReadFile(manager.config.File) 49 | if err != nil { 50 | // if file does not exist 51 | if errors.Is(err, os.ErrNotExist) { 52 | manager.logger.Info(). 53 | Str("file", manager.config.File). 54 | Msg("sessions file does not exist") 55 | return 56 | } 57 | manager.logger.Error().Err(err). 58 | Str("file", manager.config.File). 59 | Msg("failed to read sessions from a file") 60 | return 61 | } 62 | 63 | // if file is empty 64 | if len(data) == 0 { 65 | manager.logger.Info(). 66 | Str("file", manager.config.File). 67 | Msg("sessions file is empty") 68 | return 69 | } 70 | 71 | // deserialize sessions 72 | sessions := make([]types.SessionProfile, 0) 73 | err = json.Unmarshal(data, &sessions) 74 | if err != nil { 75 | manager.logger.Error().Err(err).Msg("failed to unmarshal sessions") 76 | return 77 | } 78 | 79 | // create sessions 80 | manager.sessionsMu.Lock() 81 | for _, session := range sessions { 82 | manager.tokens[session.Token] = session.Id 83 | manager.sessions[session.Id] = &SessionCtx{ 84 | id: session.Id, 85 | token: session.Token, 86 | manager: manager, 87 | logger: manager.logger.With().Str("session_id", session.Id).Logger(), 88 | profile: session.Profile, 89 | } 90 | } 91 | manager.sessionsMu.Unlock() 92 | 93 | manager.logger.Info(). 94 | Int("sessions", len(sessions)). 95 | Str("file", manager.config.File). 96 | Msg("loaded sessions from a file") 97 | } 98 | -------------------------------------------------------------------------------- /internal/api/members/bluk.go: -------------------------------------------------------------------------------- 1 | package members 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/demodesk/neko/pkg/types" 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type MemberBulkUpdatePayload struct { 13 | IDs []string `json:"ids"` 14 | Profile types.MemberProfile `json:"profile"` 15 | } 16 | 17 | func (h *MembersHandler) membersBulkUpdate(w http.ResponseWriter, r *http.Request) error { 18 | bytes, err := io.ReadAll(r.Body) 19 | if err != nil { 20 | return utils.HttpBadRequest("unable to read post body").WithInternalErr(err) 21 | } 22 | 23 | header := &MemberBulkUpdatePayload{} 24 | if err := json.Unmarshal(bytes, &header); err != nil { 25 | return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err) 26 | } 27 | 28 | for _, memberId := range header.IDs { 29 | // TODO: Bulk select? 30 | profile, err := h.members.Select(memberId) 31 | if err != nil { 32 | return utils.HttpInternalServerError(). 33 | WithInternalErr(err). 34 | WithInternalMsg("unable to select member profile"). 35 | Msgf("failed to update member %s", memberId) 36 | } 37 | 38 | body := &MemberBulkUpdatePayload{ 39 | Profile: profile, 40 | } 41 | 42 | if err := json.Unmarshal(bytes, &body); err != nil { 43 | return utils.HttpBadRequest(). 44 | WithInternalErr(err). 45 | Msgf("unable to unmarshal payload for member %s", memberId) 46 | } 47 | 48 | if err := h.members.UpdateProfile(memberId, body.Profile); err != nil { 49 | return utils.HttpInternalServerError(). 50 | WithInternalErr(err). 51 | WithInternalMsg("unable to update member profile"). 52 | Msgf("failed to update member %s", memberId) 53 | } 54 | } 55 | 56 | return utils.HttpSuccess(w) 57 | } 58 | 59 | type MemberBulkDeletePayload struct { 60 | IDs []string `json:"ids"` 61 | } 62 | 63 | func (h *MembersHandler) membersBulkDelete(w http.ResponseWriter, r *http.Request) error { 64 | bytes, err := io.ReadAll(r.Body) 65 | if err != nil { 66 | return utils.HttpBadRequest("unable to read post body").WithInternalErr(err) 67 | } 68 | 69 | data := &MemberBulkDeletePayload{} 70 | if err := json.Unmarshal(bytes, &data); err != nil { 71 | return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err) 72 | } 73 | 74 | for _, memberId := range data.IDs { 75 | if err := h.members.Delete(memberId); err != nil { 76 | return utils.HttpInternalServerError(). 77 | WithInternalErr(err). 78 | WithInternalMsg("unable to delete member"). 79 | Msgf("failed to delete member %s", memberId) 80 | } 81 | } 82 | 83 | return utils.HttpSuccess(w) 84 | } 85 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/src/dummy_cursor.c: -------------------------------------------------------------------------------- 1 | #ifdef HAVE_CONFIG_H 2 | #include "config.h" 3 | #endif 4 | 5 | /* All drivers should typically include these */ 6 | #include "xf86.h" 7 | #include "xf86_OSproc.h" 8 | 9 | #include "xf86Cursor.h" 10 | #include "cursorstr.h" 11 | /* Driver specific headers */ 12 | #include "dummy.h" 13 | 14 | static void 15 | dummyShowCursor(ScrnInfoPtr pScrn) 16 | { 17 | DUMMYPtr dPtr = DUMMYPTR(pScrn); 18 | 19 | /* turn cursor on */ 20 | dPtr->DummyHWCursorShown = TRUE; 21 | } 22 | 23 | static void 24 | dummyHideCursor(ScrnInfoPtr pScrn) 25 | { 26 | DUMMYPtr dPtr = DUMMYPTR(pScrn); 27 | 28 | /* 29 | * turn cursor off 30 | * 31 | */ 32 | dPtr->DummyHWCursorShown = FALSE; 33 | } 34 | 35 | #define MAX_CURS 64 36 | 37 | static void 38 | dummySetCursorPosition(ScrnInfoPtr pScrn, int x, int y) 39 | { 40 | DUMMYPtr dPtr = DUMMYPTR(pScrn); 41 | 42 | /* unsigned char *_dest = ((unsigned char *)dPtr->FBBase + */ 43 | /* pScrn->videoRam * 1024 - 1024); */ 44 | dPtr->cursorX = x; 45 | dPtr->cursorY = y; 46 | } 47 | 48 | static void 49 | dummySetCursorColors(ScrnInfoPtr pScrn, int bg, int fg) 50 | { 51 | DUMMYPtr dPtr = DUMMYPTR(pScrn); 52 | 53 | dPtr->cursorFG = fg; 54 | dPtr->cursorBG = bg; 55 | } 56 | 57 | static void 58 | dummyLoadCursorImage(ScrnInfoPtr pScrn, unsigned char *src) 59 | { 60 | } 61 | 62 | static Bool 63 | dummyUseHWCursor(ScreenPtr pScr, CursorPtr pCurs) 64 | { 65 | DUMMYPtr dPtr = DUMMYPTR(xf86ScreenToScrn(pScr)); 66 | return(!dPtr->swCursor); 67 | } 68 | 69 | #if 0 70 | static unsigned char* 71 | dummyRealizeCursor(xf86CursorInfoPtr infoPtr, CursorPtr pCurs) 72 | { 73 | return NULL; 74 | } 75 | #endif 76 | 77 | Bool 78 | DUMMYCursorInit(ScreenPtr pScreen) 79 | { 80 | DUMMYPtr dPtr = DUMMYPTR(xf86ScreenToScrn(pScreen)); 81 | 82 | xf86CursorInfoPtr infoPtr; 83 | infoPtr = xf86CreateCursorInfoRec(); 84 | if(!infoPtr) return FALSE; 85 | 86 | dPtr->CursorInfo = infoPtr; 87 | 88 | infoPtr->MaxHeight = 64; 89 | infoPtr->MaxWidth = 64; 90 | infoPtr->Flags = HARDWARE_CURSOR_TRUECOLOR_AT_8BPP; 91 | 92 | infoPtr->SetCursorColors = dummySetCursorColors; 93 | infoPtr->SetCursorPosition = dummySetCursorPosition; 94 | infoPtr->LoadCursorImage = dummyLoadCursorImage; 95 | infoPtr->HideCursor = dummyHideCursor; 96 | infoPtr->ShowCursor = dummyShowCursor; 97 | infoPtr->UseHWCursor = dummyUseHWCursor; 98 | /* infoPtr->RealizeCursor = dummyRealizeCursor; */ 99 | 100 | return(xf86InitCursor(pScreen, infoPtr)); 101 | } 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /pkg/xevent/xevent.go: -------------------------------------------------------------------------------- 1 | package xevent 2 | 3 | /* 4 | #cgo LDFLAGS: -lX11 -lXfixes 5 | 6 | #include "xevent.h" 7 | */ 8 | import "C" 9 | 10 | import ( 11 | "strings" 12 | "unsafe" 13 | 14 | "github.com/kataras/go-events" 15 | ) 16 | 17 | var Emmiter events.EventEmmiter 18 | var Unminimize bool = false 19 | var FileChooserDialog bool = false 20 | var fileChooserDialogWindow uint32 = 0 21 | 22 | func init() { 23 | Emmiter = events.New() 24 | } 25 | 26 | func SetupErrorHandler() { 27 | C.XSetupErrorHandler() 28 | } 29 | 30 | func EventLoop(display string) { 31 | displayUnsafe := C.CString(display) 32 | defer C.free(unsafe.Pointer(displayUnsafe)) 33 | 34 | C.XEventLoop(displayUnsafe) 35 | } 36 | 37 | //export goXEventCursorChanged 38 | func goXEventCursorChanged(event C.XFixesCursorNotifyEvent) { 39 | Emmiter.Emit("cursor-changed", uint64(event.cursor_serial)) 40 | } 41 | 42 | //export goXEventClipboardUpdated 43 | func goXEventClipboardUpdated() { 44 | Emmiter.Emit("clipboard-updated") 45 | } 46 | 47 | //export goXEventConfigureNotify 48 | func goXEventConfigureNotify(display *C.Display, window C.Window, name *C.char, role *C.char) { 49 | if C.GoString(role) != "GtkFileChooserDialog" || !FileChooserDialog { 50 | return 51 | } 52 | 53 | // TODO: Refactor. Right now processing of this dialog relies on identifying 54 | // via its name. When that changes to role, this condition should be removed. 55 | if !strings.HasPrefix(C.GoString(name), "Open File") { 56 | return 57 | } 58 | 59 | C.XFileChooserHide(display, window) 60 | 61 | if fileChooserDialogWindow == 0 { 62 | fileChooserDialogWindow = uint32(window) 63 | Emmiter.Emit("file-chooser-dialog-opened") 64 | } 65 | } 66 | 67 | //export goXEventUnmapNotify 68 | func goXEventUnmapNotify(window C.Window) { 69 | if uint32(window) != fileChooserDialogWindow || !FileChooserDialog { 70 | return 71 | } 72 | 73 | fileChooserDialogWindow = 0 74 | Emmiter.Emit("file-chooser-dialog-closed") 75 | } 76 | 77 | //export goXEventWMChangeState 78 | func goXEventWMChangeState(display *C.Display, window C.Window, window_state C.ulong) { 79 | // if we just realized that window is minimized and we want it to be unminimized 80 | if window_state != C.NormalState && Unminimize { 81 | // we want to unmap and map the window to force it to redraw 82 | C.XUnmapWindow(display, window) 83 | C.XMapWindow(display, window) 84 | C.XFlush(display) 85 | } 86 | } 87 | 88 | //export goXEventError 89 | func goXEventError(event *C.XErrorEvent, message *C.char) { 90 | Emmiter.Emit("event-error", uint8(event.error_code), C.GoString(message), uint8(event.request_code), uint8(event.minor_code)) 91 | } 92 | 93 | //export goXEventActive 94 | func goXEventActive() C.int { 95 | return C.int(1) 96 | } 97 | -------------------------------------------------------------------------------- /internal/desktop/filechooserdialog.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | 7 | "github.com/demodesk/neko/pkg/xorg" 8 | ) 9 | 10 | // name of the window that is being controlled 11 | const fileChooserDialogName = "Open File" 12 | 13 | // short sleep value between fake user interactions 14 | const fileChooserDialogShortSleep = "0.2" 15 | 16 | // long sleep value between fake user interactions 17 | const fileChooserDialogLongSleep = "0.4" 18 | 19 | func (manager *DesktopManagerCtx) HandleFileChooserDialog(uri string) error { 20 | mu.Lock() 21 | defer mu.Unlock() 22 | 23 | // TODO: Use native API. 24 | err1 := exec.Command( 25 | "xdotool", 26 | "search", "--name", fileChooserDialogName, "windowfocus", 27 | "sleep", fileChooserDialogShortSleep, 28 | "key", "--clearmodifiers", "ctrl+l", 29 | "type", "--args", "1", uri+"//", 30 | "sleep", fileChooserDialogShortSleep, 31 | "key", "Delete", // remove autocomplete results 32 | "sleep", fileChooserDialogShortSleep, 33 | "key", "Return", 34 | "sleep", fileChooserDialogLongSleep, 35 | "key", "Down", 36 | "key", "--clearmodifiers", "ctrl+a", 37 | "key", "Return", 38 | "sleep", fileChooserDialogLongSleep, 39 | ).Run() 40 | 41 | if err1 != nil { 42 | return err1 43 | } 44 | 45 | // TODO: Use native API. 46 | err2 := exec.Command( 47 | "xdotool", 48 | "search", "--name", fileChooserDialogName, 49 | ).Run() 50 | 51 | // if last command didn't return error, consider dialog as still open 52 | if err2 == nil { 53 | return errors.New("unable to select files in dialog") 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (manager *DesktopManagerCtx) CloseFileChooserDialog() { 60 | for i := 0; i < 5; i++ { 61 | mu.Lock() 62 | 63 | manager.logger.Debug().Msg("attempting to close file chooser dialog") 64 | 65 | // TODO: Use native API. 66 | err := exec.Command( 67 | "xdotool", 68 | "search", "--name", fileChooserDialogName, "windowfocus", 69 | ).Run() 70 | 71 | if err != nil { 72 | mu.Unlock() 73 | manager.logger.Info().Msg("file chooser dialog is closed") 74 | return 75 | } 76 | 77 | // custom press Alt + F4 78 | // because xdotool is failing to send proper Alt+F4 79 | 80 | //nolint 81 | manager.KeyPress(xorg.XK_Alt_L, xorg.XK_F4) 82 | 83 | mu.Unlock() 84 | } 85 | } 86 | 87 | func (manager *DesktopManagerCtx) IsFileChooserDialogEnabled() bool { 88 | return manager.config.FileChooserDialog 89 | } 90 | 91 | func (manager *DesktopManagerCtx) IsFileChooserDialogOpened() bool { 92 | mu.Lock() 93 | defer mu.Unlock() 94 | 95 | // TODO: Use native API. 96 | err := exec.Command( 97 | "xdotool", 98 | "search", "--name", fileChooserDialogName, 99 | ).Run() 100 | 101 | return err == nil 102 | } 103 | -------------------------------------------------------------------------------- /internal/api/session.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/demodesk/neko/pkg/auth" 8 | "github.com/demodesk/neko/pkg/types" 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type SessionLoginPayload struct { 13 | Username string `json:"username"` 14 | Password string `json:"password"` 15 | } 16 | 17 | type SessionDataPayload struct { 18 | ID string `json:"id"` 19 | Token string `json:"token,omitempty"` 20 | Profile types.MemberProfile `json:"profile"` 21 | State types.SessionState `json:"state"` 22 | } 23 | 24 | func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error { 25 | data := &SessionLoginPayload{} 26 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 27 | return err 28 | } 29 | 30 | session, token, err := api.members.Login(data.Username, data.Password) 31 | if err != nil { 32 | if errors.Is(err, types.ErrSessionAlreadyConnected) { 33 | return utils.HttpUnprocessableEntity("session already connected") 34 | } else if errors.Is(err, types.ErrMemberDoesNotExist) || errors.Is(err, types.ErrMemberInvalidPassword) { 35 | return utils.HttpUnauthorized().WithInternalErr(err) 36 | } else { 37 | return utils.HttpInternalServerError().WithInternalErr(err) 38 | } 39 | } 40 | 41 | sessionData := SessionDataPayload{ 42 | ID: session.ID(), 43 | Profile: session.Profile(), 44 | State: session.State(), 45 | } 46 | 47 | if api.sessions.CookieEnabled() { 48 | api.sessions.CookieSetToken(w, token) 49 | } else { 50 | sessionData.Token = token 51 | } 52 | 53 | return utils.HttpSuccess(w, sessionData) 54 | } 55 | 56 | func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error { 57 | session, _ := auth.GetSession(r) 58 | 59 | err := api.members.Logout(session.ID()) 60 | if err != nil { 61 | if errors.Is(err, types.ErrSessionNotFound) { 62 | return utils.HttpBadRequest("session is not logged in") 63 | } else { 64 | return utils.HttpInternalServerError().WithInternalErr(err) 65 | } 66 | } 67 | 68 | if api.sessions.CookieEnabled() { 69 | api.sessions.CookieClearToken(w, r) 70 | } 71 | 72 | return utils.HttpSuccess(w, true) 73 | } 74 | 75 | func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error { 76 | session, _ := auth.GetSession(r) 77 | 78 | return utils.HttpSuccess(w, SessionDataPayload{ 79 | ID: session.ID(), 80 | Profile: session.Profile(), 81 | State: session.State(), 82 | }) 83 | } 84 | 85 | func (api *ApiManagerCtx) Sessions(w http.ResponseWriter, r *http.Request) error { 86 | sessions := []SessionDataPayload{} 87 | for _, session := range api.sessions.List() { 88 | sessions = append(sessions, SessionDataPayload{ 89 | ID: session.ID(), 90 | Profile: session.Profile(), 91 | State: session.State(), 92 | }) 93 | } 94 | 95 | return utils.HttpSuccess(w, sessions) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/xinput/xinput.go: -------------------------------------------------------------------------------- 1 | /* custom xf86 input driver communication protocol */ 2 | package xinput 3 | 4 | import ( 5 | "fmt" 6 | "net" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type driver struct { 12 | mu sync.Mutex 13 | socket string 14 | conn net.Conn 15 | 16 | debounceTouchIds map[uint32]time.Time 17 | } 18 | 19 | func NewDriver(socket string) Driver { 20 | return &driver{ 21 | socket: socket, 22 | 23 | debounceTouchIds: make(map[uint32]time.Time), 24 | } 25 | } 26 | 27 | func (d *driver) Connect() error { 28 | c, err := net.Dial("unix", d.socket) 29 | if err != nil { 30 | return err 31 | } 32 | d.conn = c 33 | return nil 34 | } 35 | 36 | func (d *driver) Close() error { 37 | return d.conn.Close() 38 | } 39 | 40 | func (d *driver) Debounce(duration time.Duration) { 41 | d.mu.Lock() 42 | defer d.mu.Unlock() 43 | 44 | t := time.Now() 45 | for touchId, start := range d.debounceTouchIds { 46 | if t.Sub(start) < duration { 47 | continue 48 | } 49 | 50 | msg := Message{ 51 | _type: XI_TouchEnd, 52 | touchId: touchId, 53 | x: -1, 54 | y: -1, 55 | } 56 | _, _ = d.conn.Write(msg.Pack()) 57 | delete(d.debounceTouchIds, touchId) 58 | } 59 | } 60 | 61 | func (d *driver) TouchBegin(touchId uint32, x, y int, pressure uint8) error { 62 | d.mu.Lock() 63 | defer d.mu.Unlock() 64 | 65 | if _, ok := d.debounceTouchIds[touchId]; ok { 66 | return fmt.Errorf("debounced touch id %v", touchId) 67 | } 68 | 69 | d.debounceTouchIds[touchId] = time.Now() 70 | 71 | msg := Message{ 72 | _type: XI_TouchBegin, 73 | touchId: touchId, 74 | x: int32(x), 75 | y: int32(y), 76 | pressure: pressure, 77 | } 78 | _, err := d.conn.Write(msg.Pack()) 79 | return err 80 | } 81 | 82 | func (d *driver) TouchUpdate(touchId uint32, x, y int, pressure uint8) error { 83 | d.mu.Lock() 84 | defer d.mu.Unlock() 85 | 86 | if _, ok := d.debounceTouchIds[touchId]; !ok { 87 | return fmt.Errorf("unknown touch id %v", touchId) 88 | } 89 | 90 | d.debounceTouchIds[touchId] = time.Now() 91 | 92 | msg := Message{ 93 | _type: XI_TouchUpdate, 94 | touchId: touchId, 95 | x: int32(x), 96 | y: int32(y), 97 | pressure: pressure, 98 | } 99 | _, err := d.conn.Write(msg.Pack()) 100 | return err 101 | } 102 | 103 | func (d *driver) TouchEnd(touchId uint32, x, y int, pressure uint8) error { 104 | d.mu.Lock() 105 | defer d.mu.Unlock() 106 | 107 | if _, ok := d.debounceTouchIds[touchId]; !ok { 108 | return fmt.Errorf("unknown touch id %v", touchId) 109 | } 110 | 111 | delete(d.debounceTouchIds, touchId) 112 | 113 | msg := Message{ 114 | _type: XI_TouchEnd, 115 | touchId: touchId, 116 | x: int32(x), 117 | y: int32(y), 118 | pressure: pressure, 119 | } 120 | _, err := d.conn.Write(msg.Pack()) 121 | return err 122 | } 123 | -------------------------------------------------------------------------------- /pkg/drop/drop.c: -------------------------------------------------------------------------------- 1 | #include "drop.h" 2 | 3 | GtkWidget *drag_widget = NULL; 4 | 5 | static void dragDataGet( 6 | GtkWidget *widget, 7 | GdkDragContext *context, 8 | GtkSelectionData *data, 9 | guint target_type, 10 | guint time, 11 | gpointer user_data 12 | ) { 13 | gchar **uris = (gchar **) user_data; 14 | 15 | if (target_type == DRAG_TARGET_TYPE_URI) { 16 | gtk_selection_data_set_uris(data, uris); 17 | return; 18 | } 19 | 20 | if (target_type == DRAG_TARGET_TYPE_TEXT) { 21 | gtk_selection_data_set_text(data, uris[0], -1); 22 | return; 23 | } 24 | } 25 | 26 | static void dragEnd( 27 | GtkWidget *widget, 28 | GdkDragContext *context, 29 | gpointer user_data 30 | ) { 31 | gboolean succeeded = gdk_drag_drop_succeeded(context); 32 | gtk_widget_destroy(widget); 33 | goDragFinish(succeeded); 34 | drag_widget = NULL; 35 | } 36 | 37 | void dragWindowOpen(char **uris) { 38 | if (drag_widget != NULL) dragWindowClose(); 39 | 40 | gtk_init(NULL, NULL); 41 | 42 | GtkWidget *widget = gtk_window_new(GTK_WINDOW_POPUP); 43 | GtkWindow *window = GTK_WINDOW(widget); 44 | 45 | gtk_window_move(window, 0, 0); 46 | gtk_window_set_title(window, "Neko Drag & Drop Window"); 47 | gtk_window_set_decorated(window, FALSE); 48 | gtk_window_set_keep_above(window, TRUE); 49 | gtk_window_set_default_size(window, 100, 100); 50 | 51 | GtkTargetList* target_list = gtk_target_list_new(NULL, 0); 52 | gtk_target_list_add_uri_targets(target_list, DRAG_TARGET_TYPE_URI); 53 | gtk_target_list_add_text_targets(target_list, DRAG_TARGET_TYPE_TEXT); 54 | 55 | gtk_drag_source_set(widget, GDK_BUTTON1_MASK, NULL, 0, GDK_ACTION_COPY | GDK_ACTION_LINK | GDK_ACTION_ASK); 56 | gtk_drag_source_set_target_list(widget, target_list); 57 | 58 | g_signal_connect(widget, "map-event", G_CALLBACK(goDragCreate), NULL); 59 | g_signal_connect(widget, "enter-notify-event", G_CALLBACK(goDragCursorEnter), NULL); 60 | g_signal_connect(widget, "button-press-event", G_CALLBACK(goDragButtonPress), NULL); 61 | g_signal_connect(widget, "drag-begin", G_CALLBACK(goDragBegin), NULL); 62 | 63 | g_signal_connect(widget, "drag-data-get", G_CALLBACK(dragDataGet), uris); 64 | g_signal_connect(widget, "drag-end", G_CALLBACK(dragEnd), NULL); 65 | g_signal_connect(window, "destroy", G_CALLBACK(gtk_main_quit), NULL); 66 | 67 | gtk_widget_show_all(widget); 68 | drag_widget = widget; 69 | 70 | gtk_main(); 71 | } 72 | 73 | void dragWindowClose() { 74 | gtk_widget_destroy(drag_widget); 75 | drag_widget = NULL; 76 | } 77 | 78 | char **dragUrisMake(int size) { 79 | return calloc(size + 1, sizeof(char *)); 80 | } 81 | 82 | void dragUrisSetFile(char **uris, char *file, int n) { 83 | GFile *gfile = g_file_new_for_path(file); 84 | uris[n] = g_file_get_uri(gfile); 85 | } 86 | 87 | void dragUrisFree(char **uris, int size) { 88 | for (int i = 0; i < size; i++) { 89 | free(uris[i]); 90 | } 91 | 92 | free(uris); 93 | } 94 | -------------------------------------------------------------------------------- /internal/websocket/handler/system.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | "github.com/rs/zerolog/log" 6 | 7 | "github.com/demodesk/neko/pkg/types" 8 | "github.com/demodesk/neko/pkg/types/event" 9 | "github.com/demodesk/neko/pkg/types/message" 10 | ) 11 | 12 | func (h *MessageHandlerCtx) systemInit(session types.Session) error { 13 | host, hasHost := h.sessions.GetHost() 14 | 15 | var hostID string 16 | if hasHost { 17 | hostID = host.ID() 18 | } 19 | 20 | controlHost := message.ControlHost{ 21 | HasHost: hasHost, 22 | HostID: hostID, 23 | } 24 | 25 | size := h.desktop.GetScreenSize() 26 | screenSize := message.ScreenSize{ 27 | Width: size.Width, 28 | Height: size.Height, 29 | Rate: size.Rate, 30 | } 31 | 32 | sessions := map[string]message.SessionData{} 33 | for _, session := range h.sessions.List() { 34 | sessionId := session.ID() 35 | sessions[sessionId] = message.SessionData{ 36 | ID: sessionId, 37 | Profile: session.Profile(), 38 | State: session.State(), 39 | } 40 | } 41 | 42 | session.Send( 43 | event.SYSTEM_INIT, 44 | message.SystemInit{ 45 | SessionId: session.ID(), 46 | ControlHost: controlHost, 47 | ScreenSize: screenSize, 48 | Sessions: sessions, 49 | Settings: h.sessions.Settings(), 50 | TouchEvents: h.desktop.HasTouchSupport(), 51 | ScreencastEnabled: h.capture.Screencast().Enabled(), 52 | WebRTC: message.SystemWebRTC{ 53 | Videos: h.capture.Video().IDs(), 54 | }, 55 | }) 56 | 57 | return nil 58 | } 59 | 60 | func (h *MessageHandlerCtx) systemAdmin(session types.Session) error { 61 | configurations := h.desktop.ScreenConfigurations() 62 | 63 | list := make([]message.ScreenSize, 0, len(configurations)) 64 | for _, conf := range configurations { 65 | list = append(list, message.ScreenSize{ 66 | Width: conf.Width, 67 | Height: conf.Height, 68 | Rate: conf.Rate, 69 | }) 70 | } 71 | 72 | broadcast := h.capture.Broadcast() 73 | session.Send( 74 | event.SYSTEM_ADMIN, 75 | message.SystemAdmin{ 76 | ScreenSizesList: list, // TODO: remove 77 | BroadcastStatus: message.BroadcastStatus{ 78 | IsActive: broadcast.Started(), 79 | URL: broadcast.Url(), 80 | }, 81 | }) 82 | 83 | return nil 84 | } 85 | 86 | func (h *MessageHandlerCtx) systemLogs(session types.Session, payload *message.SystemLogs) error { 87 | for _, msg := range *payload { 88 | level, _ := zerolog.ParseLevel(msg.Level) 89 | 90 | if level < zerolog.DebugLevel || level > zerolog.ErrorLevel { 91 | level = zerolog.NoLevel 92 | } 93 | 94 | // do not use handler logger context 95 | log.WithLevel(level). 96 | Fields(msg.Fields). 97 | Str("module", "client"). 98 | Str("session_id", session.ID()). 99 | Msg(msg.Message) 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/demodesk/neko 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/PaesslerAG/gval v1.2.2 7 | github.com/go-chi/chi v1.5.5 8 | github.com/go-chi/cors v1.2.1 9 | github.com/gorilla/websocket v1.5.1 10 | github.com/kataras/go-events v0.0.3 11 | github.com/pion/ice/v2 v2.3.12 12 | github.com/pion/interceptor v0.1.25 13 | github.com/pion/logging v0.2.2 14 | github.com/pion/rtcp v1.2.13 15 | github.com/pion/webrtc/v3 v3.2.24 16 | github.com/prometheus/client_golang v1.18.0 17 | github.com/rs/zerolog v1.31.0 18 | github.com/spf13/cobra v1.8.0 19 | github.com/spf13/viper v1.18.2 20 | ) 21 | 22 | require ( 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 26 | github.com/fsnotify/fsnotify v1.7.0 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/hashicorp/hcl v1.0.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/magiconair/properties v1.8.7 // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mitchellh/mapstructure v1.5.0 // indirect 34 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 35 | github.com/pion/datachannel v1.5.5 // indirect 36 | github.com/pion/dtls/v2 v2.2.9 // indirect 37 | github.com/pion/mdns v0.0.9 // indirect 38 | github.com/pion/randutil v0.1.0 // indirect 39 | github.com/pion/rtp v1.8.3 // indirect 40 | github.com/pion/sctp v1.8.9 // indirect 41 | github.com/pion/sdp/v3 v3.0.6 // indirect 42 | github.com/pion/srtp/v2 v2.0.18 // indirect 43 | github.com/pion/stun v0.6.1 // indirect 44 | github.com/pion/transport/v2 v2.2.4 // indirect 45 | github.com/pion/turn/v2 v2.1.4 // indirect 46 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 47 | github.com/prometheus/client_model v0.5.0 // indirect 48 | github.com/prometheus/common v0.46.0 // indirect 49 | github.com/prometheus/procfs v0.12.0 // indirect 50 | github.com/sagikazarmark/locafero v0.4.0 // indirect 51 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 52 | github.com/shopspring/decimal v1.3.1 // indirect 53 | github.com/sourcegraph/conc v0.3.0 // indirect 54 | github.com/spf13/afero v1.11.0 // indirect 55 | github.com/spf13/cast v1.6.0 // indirect 56 | github.com/spf13/pflag v1.0.5 // indirect 57 | github.com/stretchr/testify v1.8.4 // indirect 58 | github.com/subosito/gotenv v1.6.0 // indirect 59 | go.uber.org/multierr v1.11.0 // indirect 60 | golang.org/x/crypto v0.18.0 // indirect 61 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect 62 | golang.org/x/net v0.20.0 // indirect 63 | golang.org/x/sys v0.16.0 // indirect 64 | golang.org/x/text v0.14.0 // indirect 65 | google.golang.org/protobuf v1.32.0 // indirect 66 | gopkg.in/ini.v1 v1.67.0 // indirect 67 | gopkg.in/yaml.v3 v3.0.1 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /pkg/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/demodesk/neko/pkg/types" 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type key int 13 | 14 | const keySessionCtx key = iota 15 | 16 | func SetSession(r *http.Request, session types.Session) context.Context { 17 | return context.WithValue(r.Context(), keySessionCtx, session) 18 | } 19 | 20 | func GetSession(r *http.Request) (types.Session, bool) { 21 | session, ok := r.Context().Value(keySessionCtx).(types.Session) 22 | return session, ok 23 | } 24 | 25 | func AdminsOnly(w http.ResponseWriter, r *http.Request) (context.Context, error) { 26 | session, ok := GetSession(r) 27 | if !ok || !session.Profile().IsAdmin { 28 | return nil, utils.HttpForbidden("session is not admin") 29 | } 30 | 31 | return nil, nil 32 | } 33 | 34 | func HostsOnly(w http.ResponseWriter, r *http.Request) (context.Context, error) { 35 | session, ok := GetSession(r) 36 | if !ok || !session.IsHost() { 37 | return nil, utils.HttpForbidden("session is not host") 38 | } 39 | 40 | return nil, nil 41 | } 42 | 43 | func CanWatchOnly(w http.ResponseWriter, r *http.Request) (context.Context, error) { 44 | session, ok := GetSession(r) 45 | if !ok || !session.Profile().CanWatch { 46 | return nil, utils.HttpForbidden("session cannot watch") 47 | } 48 | 49 | return nil, nil 50 | } 51 | 52 | func CanHostOnly(w http.ResponseWriter, r *http.Request) (context.Context, error) { 53 | session, ok := GetSession(r) 54 | if !ok || !session.Profile().CanHost { 55 | return nil, utils.HttpForbidden("session cannot host") 56 | } 57 | 58 | if session.PrivateModeEnabled() { 59 | return nil, utils.HttpUnprocessableEntity("private mode is enabled") 60 | } 61 | 62 | return nil, nil 63 | } 64 | 65 | func CanAccessClipboardOnly(w http.ResponseWriter, r *http.Request) (context.Context, error) { 66 | session, ok := GetSession(r) 67 | if !ok || !session.Profile().CanAccessClipboard { 68 | return nil, utils.HttpForbidden("session cannot access clipboard") 69 | } 70 | 71 | return nil, nil 72 | } 73 | 74 | func PluginsGenericOnly[V comparable](key string, exp V) func(w http.ResponseWriter, r *http.Request) (context.Context, error) { 75 | return func(w http.ResponseWriter, r *http.Request) (context.Context, error) { 76 | session, ok := GetSession(r) 77 | if !ok { 78 | return nil, utils.HttpForbidden("session not found") 79 | } 80 | 81 | plugins := session.Profile().Plugins 82 | 83 | if plugins[key] == nil { 84 | return nil, utils.HttpForbidden(fmt.Sprintf("missing plugin permission: %s=%T", key, exp)) 85 | } 86 | 87 | val, ok := plugins[key].(V) 88 | if !ok { 89 | return nil, utils.HttpForbidden(fmt.Sprintf("invalid plugin permission type: %s=%T expected %T", key, plugins[key], exp)) 90 | } 91 | 92 | if val != exp { 93 | return nil, utils.HttpForbidden(fmt.Sprintf("wrong plugin permission value for %s=%T", key, exp)) 94 | } 95 | 96 | return nil, nil 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/config/root.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | type Root struct { 13 | Config string 14 | 15 | LogLevel zerolog.Level 16 | LogTime string 17 | LogJson bool 18 | LogNocolor bool 19 | LogDir string 20 | } 21 | 22 | func (Root) Init(cmd *cobra.Command) error { 23 | cmd.PersistentFlags().StringP("config", "c", "", "configuration file path") 24 | if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil { 25 | return err 26 | } 27 | 28 | // just a shortcut 29 | cmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode") 30 | if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil { 31 | return err 32 | } 33 | 34 | cmd.PersistentFlags().String("log.level", "info", "set log level (trace, debug, info, warn, error, fatal, panic, disabled)") 35 | if err := viper.BindPFlag("log.level", cmd.PersistentFlags().Lookup("log.level")); err != nil { 36 | return err 37 | } 38 | 39 | cmd.PersistentFlags().String("log.time", "unix", "time format used in logs (unix, unixms, unixmicro)") 40 | if err := viper.BindPFlag("log.time", cmd.PersistentFlags().Lookup("log.time")); err != nil { 41 | return err 42 | } 43 | 44 | cmd.PersistentFlags().Bool("log.json", false, "logs in JSON format") 45 | if err := viper.BindPFlag("log.json", cmd.PersistentFlags().Lookup("log.json")); err != nil { 46 | return err 47 | } 48 | 49 | cmd.PersistentFlags().Bool("log.nocolor", false, "no ANSI colors in non-JSON output") 50 | if err := viper.BindPFlag("log.nocolor", cmd.PersistentFlags().Lookup("log.nocolor")); err != nil { 51 | return err 52 | } 53 | 54 | cmd.PersistentFlags().String("log.dir", "", "logging directory to store logs") 55 | if err := viper.BindPFlag("log.dir", cmd.PersistentFlags().Lookup("log.dir")); err != nil { 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (s *Root) Set() { 63 | s.Config = viper.GetString("config") 64 | 65 | logLevel := viper.GetString("log.level") 66 | level, err := zerolog.ParseLevel(logLevel) 67 | if err != nil { 68 | log.Warn().Msgf("unknown log level %s", logLevel) 69 | } else { 70 | s.LogLevel = level 71 | } 72 | 73 | logTime := viper.GetString("log.time") 74 | switch logTime { 75 | case "unix": 76 | s.LogTime = zerolog.TimeFormatUnix 77 | case "unixms": 78 | s.LogTime = zerolog.TimeFormatUnixMs 79 | case "unixmicro": 80 | s.LogTime = zerolog.TimeFormatUnixMicro 81 | default: 82 | log.Warn().Msgf("unknown log time %s", logTime) 83 | } 84 | 85 | s.LogJson = viper.GetBool("log.json") 86 | s.LogNocolor = viper.GetBool("log.nocolor") 87 | s.LogDir = viper.GetString("log.dir") 88 | 89 | if viper.GetBool("debug") && s.LogLevel != zerolog.TraceLevel { 90 | s.LogLevel = zerolog.DebugLevel 91 | } 92 | 93 | // support for NO_COLOR env variable: https://no-color.org/ 94 | if os.Getenv("NO_COLOR") != "" { 95 | s.LogNocolor = true 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/types/desktop.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | ) 7 | 8 | type CursorImage struct { 9 | Width uint16 10 | Height uint16 11 | Xhot uint16 12 | Yhot uint16 13 | Serial uint64 14 | Image *image.RGBA 15 | } 16 | 17 | type ScreenSize struct { 18 | Width int 19 | Height int 20 | Rate int16 21 | } 22 | 23 | func (s ScreenSize) String() string { 24 | return fmt.Sprintf("%dx%d@%d", s.Width, s.Height, s.Rate) 25 | } 26 | 27 | type KeyboardModifiers struct { 28 | Shift *bool `json:"shift"` 29 | CapsLock *bool `json:"capslock"` 30 | Control *bool `json:"control"` 31 | Alt *bool `json:"alt"` 32 | NumLock *bool `json:"numlock"` 33 | Meta *bool `json:"meta"` 34 | Super *bool `json:"super"` 35 | AltGr *bool `json:"altgr"` 36 | } 37 | 38 | type KeyboardMap struct { 39 | Layout string `json:"layout"` 40 | Variant string `json:"variant"` 41 | } 42 | 43 | type ClipboardText struct { 44 | Text string 45 | HTML string 46 | } 47 | 48 | type DesktopManager interface { 49 | Start() 50 | Shutdown() error 51 | OnBeforeScreenSizeChange(listener func()) 52 | OnAfterScreenSizeChange(listener func()) 53 | 54 | // xorg 55 | Move(x, y int) 56 | GetCursorPosition() (int, int) 57 | Scroll(deltaX, deltaY int, controlKey bool) 58 | ButtonDown(code uint32) error 59 | KeyDown(code uint32) error 60 | ButtonUp(code uint32) error 61 | KeyUp(code uint32) error 62 | ButtonPress(code uint32) error 63 | KeyPress(codes ...uint32) error 64 | ResetKeys() 65 | ScreenConfigurations() []ScreenSize 66 | SetScreenSize(ScreenSize) (ScreenSize, error) 67 | GetScreenSize() ScreenSize 68 | SetKeyboardMap(KeyboardMap) error 69 | GetKeyboardMap() (*KeyboardMap, error) 70 | SetKeyboardModifiers(mod KeyboardModifiers) 71 | GetKeyboardModifiers() KeyboardModifiers 72 | GetCursorImage() *CursorImage 73 | GetScreenshotImage() *image.RGBA 74 | 75 | // xevent 76 | OnCursorChanged(listener func(serial uint64)) 77 | OnClipboardUpdated(listener func()) 78 | OnFileChooserDialogOpened(listener func()) 79 | OnFileChooserDialogClosed(listener func()) 80 | OnEventError(listener func(error_code uint8, message string, request_code uint8, minor_code uint8)) 81 | 82 | // input driver 83 | HasTouchSupport() bool 84 | TouchBegin(touchId uint32, x, y int, pressure uint8) error 85 | TouchUpdate(touchId uint32, x, y int, pressure uint8) error 86 | TouchEnd(touchId uint32, x, y int, pressure uint8) error 87 | 88 | // clipboard 89 | ClipboardGetText() (*ClipboardText, error) 90 | ClipboardSetText(data ClipboardText) error 91 | ClipboardGetBinary(mime string) ([]byte, error) 92 | ClipboardSetBinary(mime string, data []byte) error 93 | ClipboardGetTargets() ([]string, error) 94 | 95 | // drop 96 | DropFiles(x int, y int, files []string) bool 97 | IsUploadDropEnabled() bool 98 | 99 | // filechooser 100 | HandleFileChooserDialog(uri string) error 101 | CloseFileChooserDialog() 102 | IsFileChooserDialogEnabled() bool 103 | IsFileChooserDialogOpened() bool 104 | } 105 | -------------------------------------------------------------------------------- /internal/config/desktop.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strconv" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/demodesk/neko/pkg/types" 12 | ) 13 | 14 | type Desktop struct { 15 | Display string 16 | 17 | ScreenSize types.ScreenSize 18 | 19 | UseInputDriver bool 20 | InputSocket string 21 | 22 | Unminimize bool 23 | UploadDrop bool 24 | FileChooserDialog bool 25 | } 26 | 27 | func (Desktop) Init(cmd *cobra.Command) error { 28 | cmd.PersistentFlags().String("desktop.screen", "1280x720@30", "default screen size and framerate") 29 | if err := viper.BindPFlag("desktop.screen", cmd.PersistentFlags().Lookup("desktop.screen")); err != nil { 30 | return err 31 | } 32 | 33 | cmd.PersistentFlags().Bool("desktop.input.enabled", true, "whether custom xf86 input driver should be used to handle touchscreen") 34 | if err := viper.BindPFlag("desktop.input.enabled", cmd.PersistentFlags().Lookup("desktop.input.enabled")); err != nil { 35 | return err 36 | } 37 | 38 | cmd.PersistentFlags().String("desktop.input.socket", "/tmp/xf86-input-neko.sock", "socket path for custom xf86 input driver connection") 39 | if err := viper.BindPFlag("desktop.input.socket", cmd.PersistentFlags().Lookup("desktop.input.socket")); err != nil { 40 | return err 41 | } 42 | 43 | cmd.PersistentFlags().Bool("desktop.unminimize", true, "automatically unminimize window when it is minimized") 44 | if err := viper.BindPFlag("desktop.unminimize", cmd.PersistentFlags().Lookup("desktop.unminimize")); err != nil { 45 | return err 46 | } 47 | 48 | cmd.PersistentFlags().Bool("desktop.upload_drop", true, "whether drop upload is enabled") 49 | if err := viper.BindPFlag("desktop.upload_drop", cmd.PersistentFlags().Lookup("desktop.upload_drop")); err != nil { 50 | return err 51 | } 52 | 53 | cmd.PersistentFlags().Bool("desktop.file_chooser_dialog", false, "whether to handle file chooser dialog externally") 54 | if err := viper.BindPFlag("desktop.file_chooser_dialog", cmd.PersistentFlags().Lookup("desktop.file_chooser_dialog")); err != nil { 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (s *Desktop) Set() { 62 | // Display is provided by env variable 63 | s.Display = os.Getenv("DISPLAY") 64 | 65 | s.ScreenSize = types.ScreenSize{ 66 | Width: 1280, 67 | Height: 720, 68 | Rate: 30, 69 | } 70 | 71 | r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`) 72 | res := r.FindStringSubmatch(viper.GetString("desktop.screen")) 73 | 74 | if len(res) > 0 { 75 | width, err1 := strconv.ParseInt(res[1], 10, 64) 76 | height, err2 := strconv.ParseInt(res[2], 10, 64) 77 | rate, err3 := strconv.ParseInt(res[3], 10, 64) 78 | 79 | if err1 == nil && err2 == nil && err3 == nil { 80 | s.ScreenSize.Width = int(width) 81 | s.ScreenSize.Height = int(height) 82 | s.ScreenSize.Rate = int16(rate) 83 | } 84 | } 85 | 86 | s.UseInputDriver = viper.GetBool("desktop.input.enabled") 87 | s.InputSocket = viper.GetString("desktop.input.socket") 88 | s.Unminimize = viper.GetBool("desktop.unminimize") 89 | s.UploadDrop = viper.GetBool("desktop.upload_drop") 90 | s.FileChooserDialog = viper.GetBool("desktop.file_chooser_dialog") 91 | } 92 | -------------------------------------------------------------------------------- /internal/api/room/clipboard.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | // TODO: Unused now. 5 | //"bytes" 6 | //"strings" 7 | 8 | "net/http" 9 | 10 | "github.com/demodesk/neko/pkg/types" 11 | "github.com/demodesk/neko/pkg/utils" 12 | ) 13 | 14 | type ClipboardPayload struct { 15 | Text string `json:"text,omitempty"` 16 | HTML string `json:"html,omitempty"` 17 | } 18 | 19 | func (h *RoomHandler) clipboardGetText(w http.ResponseWriter, r *http.Request) error { 20 | data, err := h.desktop.ClipboardGetText() 21 | if err != nil { 22 | return utils.HttpInternalServerError().WithInternalErr(err) 23 | } 24 | 25 | return utils.HttpSuccess(w, ClipboardPayload{ 26 | Text: data.Text, 27 | HTML: data.HTML, 28 | }) 29 | } 30 | 31 | func (h *RoomHandler) clipboardSetText(w http.ResponseWriter, r *http.Request) error { 32 | data := &ClipboardPayload{} 33 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 34 | return err 35 | } 36 | 37 | err := h.desktop.ClipboardSetText(types.ClipboardText{ 38 | Text: data.Text, 39 | HTML: data.HTML, 40 | }) 41 | 42 | if err != nil { 43 | return utils.HttpInternalServerError().WithInternalErr(err) 44 | } 45 | 46 | return utils.HttpSuccess(w) 47 | } 48 | 49 | func (h *RoomHandler) clipboardGetImage(w http.ResponseWriter, r *http.Request) error { 50 | bytes, err := h.desktop.ClipboardGetBinary("image/png") 51 | if err != nil { 52 | return utils.HttpInternalServerError().WithInternalErr(err) 53 | } 54 | 55 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 56 | w.Header().Set("Content-Type", "image/png") 57 | 58 | _, err = w.Write(bytes) 59 | return err 60 | } 61 | 62 | /* TODO: Unused now. 63 | func (h *RoomHandler) clipboardSetImage(w http.ResponseWriter, r *http.Request) error { 64 | err := r.ParseMultipartForm(MAX_UPLOAD_SIZE) 65 | if err != nil { 66 | return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err) 67 | } 68 | 69 | //nolint 70 | defer r.MultipartForm.RemoveAll() 71 | 72 | file, header, err := r.FormFile("file") 73 | if err != nil { 74 | return utils.HttpBadRequest("no file received").WithInternalErr(err) 75 | } 76 | 77 | defer file.Close() 78 | 79 | mime := header.Header.Get("Content-Type") 80 | if !strings.HasPrefix(mime, "image/") { 81 | return utils.HttpBadRequest("file must be image") 82 | } 83 | 84 | buffer := new(bytes.Buffer) 85 | _, err = buffer.ReadFrom(file) 86 | if err != nil { 87 | return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable to read from uploaded file") 88 | } 89 | 90 | err = h.desktop.ClipboardSetBinary("image/png", buffer.Bytes()) 91 | if err != nil { 92 | return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable set image to clipboard") 93 | } 94 | 95 | return utils.HttpSuccess(w) 96 | } 97 | 98 | func (h *RoomHandler) clipboardGetTargets(w http.ResponseWriter, r *http.Request) error { 99 | targets, err := h.desktop.ClipboardGetTargets() 100 | if err != nil { 101 | return utils.HttpInternalServerError().WithInternalErr(err) 102 | } 103 | 104 | return utils.HttpSuccess(w, targets) 105 | } 106 | 107 | */ 108 | -------------------------------------------------------------------------------- /internal/http/batch.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/demodesk/neko/pkg/types" 11 | "github.com/demodesk/neko/pkg/utils" 12 | ) 13 | 14 | type BatchRequest struct { 15 | Path string `json:"path"` 16 | Method string `json:"method"` 17 | Body json.RawMessage `json:"body,omitempty"` 18 | } 19 | 20 | type BatchResponse struct { 21 | Path string `json:"path"` 22 | Method string `json:"method"` 23 | Body json.RawMessage `json:"body,omitempty"` 24 | Status int `json:"status"` 25 | } 26 | 27 | func (b *BatchResponse) Error(httpErr *utils.HTTPError) (err error) { 28 | b.Body, err = json.Marshal(httpErr) 29 | b.Status = httpErr.Code 30 | return 31 | } 32 | 33 | type batchHandler struct { 34 | Router types.Router 35 | PathPrefix string 36 | Excluded []string 37 | } 38 | 39 | func (b *batchHandler) Handle(w http.ResponseWriter, r *http.Request) error { 40 | var requests []BatchRequest 41 | if err := json.NewDecoder(r.Body).Decode(&requests); err != nil { 42 | return err 43 | } 44 | 45 | responses := make([]BatchResponse, len(requests)) 46 | for i, request := range requests { 47 | res := BatchResponse{ 48 | Path: request.Path, 49 | Method: request.Method, 50 | } 51 | 52 | if !strings.HasPrefix(request.Path, b.PathPrefix) { 53 | res.Error(utils.HttpBadRequest("this path is not allowed in batch requests")) 54 | responses[i] = res 55 | continue 56 | } 57 | 58 | if exists, _ := utils.ArrayIn(request.Path, b.Excluded); exists { 59 | res.Error(utils.HttpBadRequest("this path is excluded from batch requests")) 60 | responses[i] = res 61 | continue 62 | } 63 | 64 | // prepare request 65 | req, err := http.NewRequest(request.Method, request.Path, bytes.NewBuffer(request.Body)) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | // copy headers 71 | for k, vv := range r.Header { 72 | for _, v := range vv { 73 | req.Header.Add(k, v) 74 | } 75 | } 76 | 77 | // execute request 78 | rr := newResponseRecorder() 79 | b.Router.ServeHTTP(rr, req) 80 | 81 | // read response 82 | body, err := io.ReadAll(rr.Body) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | // write response 88 | responses[i] = BatchResponse{ 89 | Path: request.Path, 90 | Method: request.Method, 91 | Body: body, 92 | Status: rr.Code, 93 | } 94 | } 95 | 96 | return utils.HttpSuccess(w, responses) 97 | } 98 | 99 | type responseRecorder struct { 100 | Code int 101 | HeaderMap http.Header 102 | Body *bytes.Buffer 103 | } 104 | 105 | func newResponseRecorder() *responseRecorder { 106 | return &responseRecorder{ 107 | Code: http.StatusOK, 108 | HeaderMap: make(http.Header), 109 | Body: new(bytes.Buffer), 110 | } 111 | } 112 | 113 | func (w *responseRecorder) Header() http.Header { 114 | return w.HeaderMap 115 | } 116 | 117 | func (w *responseRecorder) Write(b []byte) (int, error) { 118 | return w.Body.Write(b) 119 | } 120 | 121 | func (w *responseRecorder) WriteHeader(code int) { 122 | w.Code = code 123 | } 124 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/configure.ac: -------------------------------------------------------------------------------- 1 | # Copyright 2005 Adam Jackson. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # on the rights to use, copy, modify, merge, publish, distribute, sub 7 | # license, and/or sell copies of the Software, and to permit persons to whom 8 | # the Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice (including the next 11 | # paragraph) shall be included in all copies or substantial portions of the 12 | # Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 17 | # ADAM JACKSON BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | # 21 | # Process this file with autoconf to produce a configure script 22 | 23 | # Initialize Autoconf 24 | AC_PREREQ([2.60]) 25 | AC_INIT([xf86-video-dummy], 26 | [0.3.8], 27 | [https://bugs.freedesktop.org/enter_bug.cgi?product=xorg], 28 | [xf86-video-dummy]) 29 | AC_CONFIG_SRCDIR([Makefile.am]) 30 | AC_CONFIG_HEADERS([config.h]) 31 | AC_CONFIG_AUX_DIR(.) 32 | 33 | # Initialize Automake 34 | AM_INIT_AUTOMAKE([foreign dist-bzip2]) 35 | 36 | # Require xorg-macros: XORG_DEFAULT_OPTIONS 37 | m4_ifndef([XORG_MACROS_VERSION], 38 | [m4_fatal([must install xorg-macros 1.3 or later before running autoconf/autogen])]) 39 | XORG_MACROS_VERSION(1.3) 40 | XORG_DEFAULT_OPTIONS 41 | 42 | # Initialize libtool 43 | AC_DISABLE_STATIC 44 | AC_PROG_LIBTOOL 45 | 46 | AH_TOP([#include "xorg-server.h"]) 47 | 48 | # Define a configure option for an alternate module directory 49 | AC_ARG_ENABLE(dga, AS_HELP_STRING([--disable-dga], [Build DGA extension (default: yes)]), [DGA=$enableval], [DGA=yes]) 50 | AC_ARG_WITH(xorg-module-dir, [ --with-xorg-module-dir=DIR ], 51 | [ moduledir="$withval" ], 52 | [ moduledir="$libdir/xorg/modules" ]) 53 | AC_SUBST(moduledir) 54 | 55 | 56 | # Store the list of server defined optional extensions in REQUIRED_MODULES 57 | XORG_DRIVER_CHECK_EXT(RANDR, randrproto) 58 | XORG_DRIVER_CHECK_EXT(RENDER, renderproto) 59 | XORG_DRIVER_CHECK_EXT(XV, videoproto) 60 | 61 | if test "x$DGA" = xyes; then 62 | XORG_DRIVER_CHECK_EXT(XFreeXDGA, xf86dgaproto) 63 | AC_DEFINE(USE_DGA, 1, [Support DGA extension]) 64 | fi 65 | AC_SUBST([DGA]) 66 | AM_CONDITIONAL([DGA], [test "x$DGA" = xyes]) 67 | 68 | # Obtain compiler/linker options for the driver dependencies 69 | PKG_CHECK_MODULES(XORG, [xorg-server >= 1.4.99.901] xproto fontsproto $REQUIRED_MODULES) 70 | 71 | # Checks for libraries. 72 | 73 | 74 | DRIVER_NAME=dummy 75 | AC_SUBST([DRIVER_NAME]) 76 | 77 | AC_CONFIG_FILES([ 78 | Makefile 79 | src/Makefile 80 | ]) 81 | AC_OUTPUT 82 | -------------------------------------------------------------------------------- /internal/desktop/clipboard.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/demodesk/neko/pkg/types" 10 | "github.com/demodesk/neko/pkg/xevent" 11 | ) 12 | 13 | func (manager *DesktopManagerCtx) ClipboardGetText() (*types.ClipboardText, error) { 14 | text, err := manager.ClipboardGetBinary("STRING") 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | // Rich text must not always be available, can fail silently. 20 | html, _ := manager.ClipboardGetBinary("text/html") 21 | 22 | return &types.ClipboardText{ 23 | Text: string(text), 24 | HTML: string(html), 25 | }, nil 26 | } 27 | 28 | func (manager *DesktopManagerCtx) ClipboardSetText(data types.ClipboardText) error { 29 | // TODO: Refactor. 30 | // Current implementation is unable to set multiple targets. HTML 31 | // is set, if available. Otherwise plain text. 32 | 33 | if data.HTML != "" { 34 | return manager.ClipboardSetBinary("text/html", []byte(data.HTML)) 35 | } 36 | 37 | return manager.ClipboardSetBinary("STRING", []byte(data.Text)) 38 | } 39 | 40 | func (manager *DesktopManagerCtx) ClipboardGetBinary(mime string) ([]byte, error) { 41 | cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", mime) 42 | 43 | var stdout, stderr bytes.Buffer 44 | cmd.Stdout = &stdout 45 | cmd.Stderr = &stderr 46 | 47 | err := cmd.Run() 48 | if err != nil { 49 | msg := strings.TrimSpace(stderr.String()) 50 | return nil, fmt.Errorf("%s", msg) 51 | } 52 | 53 | return stdout.Bytes(), nil 54 | } 55 | 56 | func (manager *DesktopManagerCtx) ClipboardSetBinary(mime string, data []byte) error { 57 | cmd := exec.Command("xclip", "-selection", "clipboard", "-in", "-target", mime) 58 | 59 | var stderr bytes.Buffer 60 | cmd.Stderr = &stderr 61 | 62 | stdin, err := cmd.StdinPipe() 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // TODO: Refactor. 68 | // We need to wait until the data came to the clipboard. 69 | wait := make(chan struct{}) 70 | xevent.Emmiter.Once("clipboard-updated", func(payload ...any) { 71 | wait <- struct{}{} 72 | }) 73 | 74 | err = cmd.Start() 75 | if err != nil { 76 | msg := strings.TrimSpace(stderr.String()) 77 | return fmt.Errorf("%s", msg) 78 | } 79 | 80 | _, err = stdin.Write(data) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | stdin.Close() 86 | 87 | // TODO: Refactor. 88 | // cmd.Wait() 89 | <-wait 90 | 91 | return nil 92 | } 93 | 94 | func (manager *DesktopManagerCtx) ClipboardGetTargets() ([]string, error) { 95 | cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", "TARGETS") 96 | 97 | var stdout, stderr bytes.Buffer 98 | cmd.Stdout = &stdout 99 | cmd.Stderr = &stderr 100 | 101 | err := cmd.Run() 102 | if err != nil { 103 | msg := strings.TrimSpace(stderr.String()) 104 | return nil, fmt.Errorf("%s", msg) 105 | } 106 | 107 | var response []string 108 | targets := strings.Split(stdout.String(), "\n") 109 | for _, target := range targets { 110 | if target == "" { 111 | continue 112 | } 113 | 114 | if !strings.Contains(target, "/") { 115 | continue 116 | } 117 | 118 | response = append(response, target) 119 | } 120 | 121 | return response, nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/member/object/provider.go: -------------------------------------------------------------------------------- 1 | package object 2 | 3 | import ( 4 | "github.com/demodesk/neko/pkg/types" 5 | ) 6 | 7 | func New(config Config) types.MemberProvider { 8 | return &MemberProviderCtx{ 9 | config: config, 10 | entries: make(map[string]*memberEntry), 11 | } 12 | } 13 | 14 | type MemberProviderCtx struct { 15 | config Config 16 | entries map[string]*memberEntry 17 | } 18 | 19 | func (provider *MemberProviderCtx) Connect() error { 20 | var err error 21 | 22 | for _, entry := range provider.config.Users { 23 | _, err = provider.Insert(entry.Username, entry.Password, entry.Profile) 24 | } 25 | 26 | return err 27 | } 28 | 29 | func (provider *MemberProviderCtx) Disconnect() error { 30 | return nil 31 | } 32 | 33 | func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) { 34 | // id will be also username 35 | id := username 36 | 37 | entry, ok := provider.entries[id] 38 | if !ok { 39 | return "", types.MemberProfile{}, types.ErrMemberDoesNotExist 40 | } 41 | 42 | // TODO: Use hash function. 43 | if !entry.CheckPassword(password) { 44 | return "", types.MemberProfile{}, types.ErrMemberInvalidPassword 45 | } 46 | 47 | return id, entry.profile, nil 48 | } 49 | 50 | func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) { 51 | // id will be also username 52 | id := username 53 | 54 | _, ok := provider.entries[id] 55 | if ok { 56 | return "", types.ErrMemberAlreadyExists 57 | } 58 | 59 | provider.entries[id] = &memberEntry{ 60 | // TODO: Use hash function. 61 | password: password, 62 | profile: profile, 63 | } 64 | 65 | return id, nil 66 | } 67 | 68 | func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error { 69 | entry, ok := provider.entries[id] 70 | if !ok { 71 | return types.ErrMemberDoesNotExist 72 | } 73 | 74 | entry.profile = profile 75 | 76 | return nil 77 | } 78 | 79 | func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error { 80 | entry, ok := provider.entries[id] 81 | if !ok { 82 | return types.ErrMemberDoesNotExist 83 | } 84 | 85 | // TODO: Use hash function. 86 | entry.password = password 87 | 88 | return nil 89 | } 90 | 91 | func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) { 92 | entry, ok := provider.entries[id] 93 | if !ok { 94 | return types.MemberProfile{}, types.ErrMemberDoesNotExist 95 | } 96 | 97 | return entry.profile, nil 98 | } 99 | 100 | func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) { 101 | profiles := make(map[string]types.MemberProfile) 102 | 103 | i := 0 104 | for id, entry := range provider.entries { 105 | if i >= offset && (limit == 0 || i < offset+limit) { 106 | profiles[id] = entry.profile 107 | } 108 | 109 | i = i + 1 110 | } 111 | 112 | return profiles, nil 113 | } 114 | 115 | func (provider *MemberProviderCtx) Delete(id string) error { 116 | _, ok := provider.entries[id] 117 | if !ok { 118 | return types.ErrMemberDoesNotExist 119 | } 120 | 121 | delete(provider.entries, id) 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /dev/runtime/config.nvidia.yml: -------------------------------------------------------------------------------- 1 | capture: 2 | video: 3 | codec: h264 4 | ids: 5 | - nvh264enc 6 | - x264enc 7 | pipelines: 8 | nvh264enc: 9 | fps: 25 10 | bitrate: 2 11 | #gst_prefix: "! cudaupload ! cudaconvert ! video/x-raw(memory:CUDAMemory),format=NV12" 12 | gst_prefix: "! video/x-raw,format=NV12" 13 | gst_encoder: "nvh264enc" 14 | gst_params: 15 | bitrate: 3000 16 | rc-mode: 5 # Low-Delay CBR, High Quality 17 | preset: 5 # Low Latency, High Performance 18 | zerolatency: true 19 | gop-size: 25 20 | gst_suffix: "! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline" 21 | x264enc: 22 | fps: 25 23 | bitrate: 1 24 | gst_prefix: "! video/x-raw,format=I420" 25 | gst_encoder: "x264enc" 26 | gst_params: 27 | threads: 4 28 | bitrate: 4096 29 | key-int-max: 25 30 | byte-stream: true 31 | tune: zerolatency 32 | speed-preset: veryfast 33 | gst_suffix: "! video/x-h264,stream-format=byte-stream,profile=constrained-baseline" 34 | 35 | server: 36 | pprof: true 37 | 38 | desktop: 39 | screen: "1920x1080@60" 40 | 41 | member: 42 | provider: "object" 43 | object: 44 | users: 45 | - username: "admin" 46 | password: "admin" 47 | profile: 48 | name: "Administrator" 49 | is_admin: true 50 | can_login: true 51 | can_connect: true 52 | can_watch: true 53 | can_host: true 54 | can_share_media: true 55 | can_access_clipboard: true 56 | sends_inactive_cursor: true 57 | can_see_inactive_cursors: true 58 | - username: "user" 59 | password: "neko" 60 | profile: 61 | name: "User" 62 | is_admin: false 63 | can_login: true 64 | can_connect: true 65 | can_watch: true 66 | can_host: true 67 | can_share_media: true 68 | can_access_clipboard: true 69 | sends_inactive_cursor: true 70 | can_see_inactive_cursors: false 71 | # provider: "file" 72 | # file: 73 | # path: "/home/neko/members.json" 74 | # provider: "multiuser" 75 | # multiuser: 76 | # admin_password: "admin" 77 | # user_password: "neko" 78 | # provider: "noauth" 79 | 80 | session: 81 | # Allows reconnecting the websocket even if the previous 82 | # connection was not closed. Can lead to session hijacking. 83 | merciful_reconnect: true 84 | # Show inactive cursors on the screen. Can lead to multiple 85 | # data sent via WebSockets and additonal rendering cost on 86 | # the clients. 87 | inactive_cursors: true 88 | api_token: "neko123" 89 | cookie: 90 | # Disabling cookies will result to use Bearer Authentication. 91 | # This is less secure, because access token will be sent to 92 | # client in playload and accessible via JS app. 93 | enabled: false 94 | secure: false 95 | 96 | webrtc: 97 | icelite: true 98 | iceservers: 99 | - urls: [ stun:stun.l.google.com:19302 ] 100 | # username: foo 101 | # credential: bar 102 | -------------------------------------------------------------------------------- /internal/plugins/dependency.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rs/zerolog" 7 | 8 | "github.com/demodesk/neko/pkg/types" 9 | ) 10 | 11 | type dependency struct { 12 | plugin types.Plugin 13 | dependsOn []*dependency 14 | invoked bool 15 | logger zerolog.Logger 16 | } 17 | 18 | func (a *dependency) findPlugin(name string) (*dependency, bool) { 19 | if a == nil { 20 | return nil, false 21 | } 22 | 23 | if a.plugin.Name() == name { 24 | return a, true 25 | } 26 | 27 | for _, dep := range a.dependsOn { 28 | plug, ok := dep.findPlugin(name) 29 | if ok { 30 | return plug, true 31 | } 32 | } 33 | 34 | return nil, false 35 | } 36 | 37 | func (a *dependency) startPlugin(pm types.PluginManagers) error { 38 | if a.invoked { 39 | return nil 40 | } 41 | 42 | a.invoked = true 43 | 44 | for _, do := range a.dependsOn { 45 | if err := do.startPlugin(pm); err != nil { 46 | return fmt.Errorf("plugin's '%s' dependency: %w", a.plugin.Name(), err) 47 | } 48 | } 49 | 50 | err := a.plugin.Start(pm) 51 | if err != nil { 52 | return fmt.Errorf("plugin '%s' failed to start: %w", a.plugin.Name(), err) 53 | } 54 | 55 | a.logger.Info().Str("plugin", a.plugin.Name()).Msg("plugin started") 56 | return nil 57 | } 58 | 59 | type dependiencies struct { 60 | deps map[string]*dependency 61 | logger zerolog.Logger 62 | } 63 | 64 | func (d *dependiencies) addPlugin(plugin types.Plugin) error { 65 | pluginName := plugin.Name() 66 | 67 | plug, ok := d.deps[pluginName] 68 | if !ok { 69 | plug = &dependency{} 70 | } else if plug.plugin != nil { 71 | return fmt.Errorf("plugin '%s' already added", pluginName) 72 | } 73 | 74 | plug.plugin = plugin 75 | plug.logger = d.logger 76 | d.deps[pluginName] = plug 77 | 78 | dplug, ok := plugin.(types.DependablePlugin) 79 | if !ok { 80 | return nil 81 | } 82 | 83 | for _, depName := range dplug.DependsOn() { 84 | dependsOn, ok := d.deps[depName] 85 | if !ok { 86 | dependsOn = &dependency{} 87 | } else if dependsOn.plugin != nil { 88 | // if there is a cyclical dependency, break it and return error 89 | if tdep, ok := dependsOn.findPlugin(pluginName); ok { 90 | dependsOn.dependsOn = nil 91 | delete(d.deps, pluginName) 92 | return fmt.Errorf("cyclical dependency detected: '%s' <-> '%s'", pluginName, tdep.plugin.Name()) 93 | } 94 | } 95 | 96 | plug.dependsOn = append(plug.dependsOn, dependsOn) 97 | d.deps[depName] = dependsOn 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (d *dependiencies) findPlugin(name string) (*dependency, bool) { 104 | for _, dep := range d.deps { 105 | plug, ok := dep.findPlugin(name) 106 | if ok { 107 | return plug, true 108 | } 109 | } 110 | return nil, false 111 | } 112 | 113 | func (d *dependiencies) start(pm types.PluginManagers) error { 114 | for _, dep := range d.deps { 115 | if err := dep.startPlugin(pm); err != nil { 116 | return err 117 | } 118 | } 119 | return nil 120 | } 121 | 122 | func (d *dependiencies) forEach(f func(*dependency) error) error { 123 | for _, dep := range d.deps { 124 | if err := f(dep); err != nil { 125 | return err 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | func (d *dependiencies) len() int { 132 | return len(d.deps) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/types/session.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | var ( 10 | ErrSessionNotFound = errors.New("session not found") 11 | ErrSessionAlreadyExists = errors.New("session already exists") 12 | ErrSessionAlreadyConnected = errors.New("session is already connected") 13 | ErrSessionLoginDisabled = errors.New("session login disabled") 14 | ) 15 | 16 | type Cursor struct { 17 | X int `json:"x"` 18 | Y int `json:"y"` 19 | } 20 | 21 | type SessionProfile struct { 22 | Id string 23 | Token string 24 | Profile MemberProfile 25 | } 26 | 27 | type SessionState struct { 28 | IsConnected bool `json:"is_connected"` 29 | // when the session was last connected 30 | ConnectedSince *time.Time `json:"connected_since,omitempty"` 31 | // when the session was last not connected 32 | NotConnectedSince *time.Time `json:"not_connected_since,omitempty"` 33 | 34 | IsWatching bool `json:"is_watching"` 35 | // when the session was last watching 36 | WatchingSince *time.Time `json:"watching_since,omitempty"` 37 | // when the session was last not watching 38 | NotWatchingSince *time.Time `json:"not_watching_since,omitempty"` 39 | } 40 | 41 | type Settings struct { 42 | PrivateMode bool `json:"private_mode"` 43 | LockedControls bool `json:"locked_controls"` 44 | ImplicitHosting bool `json:"implicit_hosting"` 45 | InactiveCursors bool `json:"inactive_cursors"` 46 | MercifulReconnect bool `json:"merciful_reconnect"` 47 | 48 | // plugin scope 49 | Plugins map[string]any `json:"plugins"` 50 | } 51 | 52 | type Session interface { 53 | ID() string 54 | Profile() MemberProfile 55 | State() SessionState 56 | IsHost() bool 57 | PrivateModeEnabled() bool 58 | 59 | // cursor 60 | SetCursor(cursor Cursor) 61 | 62 | // websocket 63 | ConnectWebSocketPeer(websocketPeer WebSocketPeer) 64 | DisconnectWebSocketPeer(websocketPeer WebSocketPeer, delayed bool) 65 | DestroyWebSocketPeer(reason string) 66 | Send(event string, payload any) 67 | 68 | // webrtc 69 | SetWebRTCPeer(webrtcPeer WebRTCPeer) 70 | SetWebRTCConnected(webrtcPeer WebRTCPeer, connected bool) 71 | GetWebRTCPeer() WebRTCPeer 72 | } 73 | 74 | type SessionManager interface { 75 | Create(id string, profile MemberProfile) (Session, string, error) 76 | Update(id string, profile MemberProfile) error 77 | Delete(id string) error 78 | Get(id string) (Session, bool) 79 | GetByToken(token string) (Session, bool) 80 | List() []Session 81 | 82 | SetHost(host Session) 83 | GetHost() (Session, bool) 84 | ClearHost() 85 | 86 | SetCursor(cursor Cursor, session Session) 87 | PopCursors() map[Session][]Cursor 88 | 89 | Broadcast(event string, payload any, exclude ...string) 90 | AdminBroadcast(event string, payload any, exclude ...string) 91 | InactiveCursorsBroadcast(event string, payload any, exclude ...string) 92 | 93 | OnCreated(listener func(session Session)) 94 | OnDeleted(listener func(session Session)) 95 | OnConnected(listener func(session Session)) 96 | OnDisconnected(listener func(session Session)) 97 | OnProfileChanged(listener func(session Session)) 98 | OnStateChanged(listener func(session Session)) 99 | OnHostChanged(listener func(session Session)) 100 | OnSettingsChanged(listener func(new Settings, old Settings)) 101 | 102 | UpdateSettings(Settings) 103 | Settings() Settings 104 | CookieEnabled() bool 105 | 106 | CookieSetToken(w http.ResponseWriter, token string) 107 | CookieClearToken(w http.ResponseWriter, r *http.Request) 108 | Authenticate(r *http.Request) (Session, error) 109 | } 110 | -------------------------------------------------------------------------------- /pkg/utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func HttpJsonRequest(w http.ResponseWriter, r *http.Request, res any) error { 13 | err := json.NewDecoder(r.Body).Decode(res) 14 | 15 | if err == nil { 16 | return nil 17 | } 18 | 19 | if err == io.EOF { 20 | return HttpBadRequest("no data provided").WithInternalErr(err) 21 | } 22 | 23 | return HttpBadRequest("unable to parse provided data").WithInternalErr(err) 24 | } 25 | 26 | func HttpJsonResponse(w http.ResponseWriter, code int, res any) { 27 | w.Header().Set("Content-Type", "application/json") 28 | w.WriteHeader(code) 29 | 30 | if err := json.NewEncoder(w).Encode(res); err != nil { 31 | log.Err(err).Str("module", "http").Msg("sending http json response failed") 32 | } 33 | } 34 | 35 | func HttpSuccess(w http.ResponseWriter, res ...any) error { 36 | if len(res) == 0 { 37 | w.WriteHeader(http.StatusNoContent) 38 | } else { 39 | HttpJsonResponse(w, http.StatusOK, res[0]) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | // HTTPError is an error with a message and an HTTP status code. 46 | type HTTPError struct { 47 | Code int `json:"code"` 48 | Message string `json:"message"` 49 | 50 | InternalErr error `json:"-"` 51 | InternalMsg string `json:"-"` 52 | } 53 | 54 | func (e *HTTPError) Error() string { 55 | if e.InternalMsg != "" { 56 | return e.InternalMsg 57 | } 58 | return fmt.Sprintf("%d: %s", e.Code, e.Message) 59 | } 60 | 61 | func (e *HTTPError) Cause() error { 62 | if e.InternalErr != nil { 63 | return e.InternalErr 64 | } 65 | return e 66 | } 67 | 68 | // WithInternalErr adds internal error information to the error 69 | func (e *HTTPError) WithInternalErr(err error) *HTTPError { 70 | e.InternalErr = err 71 | return e 72 | } 73 | 74 | // WithInternalMsg adds internal message information to the error 75 | func (e *HTTPError) WithInternalMsg(msg string) *HTTPError { 76 | e.InternalMsg = msg 77 | return e 78 | } 79 | 80 | // WithInternalMsg adds internal formated message information to the error 81 | func (e *HTTPError) WithInternalMsgf(fmtStr string, args ...any) *HTTPError { 82 | e.InternalMsg = fmt.Sprintf(fmtStr, args...) 83 | return e 84 | } 85 | 86 | // Sends error with custom formated message 87 | func (e *HTTPError) Msgf(fmtSt string, args ...any) *HTTPError { 88 | e.Message = fmt.Sprintf(fmtSt, args...) 89 | return e 90 | } 91 | 92 | // Sends error with custom message 93 | func (e *HTTPError) Msg(str string) *HTTPError { 94 | e.Message = str 95 | return e 96 | } 97 | 98 | func HttpError(code int, res ...string) *HTTPError { 99 | err := &HTTPError{ 100 | Code: code, 101 | Message: http.StatusText(code), 102 | } 103 | 104 | if len(res) == 1 { 105 | err.Message = res[0] 106 | } 107 | 108 | return err 109 | } 110 | 111 | func HttpBadRequest(res ...string) *HTTPError { 112 | return HttpError(http.StatusBadRequest, res...) 113 | } 114 | 115 | func HttpUnauthorized(res ...string) *HTTPError { 116 | return HttpError(http.StatusUnauthorized, res...) 117 | } 118 | 119 | func HttpForbidden(res ...string) *HTTPError { 120 | return HttpError(http.StatusForbidden, res...) 121 | } 122 | 123 | func HttpNotFound(res ...string) *HTTPError { 124 | return HttpError(http.StatusNotFound, res...) 125 | } 126 | 127 | func HttpUnprocessableEntity(res ...string) *HTTPError { 128 | return HttpError(http.StatusUnprocessableEntity, res...) 129 | } 130 | 131 | func HttpInternalServerError(res ...string) *HTTPError { 132 | return HttpError(http.StatusInternalServerError, res...) 133 | } 134 | -------------------------------------------------------------------------------- /internal/http/logger.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/go-chi/chi/middleware" 9 | "github.com/rs/zerolog" 10 | 11 | "github.com/demodesk/neko/pkg/types" 12 | "github.com/demodesk/neko/pkg/utils" 13 | ) 14 | 15 | type logFormatter struct { 16 | logger zerolog.Logger 17 | } 18 | 19 | func (l *logFormatter) NewLogEntry(r *http.Request) middleware.LogEntry { 20 | // exclude health & metrics from logs 21 | if r.RequestURI == "/health" || r.RequestURI == "/metrics" { 22 | return &nulllog{} 23 | } 24 | 25 | req := map[string]any{} 26 | 27 | if reqID := middleware.GetReqID(r.Context()); reqID != "" { 28 | req["id"] = reqID 29 | } 30 | 31 | scheme := "http" 32 | if r.TLS != nil { 33 | scheme = "https" 34 | } 35 | 36 | req["scheme"] = scheme 37 | req["proto"] = r.Proto 38 | req["method"] = r.Method 39 | req["remote"] = r.RemoteAddr 40 | req["agent"] = r.UserAgent() 41 | req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) 42 | 43 | return &logEntry{ 44 | logger: l.logger.With().Interface("req", req).Logger(), 45 | } 46 | } 47 | 48 | type logEntry struct { 49 | logger zerolog.Logger 50 | err error 51 | panic *logPanic 52 | session types.Session 53 | } 54 | 55 | type logPanic struct { 56 | message string 57 | stack string 58 | } 59 | 60 | func (e *logEntry) Panic(v any, stack []byte) { 61 | e.panic = &logPanic{ 62 | message: fmt.Sprintf("%+v", v), 63 | stack: string(stack), 64 | } 65 | } 66 | 67 | func (e *logEntry) Error(err error) { 68 | e.err = err 69 | } 70 | 71 | func (e *logEntry) SetSession(session types.Session) { 72 | e.session = session 73 | } 74 | 75 | func (e *logEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) { 76 | res := map[string]any{} 77 | res["time"] = time.Now().UTC().Format(time.RFC1123) 78 | res["status"] = status 79 | res["bytes"] = bytes 80 | res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0 81 | 82 | logger := e.logger.With().Interface("res", res).Logger() 83 | 84 | // add session ID to logs (if exists) 85 | if e.session != nil { 86 | logger = logger.With().Str("session_id", e.session.ID()).Logger() 87 | } 88 | 89 | // handle panic error message 90 | if e.panic != nil { 91 | logger.WithLevel(zerolog.PanicLevel). 92 | Err(e.err). 93 | Str("stack", e.panic.stack). 94 | Msgf("request failed (%d): %s", status, e.panic.message) 95 | return 96 | } 97 | 98 | // handle panic error message 99 | if e.err != nil { 100 | httpErr, ok := e.err.(*utils.HTTPError) 101 | if !ok { 102 | logger.Err(e.err).Msgf("request failed (%d)", status) 103 | return 104 | } 105 | 106 | if httpErr.Message == "" { 107 | httpErr.Message = http.StatusText(httpErr.Code) 108 | } 109 | 110 | var logLevel zerolog.Level 111 | if httpErr.Code < 500 { 112 | logLevel = zerolog.WarnLevel 113 | } else { 114 | logLevel = zerolog.ErrorLevel 115 | } 116 | 117 | message := httpErr.Message 118 | if httpErr.InternalMsg != "" { 119 | message = httpErr.InternalMsg 120 | } 121 | 122 | logger.WithLevel(logLevel).Err(httpErr.InternalErr).Msgf("request failed (%d): %s", status, message) 123 | return 124 | } 125 | 126 | logger.Debug().Msgf("request complete (%d)", status) 127 | } 128 | 129 | type nulllog struct{} 130 | 131 | func (e *nulllog) Panic(v any, stack []byte) {} 132 | func (e *nulllog) Error(err error) {} 133 | func (e *nulllog) SetSession(session types.Session) {} 134 | func (e *nulllog) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) { 135 | } 136 | -------------------------------------------------------------------------------- /internal/http/manager.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | 12 | "github.com/demodesk/neko/internal/config" 13 | "github.com/demodesk/neko/pkg/types" 14 | ) 15 | 16 | type HttpManagerCtx struct { 17 | logger zerolog.Logger 18 | config *config.Server 19 | router types.Router 20 | http *http.Server 21 | } 22 | 23 | func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx { 24 | logger := log.With().Str("module", "http").Logger() 25 | 26 | opts := []RouterOption{ 27 | WithRequestID(), // create a request id for each request 28 | } 29 | 30 | // use real ip if behind proxy 31 | // before logger so it can log the real ip 32 | if config.Proxy { 33 | opts = append(opts, WithRealIP()) 34 | } 35 | 36 | opts = append(opts, 37 | WithLogger(logger), 38 | WithRecoverer(), // recover from panics without crashing server 39 | ) 40 | 41 | if config.HasCors() { 42 | opts = append(opts, WithCORS(config.AllowOrigin)) 43 | } 44 | 45 | if config.PathPrefix != "/" { 46 | opts = append(opts, WithPathPrefix(config.PathPrefix)) 47 | } 48 | 49 | router := newRouter(opts...) 50 | 51 | router.Route("/api", ApiManager.Route) 52 | 53 | router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool { 54 | return config.AllowOrigin(r.Header.Get("Origin")) 55 | })) 56 | 57 | batch := batchHandler{ 58 | Router: router, 59 | PathPrefix: "/api", 60 | Excluded: []string{ 61 | "/api/batch", // do not allow batchception 62 | "/api/ws", 63 | }, 64 | } 65 | router.Post("/api/batch", batch.Handle) 66 | 67 | router.Get("/health", func(w http.ResponseWriter, r *http.Request) error { 68 | _, err := w.Write([]byte("true")) 69 | return err 70 | }) 71 | 72 | if config.Metrics { 73 | router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) error { 74 | promhttp.Handler().ServeHTTP(w, r) 75 | return nil 76 | }) 77 | } 78 | 79 | if config.Static != "" { 80 | fs := http.FileServer(http.Dir(config.Static)) 81 | router.Get("/*", func(w http.ResponseWriter, r *http.Request) error { 82 | _, err := os.Stat(config.Static + r.URL.Path) 83 | if err == nil { 84 | fs.ServeHTTP(w, r) 85 | return nil 86 | } 87 | if os.IsNotExist(err) { 88 | http.NotFound(w, r) 89 | return nil 90 | } 91 | return err 92 | }) 93 | } 94 | 95 | if config.PProf { 96 | pprofHandler(router) 97 | } 98 | 99 | return &HttpManagerCtx{ 100 | logger: logger, 101 | config: config, 102 | router: router, 103 | http: &http.Server{ 104 | Addr: config.Bind, 105 | Handler: router, 106 | }, 107 | } 108 | } 109 | 110 | func (manager *HttpManagerCtx) Start() { 111 | if manager.config.Cert != "" && manager.config.Key != "" { 112 | go func() { 113 | if err := manager.http.ListenAndServeTLS(manager.config.Cert, manager.config.Key); err != http.ErrServerClosed { 114 | manager.logger.Panic().Err(err).Msg("unable to start https server") 115 | } 116 | }() 117 | manager.logger.Info().Msgf("https listening on %s", manager.http.Addr) 118 | } else { 119 | go func() { 120 | if err := manager.http.ListenAndServe(); err != http.ErrServerClosed { 121 | manager.logger.Panic().Err(err).Msg("unable to start http server") 122 | } 123 | }() 124 | manager.logger.Info().Msgf("http listening on %s", manager.http.Addr) 125 | } 126 | } 127 | 128 | func (manager *HttpManagerCtx) Shutdown() error { 129 | manager.logger.Info().Msg("shutdown") 130 | 131 | return manager.http.Shutdown(context.Background()) 132 | } 133 | -------------------------------------------------------------------------------- /internal/api/room/screen.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/demodesk/neko/pkg/auth" 8 | "github.com/demodesk/neko/pkg/types" 9 | "github.com/demodesk/neko/pkg/types/event" 10 | "github.com/demodesk/neko/pkg/types/message" 11 | "github.com/demodesk/neko/pkg/utils" 12 | ) 13 | 14 | type ScreenConfigurationPayload struct { 15 | Width int `json:"width"` 16 | Height int `json:"height"` 17 | Rate int16 `json:"rate"` 18 | } 19 | 20 | func (h *RoomHandler) screenConfiguration(w http.ResponseWriter, r *http.Request) error { 21 | size := h.desktop.GetScreenSize() 22 | 23 | return utils.HttpSuccess(w, ScreenConfigurationPayload{ 24 | Width: size.Width, 25 | Height: size.Height, 26 | Rate: size.Rate, 27 | }) 28 | } 29 | 30 | func (h *RoomHandler) screenConfigurationChange(w http.ResponseWriter, r *http.Request) error { 31 | data := &ScreenConfigurationPayload{} 32 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 33 | return err 34 | } 35 | 36 | size, err := h.desktop.SetScreenSize(types.ScreenSize{ 37 | Width: data.Width, 38 | Height: data.Height, 39 | Rate: data.Rate, 40 | }) 41 | 42 | if err != nil { 43 | return utils.HttpUnprocessableEntity("cannot set screen size").WithInternalErr(err) 44 | } 45 | 46 | h.sessions.Broadcast(event.SCREEN_UPDATED, message.ScreenSize{ 47 | Width: size.Width, 48 | Height: size.Height, 49 | Rate: size.Rate, 50 | }) 51 | 52 | return utils.HttpSuccess(w, data) 53 | } 54 | 55 | // TODO: remove. 56 | func (h *RoomHandler) screenConfigurationsList(w http.ResponseWriter, r *http.Request) error { 57 | configurations := h.desktop.ScreenConfigurations() 58 | 59 | list := make([]ScreenConfigurationPayload, 0, len(configurations)) 60 | for _, conf := range configurations { 61 | list = append(list, ScreenConfigurationPayload{ 62 | Width: conf.Width, 63 | Height: conf.Height, 64 | Rate: conf.Rate, 65 | }) 66 | } 67 | 68 | return utils.HttpSuccess(w, list) 69 | } 70 | 71 | func (h *RoomHandler) screenShotGet(w http.ResponseWriter, r *http.Request) error { 72 | quality, err := strconv.Atoi(r.URL.Query().Get("quality")) 73 | if err != nil { 74 | quality = 90 75 | } 76 | 77 | img := h.desktop.GetScreenshotImage() 78 | bytes, err := utils.CreateJPGImage(img, quality) 79 | if err != nil { 80 | return utils.HttpInternalServerError().WithInternalErr(err) 81 | } 82 | 83 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 84 | w.Header().Set("Content-Type", "image/jpeg") 85 | 86 | _, err = w.Write(bytes) 87 | return err 88 | } 89 | 90 | func (h *RoomHandler) screenCastGet(w http.ResponseWriter, r *http.Request) error { 91 | // display fallback image when private mode is enabled even if screencast is not 92 | if session, ok := auth.GetSession(r); ok && session.PrivateModeEnabled() { 93 | if h.privateModeImage != nil { 94 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 95 | w.Header().Set("Content-Type", "image/jpeg") 96 | 97 | _, err := w.Write(h.privateModeImage) 98 | return err 99 | } 100 | 101 | return utils.HttpBadRequest("private mode is enabled but no fallback image available") 102 | } 103 | 104 | screencast := h.capture.Screencast() 105 | if !screencast.Enabled() { 106 | return utils.HttpBadRequest("screencast pipeline is not enabled") 107 | } 108 | 109 | bytes, err := screencast.Image() 110 | if err != nil { 111 | return utils.HttpInternalServerError().WithInternalErr(err) 112 | } 113 | 114 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 115 | w.Header().Set("Content-Type", "image/jpeg") 116 | 117 | _, err = w.Write(bytes) 118 | return err 119 | } 120 | -------------------------------------------------------------------------------- /internal/config/server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type Server struct { 13 | Cert string 14 | Key string 15 | Bind string 16 | Proxy bool 17 | Static string 18 | PathPrefix string 19 | PProf bool 20 | Metrics bool 21 | CORS []string 22 | } 23 | 24 | func (Server) Init(cmd *cobra.Command) error { 25 | cmd.PersistentFlags().String("server.bind", "127.0.0.1:8080", "address/port/socket to serve neko") 26 | if err := viper.BindPFlag("server.bind", cmd.PersistentFlags().Lookup("server.bind")); err != nil { 27 | return err 28 | } 29 | 30 | cmd.PersistentFlags().String("server.cert", "", "path to the SSL cert used to secure the neko server") 31 | if err := viper.BindPFlag("server.cert", cmd.PersistentFlags().Lookup("server.cert")); err != nil { 32 | return err 33 | } 34 | 35 | cmd.PersistentFlags().String("server.key", "", "path to the SSL key used to secure the neko server") 36 | if err := viper.BindPFlag("server.key", cmd.PersistentFlags().Lookup("server.key")); err != nil { 37 | return err 38 | } 39 | 40 | cmd.PersistentFlags().Bool("server.proxy", false, "trust reverse proxy headers") 41 | if err := viper.BindPFlag("server.proxy", cmd.PersistentFlags().Lookup("server.proxy")); err != nil { 42 | return err 43 | } 44 | 45 | cmd.PersistentFlags().String("server.static", "", "path to neko client files to serve") 46 | if err := viper.BindPFlag("server.static", cmd.PersistentFlags().Lookup("server.static")); err != nil { 47 | return err 48 | } 49 | 50 | cmd.PersistentFlags().String("server.path_prefix", "/", "path prefix for HTTP requests") 51 | if err := viper.BindPFlag("server.path_prefix", cmd.PersistentFlags().Lookup("server.path_prefix")); err != nil { 52 | return err 53 | } 54 | 55 | cmd.PersistentFlags().Bool("server.pprof", false, "enable pprof endpoint available at /debug/pprof") 56 | if err := viper.BindPFlag("server.pprof", cmd.PersistentFlags().Lookup("server.pprof")); err != nil { 57 | return err 58 | } 59 | 60 | cmd.PersistentFlags().Bool("server.metrics", true, "enable prometheus metrics available at /metrics") 61 | if err := viper.BindPFlag("server.metrics", cmd.PersistentFlags().Lookup("server.metrics")); err != nil { 62 | return err 63 | } 64 | 65 | cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed") 66 | if err := viper.BindPFlag("server.cors", cmd.PersistentFlags().Lookup("server.cors")); err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (s *Server) Set() { 74 | s.Cert = viper.GetString("server.cert") 75 | s.Key = viper.GetString("server.key") 76 | s.Bind = viper.GetString("server.bind") 77 | s.Proxy = viper.GetBool("server.proxy") 78 | s.Static = viper.GetString("server.static") 79 | s.PathPrefix = path.Join("/", path.Clean(viper.GetString("server.path_prefix"))) 80 | s.PProf = viper.GetBool("server.pprof") 81 | s.Metrics = viper.GetBool("server.metrics") 82 | 83 | s.CORS = viper.GetStringSlice("server.cors") 84 | in, _ := utils.ArrayIn("*", s.CORS) 85 | if len(s.CORS) == 0 || in { 86 | s.CORS = []string{"*"} 87 | } 88 | } 89 | 90 | func (s *Server) HasCors() bool { 91 | return len(s.CORS) > 0 92 | } 93 | 94 | func (s *Server) AllowOrigin(origin string) bool { 95 | // if CORS is disabled, allow all origins 96 | if len(s.CORS) == 0 { 97 | return true 98 | } 99 | 100 | // if CORS is enabled, allow only origins in the list 101 | in, _ := utils.ArrayIn(origin, s.CORS) 102 | return in || s.CORS[0] == "*" 103 | } 104 | -------------------------------------------------------------------------------- /internal/capture/broadcast.go: -------------------------------------------------------------------------------- 1 | package capture 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/demodesk/neko/pkg/gst" 12 | "github.com/demodesk/neko/pkg/types" 13 | ) 14 | 15 | type BroacastManagerCtx struct { 16 | logger zerolog.Logger 17 | mu sync.Mutex 18 | 19 | pipeline gst.Pipeline 20 | pipelineMu sync.Mutex 21 | pipelineFn func(url string) (string, error) 22 | 23 | url string 24 | started bool 25 | 26 | // metrics 27 | pipelinesCounter prometheus.Counter 28 | pipelinesActive prometheus.Gauge 29 | } 30 | 31 | func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string) *BroacastManagerCtx { 32 | logger := log.With(). 33 | Str("module", "capture"). 34 | Str("submodule", "broadcast"). 35 | Logger() 36 | 37 | return &BroacastManagerCtx{ 38 | logger: logger, 39 | pipelineFn: pipelineFn, 40 | url: defaultUrl, 41 | started: defaultUrl != "", 42 | 43 | // metrics 44 | pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{ 45 | Name: "pipelines_total", 46 | Namespace: "neko", 47 | Subsystem: "capture", 48 | Help: "Total number of created pipelines.", 49 | ConstLabels: map[string]string{ 50 | "submodule": "broadcast", 51 | "video_id": "main", 52 | "codec_name": "-", 53 | "codec_type": "-", 54 | }, 55 | }), 56 | pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{ 57 | Name: "pipelines_active", 58 | Namespace: "neko", 59 | Subsystem: "capture", 60 | Help: "Total number of active pipelines.", 61 | ConstLabels: map[string]string{ 62 | "submodule": "broadcast", 63 | "video_id": "main", 64 | "codec_name": "-", 65 | "codec_type": "-", 66 | }, 67 | }), 68 | } 69 | } 70 | 71 | func (manager *BroacastManagerCtx) shutdown() { 72 | manager.logger.Info().Msgf("shutdown") 73 | 74 | manager.destroyPipeline() 75 | } 76 | 77 | func (manager *BroacastManagerCtx) Start(url string) error { 78 | manager.mu.Lock() 79 | defer manager.mu.Unlock() 80 | 81 | err := manager.createPipeline() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | manager.url = url 87 | manager.started = true 88 | return nil 89 | } 90 | 91 | func (manager *BroacastManagerCtx) Stop() { 92 | manager.mu.Lock() 93 | defer manager.mu.Unlock() 94 | 95 | manager.started = false 96 | manager.destroyPipeline() 97 | } 98 | 99 | func (manager *BroacastManagerCtx) Started() bool { 100 | manager.mu.Lock() 101 | defer manager.mu.Unlock() 102 | 103 | return manager.started 104 | } 105 | 106 | func (manager *BroacastManagerCtx) Url() string { 107 | manager.mu.Lock() 108 | defer manager.mu.Unlock() 109 | 110 | return manager.url 111 | } 112 | 113 | func (manager *BroacastManagerCtx) createPipeline() error { 114 | manager.pipelineMu.Lock() 115 | defer manager.pipelineMu.Unlock() 116 | 117 | if manager.pipeline != nil { 118 | return types.ErrCapturePipelineAlreadyExists 119 | } 120 | 121 | pipelineStr, err := manager.pipelineFn(manager.url) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | manager.logger.Info(). 127 | Str("url", manager.url). 128 | Str("src", pipelineStr). 129 | Msgf("starting pipeline") 130 | 131 | manager.pipeline, err = gst.CreatePipeline(pipelineStr) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | manager.pipeline.Play() 137 | manager.pipelinesCounter.Inc() 138 | manager.pipelinesActive.Set(1) 139 | 140 | return nil 141 | } 142 | 143 | func (manager *BroacastManagerCtx) destroyPipeline() { 144 | manager.pipelineMu.Lock() 145 | defer manager.pipelineMu.Unlock() 146 | 147 | if manager.pipeline == nil { 148 | return 149 | } 150 | 151 | manager.pipeline.Destroy() 152 | manager.logger.Info().Msgf("destroying pipeline") 153 | manager.pipeline = nil 154 | 155 | manager.pipelinesActive.Set(0) 156 | } 157 | -------------------------------------------------------------------------------- /internal/desktop/manager.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/kataras/go-events" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/demodesk/neko/internal/config" 12 | "github.com/demodesk/neko/pkg/types" 13 | "github.com/demodesk/neko/pkg/xevent" 14 | "github.com/demodesk/neko/pkg/xinput" 15 | "github.com/demodesk/neko/pkg/xorg" 16 | ) 17 | 18 | var mu = sync.Mutex{} 19 | 20 | type DesktopManagerCtx struct { 21 | logger zerolog.Logger 22 | wg sync.WaitGroup 23 | shutdown chan struct{} 24 | emmiter events.EventEmmiter 25 | config *config.Desktop 26 | screenSize types.ScreenSize // cached screen size 27 | input xinput.Driver 28 | } 29 | 30 | func New(config *config.Desktop) *DesktopManagerCtx { 31 | var input xinput.Driver 32 | if config.UseInputDriver { 33 | input = xinput.NewDriver(config.InputSocket) 34 | } else { 35 | input = xinput.NewDummy() 36 | } 37 | 38 | return &DesktopManagerCtx{ 39 | logger: log.With().Str("module", "desktop").Logger(), 40 | shutdown: make(chan struct{}), 41 | emmiter: events.New(), 42 | config: config, 43 | screenSize: config.ScreenSize, 44 | input: input, 45 | } 46 | } 47 | 48 | func (manager *DesktopManagerCtx) Start() { 49 | if xorg.DisplayOpen(manager.config.Display) { 50 | manager.logger.Panic().Str("display", manager.config.Display).Msg("unable to open display") 51 | } 52 | 53 | // X11 can throw errors below, and the default error handler exits 54 | xevent.SetupErrorHandler() 55 | 56 | xorg.GetScreenConfigurations() 57 | 58 | screenSize, err := xorg.ChangeScreenSize(manager.config.ScreenSize) 59 | if err != nil { 60 | manager.logger.Err(err). 61 | Str("screen_size", screenSize.String()). 62 | Msgf("unable to set initial screen size") 63 | } else { 64 | // cache screen size 65 | manager.screenSize = screenSize 66 | manager.logger.Info(). 67 | Str("screen_size", screenSize.String()). 68 | Msgf("setting initial screen size") 69 | } 70 | 71 | err = manager.input.Connect() 72 | if err != nil { 73 | // TODO: fail silently to dummy driver? 74 | manager.logger.Panic().Err(err).Msg("unable to connect to input driver") 75 | } 76 | 77 | // set up event listeners 78 | xevent.Unminimize = manager.config.Unminimize 79 | xevent.FileChooserDialog = manager.config.FileChooserDialog 80 | go xevent.EventLoop(manager.config.Display) 81 | 82 | // in case it was opened 83 | if manager.config.FileChooserDialog { 84 | go manager.CloseFileChooserDialog() 85 | } 86 | 87 | manager.OnEventError(func(error_code uint8, message string, request_code uint8, minor_code uint8) { 88 | manager.logger.Warn(). 89 | Uint8("error_code", error_code). 90 | Str("message", message). 91 | Uint8("request_code", request_code). 92 | Uint8("minor_code", minor_code). 93 | Msg("X event error occured") 94 | }) 95 | 96 | manager.wg.Add(1) 97 | 98 | go func() { 99 | defer manager.wg.Done() 100 | 101 | ticker := time.NewTicker(1 * time.Second) 102 | defer ticker.Stop() 103 | 104 | const debounceDuration = 10 * time.Second 105 | 106 | for { 107 | select { 108 | case <-manager.shutdown: 109 | return 110 | case <-ticker.C: 111 | xorg.CheckKeys(debounceDuration) 112 | manager.input.Debounce(debounceDuration) 113 | } 114 | } 115 | }() 116 | } 117 | 118 | func (manager *DesktopManagerCtx) OnBeforeScreenSizeChange(listener func()) { 119 | manager.emmiter.On("before_screen_size_change", func(payload ...any) { 120 | listener() 121 | }) 122 | } 123 | 124 | func (manager *DesktopManagerCtx) OnAfterScreenSizeChange(listener func()) { 125 | manager.emmiter.On("after_screen_size_change", func(payload ...any) { 126 | listener() 127 | }) 128 | } 129 | 130 | func (manager *DesktopManagerCtx) Shutdown() error { 131 | manager.logger.Info().Msgf("shutdown") 132 | 133 | close(manager.shutdown) 134 | manager.wg.Wait() 135 | 136 | xorg.DisplayClose() 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /pkg/utils/trenddetector.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/livekit/livekit/blob/master/pkg/sfu/streamallocator/trenddetector.go 2 | package utils 3 | 4 | import ( 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // ------------------------------------------------ 10 | 11 | type TrendDirection int 12 | 13 | const ( 14 | TrendDirectionNeutral TrendDirection = iota 15 | TrendDirectionUpward 16 | TrendDirectionDownward 17 | ) 18 | 19 | func (t TrendDirection) String() string { 20 | switch t { 21 | case TrendDirectionNeutral: 22 | return "NEUTRAL" 23 | case TrendDirectionUpward: 24 | return "UPWARD" 25 | case TrendDirectionDownward: 26 | return "DOWNWARD" 27 | default: 28 | return fmt.Sprintf("%d", int(t)) 29 | } 30 | } 31 | 32 | // ------------------------------------------------ 33 | 34 | type TrendDetectorParams struct { 35 | RequiredSamples int 36 | DownwardTrendThreshold float64 37 | CollapseValues bool 38 | } 39 | 40 | type TrendDetector struct { 41 | params TrendDetectorParams 42 | 43 | startTime time.Time 44 | numSamples int 45 | values []int64 46 | lowestValue int64 47 | highestValue int64 48 | 49 | direction TrendDirection 50 | } 51 | 52 | func NewTrendDetector(params TrendDetectorParams) *TrendDetector { 53 | return &TrendDetector{ 54 | params: params, 55 | startTime: time.Now(), 56 | direction: TrendDirectionNeutral, 57 | } 58 | } 59 | 60 | func (t *TrendDetector) Seed(value int64) { 61 | if len(t.values) != 0 { 62 | return 63 | } 64 | 65 | t.values = append(t.values, value) 66 | } 67 | 68 | func (t *TrendDetector) AddValue(value int64) { 69 | t.numSamples++ 70 | if t.lowestValue == 0 || value < t.lowestValue { 71 | t.lowestValue = value 72 | } 73 | if value > t.highestValue { 74 | t.highestValue = value 75 | } 76 | 77 | // ignore duplicate values 78 | if t.params.CollapseValues && len(t.values) != 0 && t.values[len(t.values)-1] == value { 79 | return 80 | } 81 | 82 | if len(t.values) == t.params.RequiredSamples { 83 | t.values = t.values[1:] 84 | } 85 | t.values = append(t.values, value) 86 | 87 | t.updateDirection() 88 | } 89 | 90 | func (t *TrendDetector) GetLowest() int64 { 91 | return t.lowestValue 92 | } 93 | 94 | func (t *TrendDetector) GetHighest() int64 { 95 | return t.highestValue 96 | } 97 | 98 | func (t *TrendDetector) GetValues() []int64 { 99 | return t.values 100 | } 101 | 102 | func (t *TrendDetector) GetDirection() TrendDirection { 103 | return t.direction 104 | } 105 | 106 | func (t *TrendDetector) ToString() string { 107 | now := time.Now() 108 | elapsed := now.Sub(t.startTime).Seconds() 109 | str := fmt.Sprintf("t: %+v|%+v|%.2fs", t.startTime.Format(time.UnixDate), now.Format(time.UnixDate), elapsed) 110 | str += fmt.Sprintf(", v: %d|%d|%d|%+v|%.2f", t.numSamples, t.lowestValue, t.highestValue, t.values, kendallsTau(t.values)) 111 | return str 112 | } 113 | 114 | func (t *TrendDetector) updateDirection() { 115 | if len(t.values) < t.params.RequiredSamples { 116 | t.direction = TrendDirectionNeutral 117 | return 118 | } 119 | 120 | // using Kendall's Tau to find trend 121 | kt := kendallsTau(t.values) 122 | 123 | t.direction = TrendDirectionNeutral 124 | switch { 125 | case kt > 0: 126 | t.direction = TrendDirectionUpward 127 | case kt < t.params.DownwardTrendThreshold: 128 | t.direction = TrendDirectionDownward 129 | } 130 | } 131 | 132 | // ------------------------------------------------ 133 | 134 | func kendallsTau(values []int64) float64 { 135 | concordantPairs := 0 136 | discordantPairs := 0 137 | 138 | for i := 0; i < len(values)-1; i++ { 139 | for j := i + 1; j < len(values); j++ { 140 | if values[i] < values[j] { 141 | concordantPairs++ 142 | } else if values[i] > values[j] { 143 | discordantPairs++ 144 | } 145 | } 146 | } 147 | 148 | if (concordantPairs + discordantPairs) == 0 { 149 | return 0.0 150 | } 151 | 152 | return (float64(concordantPairs) - float64(discordantPairs)) / (float64(concordantPairs) + float64(discordantPairs)) 153 | } 154 | -------------------------------------------------------------------------------- /xorg/xf86-video-dummy/v0.3.8/src/compat-api.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Red Hat, Inc. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a 5 | * copy of this software and associated documentation files (the "Software"), 6 | * to deal in the Software without restriction, including without limitation 7 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 8 | * and/or sell copies of the Software, and to permit persons to whom the 9 | * Software is furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice (including the next 12 | * paragraph) shall be included in all copies or substantial portions of the 13 | * Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 18 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | * DEALINGS IN THE SOFTWARE. 22 | * 23 | * Author: Dave Airlie 24 | */ 25 | 26 | /* this file provides API compat between server post 1.13 and pre it, 27 | it should be reused inside as many drivers as possible */ 28 | #ifndef COMPAT_API_H 29 | #define COMPAT_API_H 30 | 31 | #ifndef GLYPH_HAS_GLYPH_PICTURE_ACCESSOR 32 | #define GetGlyphPicture(g, s) GlyphPicture((g))[(s)->myNum] 33 | #define SetGlyphPicture(g, s, p) GlyphPicture((g))[(s)->myNum] = p 34 | #endif 35 | 36 | #ifndef XF86_HAS_SCRN_CONV 37 | #define xf86ScreenToScrn(s) xf86Screens[(s)->myNum] 38 | #define xf86ScrnToScreen(s) screenInfo.screens[(s)->scrnIndex] 39 | #endif 40 | 41 | #ifndef XF86_SCRN_INTERFACE 42 | 43 | #define SCRN_ARG_TYPE int 44 | #define SCRN_INFO_PTR(arg1) ScrnInfoPtr pScrn = xf86Screens[(arg1)] 45 | 46 | #define SCREEN_ARG_TYPE int 47 | #define SCREEN_PTR(arg1) ScreenPtr pScreen = screenInfo.screens[(arg1)] 48 | 49 | #define SCREEN_INIT_ARGS_DECL int i, ScreenPtr pScreen, int argc, char **argv 50 | 51 | #define BLOCKHANDLER_ARGS_DECL int arg, pointer blockData, pointer pTimeout, pointer pReadmask 52 | #define BLOCKHANDLER_ARGS arg, blockData, pTimeout, pReadmask 53 | 54 | #define CLOSE_SCREEN_ARGS_DECL int scrnIndex, ScreenPtr pScreen 55 | #define CLOSE_SCREEN_ARGS scrnIndex, pScreen 56 | 57 | #define ADJUST_FRAME_ARGS_DECL int arg, int x, int y, int flags 58 | #define ADJUST_FRAME_ARGS(arg, x, y) (arg)->scrnIndex, x, y, 0 59 | 60 | #define SWITCH_MODE_ARGS_DECL int arg, DisplayModePtr mode, int flags 61 | #define SWITCH_MODE_ARGS(arg, m) (arg)->scrnIndex, m, 0 62 | 63 | #define FREE_SCREEN_ARGS_DECL int arg, int flags 64 | #define FREE_SCREEN_ARGS(x) (x)->scrnIndex, 0 65 | 66 | #define VT_FUNC_ARGS_DECL int arg, int flags 67 | #define VT_FUNC_ARGS(flags) pScrn->scrnIndex, (flags) 68 | 69 | #define XF86_ENABLEDISABLEFB_ARG(x) ((x)->scrnIndex) 70 | #else 71 | #define SCRN_ARG_TYPE ScrnInfoPtr 72 | #define SCRN_INFO_PTR(arg1) ScrnInfoPtr pScrn = (arg1) 73 | 74 | #define SCREEN_ARG_TYPE ScreenPtr 75 | #define SCREEN_PTR(arg1) ScreenPtr pScreen = (arg1) 76 | 77 | #define SCREEN_INIT_ARGS_DECL ScreenPtr pScreen, int argc, char **argv 78 | 79 | #define BLOCKHANDLER_ARGS_DECL ScreenPtr arg, pointer pTimeout, pointer pReadmask 80 | #define BLOCKHANDLER_ARGS arg, pTimeout, pReadmask 81 | 82 | #define CLOSE_SCREEN_ARGS_DECL ScreenPtr pScreen 83 | #define CLOSE_SCREEN_ARGS pScreen 84 | 85 | #define ADJUST_FRAME_ARGS_DECL ScrnInfoPtr arg, int x, int y 86 | #define ADJUST_FRAME_ARGS(arg, x, y) arg, x, y 87 | 88 | #define SWITCH_MODE_ARGS_DECL ScrnInfoPtr arg, DisplayModePtr mode 89 | #define SWITCH_MODE_ARGS(arg, m) arg, m 90 | 91 | #define FREE_SCREEN_ARGS_DECL ScrnInfoPtr arg 92 | #define FREE_SCREEN_ARGS(x) (x) 93 | 94 | #define VT_FUNC_ARGS_DECL ScrnInfoPtr arg 95 | #define VT_FUNC_ARGS(flags) pScrn 96 | 97 | #define XF86_ENABLEDISABLEFB_ARG(x) (x) 98 | 99 | #endif 100 | 101 | #endif 102 | -------------------------------------------------------------------------------- /internal/api/members/controler.go: -------------------------------------------------------------------------------- 1 | package members 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/demodesk/neko/pkg/types" 9 | "github.com/demodesk/neko/pkg/utils" 10 | ) 11 | 12 | type MemberDataPayload struct { 13 | ID string `json:"id"` 14 | Profile types.MemberProfile `json:"profile"` 15 | } 16 | 17 | type MemberCreatePayload struct { 18 | Username string `json:"username"` 19 | Password string `json:"password"` 20 | Profile types.MemberProfile `json:"profile"` 21 | } 22 | 23 | type MemberPasswordPayload struct { 24 | Password string `json:"password"` 25 | } 26 | 27 | func (h *MembersHandler) membersList(w http.ResponseWriter, r *http.Request) error { 28 | limit, err := strconv.Atoi(r.URL.Query().Get("limit")) 29 | if err != nil { 30 | // TODO: Default zero. 31 | limit = 0 32 | } 33 | 34 | offset, err := strconv.Atoi(r.URL.Query().Get("offset")) 35 | if err != nil { 36 | // TODO: Default zero. 37 | offset = 0 38 | } 39 | 40 | entries, err := h.members.SelectAll(limit, offset) 41 | if err != nil { 42 | return utils.HttpInternalServerError().WithInternalErr(err) 43 | } 44 | 45 | members := []MemberDataPayload{} 46 | for id, profile := range entries { 47 | members = append(members, MemberDataPayload{ 48 | ID: id, 49 | Profile: profile, 50 | }) 51 | } 52 | 53 | return utils.HttpSuccess(w, members) 54 | } 55 | 56 | func (h *MembersHandler) membersCreate(w http.ResponseWriter, r *http.Request) error { 57 | data := &MemberCreatePayload{ 58 | // default values 59 | Profile: types.MemberProfile{ 60 | IsAdmin: false, 61 | CanLogin: true, 62 | CanConnect: true, 63 | CanWatch: true, 64 | CanHost: true, 65 | CanShareMedia: true, 66 | CanAccessClipboard: true, 67 | SendsInactiveCursor: true, 68 | CanSeeInactiveCursors: true, 69 | }, 70 | } 71 | 72 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 73 | return err 74 | } 75 | 76 | if data.Username == "" { 77 | return utils.HttpBadRequest("username cannot be empty") 78 | } 79 | 80 | if data.Password == "" { 81 | return utils.HttpBadRequest("password cannot be empty") 82 | } 83 | 84 | id, err := h.members.Insert(data.Username, data.Password, data.Profile) 85 | if err != nil { 86 | if errors.Is(err, types.ErrMemberAlreadyExists) { 87 | return utils.HttpUnprocessableEntity("member already exists") 88 | } 89 | 90 | return utils.HttpInternalServerError().WithInternalErr(err) 91 | } 92 | 93 | return utils.HttpSuccess(w, MemberDataPayload{ 94 | ID: id, 95 | Profile: data.Profile, 96 | }) 97 | } 98 | 99 | func (h *MembersHandler) membersRead(w http.ResponseWriter, r *http.Request) error { 100 | member := GetMember(r) 101 | profile := member.Profile 102 | 103 | return utils.HttpSuccess(w, profile) 104 | } 105 | 106 | func (h *MembersHandler) membersUpdateProfile(w http.ResponseWriter, r *http.Request) error { 107 | member := GetMember(r) 108 | data := &member.Profile 109 | 110 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 111 | return err 112 | } 113 | 114 | if err := h.members.UpdateProfile(member.ID, *data); err != nil { 115 | return utils.HttpInternalServerError().WithInternalErr(err) 116 | } 117 | 118 | return utils.HttpSuccess(w) 119 | } 120 | 121 | func (h *MembersHandler) membersUpdatePassword(w http.ResponseWriter, r *http.Request) error { 122 | member := GetMember(r) 123 | data := &MemberPasswordPayload{} 124 | 125 | if err := utils.HttpJsonRequest(w, r, data); err != nil { 126 | return err 127 | } 128 | 129 | if err := h.members.UpdatePassword(member.ID, data.Password); err != nil { 130 | return utils.HttpInternalServerError().WithInternalErr(err) 131 | } 132 | 133 | return utils.HttpSuccess(w) 134 | } 135 | 136 | func (h *MembersHandler) membersDelete(w http.ResponseWriter, r *http.Request) error { 137 | member := GetMember(r) 138 | 139 | if err := h.members.Delete(member.ID); err != nil { 140 | return utils.HttpInternalServerError().WithInternalErr(err) 141 | } 142 | 143 | return utils.HttpSuccess(w) 144 | } 145 | --------------------------------------------------------------------------------