mirror of
https://codeberg.org/PLG-Development/PLG-MuDiCS
synced 2026-07-05 16:37:09 +00:00
feat: display
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
*_templ.go
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# PLG-MuDICS Display
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
- install Go (at least version v1.24)
|
||||||
|
- build template: `go tool templ generate`
|
||||||
|
- run code: `go run .`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
module plg-mudics-display
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/micmonay/keybd_event v1.1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
|
github.com/a-h/templ v0.3.924 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/natefinch/atomic v1.0.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
|
golang.org/x/mod v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.40.0 // indirect
|
||||||
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
golang.org/x/tools v0.32.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool github.com/a-h/templ/cmd/templ
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||||
|
github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
|
||||||
|
github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
|
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/micmonay/keybd_event v1.1.2 h1:RpgvPJKOh4Jc+ZYe0OrVzGd2eNMCfuVg3dFTCsuSah4=
|
||||||
|
github.com/micmonay/keybd_event v1.1.2/go.mod h1:CGMWMDNgsfPljzrAWoybUOSKafQPZpv+rLigt2LzNGI=
|
||||||
|
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||||
|
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||||
|
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/micmonay/keybd_event"
|
||||||
|
|
||||||
|
var keyboardEvents = map[string]int{
|
||||||
|
"VK_SP1": keybd_event.VK_SP1,
|
||||||
|
"VK_SP2": keybd_event.VK_SP2,
|
||||||
|
"VK_SP3": keybd_event.VK_SP3,
|
||||||
|
"VK_SP4": keybd_event.VK_SP4,
|
||||||
|
"VK_SP5": keybd_event.VK_SP5,
|
||||||
|
"VK_SP6": keybd_event.VK_SP6,
|
||||||
|
"VK_SP7": keybd_event.VK_SP7,
|
||||||
|
"VK_SP8": keybd_event.VK_SP8,
|
||||||
|
"VK_SP9": keybd_event.VK_SP9,
|
||||||
|
"VK_SP10": keybd_event.VK_SP10,
|
||||||
|
"VK_SP11": keybd_event.VK_SP11,
|
||||||
|
"VK_SP12": keybd_event.VK_SP12,
|
||||||
|
"VK_ESC": keybd_event.VK_ESC,
|
||||||
|
"VK_1": keybd_event.VK_1,
|
||||||
|
"VK_2": keybd_event.VK_2,
|
||||||
|
"VK_3": keybd_event.VK_3,
|
||||||
|
"VK_4": keybd_event.VK_4,
|
||||||
|
"VK_5": keybd_event.VK_5,
|
||||||
|
"VK_6": keybd_event.VK_6,
|
||||||
|
"VK_7": keybd_event.VK_7,
|
||||||
|
"VK_8": keybd_event.VK_8,
|
||||||
|
"VK_9": keybd_event.VK_9,
|
||||||
|
"VK_0": keybd_event.VK_0,
|
||||||
|
"VK_Q": keybd_event.VK_Q,
|
||||||
|
"VK_W": keybd_event.VK_W,
|
||||||
|
"VK_E": keybd_event.VK_E,
|
||||||
|
"VK_R": keybd_event.VK_R,
|
||||||
|
"VK_T": keybd_event.VK_T,
|
||||||
|
"VK_Y": keybd_event.VK_Y,
|
||||||
|
"VK_U": keybd_event.VK_U,
|
||||||
|
"VK_I": keybd_event.VK_I,
|
||||||
|
"VK_O": keybd_event.VK_O,
|
||||||
|
"VK_P": keybd_event.VK_P,
|
||||||
|
"VK_A": keybd_event.VK_A,
|
||||||
|
"VK_S": keybd_event.VK_S,
|
||||||
|
"VK_D": keybd_event.VK_D,
|
||||||
|
"VK_F": keybd_event.VK_F,
|
||||||
|
"VK_G": keybd_event.VK_G,
|
||||||
|
"VK_H": keybd_event.VK_H,
|
||||||
|
"VK_J": keybd_event.VK_J,
|
||||||
|
"VK_K": keybd_event.VK_K,
|
||||||
|
"VK_L": keybd_event.VK_L,
|
||||||
|
"VK_Z": keybd_event.VK_Z,
|
||||||
|
"VK_X": keybd_event.VK_X,
|
||||||
|
"VK_C": keybd_event.VK_C,
|
||||||
|
"VK_V": keybd_event.VK_V,
|
||||||
|
"VK_B": keybd_event.VK_B,
|
||||||
|
"VK_N": keybd_event.VK_N,
|
||||||
|
"VK_M": keybd_event.VK_M,
|
||||||
|
"VK_F1": keybd_event.VK_F1,
|
||||||
|
"VK_F2": keybd_event.VK_F2,
|
||||||
|
"VK_F3": keybd_event.VK_F3,
|
||||||
|
"VK_F4": keybd_event.VK_F4,
|
||||||
|
"VK_F5": keybd_event.VK_F5,
|
||||||
|
"VK_F6": keybd_event.VK_F6,
|
||||||
|
"VK_F7": keybd_event.VK_F7,
|
||||||
|
"VK_F8": keybd_event.VK_F8,
|
||||||
|
"VK_F9": keybd_event.VK_F9,
|
||||||
|
"VK_F10": keybd_event.VK_F10,
|
||||||
|
"VK_F11": keybd_event.VK_F11,
|
||||||
|
"VK_F12": keybd_event.VK_F12,
|
||||||
|
"VK_F13": keybd_event.VK_F13,
|
||||||
|
"VK_F14": keybd_event.VK_F14,
|
||||||
|
"VK_F15": keybd_event.VK_F15,
|
||||||
|
"VK_F16": keybd_event.VK_F16,
|
||||||
|
"VK_F17": keybd_event.VK_F17,
|
||||||
|
"VK_F18": keybd_event.VK_F18,
|
||||||
|
"VK_F19": keybd_event.VK_F19,
|
||||||
|
"VK_F20": keybd_event.VK_F20,
|
||||||
|
"VK_F21": keybd_event.VK_F21,
|
||||||
|
"VK_F22": keybd_event.VK_F22,
|
||||||
|
"VK_F23": keybd_event.VK_F23,
|
||||||
|
"VK_F24": keybd_event.VK_F24,
|
||||||
|
"VK_NUMLOCK": keybd_event.VK_NUMLOCK,
|
||||||
|
"VK_SCROLLLOCK": keybd_event.VK_SCROLLLOCK,
|
||||||
|
"VK_RESERVED": keybd_event.VK_RESERVED,
|
||||||
|
"VK_MINUS": keybd_event.VK_MINUS,
|
||||||
|
"VK_EQUAL": keybd_event.VK_EQUAL,
|
||||||
|
"VK_BACKSPACE": keybd_event.VK_BACKSPACE,
|
||||||
|
"VK_TAB": keybd_event.VK_TAB,
|
||||||
|
"VK_LEFTBRACE": keybd_event.VK_LEFTBRACE,
|
||||||
|
"VK_RIGHTBRACE": keybd_event.VK_RIGHTBRACE,
|
||||||
|
"VK_ENTER": keybd_event.VK_ENTER,
|
||||||
|
"VK_SEMICOLON": keybd_event.VK_SEMICOLON,
|
||||||
|
"VK_APOSTROPHE": keybd_event.VK_APOSTROPHE,
|
||||||
|
"VK_GRAVE": keybd_event.VK_GRAVE,
|
||||||
|
"VK_BACKSLASH": keybd_event.VK_BACKSLASH,
|
||||||
|
"VK_COMMA": keybd_event.VK_COMMA,
|
||||||
|
"VK_DOT": keybd_event.VK_DOT,
|
||||||
|
"VK_SLASH": keybd_event.VK_SLASH,
|
||||||
|
"VK_KPASTERISK": keybd_event.VK_KPASTERISK,
|
||||||
|
"VK_SPACE": keybd_event.VK_SPACE,
|
||||||
|
"VK_CAPSLOCK": keybd_event.VK_CAPSLOCK,
|
||||||
|
"VK_KP0": keybd_event.VK_KP0,
|
||||||
|
"VK_KP1": keybd_event.VK_KP1,
|
||||||
|
"VK_KP2": keybd_event.VK_KP2,
|
||||||
|
"VK_KP3": keybd_event.VK_KP3,
|
||||||
|
"VK_KP4": keybd_event.VK_KP4,
|
||||||
|
"VK_KP5": keybd_event.VK_KP5,
|
||||||
|
"VK_KP6": keybd_event.VK_KP6,
|
||||||
|
"VK_KP7": keybd_event.VK_KP7,
|
||||||
|
"VK_KP8": keybd_event.VK_KP8,
|
||||||
|
"VK_KP9": keybd_event.VK_KP9,
|
||||||
|
"VK_KPMINUS": keybd_event.VK_KPMINUS,
|
||||||
|
"VK_KPPLUS": keybd_event.VK_KPPLUS,
|
||||||
|
"VK_KPDOT": keybd_event.VK_KPDOT,
|
||||||
|
"VK_CANCEL": keybd_event.VK_CANCEL,
|
||||||
|
"VK_BACK": keybd_event.VK_BACK,
|
||||||
|
"VK_PAUSE": keybd_event.VK_PAUSE,
|
||||||
|
"VK_HANGUEL": keybd_event.VK_HANGUEL,
|
||||||
|
"VK_HANJA": keybd_event.VK_HANJA,
|
||||||
|
"VK_PAGEUP": keybd_event.VK_PAGEUP,
|
||||||
|
"VK_PAGEDOWN": keybd_event.VK_PAGEDOWN,
|
||||||
|
"VK_END": keybd_event.VK_END,
|
||||||
|
"VK_HOME": keybd_event.VK_HOME,
|
||||||
|
"VK_LEFT": keybd_event.VK_LEFT,
|
||||||
|
"VK_UP": keybd_event.VK_UP,
|
||||||
|
"VK_RIGHT": keybd_event.VK_RIGHT,
|
||||||
|
"VK_DOWN": keybd_event.VK_DOWN,
|
||||||
|
"VK_PRINT": keybd_event.VK_PRINT,
|
||||||
|
"VK_INSERT": keybd_event.VK_INSERT,
|
||||||
|
"VK_DELETE": keybd_event.VK_DELETE,
|
||||||
|
"VK_HELP": keybd_event.VK_HELP,
|
||||||
|
}
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/micmonay/keybd_event"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommandRequest struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandResponse struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyboardInputRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTMLRequest struct {
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var storagePath string
|
||||||
|
var chromiumBin string
|
||||||
|
var sseConnection chan string
|
||||||
|
var supportedExtensions = map[string]bool{
|
||||||
|
".mp4": true,
|
||||||
|
".jpg": true,
|
||||||
|
".jpeg": true,
|
||||||
|
".png": true,
|
||||||
|
".gif": true,
|
||||||
|
".pptx": true,
|
||||||
|
".odp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if err := checkDependencies(); err != nil {
|
||||||
|
slog.Error("Dependency check failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure local config directory exists
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Unable to determine user home directory", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
storagePath = filepath.Join(home, ".local", "share", "plg-connect-display")
|
||||||
|
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
|
||||||
|
slog.Error("Failed to create local config directory", "path", storagePath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open browser window
|
||||||
|
go func() {
|
||||||
|
args := fmt.Sprintf("%s --app='http://127.0.0.1:1323' --start-fullscreen --user-data-dir=$(mktemp -d) --autoplay-policy=no-user-gesture-required", chromiumBin)
|
||||||
|
cmd := exec.Command("bash", "-c", args)
|
||||||
|
_ = cmd.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Webserver
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
e.GET("/", indexRoute)
|
||||||
|
e.GET("/sse", sseRoute)
|
||||||
|
|
||||||
|
apiGroup := e.Group("/api")
|
||||||
|
apiGroup.PATCH("/shellCommand", shellCommandRoute)
|
||||||
|
apiGroup.PATCH("/keyboardInput", keyboardInputRoute)
|
||||||
|
apiGroup.PATCH("/showHTML", showHTMLRoute)
|
||||||
|
|
||||||
|
fileGroup := apiGroup.Group("/file")
|
||||||
|
fileGroup.Use(extractFilePathMiddleware)
|
||||||
|
fileGroup.POST("/:path", uploadFileRoute)
|
||||||
|
fileGroup.GET("/:path", downloadFileRoute)
|
||||||
|
fileGroup.PATCH("/:path", openFileRoute)
|
||||||
|
|
||||||
|
err = e.Start(":1323")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to start server", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDependencies() error {
|
||||||
|
// Detect available Chromium binary name
|
||||||
|
for _, b := range []string{"chromium", "chromium-browser"} {
|
||||||
|
if _, err := exec.LookPath(b); err == nil {
|
||||||
|
chromiumBin = b
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chromiumBin == "" {
|
||||||
|
return errors.New("chromium or chromium-browser not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check other dependencies
|
||||||
|
deps := []string{
|
||||||
|
"soffice", // LibreOffice
|
||||||
|
"bash",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dep := range deps {
|
||||||
|
if _, err := exec.LookPath(dep); err != nil {
|
||||||
|
return errors.New(dep + " not found in PATH")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexRoute(ctx echo.Context) error {
|
||||||
|
return indexTemplate().Render(ctx.Request().Context(), ctx.Response().Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sseRoute(ctx echo.Context) error {
|
||||||
|
slog.Info("SSE client connected")
|
||||||
|
|
||||||
|
w := ctx.Response()
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := w.Writer.(http.Flusher)
|
||||||
|
|
||||||
|
sseConnection = make(chan string)
|
||||||
|
|
||||||
|
// init display
|
||||||
|
ip, err := getDeviceIp()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get device IP address", "error", err)
|
||||||
|
}
|
||||||
|
mac, err := getDeviceMac()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get device MAC address", "error", err)
|
||||||
|
}
|
||||||
|
var status bytes.Buffer
|
||||||
|
deviceInfoTemplate(ip, mac).Render(context.Background(), &status)
|
||||||
|
connectedEvent := Event{
|
||||||
|
Data: status.Bytes(),
|
||||||
|
}
|
||||||
|
connectedEvent.MarshalTo(w)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Request().Context().Done():
|
||||||
|
slog.Info("SSE client disconnected")
|
||||||
|
sseConnection = nil
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case event := <-sseConnection:
|
||||||
|
rawEvent := Event{
|
||||||
|
Event: []byte(""),
|
||||||
|
Data: []byte(event),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rawEvent.MarshalTo(w); err != nil {
|
||||||
|
slog.Warn("Error writing to client", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDeviceIp() (string, error) {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get network interfaces: %w", err)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
|
||||||
|
return ipNet.IP.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no suitable IP address found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDeviceMac() (string, error) {
|
||||||
|
interfaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get network interfaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, interf := range interfaces {
|
||||||
|
mac := interf.HardwareAddr.String()
|
||||||
|
if mac != "" {
|
||||||
|
return mac, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no suitable MAC address found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(ctx echo.Context) error {
|
||||||
|
// Retrieve and clean the path parameter
|
||||||
|
pathParam := ctx.Param("path")
|
||||||
|
cleanPath := filepath.Clean(pathParam)
|
||||||
|
fullPath := filepath.Join(storagePath, cleanPath)
|
||||||
|
rel, err := filepath.Rel(storagePath, fullPath)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid file path"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the target path exists and is a file
|
||||||
|
var exists bool
|
||||||
|
info, statErr := os.Stat(fullPath)
|
||||||
|
if statErr != nil {
|
||||||
|
if os.IsNotExist(statErr) {
|
||||||
|
exists = false
|
||||||
|
} else {
|
||||||
|
slog.Error("Failed to stat path", "path", fullPath, "error", statErr)
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if info.IsDir() {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Path is a directory"})
|
||||||
|
}
|
||||||
|
exists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Set("fullPath", fullPath)
|
||||||
|
ctx.Set("fileExists", exists)
|
||||||
|
return next(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellCommandRoute(ctx echo.Context) error {
|
||||||
|
var commandInput CommandRequest
|
||||||
|
if err := ctx.Bind(&commandInput); err != nil {
|
||||||
|
slog.Error("Failed to parse shell command", "error", err)
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("%s", commandInput.Command))
|
||||||
|
cmd.Dir = storagePath
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
commandOutput := CommandResponse{
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
ExitCode: cmd.ProcessState.ExitCode(),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
commandOutput.ExitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
commandOutput.Stderr = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Shell command execution error", "error", commandOutput.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Shell command executed successfully", "command", commandInput.Command, "exitCode", commandOutput.ExitCode)
|
||||||
|
return ctx.JSON(http.StatusOK, commandOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardInputRoute(ctx echo.Context) error {
|
||||||
|
var req KeyboardInputRequest
|
||||||
|
if err := ctx.Bind(&req); err != nil {
|
||||||
|
slog.Error("Failed to parse keyboard input", "error", err)
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
code, ok := keyboardEvents[req.Key]
|
||||||
|
if !ok {
|
||||||
|
slog.Error("Unsupported key", "key", req.Key)
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", req.Key)})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := keyboardInput(code)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to send keyboard input", "key", req.Key, "error", err)
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Keyboard input sent", "key", req.Key)
|
||||||
|
return ctx.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadFileRoute(ctx echo.Context) error {
|
||||||
|
fullPath := ctx.Get("fullPath").(string)
|
||||||
|
|
||||||
|
// Ensure parent directories exist
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Get("fileExists").(bool) {
|
||||||
|
return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||||
|
if !supportedExtensions[ext] {
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(ctx.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("File uploaded successfully", "path", fullPath)
|
||||||
|
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFileRoute(ctx echo.Context) error {
|
||||||
|
fullPath := ctx.Get("fullPath").(string)
|
||||||
|
|
||||||
|
if !ctx.Get("fileExists").(bool) {
|
||||||
|
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ctx.File(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to serve file", "file", fullPath, "error", err)
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("File downloaded successfully", "path", fullPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openFileRoute(ctx echo.Context) error {
|
||||||
|
pathParam := ctx.Param("path")
|
||||||
|
fullPath := ctx.Get("fullPath").(string)
|
||||||
|
|
||||||
|
if !ctx.Get("fileExists").(bool) {
|
||||||
|
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sseConnection == nil {
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Cant connect to display browser client"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := resetView()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to reset view", "error", err)
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4":
|
||||||
|
var templateBuffer bytes.Buffer
|
||||||
|
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
|
||||||
|
|
||||||
|
sseConnection <- templateBuffer.String()
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif":
|
||||||
|
var templateBuffer bytes.Buffer
|
||||||
|
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
|
||||||
|
sseConnection <- templateBuffer.String()
|
||||||
|
case ".pptx", ".odp":
|
||||||
|
openPresentation(fullPath)
|
||||||
|
default:
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Successfully run file", "file", pathParam)
|
||||||
|
return ctx.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openPresentation(path string) {
|
||||||
|
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardInput(key int) error {
|
||||||
|
kb, err := keybd_event.NewKeyBonding()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create key bonding: %w", err)
|
||||||
|
}
|
||||||
|
kb.SetKeys(key)
|
||||||
|
|
||||||
|
if err := kb.Launching(); err != nil {
|
||||||
|
return fmt.Errorf("failed to launch key event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showHTMLRoute(ctx echo.Context) error {
|
||||||
|
var request HTMLRequest
|
||||||
|
if err := ctx.Bind(&request); err != nil {
|
||||||
|
slog.Error("Failed to parse request", "error", err)
|
||||||
|
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := resetView()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to reset view", "error", err)
|
||||||
|
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
|
||||||
|
}
|
||||||
|
|
||||||
|
sseConnection <- request.HTML
|
||||||
|
|
||||||
|
slog.Info("HTML content sent to client")
|
||||||
|
return ctx.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset previous file views so they dont collide with the new one
|
||||||
|
func resetView() error {
|
||||||
|
err := keyboardInput(keybd_event.VK_ESC)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send ESC key: %w", err)
|
||||||
|
}
|
||||||
|
sseConnection <- ""
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
templ indexTemplate() {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>PLG Connect Display</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2" crossorigin="anonymous"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* centers horizontally */
|
||||||
|
align-items: center; /* centers vertically */
|
||||||
|
width: 100vw; /* Viewport width */
|
||||||
|
height: 100vh; /* Viewport height */
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: hsl(256, 10%, 10%);
|
||||||
|
color: hsl(256, 90%, 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
video, img, iframe {
|
||||||
|
width: 100vw; /* Viewport width */
|
||||||
|
height: 100vh; /* Viewport height */
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 6rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
text-wrap: balance;
|
||||||
|
max-width: 90vw;
|
||||||
|
word-break: break-word;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
if (video.paused) {
|
||||||
|
video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body hx-ext="sse" sse-connect="/sse" sse-swap="message"></body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ videoTemplate(path string) {
|
||||||
|
<video autoplay>
|
||||||
|
<source src={ "/api/file/" + path } type="video/mp4"/>
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ imageTemplate(path string) {
|
||||||
|
<img src={ "/api/file/" + path }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ deviceInfoTemplate(ip string, mac string) {
|
||||||
|
<p>
|
||||||
|
{ ip }
|
||||||
|
<br/>
|
||||||
|
<span style="text-transform: uppercase;">{ mac }</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents Server-Sent Event.
|
||||||
|
// SSE explanation: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
||||||
|
type Event struct {
|
||||||
|
// ID is used to set the EventSource object's last event ID value.
|
||||||
|
ID []byte
|
||||||
|
// Data field is for the message. When the EventSource receives multiple consecutive lines
|
||||||
|
// that begin with data:, it concatenates them, inserting a newline character between each one.
|
||||||
|
// Trailing newlines are removed.
|
||||||
|
Data []byte
|
||||||
|
// Event is a string identifying the type of event described. If this is specified, an event
|
||||||
|
// will be dispatched on the browser to the listener for the specified event name; the website
|
||||||
|
// source code should use addEventListener() to listen for named events. The onmessage handler
|
||||||
|
// is called if no event name is specified for a message.
|
||||||
|
Event []byte
|
||||||
|
// Retry is the reconnection time. If the connection to the server is lost, the browser will
|
||||||
|
// wait for the specified time before attempting to reconnect. This must be an integer, specifying
|
||||||
|
// the reconnection time in milliseconds. If a non-integer value is specified, the field is ignored.
|
||||||
|
Retry []byte
|
||||||
|
// Comment line can be used to prevent connections from timing out; a server can send a comment
|
||||||
|
// periodically to keep the connection alive.
|
||||||
|
Comment []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalTo marshals Event to given Writer
|
||||||
|
func (ev *Event) MarshalTo(w io.Writer) error {
|
||||||
|
// Marshalling part is taken from: https://github.com/r3labs/sse/blob/c6d5381ee3ca63828b321c16baa008fd6c0b4564/http.go#L16
|
||||||
|
if len(ev.Data) == 0 && len(ev.Comment) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ev.Data) > 0 {
|
||||||
|
if _, err := fmt.Fprintf(w, "id: %s\n", ev.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sd := bytes.Split(ev.Data, []byte("\n"))
|
||||||
|
for i := range sd {
|
||||||
|
if _, err := fmt.Fprintf(w, "data: %s\n", sd[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ev.Event) > 0 {
|
||||||
|
if _, err := fmt.Fprintf(w, "event: %s\n", ev.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ev.Retry) > 0 {
|
||||||
|
if _, err := fmt.Fprintf(w, "retry: %s\n", ev.Retry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ev.Comment) > 0 {
|
||||||
|
if _, err := fmt.Fprintf(w, ": %s\n", ev.Comment); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprint(w, "\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user