Compare commits

...

30 Commits

Author SHA1 Message Date
2mal3 aa7d1e7ee6 feat: nixos config (#1) 2025-11-07 13:26:10 +01:00
2mal3 93f7b17222 chore(openBrowser): more detailed error logs 2025-11-07 12:42:34 +01:00
E44 0bfe6371fd add Keyboard Input, improve PopUp, improve general code 2025-11-07 12:15:58 +01:00
E44 10a09bc45e add recursive file handling 2025-11-06 16:07:37 +01:00
2mal3 bf08ec946a deps: udpdate 2025-11-06 15:46:41 +01:00
E44 e0f7f36383 add update_all_display_status interval, improve code structure 2025-11-06 12:52:56 +01:00
E44 cf8eeacb5e improve api error handling 2025-11-06 12:51:55 +01:00
E44 2168873d3a add control requests, use pings and improve display_status 2025-11-06 11:50:21 +01:00
E44 9fd81821f1 add Notifications 2025-11-06 11:49:32 +01:00
2mal3 9487fdd13e docs: add lines of code badge 2025-11-05 22:16:15 +01:00
2mal3 aeea501a05 ci(release): fix could not find assets 2025-11-05 22:05:45 +01:00
2mal3 97274243b8 ci(build): ignore yaml file changes 2025-11-05 22:03:09 +01:00
2mal3 d4f9046f3e ci(release): upload executables 2025-11-05 22:02:35 +01:00
2mal3 78d12cf49f docs(display): update with new tooling 2025-11-05 21:54:22 +01:00
2mal3 bbaf08d6b8 fix: typo in mudics name 2025-11-05 21:50:55 +01:00
2mal3 9d2310dace refactor(display): rename bruno project 2025-11-05 21:49:18 +01:00
2mal3 ceba7b9e56 feat(control): advanced device ping route 2025-11-05 21:48:53 +01:00
2mal3 01e2441943 fix(shared): dont log browser start command 2025-11-05 21:36:08 +01:00
2mal3 3297327321 fix(control): would exit instantly 2025-11-05 21:29:12 +01:00
E44 c042ce5ef6 change display ip to correct ip with port 1323 2025-11-04 21:11:22 +01:00
E44 6c042c8129 change control port to 8080 2025-11-04 21:08:16 +01:00
E44 9f8bf01aba edit and delete display added 2025-11-04 20:24:33 +01:00
E44 d355891517 Fix inconsistent naming and empty displaygroup visual bug 2025-11-04 18:53:29 +01:00
2mal3 80271dc8ae ci: fix 2025-11-04 17:48:37 +01:00
2mal3 edc9d312bc ci: fix control build 2025-11-04 17:43:38 +01:00
2mal3 74470c3bb5 deps(control): update deno.lock 2025-11-04 17:43:20 +01:00
2mal3 012934362a ci: build control block 2025-11-04 17:37:10 +01:00
2mal3 8bbacc6ce5 feat(control): open app in browser on startup 2025-11-04 17:21:49 +01:00
2mal3 4ebac7d7fc refactor: move browser open into shared modules 2025-11-04 17:21:23 +01:00
2mal3 fa3d5198d2 deps(control): update deno lock 2025-11-04 16:51:26 +01:00
46 changed files with 1390 additions and 396 deletions
+25 -13
View File
@@ -2,16 +2,24 @@ name: Build
on: on:
push: push:
paths: paths-ignore:
- go.mod - "**/*.md"
- go.sum - "**/*.yml"
- "**/*.go" - "**/*.sh"
- "**/*.tmpl"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
block:
- control
- display
defaults:
run:
working-directory: ${{ matrix.block }}
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,21 +28,25 @@ jobs:
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "^1.24" go-version: "^1.24"
cache-dependency-path: display/go.sum cache-dependency-path: go.sum
- name: Setup Deno
uses: denoland/setup-deno@v2
if: matrix.block == 'control'
with:
deno-version: v2.x
- name: Install Dependencies - name: Install Dependencies
working-directory: display
run: go mod download run: go mod download
- name: Generate Templates - name: Generate
working-directory: display run: go generate ./...
run: go tool templ generate
- name: Build - name: Build
working-directory: display run: go build -o plg-mudics-${{ matrix.block }} .
run: go build -o plg-mudics-display .
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
path: display/plg-mudics-display name: plg-mudics-${{ matrix.block }}
path: ${{matrix.block}}/plg-mudics-${{ matrix.block }}
+50
View File
@@ -10,14 +10,64 @@ permissions:
jobs: jobs:
release: release:
name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Zip NixOS Config
run: zip -rj nixos.zip nixos/
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: PLG-MuDiCS ${{ github.ref_name }} name: PLG-MuDiCS ${{ github.ref_name }}
body_path: CHANGELOG.md body_path: CHANGELOG.md
files: nixos.zip
build:
name: Build and Upload Assets
needs: release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
block:
- control
- display
defaults:
run:
working-directory: ${{ matrix.block }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "^1.24"
cache-dependency-path: go.sum
- name: Setup Deno
uses: denoland/setup-deno@v2
if: matrix.block == 'control'
with:
deno-version: v2.x
- name: Install Dependencies
run: go mod download
- name: Generate
run: go generate ./...
- name: Build
run: go build -o plg-mudics-${{ matrix.block }} .
- name: Upload Release Assets
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ matrix.block }}/plg-mudics-${{ matrix.block }}
tag: ${{ github.ref_name }}
+3 -1
View File
@@ -1 +1,3 @@
# PLG-MuDICS # PLG-MuDiCS
![Lines of Code](https://tokei.rs/b1/github/PLG-Development/PLG-MuDiCS?category=code)
+1 -1
View File
@@ -1,3 +1,3 @@
# PLG-MuDICS Control # PLG-MuDiCS Control
## Development ## Development
+42
View File
@@ -9,13 +9,16 @@
"npm:@sveltejs/vite-plugin-svelte@6": "6.1.0_svelte@5.37.1__acorn@8.15.0_vite@7.0.6__picomatch@4.0.3", "npm:@sveltejs/vite-plugin-svelte@6": "6.1.0_svelte@5.37.1__acorn@8.15.0_vite@7.0.6__picomatch@4.0.3",
"npm:@tailwindcss/typography@~0.5.15": "0.5.16_tailwindcss@4.1.11", "npm:@tailwindcss/typography@~0.5.15": "0.5.16_tailwindcss@4.1.11",
"npm:@tailwindcss/vite@4": "4.1.11_vite@7.0.6__picomatch@4.0.3", "npm:@tailwindcss/vite@4": "4.1.11_vite@7.0.6__picomatch@4.0.3",
"npm:@thisux/sveltednd@^0.0.20": "0.0.20_svelte@5.37.1__acorn@8.15.0",
"npm:eslint-config-prettier@^10.0.1": "10.1.8_eslint@9.33.0", "npm:eslint-config-prettier@^10.0.1": "10.1.8_eslint@9.33.0",
"npm:eslint-plugin-svelte@3": "3.11.0_eslint@9.33.0_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6", "npm:eslint-plugin-svelte@3": "3.11.0_eslint@9.33.0_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6",
"npm:eslint@^9.18.0": "9.33.0", "npm:eslint@^9.18.0": "9.33.0",
"npm:globals@16": "16.3.0", "npm:globals@16": "16.3.0",
"npm:lucide-svelte@0.545": "0.545.0_svelte@5.37.1__acorn@8.15.0",
"npm:prettier-plugin-svelte@^3.3.3": "3.4.0_prettier@3.6.2_svelte@5.37.1__acorn@8.15.0", "npm:prettier-plugin-svelte@^3.3.3": "3.4.0_prettier@3.6.2_svelte@5.37.1__acorn@8.15.0",
"npm:prettier@^3.4.2": "3.6.2", "npm:prettier@^3.4.2": "3.6.2",
"npm:svelte-check@4": "4.3.0_svelte@5.37.1__acorn@8.15.0_typescript@5.8.3", "npm:svelte-check@4": "4.3.0_svelte@5.37.1__acorn@8.15.0_typescript@5.8.3",
"npm:svelte-dnd-action@~0.9.65": "0.9.65_svelte@5.37.1__acorn@8.15.0",
"npm:svelte@5": "5.37.1_acorn@8.15.0", "npm:svelte@5": "5.37.1_acorn@8.15.0",
"npm:tailwindcss@4": "4.1.11", "npm:tailwindcss@4": "4.1.11",
"npm:typescript-eslint@^8.20.0": "8.40.0_eslint@9.33.0_typescript@5.8.3_@typescript-eslint+parser@8.40.0__eslint@9.33.0__typescript@5.8.3", "npm:typescript-eslint@^8.20.0": "8.40.0_eslint@9.33.0_typescript@5.8.3_@typescript-eslint+parser@8.40.0__eslint@9.33.0__typescript@5.8.3",
@@ -596,6 +599,30 @@
"vite" "vite"
] ]
}, },
"@thisux/sveltednd@0.0.14": {
"integrity": "sha512-Vbq69SU3HUomPg6oCXtb89OG89hka0YIkdaErYibn3waK7tYE66IcQxD/Fzg8YNW3EVsXoA9kc7kW5EUBCSQGg=="
},
"@thisux/sveltednd@0.0.17_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-lRninjw439phhA8xAHqCpMAX0hnwFMdbXW4M0XJgAdnGxeum+QsLiIC4P3HnkNXAygsVKUqxRcbS84CxDZ9hPw==",
"dependencies": [
"@thisux/sveltednd@0.0.14",
"svelte"
]
},
"@thisux/sveltednd@0.0.18_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-MO+iR9ZRHApvtwgWujmqwUbhv9mKs0jLOmeG4tn98GmU1/9wVOi02jrNqeggKg3c8dXz6pMmUHfOfmcq7WwYZg==",
"dependencies": [
"@thisux/sveltednd@0.0.17_svelte@5.37.1__acorn@8.15.0",
"svelte"
]
},
"@thisux/sveltednd@0.0.20_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-VE0HopIlHIvNOfSZ1SsiIzl1AmTU0VSd2Nz2Q2nIdqIUsT/UP4hiLw2ed2jFqTKTbqC+kbCYL9vuBpkYnEw+Kw==",
"dependencies": [
"@thisux/sveltednd@0.0.18_svelte@5.37.1__acorn@8.15.0",
"svelte"
]
},
"@tybys/wasm-util@0.10.0": { "@tybys/wasm-util@0.10.0": {
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"dependencies": [ "dependencies": [
@@ -1259,6 +1286,12 @@
"lodash.merge@4.6.2": { "lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
}, },
"lucide-svelte@0.545.0_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-lVza6hIAf1abADTU1ThyNp+M3bQZtzTGQ9EQZ2xo3n9ftkd4QcWbDB2ekP2MqRLTJpeLXX5gaFEgFZtbWCnyqg==",
"dependencies": [
"svelte"
]
},
"magic-string@0.30.17": { "magic-string@0.30.17": {
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dependencies": [ "dependencies": [
@@ -1525,6 +1558,12 @@
], ],
"bin": true "bin": true
}, },
"svelte-dnd-action@0.9.65_svelte@5.37.1__acorn@8.15.0": {
"integrity": "sha512-GKFtrAtYAjcm27aMELoXOhkLtKA1AEoj2njjCReCer6jh1hnRtTHdEO4Kjfpayz+ZAvE0MMwIvLISW3tsiO9Qg==",
"dependencies": [
"svelte"
]
},
"svelte-eslint-parser@1.3.1_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6": { "svelte-eslint-parser@1.3.1_svelte@5.37.1__acorn@8.15.0_postcss@8.5.6": {
"integrity": "sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==", "integrity": "sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==",
"dependencies": [ "dependencies": [
@@ -1689,13 +1728,16 @@
"npm:@sveltejs/vite-plugin-svelte@6", "npm:@sveltejs/vite-plugin-svelte@6",
"npm:@tailwindcss/typography@~0.5.15", "npm:@tailwindcss/typography@~0.5.15",
"npm:@tailwindcss/vite@4", "npm:@tailwindcss/vite@4",
"npm:@thisux/sveltednd@^0.0.20",
"npm:eslint-config-prettier@^10.0.1", "npm:eslint-config-prettier@^10.0.1",
"npm:eslint-plugin-svelte@3", "npm:eslint-plugin-svelte@3",
"npm:eslint@^9.18.0", "npm:eslint@^9.18.0",
"npm:globals@16", "npm:globals@16",
"npm:lucide-svelte@0.545",
"npm:prettier-plugin-svelte@^3.3.3", "npm:prettier-plugin-svelte@^3.3.3",
"npm:prettier@^3.4.2", "npm:prettier@^3.4.2",
"npm:svelte-check@4", "npm:svelte-check@4",
"npm:svelte-dnd-action@~0.9.65",
"npm:svelte@5", "npm:svelte@5",
"npm:tailwindcss@4", "npm:tailwindcss@4",
"npm:typescript-eslint@^8.20.0", "npm:typescript-eslint@^8.20.0",
+2
View File
@@ -5,7 +5,9 @@ import (
"io/fs" "io/fs"
) )
//go:generate deno install
//go:generate deno task build //go:generate deno task build
//go:embed all:build //go:embed all:build
var buildDir embed.FS var buildDir embed.FS
@@ -1,53 +1,101 @@
<script lang="ts"> <script lang="ts">
import { ArrowBigLeft, ArrowBigRight, ChevronDown, Keyboard, Power, PowerOff, Presentation, SquareTerminal, TextAlignStart, TrafficCone } from "lucide-svelte"; import {
import { selected_display_ids } from "../ts/stores/select"; ArrowBigLeft,
import Button from "./Button.svelte"; ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone
} from 'lucide-svelte';
import Button from './Button.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import KeyInput from './KeyInput.svelte';
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
closable: true
});
function popup_close_function() {
popup_content.open = false;
}
const show_send_keys_popup = () => {
popup_content = {
open: true,
snippet: send_keys_popup,
title: 'Tastatur-Eingaben durchgeben',
title_icon: Keyboard,
closable: true
};
};
</script> </script>
{#snippet send_keys_popup()}
<div>
<KeyInput />
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Fertig</Button>
</div>
{/snippet}
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0"> <div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0"> <div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
{'Bildschirme steuern (' + $selected_display_ids.length + ' ausgewählt)'} Bildschirme steuern
</div> </div>
<div class="flex flex-col gap-2 p-2 overflow-auto"> <div class="relative flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-70 justify-normal"> <div class="flex flex-row gap-2 w-75 justify-normal">
<Button title="Vorherige Folie (Pfeil nach Links)" className="px-7" <Button title="Vorherige Folie (Pfeil nach Links)" className="px-9"
><ArrowBigLeft /></Button ><ArrowBigLeft /></Button
> >
<Button title="Nächste Folie (Pfeil nach Rechts)" className="px-7" <Button title="Nächste Folie (Pfeil nach Rechts)" className="px-9"
><ArrowBigRight /></Button ><ArrowBigRight /></Button
> >
</div> </div>
<Button className="px-3 flex gap-3 w-70 justify-normal" <Button className="px-3 flex gap-3 w-75 justify-normal"
><TextAlignStart /> Text anzeigen</Button ><TextAlignStart /> Text anzeigen</Button
> >
<Button className="px-3 flex gap-3 w-70 justify-normal"><Presentation />Blackout</Button> <Button className="px-3 flex gap-3 w-75 justify-normal"><Presentation />Blackout</Button>
<div class="flex flex-row justify-normal"> <div class="flex flex-row justify-normal">
<Button className="rounded-r-none pl-3 flex gap-3 grow w-60 justify-normal" <Button className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal"
><TrafficCone /> Fallback-Bild anzeigen</Button ><TrafficCone /> Fallback-Bild anzeigen</Button
> >
<Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button> <Button className="rounded-l-none flex grow-0 w-10"><ChevronDown /></Button>
</div> </div>
<Button className="px-3 flex gap-3 w-70 justify-normal" <Button
><Keyboard /> Tastatur-Inputs durchgeben</Button className="px-3 flex gap-3 w-75 justify-normal"
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben durchgeben</Button
> >
</div> </div>
<div class="flex flex-col gap-2 justify-between"> <div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal" <Button className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
><Power /> PC hochfahren</Button ><Power /> PC hochfahren</Button
> >
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal" <Button className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
><PowerOff /> PC herunterfahren</Button ><PowerOff /> PC herunterfahren</Button
> >
</div> </div>
<Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal" <Button className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
><SquareTerminal /> Shell-Befehl ausführen</Button ><SquareTerminal /> Shell-Befehl ausführen</Button
> >
</div> </div>
</div> </div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="overflow-hidden"
/>
</div> </div>
</div> </div>
@@ -13,11 +13,12 @@
} from '../ts/stores/displays'; } from '../ts/stores/displays';
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from './DNDGrip.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { DisplayGroup } from '../ts/types'; import type { DisplayGroup, MenuOption } from '../ts/types';
import { selected_display_ids } from '../ts/stores/select'; import { selected_display_ids } from '../ts/stores/select';
let { display_group } = $props<{ let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup; display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[]
}>(); }>();
let hovering_selectable = $state(false); let hovering_selectable = $state(false);
@@ -76,7 +77,7 @@
class="outline-none" class="outline-none"
role="figure" role="figure"
> >
<DisplayObject {display} /> <DisplayObject {display} {get_display_menu_options} />
</section> </section>
{/each} {/each}
@@ -8,11 +8,13 @@
import DNDGrip from './DNDGrip.svelte'; import DNDGrip from './DNDGrip.svelte';
import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte'; import { Menu, Pencil, Pin, PinOff, Trash2, VideoOff, X } from 'lucide-svelte';
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import type { Display } from '../ts/types'; import type { Display, MenuOption } from '../ts/types';
import { is_selected, select, selected_display_ids } from '../ts/stores/select'; import { is_selected, select, selected_display_ids } from '../ts/stores/select';
import { update_screenshot } from '../ts/stores/displays';
let { display } = $props<{ let { display, get_display_menu_options } = $props<{
display: Display; display: Display;
get_display_menu_options: (display_id: string) => MenuOption[];
}>(); }>();
let hovering_unselectable = $state(false); let hovering_unselectable = $state(false);
@@ -28,6 +30,7 @@
} else { } else {
$pinned_display_id = display.id; $pinned_display_id = display.id;
} }
update_screenshot(display.id);
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>
@@ -121,17 +124,7 @@
click_function={(e) => { click_function={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
menu_options={[ menu_options={get_display_menu_options(display.id)}
{
icon: Pencil,
name: 'Bildschirm bearbeiten'
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500'
}
]}
> >
<Menu /> <Menu />
</Button> </Button>
@@ -16,18 +16,42 @@
next_height_step_size, next_height_step_size,
pinned_display_id pinned_display_id
} from '../ts/stores/ui_behavior'; } from '../ts/stores/ui_behavior';
import { type Display, type DisplayGroup } from '../ts/types'; import { type Display, type DisplayGroup, type MenuOption } from '../ts/types';
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, Trash2, VideoOff } from 'lucide-svelte'; import { Menu, Minus, Pencil, PinOff, Plus, Settings, Trash2, VideoOff } 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';
import DisplayGroupObject from './DisplayGroupObject.svelte'; import DisplayGroupObject from './DisplayGroupObject.svelte';
let { handle_display_deletion, handle_display_editing } = $props<{
handle_display_deletion: (display_id: string) => void;
handle_display_editing: (display_id: string) => void;
}>();
let displays_scroll_box: HTMLElement; let displays_scroll_box: HTMLElement;
let pinned_display: Display | null = $derived(get_display_by_id($pinned_display_id || '', $displays)); let pinned_display: Display | null = $derived(
get_display_by_id($pinned_display_id || '', $displays)
);
function get_display_menu_options(display_id: string): MenuOption[] {
return [
{
icon: Pencil,
name: 'Bildschirm bearbeiten',
on_select: () => {handle_display_editing(display_id)},
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
on_select: () => {handle_display_deletion(display_id)},
}
];
}
function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) { function select_all(current_displays: DisplayGroup[], current_selected_display_ids: string[]) {
const new_value = !all_selected(current_displays, current_selected_display_ids); const new_value = !all_selected(current_displays, current_selected_display_ids);
@@ -78,7 +102,7 @@
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<OnlineState <OnlineState
selected={false} selected={false}
status={pinned_display?.status ?? ''} status={pinned_display?.status ?? null}
className="flex items-center px-2" className="flex items-center px-2"
/> />
<Button <Button
@@ -87,17 +111,7 @@
click_function={(e) => { click_function={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
menu_options={[ menu_options={get_display_menu_options($pinned_display_id)}
{
icon: Pencil,
name: 'Bildschirm bearbeiten'
},
{
icon: Trash2,
name: 'Bildschirm löschen',
class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500'
}
]}
> >
<Menu /> <Menu />
</Button> </Button>
@@ -136,7 +150,7 @@
<!-- Normal Heading Left --> <!-- Normal Heading Left -->
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0"> <div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0">
<span class="text-xl font-bold pl-2 content-center truncate min-w-0"> <span class="text-xl font-bold pl-2 content-center truncate min-w-0">
Bereits verbundene Displays Verbundene Bildschirme
</span> </span>
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<button <button
@@ -157,7 +171,7 @@
: 'Alle auswählen'}</span : 'Alle auswählen'}</span
> >
</button> </button>
<div class="flex flex-ro"> <div class="flex flex-row">
<Button <Button
title="Bildschirme größer darstellen" title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none" className="aspect-square !p-1 rounded-r-none"
@@ -202,16 +216,24 @@
$is_group_drag = false; $is_group_drag = false;
}} }}
> >
{#each $displays as display_group (display_group.id)} {#if $displays.length === 1 && $displays[0].data.length === 0}
<!-- Each Group --> <div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
<section Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf <Settings
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }} class="inline pb-1"
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }} /> und "Neuen Bildschirm hinzufügen".
class="outline-none" </div>
> {:else}
<DisplayGroupObject {display_group} /> {#each $displays as display_group (display_group.id)}
</section> <!-- Each Group -->
{/each} <section
out:scale={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
animate:flip={{ duration: dnd_flip_duration_ms, easing: cubicOut }}
class="outline-none"
>
<DisplayGroupObject {display_group} {get_display_menu_options} />
</section>
{/each}
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -30,7 +30,12 @@
import RefreshPlay from './RefreshPlay.svelte'; import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string } from '../ts/utils'; import { get_file_size_display_string } from '../ts/utils';
import { open_file } from '../ts/api_handler'; import { open_file } from '../ts/api_handler';
import { displays, get_display_by_id, update_screenshot } from '../ts/stores/displays'; import {
displays,
get_display_by_id,
run_on_all_selected_displays,
update_screenshot
} from '../ts/stores/displays';
let { file } = $props<{ file: FolderElement }>(); let { file } = $props<{ file: FolderElement }>();
@@ -97,13 +102,7 @@
change_file_path($current_file_path + file.name + '/'); change_file_path($current_file_path + file.name + '/');
} else { } else {
const path_to_file = $current_file_path + file.name; const path_to_file = $current_file_path + file.name;
for (const display_id of $selected_display_ids) { await run_on_all_selected_displays(open_file, true, path_to_file);
const ip = get_display_by_id(display_id, $displays)?.ip ?? null;
if (ip) {
await open_file(ip, path_to_file);
await update_screenshot(display_id);
}
}
} }
} }
</script> </script>
@@ -0,0 +1,70 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import key_map_json from './../../../../shared/keys.json';
import { fade } from 'svelte/transition';
import { selected_display_ids } from '../ts/stores/select';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { send_keyboard_input } from '../ts/api_handler';
const bg = 'bg-stone-700';
const key_map: Record<string, string> = key_map_json as Record<string, string>;
let active = $state(false);
let last_keys: { id: number; key: string }[] = $state([]);
let el: HTMLDivElement;
function add_to_last_keys(name: string) {
const id = Date.now();
last_keys.push({ id, key: name });
setTimeout(() => {
last_keys = last_keys.filter((e) => e.id !== id);
}, 1500);
}
async function on_key_down(e: KeyboardEvent) {
if (!active) return;
const id = key_map[e.code];
if (!id) return;
e.preventDefault();
e.stopPropagation();
add_to_last_keys(e.code);
if (e.repeat) return;
await run_on_all_selected_displays(send_keyboard_input, true, id);
}
</script>
<div
role="textbox"
tabindex="0"
bind:this={el}
onclick={() => {
if (active) {
el.blur();
} else {
el.focus();
active = true;
}
}}
onblur={() => (active = false)}
onkeydown={on_key_down}
class="relative flex justify-center items-center h-15 w-full cursor-pointer rounded-xl transition-colors duration-200 select-none {get_selectable_color_classes(
active,
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
>
{active ? 'Erfassung aktiv' : 'Hier für Erfassung klicken'}
<div class="absolute top-full left-0 ml-1 mt-0.5 flex flex-col-reverse text-sm text-stone-400">
{#each last_keys as key (key.id)}
<span animate:flip={{ duration: 200 }} in:fade={{ duration: 200 }} out:fade={{ duration: 500 }} >{key.key}</span>
{/each}
</div>
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import { fade } from "svelte/transition";
import Button from "./Button.svelte";
import { X } from "lucide-svelte";
import { notifications } from "../ts/stores/notification";
import { get_shifted_color } from "../ts/stores/ui_behavior";
</script>
<div class="fixed flex flex-col gap-2 {true ? "top-[41%]": "top-2"} right-2 left-2 md:top-auto md:left-auto md:bottom-2 md:w-100 z-50">
{#each $notifications as n (n.id)}
<div
transition:fade={{ duration: 200 }}
class="p-2 pl-4 pb-3 rounded-lg shadow-xl/30 text-white flex flex-col gap-2 overflow-hidden relative border-1 border-black/20 {n.className}"
class:bg-red-900={n.type === "error"}
class:bg-green-900={n.type === "success"}
class:bg-sky-900={n.type === "info"}
style="--dur: {n.duration}ms"
>
<div class="flex flex-row justify-between">
<span class="text-xl font-bold flex items-center">{n.title}</span>
<Button click_function={() => notifications.remove(n.id)} className="p-2" bg="bg-stone-900/50" hover_bg="bg-stone-600/70" active_bg="bg-stone-500/80"><X/></Button>
</div>
<span class="whitespace-break-spaces">{n.message}</span>
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<div
class="block h-full w-full bg-white/80 origin-left animate-progress-bar"
></div>
</div>
</div>
{/each}
</div>
<style>
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.animate-progress-bar {
animation: progress var(--dur) linear forwards;
transform-origin: left;
}
</style>
@@ -1,17 +1,19 @@
<script lang="ts"> <script lang="ts">
import { display_status_to_info, type DisplayStatus } from "../ts/types";
let { selected, status, className = "" } = $props<{ let { selected, status, className = "" } = $props<{
selected: boolean; selected: boolean;
status: string; status: DisplayStatus;
className?: string; className?: string;
}>(); }>();
function get_text_color(selected: boolean, status: string) { function get_text_color(selected: boolean, status: DisplayStatus) {
switch (status) { switch (status) {
case 'Online': case 'app_online':
return selected ? 'text-green-700' : 'text-green-400'; return selected ? 'text-green-700' : 'text-green-400';
case 'Lädt': case 'app_offline':
return selected ? 'text-amber-700' : 'text-amber-400'; return selected ? 'text-amber-700' : 'text-amber-400';
case 'Offline': case 'host_offline':
return selected ? 'text-red-700' : 'text-red-400'; return selected ? 'text-red-700' : 'text-red-400';
default: default:
return selected ? 'text-stone-700' : 'text-stone-400'; return selected ? 'text-stone-700' : 'text-stone-400';
@@ -20,5 +22,5 @@
</script> </script>
<div class="{get_text_color(selected, status)} {className} transition-colors duration-100"> <div class="{get_text_color(selected, status)} {className} transition-colors duration-100">
{status} {display_status_to_info(status)}
</div> </div>
+10 -8
View File
@@ -5,9 +5,11 @@
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '../ts/types';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { content, close_function } = $props<{ let { content, close_function, className = '', snippet_container_class = '' } = $props<{
content: PopupContent; content: PopupContent;
close_function: () => void; close_function: () => void;
className?: string;
snippet_container_class?: string;
}>(); }>();
function try_to_close() { function try_to_close() {
@@ -31,23 +33,23 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="absolute inset-0 backdrop-blur bg-white/10 flex justify-center items-center" class="absolute inset-0 backdrop-blur flex justify-center items-center {className}"
onclick={try_to_close} onclick={try_to_close}
transition:fade={{ duration: 100 }} transition:fade={{ duration: 100 }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="bg-stone-800 rounded-2xl min-w-[30%] min-h-[30%] max-w-[80%] max-h-[80%] flex flex-col shadow-2xl/60 overflow-hidden" class="bg-stone-800 rounded-2xl min-w-[30%] max-w-[80%] max-h-[80%] flex flex-col shadow-2xl/60 overflow-hidden"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<div <div
class="text-2xl font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-6 w-full" class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full"
> >
<div class="flex flex-row flex-1 gap-4 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 flex-shrink-0 {content.title_class ?? ''}"> <div class="flex flex-row flex-1 gap-3 pl-2 py-1 items-center grow whitespace-nowrap min-w-0 flex-shrink-0 text-lg {content.title_class ?? ''}">
{#if content.title_icon} {#if content.title_icon}
{@const Icon = content.title_icon} {@const Icon = content.title_icon}
<Icon strokeWidth="2.8" class="flex-shrink-0" /> <Icon strokeWidth="2" class="flex-shrink-0" />
{/if} {/if}
<div class="flex-shrink-0"> <div class="flex-shrink-0">
{content.title} {content.title}
@@ -61,8 +63,8 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2"> <div class="p-2 min-h-0 overflow-auto flex flex-col gap-2 {snippet_container_class}">
{@render content.snippet()} {@render content.snippet(content.snippet_arg)}
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import Notification from '../components/Notification.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
{@render children()} {@render children()}
<Notification />
+123 -27
View File
@@ -1,15 +1,26 @@
<script lang="ts"> <script lang="ts">
import { Monitor, Plus, Radio, Settings, X } from 'lucide-svelte'; import { Monitor, Plus, Radio, Settings, Trash2, X } from 'lucide-svelte';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import FileView from '../components/FileView.svelte'; import FileView from '../components/FileView.svelte';
import ControlView from '../components/ControlView.svelte'; import ControlView from '../components/ControlView.svelte';
import DisplayView from '../components/DisplayView.svelte'; import DisplayView from '../components/DisplayView.svelte';
import SplashScreen from './../../../../shared/splash_screen.html?raw'; import SplashScreen from './../../../../shared/splash_screen.html?raw';
import PopUp from '../components/PopUp.svelte'; import PopUp from '../components/PopUp.svelte';
import type { PopupContent } from '../ts/types'; import { display_status_to_info, type PopupContent } from '../ts/types';
import TextInput from '../components/TextInput.svelte'; import TextInput from '../components/TextInput.svelte';
import { add_display, is_display_name_taken } from '../ts/stores/displays'; import {
add_display,
displays,
edit_display_data,
get_display_by_id,
is_display_name_taken,
remove_display
} from '../ts/stores/displays';
import { text } from '@sveltejs/kit'; import { text } from '@sveltejs/kit';
import { notifications } from '../ts/stores/notification';
import { ping_ip } from '../ts/api_handler';
import { onMount } from 'svelte';
import { on_start } from '../ts/main';
const ip_regex = const ip_regex =
/^(?:(?:10|127)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|192\.168\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|172\.(?:1[6-9]|2\d|3[0-1])\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d))$/; /^(?:(?:10|127)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|192\.168\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)|172\.(?:1[6-9]|2\d|3[0-1])\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d))$/;
@@ -19,14 +30,16 @@
open: false, open: false,
snippet: null, snippet: null,
title: '', title: '',
title_class: '!text-xl',
closable: true closable: true
}); });
let text_inputs_valid = $state({ const text_inputs_valid_null_values = {
name: { valid: false, value: '' }, name: { valid: false, value: '' },
ip: { valid: false, value: '' }, ip: { valid: false, value: '' },
mac: { valid: false, value: '' } mac: { valid: false, value: '' }
}); };
let text_inputs_valid = $state(text_inputs_valid_null_values);
function all_text_inputs_valid(): boolean { function all_text_inputs_valid(): boolean {
for (const entry of Object.values(text_inputs_valid)) { for (const entry of Object.values(text_inputs_valid)) {
@@ -37,17 +50,17 @@
return true; return true;
} }
function finalize_add_new_display() { async function finalize_add_edit_display(existing_display_id: string | null) {
const ip = text_inputs_valid.ip.value; const ip = text_inputs_valid.ip.value;
const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value; const mac = text_inputs_valid.mac.value === '' ? null : text_inputs_valid.mac.value;
const name = text_inputs_valid.name.value; const name = text_inputs_valid.name.value;
add_display(ip, mac, name, 'Online'); if (!!existing_display_id) {
edit_display_data(existing_display_id, ip, mac, name);
} else {
const status = await ping_ip(text_inputs_valid.ip.value);
add_display(ip, mac, name, status);
}
popup_close_function(); popup_close_function();
text_inputs_valid = {
name: { valid: false, value: '' },
ip: { valid: false, value: '' },
mac: { valid: false, value: '' }
};
} }
function popup_close_function() { function popup_close_function() {
@@ -55,28 +68,87 @@
} }
const show_new_display_popup = () => { const show_new_display_popup = () => {
text_inputs_valid = text_inputs_valid_null_values;
popup_content = { popup_content = {
open: true, open: true,
snippet: add_new_display, snippet: add_new_display_popup,
title: 'Neuen Bildschirm hinzufügen', title: 'Neuen Bildschirm hinzufügen',
title_icon: Monitor, title_icon: Monitor,
title_class: '!text-xl',
closable: true closable: true
}; };
}; };
const show_remove_display_popup = (display_id: string) => {
popup_content = {
open: true,
snippet: remove_display_popup,
snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400 !text-xl',
title_icon: Trash2,
closable: true
};
};
const show_edit_display_popup = (display_id: string) => {
const display = get_display_by_id(display_id, $displays);
if (!display) return;
// insert existing values in text_inputs_valid
for (const key of Object.keys(text_inputs_valid) as (keyof typeof text_inputs_valid)[]) {
text_inputs_valid[key].valid = true;
text_inputs_valid[key].value = display[key] || '';
}
popup_content = {
open: true,
snippet: add_new_display_popup,
snippet_arg: display_id,
title: 'Bildschirm bearbeiten',
title_icon: Monitor,
title_class: '!text-xl',
closable: true
};
};
onMount(() => {
on_start();
});
</script> </script>
{#snippet add_new_display()} {#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2">
Soll der Bildschirm "{get_display_by_id(display_id, $displays)?.name || '?'}" wirklich gelöscht
werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem
Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
<Button
hover_bg="bg-red-400"
active_bg="bg-red-500"
className="px-4 flex text-red-400 hover:text-stone-100"
click_function={() => {
remove_display(display_id);
popup_close_function();
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet add_new_display_popup(existing_display_id: string | null = null)}
<TextInput <TextInput
bind:current_value={text_inputs_valid.name.value} bind:current_value={text_inputs_valid.name.value}
bind:current_valid={text_inputs_valid.name.valid} bind:current_valid={text_inputs_valid.name.valid}
title="Anzeigename" title="Anzeigename"
placeholder="z.B. Beamer vorne links" placeholder="z.B. Beamer vorne links"
is_valid_function={(input: string) => { is_valid_function={(input: string) => {
return input.length === 0 || input.length > 50 if (!!existing_display_id) {
? [false, 'Ungültige Länge'] if (input === get_display_by_id(existing_display_id, $displays)?.name)
: is_display_name_taken(input) return [true, 'Gültiger Name'];
? [false, 'Name bereits verwendet'] }
: [true, 'Gültiger Name']; if (input.length === 0 || input.length > 50) return [false, 'Ungültige Länge'];
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}} }}
/> />
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@@ -93,8 +165,18 @@
className="grow" className="grow"
/> />
<div class="flex items-end shrink-0"> <div class="flex items-end shrink-0">
<Button disabled={!text_inputs_valid.ip.valid} className="px-4 gap-2" bg="bg-stone-750" <Button
><Radio /> Ping</Button disabled={!text_inputs_valid.ip.valid}
className="px-4 gap-2"
bg="bg-stone-750"
click_function={async () => {
const status = await ping_ip(text_inputs_valid.ip.value);
notifications.push(
'info',
`Ping '${text_inputs_valid.ip.value}'`,
`Aktueller Zustand: ${display_status_to_info(status)}`
);
}}><Radio /> Ping</Button
> >
</div> </div>
</div> </div>
@@ -111,13 +193,24 @@
: [false, 'Ungültige MAC-Adresse']; : [false, 'Ungültige MAC-Adresse'];
}} }}
/> />
<div class="flex justify-end pt-2"> <div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id}
<!-- TODO: Ping mit existing_display_id -->
<Button className="px-4" click_function={popup_close_function}>Abbrechen</Button>
{/if}
<Button <Button
disabled={!all_text_inputs_valid()} disabled={!all_text_inputs_valid()}
className="pl-3 pr-4 gap-2 font-bold" className="{!!existing_display_id ? 'px-4' : 'pl-3 pr-4 gap-2'} font-bold"
bg="bg-stone-650" bg="bg-stone-650"
click_function={finalize_add_new_display}><Plus /> Bildschirm hinzufügen</Button click_function={() => {
> finalize_add_edit_display(existing_display_id);
}}
>{#if !!existing_display_id}
Speichern
{:else}
<Plus /> Bildschirm hinzufügen
{/if}
</Button>
</div> </div>
{/snippet} {/snippet}
@@ -146,7 +239,10 @@
</Button> </Button>
</div> </div>
<div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2"> <div class="w-[calc(100dvw-(8*var(--spacing)))] grid grid-cols-2 gap-2">
<DisplayView /> <DisplayView
handle_display_deletion={show_remove_display_popup}
handle_display_editing={show_edit_display_popup}
/>
<div <div
class="col-start-2 h-[calc(100dvh-3rem-(6*var(--spacing)))] rounded-2xl flex flex-col gap-2" class="col-start-2 h-[calc(100dvh-3rem-(6*var(--spacing)))] rounded-2xl flex flex-col gap-2"
> >
@@ -154,5 +250,5 @@
<FileView /> <FileView />
</div> </div>
</div> </div>
<PopUp content={popup_content} close_function={popup_close_function} /> <PopUp content={popup_content} close_function={popup_close_function} className="bg-white/10" />
</main> </main>
+3
View File
@@ -213,5 +213,8 @@ module.exports = {
'hover:bg-red-400', 'hover:bg-red-400',
'active:bg-red-500', 'active:bg-red-500',
'hover:bg-stone-600/70',
'active:bg-stone-500/80',
], ],
} }
+57 -12
View File
@@ -1,14 +1,27 @@
import type { FolderElement } from "./types"; import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement } from "./types";
import { get_uuid } from "./utils"; import { get_uuid } from "./utils";
export async function get_screenshot(ip: string) { export async function get_screenshot(ip: string) {
const options = { method: 'PATCH' }; const options = { method: 'PATCH' };
return await request(ip, '/takeScreenshot', options); return await request_display(ip, '/takeScreenshot', options);
} }
export async function open_file(ip: string, path_to_file: string) { export async function open_file(ip: string, path_to_file: string): Promise<void> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } }; const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
const raw_response = await request(ip, `/file${path_to_file}`, options); await request_display(ip, `/file${path_to_file}`, options);
}
export async function send_keyboard_input(ip: string, key: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key,
}),
};
console.log(options)
await request_display(ip, '/keyboardInput', options);
} }
export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> { export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
@@ -34,7 +47,8 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
done done
` }) ` })
}; };
const raw_response = await request(ip, '/shellCommand', options); const raw_response = await request_display(ip, '/shellCommand', options);
if (!raw_response) return [];
const response: FileInfo[] = raw_response.stdout.trim() const response: FileInfo[] = raw_response.stdout.trim()
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
@@ -62,22 +76,53 @@ done
export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response) return null;
return raw_response.status ? to_display_status(raw_response.status) : null;
}
async function request(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }) {
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `http://${ip}:1323/api${api_route}`;
return await raw_request(url, options);
}
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
const url = `${window.location.origin}/api${api_route}`;
return await raw_request(url, options);
}
async function raw_request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
try { try {
const url = `http://${ip}:1323/api${api_route}?t=${Date.now()}`; const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
console.log(url) console.log(url + cache_buster)
const response = await fetch(url, options); const response = await fetch(url + cache_buster, options);
if (!response.ok) { if (!response.ok) {
console.error(`HTTP error! Status: ${response.status}`); console.error(`HTTP error! Status: ${response.status}`);
notifications.push("error", `HTTP-Fehler bei API-Anfrage`, `${url}\nHTTP-Status: ${response.status}`);
} }
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) { if (!contentType.includes("application/json")) {
return await response.blob(); return await response.blob();
} else { } else {
return await response.json(); const json = await response.json();
if (json.error && json.error !== '') {
notifications.push("error", `Interner Fehler bei API-Anfrage`, `${url}\nJSON: ${JSON.stringify(json)}`);
}
return json;
} }
} catch (error) { } catch (error: any) {
console.error(error); if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?")
} else {
console.log(typeof error, error instanceof TypeError, error.message);
console.error(error);
notifications.push("error", `Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
}
return null;
} }
} }
+20 -1
View File
@@ -1,4 +1,23 @@
export function on_start() { import { get } from "svelte/store";
import { displays, update_displays_with_map } from "./stores/displays"
import { ping_ip } from "./api_handler";
import type { Display } from "./types";
import { change_file_path, update_all_display_files } from "./stores/files";
const update_display_status_interval_seconds = 20;
export function on_start() {
update_all_display_status();
setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
update_all_display_files('/');
} }
async function update_all_display_status() {
update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display;
return { ...display, status: new_status, };
});
console.log("Display Status updated")
}
+60 -21
View File
@@ -1,8 +1,9 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import type { Display, DisplayGroup } from "../types"; import type { Display, DisplayGroup, DisplayStatus } from "../types";
import { is_selected, select, selected_display_ids } from "./select"; import { is_selected, select, selected_display_ids } from "./select";
import { get_uuid, image_content_hash } from "../utils"; import { get_uuid, image_content_hash } from "../utils";
import { get_screenshot } from "../api_handler"; import { get_screenshot } from "../api_handler";
import DisplayGroupObject from "../../components/DisplayGroupObject.svelte";
export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{ export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
id: get_uuid(), id: get_uuid(),
@@ -11,19 +12,39 @@ export const displays: Writable<DisplayGroup[]> = writable<DisplayGroup[]>([{
export function is_display_name_taken(name: string): boolean { export function is_display_name_taken(name: string): boolean {
const display_groups = get(displays); const display_groups = get(displays);
return display_groups.some(group => return display_groups.some(group =>
group.data.some(display => display.name.trim().toLowerCase() === name.trim().toLowerCase()) group.data.some(display => display.name.trim().toLowerCase() === name.trim().toLowerCase())
); );
} }
export function add_display(ip: string, mac: string|null, name: string, status: string) { export function add_display(ip: string, mac: string | null, name: string, status: DisplayStatus) {
displays.update((displays: DisplayGroup[]) => { displays.update((displays: DisplayGroup[]) => {
displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status }); displays[0].data.push({ id: get_uuid(), ip, preview_url: null, preview_timeout_id: null, mac, name, status });
return displays; return displays;
}); });
} }
export function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name };
})
}
export function remove_display(display_id: string) {
console.log(display_id);
displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({
...display_group,
data: display_group.data.filter(display => display.id !== display_id)
}));
return displays;
});
// TODO remove ID from Files usw.
}
export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) { export function all_displays_of_group_selected(display_group: DisplayGroup, current_selected_displays: string[]) {
if (display_group.data.length === 0) return false; if (display_group.data.length === 0) return false;
for (const display of display_group.data) { for (const display of display_group.data) {
@@ -112,23 +133,41 @@ export async function update_screenshot(display_id: string, check_type: "first_c
} }
if (update_needed) { if (update_needed) {
displays.update((display_groups) => update_displays_with_map((display: Display) => {
display_groups.map((group) => ({ if (display.id !== display_id) return display;
...group, if (display.preview_url) {
data: group.data.map((display) => { URL.revokeObjectURL(display.preview_url);
if (display.id !== display_id) return display; }
if (display.preview_url) { const new_url = URL.createObjectURL(new_blob);
URL.revokeObjectURL(display.preview_url); return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
} })
const new_url = URL.createObjectURL(new_blob);
return { ...display, preview_url: new_url, preview_timeout_id: new_preview_timeout_id };
}),
}))
);
} }
} }
export async function update_displays_with_map(update_function: (display: Display) => Display | Promise<Display>) {
const display_groups = get(displays);
const updated_groups = await Promise.all(
display_groups.map(async (group: DisplayGroup) => ({
...group,
data: await Promise.all(group.data.map(update_function)),
}))
);
displays.set(updated_groups);
}
export async function run_on_all_selected_displays(run_function: ((ip: string, ...args: any[]) => void | Promise<void>), update_screenshot_afterwards: boolean, ...args: any[]) {
for (const display_id of get(selected_display_ids)) {
const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (display_ip) {
await run_function(display_ip, ...args)
if (update_screenshot_afterwards) {
await update_screenshot(display_id);
}
}
}
}
@@ -139,6 +178,6 @@ function add_testing_displays() {
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline"); // add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", name, "Offline");
// } // }
// add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "Offline"); add_display("127.0.0.1", "00:1A:2B:3C:4D:5E", "PC", "host_offline");
// add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "Online"); // add_display("192.168.178.111", "D4:81:D7:C0:DF:3C", "Laptop", "host_offline");
} }
+98 -137
View File
@@ -1,9 +1,11 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import type { FolderElement } from "../types"; import type { Display, FolderElement } from "../types";
import { displays } from "./displays"; import { displays, update_displays_with_map } from "./displays";
import { selected_display_ids, selected_file_ids } from "./select"; import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data } from "../api_handler"; import { get_file_data } from "../api_handler";
import { get_uuid } from "../utils"; import { get_uuid } from "../utils";
import type { Folder } from "lucide-svelte";
import { notifications } from "./notification";
export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({}); export const all_files: Writable<Record<string, Record<string, FolderElement[]>>> = writable<Record<string, Record<string, FolderElement[]>>>({});
// { // {
@@ -28,6 +30,7 @@ export function change_file_path(new_path: string) {
selected_file_ids.update(() => { selected_file_ids.update(() => {
return []; return [];
}) })
setTimeout(async () => { await update_all_display_files(new_path) }, 0);
} }
export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] { export function get_display_ids_where_file_is_missing(path: string, file: FolderElement, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[][] {
@@ -53,36 +56,111 @@ export function get_display_ids_where_file_is_missing(path: string, file: Folder
return [missing, colliding]; return [missing, colliding];
} }
export async function update_all_display_files(path: string) {
for (const display_group of get(displays)) {
for (const display of display_group.data) {
await update_folder_elements_recursively(display, path);
}
}
}
export function updates_files_on_display(display_id: string, new_folder_elements: FolderElement[], file_path: string) { async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
all_files.update((files) => { const new_folder_elements = await get_file_data(display.ip, file_path);
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
if (!files.hasOwnProperty(file_path)) { if (!files.hasOwnProperty(file_path)) {
files[file_path] = {}; files[file_path] = {};
} }
for (const new_folder_element of new_folder_elements) { if (!files[file_path].hasOwnProperty(display.id)) {
new_folder_element.id = get_uuid(); files[file_path][display.id] = [];
} }
files[file_path][display_id] = new_folder_elements;
return files;
});
}
async function get_files_on_all_displays(current_file_path: string) { const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : [];
for (const display_group of get(displays)) {
for (const display of display_group.data) { const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements);
const new_folder_elements = await get_file_data(display.ip, current_file_path); files[file_path][display.id].push(...diff.new);
updates_files_on_display(display.id, new_folder_elements, current_file_path) return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
console.log(new_folder_elements) })
let folder_size = 0;
for (const element of new_folder_elements) {
if (element.type === 'inode/directory') {
const folder_content_size = await update_folder_elements_recursively(display, file_path + element.name + '/');
folder_size += folder_content_size;
// Update foldersize
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => {
for (const current_folder_element of files[file_path][display.id]) {
if (current_folder_element.id === element.id) {
current_folder_element.size = folder_content_size;
}
}
return files;
})
} else {
folder_size += element.size;
} }
} }
return folder_size;
} }
function remove_folder_elements_recursively(files: Record<string, Record<string, FolderElement[]>>, display: Display, folder_elements: FolderElement[], file_path: string): Record<string, Record<string, FolderElement[]>> {
if (!files.hasOwnProperty(file_path) || !files[file_path].hasOwnProperty(display.id)) {
console.error("File remove path does not exist:", files, display, folder_elements, file_path);
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist: ${file_path} display_ip: ${display.ip}`);
return {};
}
for (const folder_element of folder_elements) {
files[file_path][display.id] = files[file_path][display.id].filter((f) => f.id !== folder_element.id);
if (folder_element.type === 'inode/directory') {
const new_file_path = file_path + folder_element.name + '/';
if (!files.hasOwnProperty(new_file_path) || !files[new_file_path].hasOwnProperty(display.id)) {
console.error("File remove path does not exist (recursion):", files, display, folder_elements, file_path, new_file_path);
notifications.push("error", "Fehler beim Aktualisieren der Dateien", `File remove path does not exist (recursion): ${new_file_path} display_ip: ${display.ip}`);
return {};
}
const sub_folder = files[new_file_path][display.id];
remove_folder_elements_recursively(files, display, sub_folder, new_file_path);
}
}
return files;
}
function get_folder_elements_difference(old_elements: FolderElement[], new_elements: FolderElement[]): { deleted: FolderElement[], new: FolderElement[] } {
const old_hashes = new Set(old_elements.map(e => e.hash));
const new_hashes = new Set(new_elements.map(e => e.hash));
const only_in_old = old_elements.filter(e => !new_hashes.has(e.hash));
const only_in_new = new_elements.filter(e => !old_hashes.has(e.hash));
return { deleted: only_in_old, new: only_in_new };
}
// export function updates_files_on_display(display_id: string, new_folder_elements: FolderElement[], file_path: string) {
// all_files.update((files) => {
// if (!files.hasOwnProperty(file_path)) {
// files[file_path] = {};
// }
// files[file_path][display_id] = new_folder_elements;
// return files;
// });
// }
// async function get_files_on_all_displays(current_file_path: string) {
// for (const display_group of get(displays)) {
// for (const display of display_group.data) {
// const new_folder_elements = await get_file_data(display.ip, current_file_path);
// updates_files_on_display(display.id, new_folder_elements, current_file_path)
// console.log(new_folder_elements)
// }
// }
// }
export function get_current_folder_elements(all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, selected_display_ids: string[]) { export function get_current_folder_elements(all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, selected_display_ids: string[]) {
if (!all_files.hasOwnProperty(current_file_path)) { if (!all_files.hasOwnProperty(current_file_path)) return [];
setTimeout(async () => { await get_files_on_all_displays(current_file_path) }, 0);
return [];
}
const files_on_display_array = all_files[current_file_path]; const files_on_display_array = all_files[current_file_path];
const files: FolderElement[] = []; const files: FolderElement[] = [];
for (const key of Object.keys(files_on_display_array)) { for (const key of Object.keys(files_on_display_array)) {
@@ -123,120 +201,3 @@ function sort_files(files: FolderElement[]) {
return files; return files;
} }
add_test_files();
export function add_test_files() {
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "ok",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// {
// hash: "lang",
// thumbnail: null,
// name: "langer Bildname der wirklich gar nicht mehr aufhört, sodass man mal gut testen kann, wie die UI darauf reagiert.odp",
// type: "application/vnd.oasis.opendocument.presentation",
// date_created: new Date("2028-09-10"),
// size: "324 KB"
// },
// {
// hash: "Schön hier aber waren Sie mal in Baden Würtemberg?",
// thumbnail: null,
// name: "Schön hier aber waren Sie mal in Baden Würtemberg?.mp4",
// type: "video/mp4",
// date_created: new Date("2024-09-10"),
// size: "32 KB"
// },
// {
// hash: "lan4234g",
// thumbnail: null,
// name: "Ein schöner Ordner",
// type: 'inode/directory',
// date_created: new Date("2025-01-02"),
// size: "324 TB"
// },
// {
// hash: "Schön hi23424er aber waren Sie mal in Baden Würtemberg?",
// thumbnail: null,
// name: "Ein hässlicher Ordner",
// type: 'inode/directory',
// date_created: new Date("2025-10-22"),
// size: "1 B"
// },
// ], '/');
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "ok",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// ], '/Ein hässlicher Ordner/');
// updates_files_on_display(get(displays)[0].data[0].id, [
// {
// hash: "nö",
// thumbnail: null,
// name: "nö.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "na gut",
// thumbnail: null,
// name: "na gut.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },
// ], '/Ein schöner Ordner/');
// updates_files_on_display(get(displays)[0].data[1].id, [
// {
// hash: "okk",
// thumbnail: null,
// name: "ok.png",
// type: "image/png",
// date_created: new Date("2022-09-10"),
// size: "32 KB"
// },
// {
// hash: "schön",
// thumbnail: null,
// name: "schön.png",
// type: "image/png",
// date_created: new Date("2023-09-10"),
// size: "2 GB"
// },], '/');
}
@@ -0,0 +1,28 @@
import { get, writable } from "svelte/store";
export type Notification = {
id: number;
title: string;
message: string;
duration: number;
className: string;
type?: "error" | "success" | "info";
};
function createNotifications() {
const { subscribe, update } = writable<Notification[]>([]);
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now();
const duration = type === "error" ? 16000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => {
update((n) => n.filter((x) => x.id !== id));
}, duration);
}
const remove = (id: number) => update(n => n.filter(x => x.id !== id));
return { subscribe, push, remove };
}
export const notifications = createNotifications();
+26 -3
View File
@@ -1,4 +1,5 @@
import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte"; import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte";
export type SupportedFileType = { export type SupportedFileType = {
display_name: display_name:
@@ -60,9 +61,9 @@ export type Display = {
ip: string; ip: string;
preview_url: string | null; preview_url: string | null;
preview_timeout_id: number | null; preview_timeout_id: number | null;
mac: string|null; mac: string | null;
name: string; name: string;
status: string; status: DisplayStatus;
} }
export type DisplayGroup = { export type DisplayGroup = {
@@ -81,9 +82,31 @@ export type MenuOption = {
export type PopupContent = { export type PopupContent = {
open: boolean; open: boolean;
snippet: any; snippet: Snippet<[string]> | null;
snippet_arg?: string;
title: string; title: string;
title_class?: string; title_class?: string;
title_icon?: typeof X | null; title_icon?: typeof X | null;
closable?: boolean; closable?: boolean;
}
export type DisplayStatus = "host_offline" | "app_offline" | "app_online" | null;
export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value)
? (value as DisplayStatus)
: null;
}
export function display_status_to_info(status: DisplayStatus): string {
switch (status) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Lädt';
case 'host_offline':
return 'Offline';
case null:
return '???';
}
} }
+1 -3
View File
@@ -2,9 +2,7 @@ module plg-mudics/control
go 1.24.5 go 1.24.5
require ( require github.com/labstack/echo/v4 v4.13.4
github.com/labstack/echo/v4 v4.13.4
)
require ( require (
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
+8
View File
@@ -1,3 +1,5 @@
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/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 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/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 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -6,6 +8,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -21,3 +27,5 @@ 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/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+36 -4
View File
@@ -2,8 +2,12 @@ package main
import ( import (
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os/exec"
"plg-mudics/control/frontend" "plg-mudics/control/frontend"
"plg-mudics/shared"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@@ -20,12 +24,40 @@ func main() {
// Servers all API endpoints, e.g. our custom logic // Servers all API endpoints, e.g. our custom logic
apiGroup := e.Group("/api") apiGroup := e.Group("/api")
apiGroup.GET("/ping", func(ctx echo.Context) error { apiGroup.GET("/ping", pingRoute)
return ctx.String(http.StatusOK, "pong")
})
err := e.Start(":1323") port := "8080"
go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
err := e.Start(":" + port)
if err != nil { if err != nil {
slog.Error("Failed to start Echo Webserver", "error", err) slog.Error("Failed to start Echo Webserver", "error", err)
} }
} }
func pingRoute(ctx echo.Context) error {
ip := ctx.QueryParam("ip")
if ip == "" {
return ctx.JSON(http.StatusBadRequest, PingResponse{Error: "missing 'ip' query parameter"})
}
cmd := exec.Command("ping", "-c", "1", "-w", "1", ip)
result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 {
return ctx.JSON(http.StatusOK, PingResponse{Status: "host_offline"})
}
conn, err := net.DialTimeout("tcp", ip+":1323", 1*time.Second)
if err != nil {
return ctx.JSON(http.StatusOK, PingResponse{Status: "app_offline"})
}
conn.Close()
return ctx.JSON(http.StatusOK, PingResponse{Status: "app_online"})
}
type PingResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
+9
View File
@@ -0,0 +1,9 @@
{
"version": "1",
"name": "PLG-MuDiCS Control",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}
+20
View File
@@ -0,0 +1,20 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: http://127.0.0.1:8080/api/ping?ip=127.0.0.1
body: none
auth: inherit
}
params:query {
ip: 127.0.0.1
}
settings {
encodeUrl: true
timeout: 0
}
+3 -3
View File
@@ -1,4 +1,4 @@
# PLG-MuDICS Display # PLG-MuDiCS Display
## Runtime Requirements ## Runtime Requirements
@@ -11,6 +11,6 @@
## Development Setup ## Development Setup
- install Go (at least version v1.24) - install Go (at least version v1.25)
- build template: `go tool templ generate` - build template: `go generate ./..`
- run code: `go run .` - run code: `go run .`
+9 -8
View File
@@ -3,13 +3,13 @@ module plg-mudics/display
go 1.24.4 go 1.24.4
require ( require (
github.com/a-h/templ v0.3.960
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
) )
require ( require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect 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/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect github.com/cli/browser v1.3.0 // indirect
@@ -21,13 +21,14 @@ require (
github.com/natefinch/atomic v1.0.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.32.0 // indirect golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.35.0 // indirect
) )
tool github.com/a-h/templ/cmd/templ tool github.com/a-h/templ/cmd/templ
+20 -16
View File
@@ -1,7 +1,7 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= 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/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.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 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 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -34,20 +36,22 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"plg-mudics/display/pkg" "plg-mudics/display/pkg"
"plg-mudics/display/web" "plg-mudics/display/web"
"plg-mudics/shared"
) )
const VERSION = "0.1.0" const VERSION = "0.1.0"
@@ -22,10 +23,10 @@ func main() {
os.Exit(1) os.Exit(1)
return return
} }
port := "1323"
go web.StartWebServer(VERSION, port)
go web.StartWebServer(VERSION) err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
err = pkg.OpenBrowserWindow("http://127.0.0.1:1323")
if err != nil { if err != nil {
slog.Error("Failed to open browser window", "error", err) slog.Error("Failed to open browser window", "error", err)
os.Exit(1) os.Exit(1)
+5 -4
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"plg-mudics/shared"
"strings" "strings"
"time" "time"
) )
@@ -36,7 +37,7 @@ func GenerateFilePreview(inputPath string) (string, error) {
func generateImagePreview(inputPath string, outputPath string) error { func generateImagePreview(inputPath string, outputPath string) error {
cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath) cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
@@ -48,12 +49,12 @@ func generateImagePreview(inputPath string, outputPath string) error {
func generatePDFPreview(inputPath string, outputPath string) error { func generatePDFPreview(inputPath string, outputPath string) error {
testCmd := exec.Command("which", "gs") testCmd := exec.Command("which", "gs")
if result := RunShellCommand(testCmd); result.ExitCode != 0 { if result := shared.RunShellCommand(testCmd); result.ExitCode != 0 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
} }
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath) cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath)
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
@@ -67,7 +68,7 @@ func generateVideoPreview(inputPath string, outputPath string) error {
tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("preview_temp_%d.webp", time.Now().Unix())) tempFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("preview_temp_%d.webp", time.Now().Unix()))
ffmpegCmd := exec.Command("ffmpeg", "-i", inputPath, "-ss", "00:00:01.000", "-vframes", "1", tempFilePath) ffmpegCmd := exec.Command("ffmpeg", "-i", inputPath, "-ss", "00:00:01.000", "-vframes", "1", tempFilePath)
result := RunShellCommand(ffmpegCmd) result := shared.RunShellCommand(ffmpegCmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
+5 -47
View File
@@ -1,7 +1,6 @@
package pkg package pkg
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -12,14 +11,12 @@ import (
"strings" "strings"
"time" "time"
"plg-mudics/shared"
"github.com/micmonay/keybd_event" "github.com/micmonay/keybd_event"
) )
type CommandResponse struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exitCode"`
}
func GetDeviceIp() (string, error) { func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs() addrs, err := net.InterfaceAddrs()
@@ -54,7 +51,7 @@ func GetDeviceMac() (string, error) {
func OpenPresentation(path string) error { func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path)) cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
result := RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
return errors.New(result.Stderr) return errors.New(result.Stderr)
} }
@@ -84,7 +81,7 @@ func TakeScreenshot() (string, error) {
exec.Command("spectacle", "--fullscreen", "--nonotify", "--background", "--output", tempFilePath), exec.Command("spectacle", "--fullscreen", "--nonotify", "--background", "--output", tempFilePath),
} }
for _, cmd := range cmds { for _, cmd := range cmds {
commandOutput := RunShellCommand(cmd) commandOutput := shared.RunShellCommand(cmd)
if commandOutput.ExitCode == 0 { if commandOutput.ExitCode == 0 {
return tempFilePath, nil return tempFilePath, nil
} }
@@ -93,45 +90,6 @@ func TakeScreenshot() (string, error) {
return "", errors.New("no screenshot utility found or all failed") return "", errors.New("no screenshot utility found or all failed")
} }
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
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()
}
}
return commandOutput
}
func OpenBrowserWindow(url string) error {
template := "%s --app='%s' --start-fullscreen --user-data-dir=$(mktemp -d) --autoplay-policy=no-user-gesture-required"
cmds := []*exec.Cmd{
exec.Command("bash", "-c", fmt.Sprintf(template, "chromium", url)),
exec.Command("bash", "-c", fmt.Sprintf(template, "chromium-browser", url)),
}
for _, cmd := range cmds {
commandOutput := RunShellCommand(cmd)
if commandOutput.ExitCode == 0 {
return nil
}
}
return errors.New("chromium not found in PATH")
}
func GetStoragePath() (string, error) { func GetStoragePath() (string, error) {
var storagePath string var storagePath string
+6 -3
View File
@@ -1,6 +1,9 @@
{ {
"version": "1", "version": "1",
"name": "PLG-MuDICS", "name": "PLG-MuDiCS Display",
"type": "collection", "type": "collection",
"ignore": ["node_modules", ".git"] "ignore": [
} "node_modules",
".git"
]
}
+3 -3
View File
@@ -37,7 +37,7 @@ var supportedExtensions = map[string]bool{
".odp": true, ".odp": true,
} }
func StartWebServer(v string) { func StartWebServer(v string, port string) {
version = v version = v
e := echo.New() e := echo.New()
@@ -63,7 +63,7 @@ func StartWebServer(v string) {
fileGroup.PATCH("/:path", openFileRoute) fileGroup.PATCH("/:path", openFileRoute)
fileGroup.GET("/preview/:path", previewRoute) fileGroup.GET("/preview/:path", previewRoute)
err := e.Start(":1323") err := e.Start(":" + port)
if err != nil { if err != nil {
slog.Error("Failed to start server", "error", err) slog.Error("Failed to start server", "error", err)
} }
@@ -155,7 +155,7 @@ func shellCommandRoute(ctx echo.Context) error {
} }
cmd.Dir = storagePath cmd.Dir = storagePath
commandOutput := pkg.RunShellCommand(cmd) commandOutput := shared.RunShellCommand(cmd)
if commandOutput.ExitCode != 0 { if commandOutput.ExitCode != 0 {
slog.Error("Shell command execution error", "error", commandOutput.Stderr) slog.Error("Shell command execution error", "error", commandOutput.Stderr)
} }
+18 -1
View File
@@ -1,7 +1,24 @@
github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0= github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
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/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
+140
View File
@@ -0,0 +1,140 @@
{
config,
pkgs,
...
}: {
imports = [
./hardware-configuration.nix
];
## System Config
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.networkmanager.enable = true;
nix.settings.experimental-features = ["nix-command" "flakes"];
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "en_US.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "de_DE.UTF-8";
LC_IDENTIFICATION = "de_DE.UTF-8";
LC_MEASUREMENT = "de_DE.UTF-8";
LC_MONETARY = "de_DE.UTF-8";
LC_NAME = "de_DE.UTF-8";
LC_NUMERIC = "de_DE.UTF-8";
LC_PAPER = "de_DE.UTF-8";
LC_TELEPHONE = "de_DE.UTF-8";
LC_TIME = "de_DE.UTF-8";
};
services.xserver.enable = true;
services.xserver.displayManager.lightdm.enable = true;
services.xserver.desktopManager.xfce.enable = true;
services.xserver.xkb = {
layout = "de";
variant = "";
};
console.keyMap = "de";
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
users.users.mudics = {
isNormalUser = true;
description = "mudics";
extraGroups = ["networkmanager" "wheel"];
};
nixpkgs.config.allowUnfree = true;
system.stateVersion = "25.05"; # Don't change
## User Config
programs.nix-ld = {
enable = true;
libraries = with pkgs; [
stdenv.cc.cc
zlib
];
};
services.displayManager.autoLogin = {
enable = true;
user = "mudics";
};
networking.hostName = "plg-mudics";
environment.systemPackages = with pkgs; [
# Programs
libreoffice
#rustdesk
ungoogled-chromium
xfce.thunar-archive-plugin
git
nushell
# Libraries
imagemagick
ffmpeg
ghostscript
];
systemd.services.update-mudics = {
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
path = with pkgs; [nushell unzip];
script = "nu ${./update.sh}";
serviceConfig = {
WorkingDirectory = "/home/mudics/plg-mudics";
User = "mudics";
Type = "oneshot";
};
};
systemd.services.run-mudics = {
wantedBy = ["default.target"];
after = ["update-mudics.service" "graphical.target"];
wants = ["graphical.target"];
path = with pkgs; [ungoogled-chromium];
script = "./plg-mudics-display";
serviceConfig = {
WorkingDirectory = "/home/mudics/plg-mudics";
User = "mudics";
Type = "simple";
};
environment = {
DISPLAY = ":0";
XDG_RUNTIME_DIR = "/run/user/1000";
};
};
systemd.services.build-mudics-system = {
wantedBy = ["default.target"];
after = ["update-mudics.service"];
path = with pkgs; [nixos-rebuild];
script = "nixos-rebuild boot --flake .#plg-mudics";
serviceConfig = {
WorkingDirectory = "/home/mudics/plg-mudics/nixos";
};
};
systemd.tmpfiles.rules = [
"d /home/mudics/plg-mudics 0755 mudics - -"
];
}
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1761999846,
"narHash": "sha256-IYlYnp4O4dzEpL77BD/lj5NnJy2J8qbHkNSFiPBCbqo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3de8f8d73e35724bf9abef41f1bdbedda1e14a31",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+21
View File
@@ -0,0 +1,21 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
nixosConfigurations.plg-mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
];
};
};
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env nu
print "Checking for new version of PLG-MuDiCS ..."
if (new_version_available) {
print "New version available. Trying to update ..."
get_new_display_file
get_new_nixos_config
}
print "Done"
def get_new_nixos_config [] {
let temp_folder_path = (mktemp -d "nixos-temp-XXXXXX")
let temp_file_path = (mktemp "nixos-temp-XXXXXX")
let nixos_config_path = "nixos"
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/nixos.zip --max-time 5sec | save -p -f $temp_file_path
unzip $temp_file_path -d $temp_folder_path
rm -rf $nixos_config_path
mv $temp_folder_path $nixos_config_path
rm $temp_file_path
cp /etc/nixos/hardware-configuration.nix $"($nixos_config_path)/hardware-configuration.nix"
}
def get_new_display_file [] {
let temp_file_path = (mktemp "display-temp-XXXXXX")
http get https://github.com/PLG-Development/PLG-MuDiCS/releases/latest/download/plg-mudics-display --max-time 5sec | save -p -f $temp_file_path
chmod +x $temp_file_path
mv $temp_file_path plg-mudics-display
}
def new_version_available [] {
let file_path = "version.json"
if not ($file_path | path exists) {
{ "version": ""} | to json | save version.json
}
let current_version = open $file_path | get version
let new_version = http get https://api.github.com/repos/PLG-Development/PLG-MuDiCS/releases/latest --max-time 5sec | get tag_name
# TODO: only write when all operations were successful
{ "version": $new_version} | to json | save version.json -f
if $current_version == $new_version {
false
} else {
true
}
}
+8
View File
@@ -0,0 +1,8 @@
package shared
import (
_ "embed"
)
//go:embed splash_screen.html
var SplashScreenTemplate string
+114
View File
@@ -0,0 +1,114 @@
{
"Escape": "VK_ESC",
"Digit1": "VK_1",
"Digit2": "VK_2",
"Digit3": "VK_3",
"Digit4": "VK_4",
"Digit5": "VK_5",
"Digit6": "VK_6",
"Digit7": "VK_7",
"Digit8": "VK_8",
"Digit9": "VK_9",
"Digit0": "VK_0",
"KeyQ": "VK_Q",
"KeyW": "VK_W",
"KeyE": "VK_E",
"KeyR": "VK_R",
"KeyT": "VK_T",
"KeyY": "VK_Y",
"KeyU": "VK_U",
"KeyI": "VK_I",
"KeyO": "VK_O",
"KeyP": "VK_P",
"KeyA": "VK_A",
"KeyS": "VK_S",
"KeyD": "VK_D",
"KeyF": "VK_F",
"KeyG": "VK_G",
"KeyH": "VK_H",
"KeyJ": "VK_J",
"KeyK": "VK_K",
"KeyL": "VK_L",
"KeyZ": "VK_Z",
"KeyX": "VK_X",
"KeyC": "VK_C",
"KeyV": "VK_V",
"KeyB": "VK_B",
"KeyN": "VK_N",
"KeyM": "VK_M",
"F1": "VK_F1",
"F2": "VK_F2",
"F3": "VK_F3",
"F4": "VK_F4",
"F5": "VK_F5",
"F6": "VK_F6",
"F7": "VK_F7",
"F8": "VK_F8",
"F9": "VK_F9",
"F10": "VK_F10",
"F11": "VK_F11",
"F12": "VK_F12",
"F13": "VK_F13",
"F14": "VK_F14",
"F15": "VK_F15",
"F16": "VK_F16",
"F17": "VK_F17",
"F18": "VK_F18",
"F19": "VK_F19",
"F20": "VK_F20",
"F21": "VK_F21",
"F22": "VK_F22",
"F23": "VK_F23",
"F24": "VK_F24",
"NumLock": "VK_NUMLOCK",
"ScrollLock": "VK_SCROLLLOCK",
"CapsLock": "VK_CAPSLOCK",
"Minus": "VK_SP2",
"Equal": "VK_SP3",
"Backspace": "VK_BACKSPACE",
"Tab": "VK_TAB",
"BracketLeft": "VK_SP4",
"BracketRight": "VK_SP5",
"Enter": "VK_ENTER",
"Semicolon": "VK_SP6",
"Quote": "VK_SP7",
"Backquote": "VK_SP1",
"Backslash": "VK_SP8",
"Comma": "VK_SP9",
"Period": "VK_SP10",
"Slash": "VK_SP11",
"IntlBackslash": "VK_SP12",
"NumpadMultiply": "VK_KPASTERISK",
"NumpadDivide": "VK_KPSLASH",
"NumpadAdd": "VK_KPPLUS",
"NumpadSubtract": "VK_KPMINUS",
"NumpadDecimal": "VK_KPDOT",
"NumpadEnter": "VK_KPENTER",
"Space": "VK_SPACE",
"Numpad0": "VK_KP0",
"Numpad1": "VK_KP1",
"Numpad2": "VK_KP2",
"Numpad3": "VK_KP3",
"Numpad4": "VK_KP4",
"Numpad5": "VK_KP5",
"Numpad6": "VK_KP6",
"Numpad7": "VK_KP7",
"Numpad8": "VK_KP8",
"Numpad9": "VK_KP9",
"PageUp": "VK_PAGEUP",
"PageDown": "VK_PAGEDOWN",
"End": "VK_END",
"Home": "VK_HOME",
"ArrowLeft": "VK_LEFT",
"ArrowUp": "VK_UP",
"ArrowRight": "VK_RIGHT",
"ArrowDown": "VK_DOWN",
"PrintScreen": "VK_PRINT",
"Insert": "VK_INSERT",
"Delete": "VK_DELETE",
"Help": "VK_HELP",
"BrowserBack": "VK_BACK",
"Pause": "VK_PAUSE",
"Lang1": "VK_HANGUEL",
"Lang2": "VK_HANJA"
}
+33 -3
View File
@@ -1,8 +1,38 @@
package shared package shared
import ( import (
_ "embed" "bytes"
"errors"
"os/exec"
) )
//go:embed splash_screen.html type CommandResponse struct {
var SplashScreenTemplate string Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exitCode"`
}
func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
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()
}
}
return commandOutput
}
+37
View File
@@ -0,0 +1,37 @@
package shared
import (
"errors"
"fmt"
"os"
"os/exec"
)
func OpenBrowserWindow(url string, fullscreen bool, temp bool) error {
bins := []string{"chromium", "chromium-browser"}
args := []string{fmt.Sprintf("--app=%s", url), "--autoplay-policy=no-user-gesture-required"}
if fullscreen {
args = append(args, "--start-fullscreen")
}
if temp {
tempDirPath, err := os.MkdirTemp("", "plg-mudics-browser-")
if err != nil {
return err
}
arg := fmt.Sprintf("--user-data-dir=%s", tempDirPath)
args = append(args, arg)
}
errs := []string{}
for _, bin := range bins {
cmd := exec.Command(bin, args...)
commandOutput := RunShellCommand(cmd)
if commandOutput.ExitCode == 0 {
return nil
}
errs = append(errs, commandOutput.Stderr)
}
return errors.New("failed to open browser window: " + fmt.Sprint(errs))
}