diff --git a/display/.gitignore b/display/.gitignore new file mode 100644 index 0000000..cfb9095 --- /dev/null +++ b/display/.gitignore @@ -0,0 +1 @@ +*_templ.go diff --git a/display/README.md b/display/README.md new file mode 100644 index 0000000..8723358 --- /dev/null +++ b/display/README.md @@ -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 .` diff --git a/display/go.mod b/display/go.mod new file mode 100644 index 0000000..c6c1e0c --- /dev/null +++ b/display/go.mod @@ -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 diff --git a/display/go.sum b/display/go.sum new file mode 100644 index 0000000..6b3b357 --- /dev/null +++ b/display/go.sum @@ -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= diff --git a/display/keys.go b/display/keys.go new file mode 100644 index 0000000..87f52a8 --- /dev/null +++ b/display/keys.go @@ -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, +} diff --git a/display/main.go b/display/main.go new file mode 100644 index 0000000..0274cc0 --- /dev/null +++ b/display/main.go @@ -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 +} diff --git a/display/main.templ b/display/main.templ new file mode 100644 index 0000000..79eea87 --- /dev/null +++ b/display/main.templ @@ -0,0 +1,78 @@ +package main + +templ indexTemplate() { + + +
+ + +
+ { ip }
+
+ { mac }
+