refactor(display): use chrome devtools protocol (#38)

This commit is contained in:
2026-01-30 15:26:57 +01:00
committed by GitHub
parent cbbf50e5a4
commit 71f152ef2a
24 changed files with 570 additions and 504 deletions
-75
View File
@@ -1,75 +0,0 @@
package pkg
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"plg-mudics/shared"
"syscall"
"github.com/gabriel-vasile/mimetype"
)
var FileHandler fileHandler = fileHandler{}
type fileHandler struct {
runningProgram *exec.Cmd
}
func (fh *fileHandler) OpenFile(path string) error {
var err error
mType, err := mimetype.DetectFile(path)
if err != nil {
return fmt.Errorf("failed to detect mime type: %w", err)
}
tempDirPath, err := os.MkdirTemp("", "plg-mudics-program-profile-")
if err != nil {
return fmt.Errorf("failed to create temporary profile directory: %w", err)
}
err = fh.CloseRunningProgram()
if err != nil {
return err
}
switch mType.String() {
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
// yes, we need this weird workaround to delete lock files since libreoffice
// doesn't expose an option to ignore them or prevent their creation
// the --view argument for some reason doesn't work with --show
parent := filepath.Dir(path)
cmd := exec.Command("find", parent, "-name", ".~lock*", "-type", "f", "-delete")
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
slog.Warn("could not remove lock files", "path", parent, "stderr", result.Stderr, "exitCode", result.ExitCode)
}
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--norestore", fmt.Sprintf("-env:UserInstallation=file://%s", tempDirPath))
case "application/pdf":
fh.runningProgram = exec.Command("xreader", path, "--presentation")
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
fh.runningProgram.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
result := shared.RunShellCommandNonBlocking(fh.runningProgram)
if result.ExitCode != 0 {
return fmt.Errorf("could not open pdf: %s (%d)", result.Stderr, result.ExitCode)
}
return nil
}
func (fh *fileHandler) CloseRunningProgram() error {
if fh.runningProgram == nil {
return nil
}
err := syscall.Kill(-fh.runningProgram.Process.Pid, syscall.SIGTERM)
fh.runningProgram = nil
return err
}
+21 -32
View File
@@ -1,49 +1,21 @@
package pkg
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"plg-mudics/display/browser"
"plg-mudics/shared"
)
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 TakeScreenshot() (string, error) {
tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("screenshot_%d.png", time.Now().Unix()))
@@ -106,3 +78,20 @@ func ResolveStorageFilePath(pathParam string) (string, bool, error) {
return fullPath, true, nil
}
func ShowHTML(html string) error {
ResetView()
var templateBuffer bytes.Buffer
htmlTemplate(html).Render(context.Background(), &templateBuffer)
err := browser.Browser.OpenHTML(templateBuffer.String())
return err
}
func ResetView() {
err := fileHandler.closeRunningProgram()
if err != nil {
slog.Error("Failed to close running program", "error", err)
}
}
+148
View File
@@ -0,0 +1,148 @@
package pkg
templ basicTemplate() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>PLG MuDiCS Display</title>
<style>
:root {
--background-color: black;
--foreground-color: oklch(92.3% 0.003 48.717);
--font-size: 5rem;
}
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: var(--background-color);
color: var(--foreground-color);
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
cursor: none;
}
video,
img {
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
object-fit: contain;
}
p,
li {
font-size: var(--font-size);
line-height: 1.2;
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
margin: 0 0 calc(var(--font-size) * 0.4) 0;
}
ul,
ol {
padding-left: calc(var(--font-size)*1.5);
}
</style>
</head>
<bod>
{ children... }
</bod>
</html>
}
templ videoTemplate(path string) {
@basicTemplate() {
<video autoplay>
<source src={ "file://" + path } type="video/mp4"/>
</video>
}
}
templ htmlTemplate(html string) {
@basicTemplate() {
@templ.Raw(html)
}
}
templ imageTemplate(path string) {
@basicTemplate() {
<img src={ "file://" + path }/>
}
}
templ deviceInfoTemplate(ip string, mac string, showQR bool) {
@basicTemplate() {
<div style="width: 100vw; height: 100vh; display: flex; flex-direction: row; justify-content: space-between;">
<div
style="display: flex; flex-direction: column; gap: 1rem; justify-content: end; padding: 2rem; font-size: 4rem;"
>
{ ip }
<span style="text-transform: uppercase;">{ mac }</span>
</div>
if showQR {
<div style="display: flex; justify-content: end; align-items: end; padding: 2rem;">
<div style="padding: 1rem; background-color: var(--foreground-color); border-radius: 1rem;">
<img
style="height: 30vh; width: auto; image-rendering: pixelated; image-rendering: crisp-edges;"
src={ "/qr?data=http://" + ip + ":8080" }
alt="QR-Code"
/>
</div>
</div>
}
</div>
<style>
:root {
--splash-bg: transparent !important;
--splash-fade-out-state: paused !important;
--background-color: oklch(21.6% 0.006 56.043);
}
</style>
}
}
templ startScreenTemplate(splashScreenHtml string, ip string, mac string, qrPath string) {
@basicTemplate() {
<div style="width: 100vw; height: 100vh; display: flex; flex-direction: row; justify-content: space-between;">
<div
style="display: flex; flex-direction: column; gap: 1rem; justify-content: end; padding: 2rem; font-size: 4rem;"
>
{ ip }
<span style="text-transform: uppercase;">{ mac }</span>
</div>
if qrPath != "" {
<div style="display: flex; justify-content: end; align-items: end; padding: 2rem;">
<div style="padding: 1rem; background-color: var(--foreground-color); border-radius: 1rem;">
<img
style="height: 30vh; width: auto; image-rendering: pixelated; image-rendering: crisp-edges;"
src={ "file://" + qrPath }
alt="QR-Code"
/>
</div>
</div>
}
</div>
@templ.Raw(splashScreenHtml)
<style>
:root {
--splash-bg: transparent !important;
--splash-fade-out-state: paused !important;
--background-color: oklch(21.6% 0.006 56.043);
}
</style>
}
}
+108
View File
@@ -0,0 +1,108 @@
package pkg
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"plg-mudics/shared"
"syscall"
"github.com/gabriel-vasile/mimetype"
"plg-mudics/display/browser"
)
var fileHandler fileHandlerType = fileHandlerType{}
type fileHandlerType struct {
runningProgram *exec.Cmd
}
func OpenFile(path string) error {
ResetView()
mType, err := mimetype.DetectFile(path)
if err != nil {
slog.Error("Failed to detect mime type", "file", path, "error", err)
}
switch mType.String() {
case "video/mp4":
var templateBuffer bytes.Buffer
videoTemplate(path).Render(context.Background(), &templateBuffer)
browser.Browser.OpenHTML(templateBuffer.String())
case "image/jpeg", "image/png", "image/gif":
var templateBuffer bytes.Buffer
imageTemplate(path).Render(context.Background(), &templateBuffer)
browser.Browser.OpenHTML(templateBuffer.String())
case "application/pdf":
browser.Browser.OpenPDF(path)
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = fileHandler.openFileWithApp(path)
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
return nil
}
func (fh *fileHandlerType) openFileWithApp(path string) error {
var err error
mType, err := mimetype.DetectFile(path)
if err != nil {
return fmt.Errorf("failed to detect mime type: %w", err)
}
switch mType.String() {
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = fh.openLibreoffice(path)
if err != nil {
return err
}
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
fh.runningProgram.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
result := shared.RunShellCommandNonBlocking(fh.runningProgram)
if result.ExitCode != 0 {
return fmt.Errorf("could not open pdf: %s (%d)", result.Stderr, result.ExitCode)
}
return nil
}
func (fh *fileHandlerType) openLibreoffice(path string) error {
// yes, we need this weird workaround to delete lock files since libreoffice
// doesn't expose an option to ignore them or prevent their creation
// the --view argument for some reason doesn't work with --show
parent := filepath.Dir(path)
cmd := exec.Command("find", parent, "-name", ".~lock*", "-type", "f", "-delete")
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
slog.Warn("could not remove lock files", "path", parent, "stderr", result.Stderr, "exitCode", result.ExitCode)
}
tempDirPath, err := os.MkdirTemp("", "plg-mudics-program-profile-")
if err != nil {
return fmt.Errorf("failed to create temporary profile directory: %w", err)
}
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--norestore", fmt.Sprintf("-env:UserInstallation=file://%s", tempDirPath))
return nil
}
func (fh *fileHandlerType) closeRunningProgram() error {
if fh.runningProgram == nil {
return nil
}
err := syscall.Kill(-fh.runningProgram.Process.Pid, syscall.SIGTERM)
fh.runningProgram = nil
return err
}
+117
View File
@@ -0,0 +1,117 @@
package pkg
import (
"bytes"
"context"
"fmt"
"image/color"
"log/slog"
"net"
"os"
"plg-mudics/shared"
"strings"
"plg-mudics/display/browser"
"github.com/skip2/go-qrcode"
)
func OpenStartScreen() {
var err error
raw := shared.RawSplashScreenTemplate
html := strings.ReplaceAll(raw, "%%APP-VERSION%%", shared.Version)
ip, err := getDeviceIp()
if err != nil {
slog.Error("Failed to get device IP", "error", err)
}
mac, err := getDeviceMac()
if err != nil {
slog.Error("Failed to get device MAC address", "error", err)
}
port := 8080
showQrCode := !isPortFree(port)
qrCodePath := ""
if showQrCode {
qrCodePath, err = generateQRCode(fmt.Sprintf("http://%s:%d", ip, port))
if err != nil {
slog.Error("could not generate qr code", "error", err)
}
}
var templateBuffer bytes.Buffer
startScreenTemplate(html, ip, mac, qrCodePath).Render(context.Background(), &templateBuffer)
browser.Browser.OpenHTML(templateBuffer.String())
}
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 isPortFree(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}
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 generateQRCode(data string) (string, error) {
qr, err := qrcode.New(data, qrcode.Medium)
if err != nil {
return "", fmt.Errorf("could not generate qr code: %w", err)
}
qr.DisableBorder = true
qr.ForegroundColor = color.RGBA{R: 0x1c, G: 0x19, B: 0x17, A: 0xff}
qr.BackgroundColor = color.RGBA{R: 0xe7, G: 0xe5, B: 0xe4, A: 0xff}
png, err := qr.PNG(-1)
if err != nil {
return "", fmt.Errorf("could not render qr code: %w", err)
}
file, err := os.CreateTemp("", "mudics-qr-code-*.png")
if err != nil {
return "", fmt.Errorf("could not save qr code: %w", err)
}
defer file.Close()
_, err = file.Write(png)
if err != nil {
return "", fmt.Errorf("could not write qr code to file: %w", err)
}
return file.Name(), nil
}