Compare commits

...

14 Commits

14 changed files with 267 additions and 61 deletions
+1
View File
@@ -10,6 +10,7 @@ Open source solution for advanced remote control and coordination of one or more
- Bash - Bash
- Chromium - Chromium
- LibreOffice (optional, presentation view) - LibreOffice (optional, presentation view)
- Xreader (optional, pdf view)
- ImageMagick (optional, image preview) - ImageMagick (optional, image preview)
- ffmpeg (optional, video preview) - ffmpeg (optional, video preview)
- Ghostscript (optional, pdf preview) - Ghostscript (optional, pdf preview)
@@ -20,7 +20,7 @@
import Button from './Button.svelte'; import Button from './Button.svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } from 'lucide-svelte'; import { Menu, Pencil, PinOff, Settings, Trash2, VideoOff, ZoomIn, ZoomOut } from 'lucide-svelte';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
import { dragHandleZone } from 'svelte-dnd-action'; import { dragHandleZone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -214,7 +214,7 @@
change_height('display', 1); change_height('display', 1);
}} }}
> >
<Plus /> <ZoomIn />
</Button> </Button>
<Button <Button
title="Bildschirme kleiner darstellen" title="Bildschirme kleiner darstellen"
@@ -225,7 +225,7 @@
change_height('display', -1); change_height('display', -1);
}} }}
> >
<Minus /> <ZoomOut />
</Button> </Button>
</div> </div>
</div> </div>
@@ -4,13 +4,13 @@
Download, Download,
FolderPlus, FolderPlus,
Info, Info,
Minus,
Pen, Pen,
Plus,
RefreshCcw, RefreshCcw,
Scissors, Scissors,
Trash2, Trash2,
Upload Upload,
ZoomIn,
ZoomOut,
} from 'lucide-svelte'; } from 'lucide-svelte';
import { change_height, current_height, next_height_step_size } from '../ts/stores/ui_behavior'; import { change_height, current_height, next_height_step_size } from '../ts/stores/ui_behavior';
import Button from './Button.svelte'; import Button from './Button.svelte';
@@ -258,7 +258,7 @@
change_height('file', 1); change_height('file', 1);
}} }}
> >
<Plus /> <ZoomIn />
</Button> </Button>
<Button <Button
title="Dateien kleiner darstellen" title="Dateien kleiner darstellen"
@@ -269,7 +269,7 @@
change_height('file', -1); change_height('file', -1);
}} }}
> >
<Minus /> <ZoomOut />
</Button> </Button>
</div> </div>
</div> </div>
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/micmonay/keybd_event v1.1.2 github.com/micmonay/keybd_event v1.1.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/gabriel-vasile/mimetype v1.4.11
) )
require ( require (
+2
View File
@@ -14,6 +14,8 @@ 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/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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
+63
View File
@@ -0,0 +1,63 @@
package pkg
import (
"fmt"
"os"
"os/exec"
"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":
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--view", "--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
}
-9
View File
@@ -47,15 +47,6 @@ func GetDeviceMac() (string, error) {
return "", fmt.Errorf("no suitable MAC address found") return "", fmt.Errorf("no suitable MAC address found")
} }
func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
return errors.New(result.Stderr)
}
return nil
}
type KeyAction int type KeyAction int
const ( const (
+57 -34
View File
@@ -14,12 +14,10 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
shared "plg-mudics/shared" shared "plg-mudics/shared"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/micmonay/keybd_event"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"plg-mudics/display/pkg" "plg-mudics/display/pkg"
@@ -224,10 +222,13 @@ func keyboardInputRoute(ctx echo.Context) error {
} }
func uploadFileRoute(ctx echo.Context) error { func uploadFileRoute(ctx echo.Context) error {
var err error
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
// Ensure parent directories exist // Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
slog.Error("Failed to create storage path", "error", err, "path", fullPath)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"}) return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
} }
@@ -235,12 +236,30 @@ func uploadFileRoute(ctx echo.Context) error {
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"}) return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"})
} }
data, err := io.ReadAll(ctx.Request().Body) file, err := os.Create(fullPath)
if err != nil { if err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) slog.Error("Failed to create file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create file"})
}
defer func() {
fileCloseErr := file.Close()
if fileCloseErr != nil {
slog.Error("Failed to close file", "file", fullPath, "error", fileCloseErr)
}
if err != nil {
os.Remove(fullPath)
}
}()
_, err = io.Copy(file, ctx.Request().Body)
if err != nil {
slog.Error("Failed to write file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write file"})
} }
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil { err = file.Sync() // ensure data is flushed to disk
if err != nil {
slog.Error("Failed to sync file to disk", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"}) return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"})
} }
@@ -255,17 +274,21 @@ func downloadFileRoute(ctx echo.Context) error {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"}) return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
} }
err := ctx.File(fullPath) slog.Info("Serving file for download", "path", fullPath)
if err != nil {
slog.Error("Failed to serve file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"})
}
slog.Info("File downloaded successfully", "path", fullPath) file, err := os.Open(fullPath)
return nil if err != nil {
slog.Error("Failed to open file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
}
defer file.Close()
return ctx.Stream(http.StatusOK, "application/octet-stream", file)
} }
func openFileRoute(ctx echo.Context) error { func openFileRoute(ctx echo.Context) error {
var err error
pathParam := ctx.Param("path") pathParam := ctx.Param("path")
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
@@ -277,31 +300,38 @@ func openFileRoute(ctx echo.Context) error {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"}) return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"})
} }
err := resetView() err = resetView()
if err != nil { if err != nil {
slog.Error("Failed to reset view", "error", err) slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"}) return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"})
} }
ext := strings.ToLower(filepath.Ext(fullPath)) mType, err := mimetype.DetectFile(fullPath)
switch ext { if err != nil {
case ".mp4": slog.Error("Failed to detect mime type", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to detect file type"})
}
switch mType.String() {
case "video/mp4":
var templateBuffer bytes.Buffer var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer) videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String() sseConnection <- templateBuffer.String()
case ".jpg", ".jpeg", ".png", ".gif": case "image/jpeg", "image/png", "image/gif":
var templateBuffer bytes.Buffer var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer) imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String() sseConnection <- templateBuffer.String()
case ".pptx", ".odp": case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err := pkg.OpenPresentation(fullPath) err = pkg.FileHandler.OpenFile(fullPath)
if err != nil {
slog.Error("Failed to open presentation", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open presentation"})
}
default: default:
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type"}) slog.Info("Unsupported file type", "type", mType)
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type: " + mType.String()})
}
if err != nil {
slog.Error("Failed to open file", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
} }
slog.Info("Successfully run file", "file", pathParam) slog.Info("Successfully run file", "file", pathParam)
@@ -377,16 +407,9 @@ func previewRoute(ctx echo.Context) error {
// Reset previous file views so they dont collide with the new one // Reset previous file views so they dont collide with the new one
func resetView() error { func resetView() error {
var err error err := pkg.FileHandler.CloseRunningProgram()
err = pkg.KeyboardInput(keybd_event.VK_ESC, pkg.KeyPress)
if err != nil { if err != nil {
return fmt.Errorf("failed to send ESC key: %w", err) return fmt.Errorf("failed to close running program: %w", err)
}
time.Sleep(400 * time.Millisecond)
err = pkg.KeyboardInput(keybd_event.VK_ESC, pkg.KeyRelease)
if err != nil {
return fmt.Errorf("failed to send ESC key: %w", err)
} }
sseConnection <- "" sseConnection <- ""
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765779637,
"narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1306659b587dc277866c7b69eb97e5f07864d8c4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+24
View File
@@ -0,0 +1,24 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
pkgs = nixpkgs.legacyPackages."x86_64-linux";
in {
devShells."x86_64-linux".default = pkgs.mkShell {
packages = with pkgs; [
libreoffice
ungoogled-chromium
xreader
imagemagick
ffmpeg
ghostscript
gnome-screenshot
];
};
};
}
+18 -2
View File
@@ -1,9 +1,11 @@
{ {
inputs,
config, config,
pkgs, pkgs,
... ...
}: { }: {
imports = [ imports = [
inputs.home-manager.nixosModules.default
./hardware-configuration.nix ./hardware-configuration.nix
]; ];
@@ -12,6 +14,8 @@
boot.loader.systemd-boot.enable = true; boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; boot.loader.efi.canTouchEfiVariables = true;
hardware.steam-hardware.enable = true;
networking.networkmanager.enable = true; networking.networkmanager.enable = true;
nix.settings.experimental-features = ["nix-command" "flakes"]; nix.settings.experimental-features = ["nix-command" "flakes"];
@@ -87,6 +91,9 @@
xfce.thunar-archive-plugin xfce.thunar-archive-plugin
git git
nushell nushell
unzip
iputils
xreader
# Libraries # Libraries
imagemagick imagemagick
@@ -94,11 +101,20 @@
ghostscript ghostscript
]; ];
home-manager.users.mudics = {
xfconf.settings = {
xfce4-power-manager."xfce4-power-manager/dpms-enabled" = false;
xfce4-screensaver."saver/enabled" = false;
};
home.stateVersion = "25.05";
};
systemd.services.update-mudics = { systemd.services.update-mudics = {
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
after = ["network-online.target"]; after = ["network-online.target"];
wants = ["network-online.target"]; wants = ["network-online.target"];
path = with pkgs; [nushell unzip iputils]; path = config.environment.systemPackages;
script = "nu ${./update.sh}"; script = "nu ${./update.sh}";
serviceConfig = { serviceConfig = {
WorkingDirectory = "/home/mudics/mudics"; WorkingDirectory = "/home/mudics/mudics";
@@ -111,7 +127,7 @@
wantedBy = ["default.target"]; wantedBy = ["default.target"];
after = ["update-mudics.service" "graphical.target"]; after = ["update-mudics.service" "graphical.target"];
wants = ["graphical.target"]; wants = ["graphical.target"];
path = with pkgs; [ungoogled-chromium]; path = config.environment.systemPackages;
script = "./plg-mudics-display"; script = "./plg-mudics-display";
serviceConfig = { serviceConfig = {
WorkingDirectory = "/home/mudics/mudics"; WorkingDirectory = "/home/mudics/mudics";
+22
View File
@@ -1,5 +1,26 @@
{ {
"nodes": { "nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763992789,
"narHash": "sha256-WHkdBlw6oyxXIra/vQPYLtqY+3G8dUVZM8bEXk0t8x4=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "44831a7eaba4360fb81f2acc5ea6de5fde90aaa3",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.05",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1761999846, "lastModified": 1761999846,
@@ -18,6 +39,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }
+14 -8
View File
@@ -1,6 +1,11 @@
{ {
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = { outputs = {
@@ -8,14 +13,15 @@
nixpkgs, nixpkgs,
... ...
} @ inputs: { } @ inputs: {
nixosConfigurations.plg-mudics = nixpkgs.lib.nixosSystem { nixosConfigurations.mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs = { specialArgs = {
inherit inputs; inherit inputs;
};
modules = [
./configuration.nix
];
}; };
modules = [
./configuration.nix
inputs.home-manager.nixosModules.default
];
};
}; };
} }
+30
View File
@@ -2,8 +2,10 @@ package shared
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"os/exec" "os/exec"
"time"
) )
type CommandResponse struct { type CommandResponse struct {
@@ -24,6 +26,34 @@ func RunShellCommand(cmd *exec.Cmd) CommandResponse {
cmd.Stderr = &stderr cmd.Stderr = &stderr
err := cmd.Run() err := cmd.Run()
return parseCmdResult(cmd, stdout, stderr, err)
}
func RunShellCommandNonBlocking(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Start()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
return CommandResponse{}
case err := <-done:
return parseCmdResult(cmd, stdout, stderr, err)
}
}
func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) CommandResponse {
commandOutput := CommandResponse{ commandOutput := CommandResponse{
Stdout: stdout.String(), Stdout: stdout.String(),
Stderr: stderr.String(), Stderr: stderr.String(),