Compare commits

..

1 Commits

Author SHA1 Message Date
2mal3 cd6ec88d23 chore(openBrowser): more detailed error logs 2025-11-07 12:42:19 +01:00
62 changed files with 824 additions and 5385 deletions
-1
View File
@@ -5,7 +5,6 @@ on:
paths-ignore: paths-ignore:
- "**/*.md" - "**/*.md"
- "**/*.yml" - "**/*.yml"
- "**/*.sh"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
-4
View File
@@ -17,15 +17,11 @@ jobs:
- 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: build:
name: Build and Upload Assets name: Build and Upload Assets
-10
View File
@@ -1,10 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"golang.go",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"a-h.templ",
"bradlc.vscode-tailwindcss"
]
}
-34
View File
@@ -1,34 +0,0 @@
# Contributing
## Development
### Architecture
The software suite comprises two main components that are designed to work together on the same network.
The _Display Block_ is responsible for displaying content on a monitor.
The _Control Block_ controls multiple Display Blocks via a user interface. One Display Block can be controlled by multiple Control Blocks.
- `control/`: Go code for the Control Block.
- `frontend/`: The actual frontend written in SvelteKit.
- `display/`: Go code for the Display Block.
- `nixos/`: NixOS configuration for a self-updating, ready-to-use Linux system for the Display Block. This is not necessary for using PLG-MuDiCS.
- `shared/`: Shared code and assets for the Control and Display Block.
### Technologies
- **Go** for the base code in both the Control and Display Block.
- **SvelteKit** in prerender mode for the frontend of the Control Block.
- **TailwindCSS** for styling the frontend of the Control Block.
- **Chromium** browser for handling most media display in the Display Block and serving the frontend of the Control Block.
- **LibreOffice** for handling more complex documents in the Display Block.
### Requirements
- Go v1.25 or higher
- Deno v2
### Running
1. open the wanted folder of the block inside a terminal
2. run `go generate ./..` to generate files required at build time
3. run `go run *.go` to compile and run the block
-17
View File
@@ -1,20 +1,3 @@
# PLG-MuDiCS # PLG-MuDiCS
![Lines of Code](https://tokei.rs/b1/github/PLG-Development/PLG-MuDiCS?category=code) ![Lines of Code](https://tokei.rs/b1/github/PLG-Development/PLG-MuDiCS?category=code)
Open source solution for advanced remote control and coordination of one or more presentation displays. Specially optimized for fast operation in highly dynamic multi-display situations.
## Display Requirements
- Linux
- Bash
- Chromium
- LibreOffice (optional, presentation view)
- Xreader (optional, pdf view)
- ImageMagick (optional, image preview)
- ffmpeg (optional, video preview)
- Ghostscript (optional, pdf preview)
## Contributing
See [Contributing.md](/CONTRIBUTING.md)
+3
View File
@@ -0,0 +1,3 @@
# PLG-MuDiCS Control
## Development
+43 -745
View File
File diff suppressed because it is too large Load Diff
+35 -1559
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -20,15 +20,14 @@
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@tiptap/extension-placeholder": "^3.11.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.93.3",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65", "svelte-dnd-action": "^0.9.65",
@@ -39,14 +38,6 @@
}, },
"dependencies": { "dependencies": {
"@thisux/sveltednd": "^0.0.20", "@thisux/sveltednd": "^0.0.20",
"@tiptap/core": "^3.11.0", "lucide-svelte": "^0.545.0"
"@tiptap/extension-highlight": "^3.11.0",
"@tiptap/extension-text-style": "^3.11.0",
"@tiptap/extensions": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"dexie": "^4.2.1",
"lucide-svelte": "^0.545.0",
"svelte-splitpanes": "^8.0.9"
} }
} }
+11 -66
View File
@@ -1,70 +1,15 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* Adding more color steps */ /* Adding more color steps */
@theme { @theme {
--color-stone-150: oklch(0.9465 0.002 77.571); --color-stone-150: oklch(0.9465 0.002 77.571);
--color-stone-250: oklch(0.896 0.004 52.542); --color-stone-250: oklch(0.896 0.004 52.542);
--color-stone-350: oklch(0.789 0.0075 56.313); --color-stone-350: oklch(0.789 0.0075 56.313);
--color-stone-450: oklch(0.631 0.0115 57.165); --color-stone-450: oklch(0.631 0.0115 57.165);
--color-stone-550: oklch(0.4985 0.012 65.855); --color-stone-550: oklch(0.4985 0.012 65.855);
--color-stone-650: oklch(0.409 0.0105 70.599); --color-stone-650: oklch(0.409 0.0105 70.599);
--color-stone-750: oklch(0.321 0.0085 50.928); --color-stone-750: oklch(0.321 0.0085 50.928);
--color-stone-850: oklch(0.242 0.0065 45.171); --color-stone-850: oklch(0.242 0.0065 45.171);
} }
/* Splitpanes */
.splitpanes.mudics-stone-theme {
.splitpanes__pane {
@apply bg-stone-900;
}
.splitpanes__splitter {
@apply bg-transparent hover:bg-stone-700 active:bg-stone-600 transition-colors duration-200 shrink-0 relative box-border rounded mx-5;
/* box-sizing: border-box;
position: relative;
flex-shrink: 0; */
&:before,
&:after {
@apply bg-stone-750 transition-colors duration-200 absolute top-[50%] left-[50%] content-[''] rounded;
}
&:hover:before,
&:hover:after {
@apply bg-stone-400;
}
&:active:before,
&:active:after {
@apply bg-stone-300;
}
&:first-child {
/* cursor: auto; */
}
}
}
.mudics-stone-theme {
&.splitpanes .splitpanes .splitpanes__splitter {
@apply z-10;
}
&.splitpanes--horizontal > .splitpanes__splitter,
.splitpanes--horizontal > .splitpanes__splitter {
@apply h-2 cursor-row-resize;
&:before,
&:after {
@apply h-0.5 w-10 translate-x-[-50%];
}
&:before {
@apply mt-[calc(-0.7*var(--spacing))];
}
&:after {
@apply mt-[calc(0.1*var(--spacing))];
}
}
}
@@ -26,13 +26,13 @@
click_function?: (e: MouseEvent) => void; click_function?: (e: MouseEvent) => void;
menu_options?: MenuOption[] | null; menu_options?: MenuOption[] | null;
menu_class?: string; menu_class?: string;
div_class?: string; div_class?: string,
children?: any; children?: any;
}>(); }>();
let menu_shown = $state(false); let menu_shown = $state(false);
let button_element: HTMLButtonElement; let button_element: HTMLButtonElement;
let menu_element: HTMLDivElement | null = $state(null); let menu_element: HTMLDivElement;
let position_bottom = $state(true); let position_bottom = $state(true);
function onclick(e: MouseEvent) { function onclick(e: MouseEvent) {
@@ -47,7 +47,7 @@
} }
function getPolygon(): [number, number][] | null { function getPolygon(): [number, number][] | null {
if (!button_element || !menu_element) return null; if (!button_element || !button_element) return null;
const b = button_element.getBoundingClientRect(); const b = button_element.getBoundingClientRect();
const m = menu_element.getBoundingClientRect(); const m = menu_element.getBoundingClientRect();
@@ -154,7 +154,7 @@
transition:fade={{ duration: 50 }} transition:fade={{ duration: 50 }}
class="absolute {position_bottom class="absolute {position_bottom
? 'top-full' ? 'top-full'
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-stone-800/45 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto" : 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-black/20 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@@ -12,137 +12,50 @@
TrafficCone TrafficCone
} from 'lucide-svelte'; } from 'lucide-svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import KeyInput from './KeyInput.svelte';
import { send_keyboard_input, show_blackscreen, show_html } from '../ts/api_handler';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { selected_display_ids } from '../ts/stores/select';
import { onMount } from 'svelte';
import TipTapInput from './TipTapInput.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
};
};
const show_text_popup = () => {
popup_content = {
open: true,
snippet: text_popup,
title: 'Text anzeigen',
title_icon: TextAlignStart,
closable: true,
window_class: 'size-full'
};
};
</script> </script>
{#snippet send_keys_popup()}
<div class="overflow-hidden flex flex-col gap-2">
<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>
</div>
{/snippet}
{#snippet text_popup()}
<TipTapInput />
{/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 Bildschirme steuern
</div> </div>
<div class="relative flex flex-col gap-2 p-2 overflow-auto"> <div class="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-75 justify-normal"> <div class="flex flex-row gap-2 w-70 justify-normal">
<Button <Button title="Vorherige Folie (Pfeil nach Links)" className="px-7"
title="Vorherige Folie (Pfeil nach Links)" ><ArrowBigLeft /></Button
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_LEFT');
}}><ArrowBigLeft /></Button
> >
<Button <Button title="Nächste Folie (Pfeil nach Rechts)" className="px-7"
title="Nächste Folie (Pfeil nach Rechts)" ><ArrowBigRight /></Button
className="px-9"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(send_keyboard_input, true, 'VK_RIGHT');
}}><ArrowBigRight /></Button
> >
</div> </div>
<Button <Button className="px-3 flex gap-3 w-70 justify-normal"
className="px-3 flex gap-3 w-75 justify-normal" ><TextAlignStart /> Text anzeigen</Button
disabled={$selected_display_ids.length === 0}
click_function={show_text_popup}><TextAlignStart /> Text anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
click_function={() => {
run_on_all_selected_displays(show_blackscreen, true);
}}><Presentation />Blackout</Button
> >
<Button className="px-3 flex gap-3 w-70 justify-normal"><Presentation />Blackout</Button>
<div class="flex flex-row justify-normal"> <div class="flex flex-row justify-normal">
<Button <Button className="rounded-r-none pl-3 flex gap-3 grow w-60 justify-normal"
className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal"
disabled={$selected_display_ids.length === 0}
><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 <Button className="px-3 flex gap-3 w-70 justify-normal"
className="px-3 flex gap-3 w-75 justify-normal" ><Keyboard /> Tastatur-Inputs durchgeben</Button
disabled={$selected_display_ids.length === 0}
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 <Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
className="px-3 flex gap-3 w-full xl:w-75 justify-normal" ><Power /> PC hochfahren</Button
disabled={$selected_display_ids.length === 0}><Power /> PC hochfahren</Button
> >
<Button <Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
className="px-3 flex gap-3 w-full xl:w-75 justify-normal" ><PowerOff /> PC herunterfahren</Button
disabled={$selected_display_ids.length === 0}><PowerOff /> PC herunterfahren</Button
> >
</div> </div>
<Button <Button className="px-3 flex gap-3 w-full xl:w-70 justify-normal"
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$selected_display_ids.length === 0}
><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="size-full"
/>
</div> </div>
</div> </div>
@@ -1,11 +1,6 @@
<script lang="ts"> <script lang="ts">
import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action'; import { dragHandleZone, TRIGGERS } from 'svelte-dnd-action';
import { import { dnd_flip_duration_ms, get_selectable_color_classes, is_display_drag, is_group_drag } from '../ts/stores/ui_behavior';
dnd_flip_duration_ms,
get_selectable_color_classes,
is_display_drag,
is_group_drag
} from '../ts/stores/ui_behavior';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import DisplayObject from './DisplayObject.svelte'; import DisplayObject from './DisplayObject.svelte';
@@ -21,10 +16,9 @@
import type { DisplayGroup, MenuOption } 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, get_display_menu_options, close_pinned_display } = $props<{ let { display_group, get_display_menu_options } = $props<{
display_group: DisplayGroup; display_group: DisplayGroup;
get_display_menu_options: (display_id: string) => MenuOption[]; get_display_menu_options: (display_id: string) => MenuOption[]
close_pinned_display: () => void;
}>(); }>();
let hovering_selectable = $state(false); let hovering_selectable = $state(false);
@@ -38,7 +32,7 @@
const { items, info } = e.detail; const { items, info } = e.detail;
if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) { if (items.length !== 1 && info.trigger === TRIGGERS.DRAG_STARTED) {
$is_display_drag = true; $is_display_drag = true;
add_empty_display_group(); add_empty_display_group();
} }
set_new_display_group_data(display_group.id, items); set_new_display_group_data(display_group.id, items);
@@ -46,7 +40,7 @@
function handle_finalize(e: CustomEvent) { function handle_finalize(e: CustomEvent) {
remove_empty_display_groups(); remove_empty_display_groups();
$is_display_drag = false; $is_display_drag = false;
set_new_display_group_data(display_group.id, e.detail.items); set_new_display_group_data(display_group.id, e.detail.items);
} }
</script> </script>
@@ -71,7 +65,7 @@
items: display_group.data, items: display_group.data,
type: 'item', type: 'item',
flipDurationMs: dnd_flip_duration_ms, flipDurationMs: dnd_flip_duration_ms,
dropTargetStyle: { outline: 'none' } dropTargetStyle: { outline: 'none' },
}} }}
onconsider={handle_consider} onconsider={handle_consider}
onfinalize={handle_finalize} onfinalize={handle_finalize}
@@ -83,7 +77,7 @@
class="outline-none" class="outline-none"
role="figure" role="figure"
> >
<DisplayObject {display} {get_display_menu_options} {close_pinned_display} /> <DisplayObject {display} {get_display_menu_options} />
</section> </section>
{/each} {/each}
@@ -10,30 +10,25 @@
import OnlineState from './OnlineState.svelte'; import OnlineState from './OnlineState.svelte';
import type { Display, MenuOption } 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';
import { filter_file_selection_for_current_selected_displays } from '../ts/stores/files';
let { display, get_display_menu_options, close_pinned_display } = $props<{ let { display, get_display_menu_options } = $props<{
display: Display; display: Display;
get_display_menu_options: (display_id: string) => MenuOption[]; get_display_menu_options: (display_id: string) => MenuOption[];
close_pinned_display: () => void;
}>(); }>();
let hovering_unselectable = $state(false); let hovering_unselectable = $state(false);
function onclick(e: Event) { function onclick(e: Event) {
select(selected_display_ids, display.id); select(selected_display_ids, display.id);
filter_file_selection_for_current_selected_displays();
e.stopPropagation(); e.stopPropagation();
} }
function on_preview_click(e: MouseEvent) { function on_preview_click(e: MouseEvent) {
if ($pinned_display_id === display.id) { if ($pinned_display_id === display.id) {
close_pinned_display(); $pinned_display_id = null;
} else { } else {
$pinned_display_id = display.id; $pinned_display_id = display.id;
} }
update_screenshot(display.id);
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>
+157 -197
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fade, scale } from 'svelte/transition'; import { fade, scale, slide } from 'svelte/transition';
import { import {
all_displays_of_group_selected, all_displays_of_group_selected,
displays, displays,
@@ -20,13 +20,11 @@
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, Pencil, PinOff, Settings, Trash2, VideoOff, ZoomIn, ZoomOut } 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';
import { Pane, Splitpanes } from 'svelte-splitpanes';
import HighlightedText from './HighlightedText.svelte';
let { handle_display_deletion, handle_display_editing } = $props<{ let { handle_display_deletion, handle_display_editing } = $props<{
handle_display_deletion: (display_id: string) => void; handle_display_deletion: (display_id: string) => void;
@@ -38,32 +36,21 @@
get_display_by_id($pinned_display_id || '', $displays) get_display_by_id($pinned_display_id || '', $displays)
); );
let last_pinned_pane_size: number = 45;
let pinned_pane_size: number = $state(last_pinned_pane_size);
function close_pinned_display() {
last_pinned_pane_size = pinned_pane_size;
pinned_pane_size = 0;
}
function get_display_menu_options(display_id: string): MenuOption[] { function get_display_menu_options(display_id: string): MenuOption[] {
return [ return [
{ {
icon: Pencil, icon: Pencil,
name: 'Bildschirm bearbeiten', name: 'Bildschirm bearbeiten',
on_select: () => { on_select: () => {handle_display_editing(display_id)},
handle_display_editing(display_id); },
} {
}, icon: Trash2,
{ name: 'Bildschirm löschen',
icon: Trash2, class: 'text-red-400 hover:text-stone-200 hover:!bg-red-400 active:!bg-red-500',
name: 'Bildschirm löschen', on_select: () => {handle_display_deletion(display_id)},
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[]) {
@@ -82,13 +69,6 @@
return true; return true;
} }
function handle_splitpane_resize(e: any) {
if (e.detail[0].size === 0) {
$pinned_display_id = null;
pinned_pane_size = last_pinned_pane_size;
}
}
function on_wheel(e: WheelEvent) { function on_wheel(e: WheelEvent) {
if (!$is_group_drag && !$is_display_drag) return; if (!$is_group_drag && !$is_display_drag) return;
if (!displays_scroll_box) return; if (!displays_scroll_box) return;
@@ -105,176 +85,156 @@
<svelte:window on:wheel={on_wheel} /> <svelte:window on:wheel={on_wheel} />
<div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2"> <div class="h-[calc(100dvh-3rem-(6*var(--spacing)))] flex flex-col gap-2">
<Splitpanes {#if $pinned_display_id}
horizontal <!-- Pinned Item -->
theme="mudics-stone-theme" <div in:fade={{ duration: 140 }} out:fade={{ duration: 120 }}>
on:resized={handle_splitpane_resize}
dblClickSplitter={false}
>
{#if $pinned_display_id}
<!-- Pinned Item -->
<Pane maxSize={60} snapSize={20} bind:size={pinned_pane_size}>
<div class="h-full" transition:fade>
<div class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] h-full">
<div
class="bg-stone-700 rounded-t-2xl flex justify-between w-full p-1 min-w-0 basis-0 flex-1"
>
<span
class="text-xl font-bold pl-2 content-center truncate min-w-0"
title={pinned_display?.name}
>
{pinned_display?.name}
</span>
<div class="flex flex-row gap-1">
<div class="flex flex-row items-center mr-1">
<span class="text-stone-400"> Aktueller Status: </span>
<OnlineState
selected={false}
status={pinned_display?.status ?? null}
className="flex items-center px-2"
/>
</div>
<Button
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={(e) => {
e.stopPropagation();
}}
menu_options={get_display_menu_options($pinned_display_id)}
>
<Menu />
</Button>
<Button
title="Bildschirm nicht mehr anpinnen"
className="aspect-square !p-1"
bg="bg-stone-600"
click_function={close_pinned_display}
>
<PinOff />
</Button>
</div>
</div>
<div
class="h-full bg-stone-800 rounded-b-2xl overflow-hidden flex justify-center items-center"
>
{#if pinned_display?.preview_url}
<img
src={pinned_display.preview_url}
alt="preview"
class="max-h-full max-w-full object-cover bg-black"
/>
{:else}
<div class="size-full bg-black flex justify-center items-center">
<VideoOff class="size-[20%]" />
</div>
{/if}
</div>
</div>
</div>
</Pane>
{/if}
<Pane>
<div <div
class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden" class="grid grid-rows-[2.5rem_auto] will-change-[height,opacity] overflow-hidden rounded-2xl"
transition:slide={{ duration: 260, easing: cubicOut }}
> >
<!-- Normal Heading Left --> <div class="bg-stone-700 flex justify-between w-full p-1 min-w-0 basis-0 flex-1">
<div class="bg-stone-700 flex justify-between w-full p-1 gap-2 min-w-0"> <span
<span class="text-xl font-bold pl-2 content-center truncate min-w-0"> class="text-xl font-bold pl-2 content-center truncate min-w-0"
Verbundene Bildschirme title={pinned_display?.name}
>
{pinned_display?.name}
</span> </span>
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<button <OnlineState
class="min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes( selected={false}
all_selected($displays, $selected_display_ids), status={pinned_display?.status ?? ''}
{ className="flex items-center px-2"
bg: true, />
hover: true, <Button
active: true, className="aspect-square !p-1"
text: true bg="bg-stone-600"
} click_function={(e) => {
)}" e.stopPropagation();
onclick={() => select_all($displays, $selected_display_ids)} }}
menu_options={get_display_menu_options($pinned_display_id)}
> >
<span <Menu />
>{all_selected($displays, $selected_display_ids) </Button>
? 'Alle abwählen'
: 'Alle auswählen'}</span <Button
> title="Bildschirm nicht mehr anpinnen"
</button> className="aspect-square !p-1"
<div class="flex flex-row"> bg="bg-stone-600"
<Button click_function={() => {
title="Bildschirme größer darstellen" $pinned_display_id = null;
className="aspect-square !p-1 rounded-r-none" }}
bg="bg-stone-600" >
disabled={!Boolean(next_height_step_size('display', $current_height, 1))} <PinOff />
click_function={() => { </Button>
change_height('display', 1);
}}
>
<ZoomIn />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<ZoomOut />
</Button>
</div>
</div> </div>
</div> </div>
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
<div <div class="w-full h-[30dvh] bg-stone-800 flex justify-center items-center">
class="min-h-full p-2 flex flex-col gap-4" {#if pinned_display?.preview_url}
use:dragHandleZone={{ <img
items: $displays, src={pinned_display.preview_url}
type: 'group', alt="preview"
flipDurationMs: dnd_flip_duration_ms, class="max-h-full max-w-full object-cover bg-black"
dropFromOthersDisabled: true, />
dropTargetStyle: { outline: 'none' } {:else}
}} <div class="size-full bg-black flex justify-center items-center">
onconsider={(e: CustomEvent) => { <VideoOff class="size-[20%]" />
$is_group_drag = true; </div>
$displays = e.detail.items; {/if}
}}
onfinalize={(e: CustomEvent) => {
$displays = e.detail.items;
$is_group_drag = false;
}}
>
{#if $displays.length === 1 && $displays[0].data.length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf
<HighlightedText fg="text-stone-450" className="!p-1"><Settings class="inline pb-1" /></HighlightedText>
und
<HighlightedText fg="text-stone-450">Neuen Bildschirm hinzufügen</HighlightedText>.
</div>
{:else}
{#each $displays as display_group (display_group.id)}
<!-- Each Group -->
<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}
{close_pinned_display}
/>
</section>
{/each}
{/if}
</div>
</div> </div>
</div> </div>
</Pane> </div>
</Splitpanes> {/if}
<div class="min-h-0 h-full grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl overflow-hidden">
<!-- Normal Heading Left -->
<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">
Verbundene Bildschirme
</span>
<div class="flex flex-row gap-1">
<button
class="gap-2 min-w-40 px-4 rounded-xl cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
all_selected($displays, $selected_display_ids),
{
bg: true,
hover: true,
active: true,
text: true
}
)}"
onclick={() => select_all($displays, $selected_display_ids)}
>
<span
>{all_selected($displays, $selected_display_ids)
? 'Alle abwählen'
: 'Alle auswählen'}</span
>
</button>
<div class="flex flex-row">
<Button
title="Bildschirme größer darstellen"
className="aspect-square !p-1 rounded-r-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, 1))}
click_function={() => {
change_height('display', 1);
}}
>
<Plus />
</Button>
<Button
title="Bildschirme kleiner darstellen"
className="aspect-square !p-1 rounded-l-none"
bg="bg-stone-600"
disabled={!Boolean(next_height_step_size('display', $current_height, -1))}
click_function={() => {
change_height('display', -1);
}}
>
<Minus />
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-y-auto" bind:this={displays_scroll_box}>
<div
class="min-h-full p-2 flex flex-col gap-4"
use:dragHandleZone={{
items: $displays,
type: 'group',
flipDurationMs: dnd_flip_duration_ms,
dropFromOthersDisabled: true,
dropTargetStyle: { outline: 'none' }
}}
onconsider={(e: CustomEvent) => {
$is_group_drag = true;
$displays = e.detail.items;
}}
onfinalize={(e: CustomEvent) => {
$displays = e.detail.items;
$is_group_drag = false;
}}
>
{#if $displays.length === 1 && $displays[0].data.length === 0}
<div class="text-stone-500 px-10 py-6 leading-relaxed text-center">
Es wurden noch keine Bildschirme hinzugefügt. Klicke oben rechts auf <Settings
class="inline pb-1"
/> und "Neuen Bildschirm hinzufügen".
</div>
{:else}
{#each $displays as display_group (display_group.id)}
<!-- Each Group -->
<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>
+26 -291
View File
@@ -1,248 +1,16 @@
<script lang="ts"> <script lang="ts">
import { import { ClipboardPaste, Download, FolderPlus, Info, Minus, Pen, Plus, RefreshCcw, Scissors, Trash2, Upload } from "lucide-svelte";
ClipboardPaste, import { change_height, current_height, next_height_step_size } from "../ts/stores/ui_behavior";
Download, import Button from "./Button.svelte";
FolderPlus, import PathBar from "./PathBar.svelte";
Info, import { selected_display_ids, selected_file_ids } from "../ts/stores/select";
Pen, import { all_files, current_file_path, get_current_folder_elements } from "../ts/stores/files";
RefreshCcw, import { slide } from "svelte/transition";
Scissors, import FolderElementObject from "./FolderElementObject.svelte";
Trash2, import PopUp from "./PopUp.svelte";
Upload,
ZoomIn,
ZoomOut,
} from 'lucide-svelte';
import { change_height, current_height, next_height_step_size } from '../ts/stores/ui_behavior';
import Button from './Button.svelte';
import PathBar from './PathBar.svelte';
import { selected_display_ids, selected_file_ids } from '../ts/stores/select';
import {
all_files,
current_file_path,
get_current_folder_elements,
get_display_ids_where_file_is_missing,
get_display_ids_where_path_does_not_exist,
get_file_by_id,
get_longest_existing_path_and_needed_parts,
run_for_selected_files_on_selected_displays,
update_current_folder_on_selected_displays
} from '../ts/stores/files';
import { slide } from 'svelte/transition';
import FolderElementObject from './FolderElementObject.svelte';
import PopUp from './PopUp.svelte';
import type { PopupContent } from '../ts/types';
import TextInput from './TextInput.svelte';
import { is_valid_name } from '../ts/utils';
import { displays, get_display_by_id, run_on_all_selected_displays } from '../ts/stores/displays';
import { create_folders, delete_files, rename_file } from '../ts/api_handler';
import { get } from 'svelte/store';
import HighlightedText from './HighlightedText.svelte';
let current_name: string = $state('');
let current_valid: boolean = $state(false);
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: '',
closable: true
});
function popup_close_function() {
popup_content.open = false;
}
async function create_new_folder() {
for (const display_id of $selected_display_ids) {
const display = get_display_by_id(display_id, $displays);
if (!display) continue;
const path_data = get_longest_existing_path_and_needed_parts(
$current_file_path,
display_id,
$all_files
);
await create_folders(display.ip, path_data.existing, [
...path_data.needed,
current_name.trim()
]);
}
await update_current_folder_on_selected_displays();
popup_close_function();
}
async function edit_file_name(new_file_name: string) {
await run_for_selected_files_on_selected_displays(async (ip: string, file_names: string[]) => {
if (file_names.length !== 1) {
console.log('EEEERRRRROOOOOOR', file_names);
return; // Error
}
await rename_file(ip, $current_file_path, file_names[0], new_file_name);
});
await update_current_folder_on_selected_displays();
popup_close_function();
}
const show_edit_file_popup = () => {
const file = get_file_by_id($selected_file_ids[0], $all_files, $current_file_path);
if (!file) return;
const is_folder = file.type === 'inode/directory';
const extension = is_folder ? '' : '.' + file.name.split('.').at(-1) || '';
current_name = file.name.slice(0, file.name.length - extension.length);
current_valid = true;
popup_content = {
open: true,
snippet: edit_file_name_popup,
title: `${is_folder ? 'Ordner' : 'Datei'} umbenennen`,
title_icon: FolderPlus,
snippet_arg: extension,
closable: true
};
};
const show_new_folder_popup = () => {
current_name = '';
current_valid = false;
popup_content = {
open: true,
snippet: new_folder_popup,
title: 'Neuen Ordner erstellen',
title_icon: FolderPlus,
closable: true
};
};
const delete_folder_element_popup = () => {
popup_content = {
open: true,
snippet: delete_request_popup,
title: `${$selected_file_ids.length} ${$selected_file_ids.length === 1 ? 'Objekt' : 'Objekte'} wirklich löschen?`,
title_icon: Trash2,
closable: true
};
};
</script> </script>
{#snippet new_folder_popup()}
{#if get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files).length > 0}
<span class="leading-relaxed"
>Der aktuelle Pfad <HighlightedText
>{$current_file_path.slice(0, $current_file_path.length - 1)}</HighlightedText
> existiert nicht auf {get_display_ids_where_path_does_not_exist(
$current_file_path,
$selected_display_ids,
$all_files
).length === 1
? 'dem Bildschirm'
: 'den Bildschirmen'}
{#each get_display_ids_where_path_does_not_exist($current_file_path, $selected_display_ids, $all_files) as display_id, i}
{#if i !== 0}
,
{/if}
<HighlightedText>{get_display_by_id(display_id, $displays)?.name}</HighlightedText>
{/each}. Mit der Erstellung dieses Ordners wird der Pfad automatisch mit leeren Ordnern bis
zum aktuellen Pfad aufgefüllt.
</span>
{/if}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Ordnername"
is_valid_function={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim();
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) => e.name === trimmed_input
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={create_new_folder}
/>
<div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={create_new_folder} disabled={!current_valid}
>Neuen Ordner erstellen</Button
>
</div>
{/snippet}
{#snippet edit_file_name_popup(extension: string)}
<TextInput
focused_on_start={true}
bind:current_value={current_name}
bind:current_valid
title="Neuer {extension === '' ? 'Ordner' : 'Datei'}name"
is_valid_function={(input: string) => {
if (input.startsWith('.')) return [false, 'Name darf nicht mit . beginnen'];
const trimmed_input = input.trim() + extension;
if (trimmed_input.length === 0 || trimmed_input.length > 50)
return [false, 'Ungültige Länge'];
if (!is_valid_name(trimmed_input)) return [false, 'Name enthält ungültige Zeichen'];
if (
get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).some(
(e) =>
e.name === trimmed_input &&
e.id !== get_file_by_id($selected_file_ids[0], $all_files, $current_file_path)?.id
)
)
return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name'];
}}
enter_mode="submit"
enter_function={() => edit_file_name(current_name.trim() + extension)}
{extension}
/>
<div class="flex flex-row justify-end gap-2">
<Button
className="px-4 font-bold"
click_function={() => edit_file_name(current_name.trim() + extension)}
disabled={!current_valid}>{extension === '' ? 'Ordner' : 'Datei'} umbenennen</Button
>
</div>
{/snippet}
{#snippet delete_request_popup()}
<div class="flex flex-col gap-1 h-full min-h-0 grow-0">
<span class="text-stone-400 px-1"
>{`${$selected_file_ids.length === 1 ? 'Folgendes Objekt' : `Folgende ${$selected_file_ids.length} Objekte`} löschen? (Wiederherstellung nicht möglich)`}</span
>
<div class="flex flex-col gap-2 overflow-auto h-full min-h-0 grow-0">
{#each $selected_file_ids
.map((file_id) => get_file_by_id(file_id, $all_files, $current_file_path))
.filter((element) => element !== null) as file}
<FolderElementObject {file} not_interactable />
{/each}
</div>
</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={async () => {
await run_for_selected_files_on_selected_displays(
async (ip: string, file_names: string[]) => {
await delete_files(ip, $current_file_path, file_names);
}
);
await update_current_folder_on_selected_displays();
popup_close_function();
}}>Löschen</Button
>
</div>
{/snippet}
{#snippet clipboard_hover_snippet()}
<div></div>
{/snippet}
<div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0"> <div class="bg-stone-800 h-full rounded-2xl grid grid-rows-[2.5rem_1fr] min-h-0">
<div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2"> <div class="bg-stone-700 flex justify-between w-full p-1 rounded-t-2xl min-w-0 gap-2">
<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">
@@ -258,7 +26,7 @@
change_height('file', 1); change_height('file', 1);
}} }}
> >
<ZoomIn /> <Plus />
</Button> </Button>
<Button <Button
title="Dateien kleiner darstellen" title="Dateien kleiner darstellen"
@@ -269,27 +37,21 @@
change_height('file', -1); change_height('file', -1);
}} }}
> >
<ZoomOut /> <Minus />
</Button> </Button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl"> <div class="flex flex-col gap-2 p-2 overflow-hidden relative rounded-b-2xl">
<div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl"> <div class="flex flex-col gap-2 p-2 bg-stone-750 rounded-xl">
<PathBar /> <PathBar />
<div class="flex flex-row justify-between gap-6"> <div class="flex flex-row justify-between gap-6 overflow-x-auto">
<div class="flex flex-row gap-2 shrink-0"> <div class="flex flex-row gap-2 shrink-0">
<Button <Button
title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)" title="Neuen Ordner erstellen (Neuen Ordner mit ausgewählten Objekten erstellen)"
className="px-3 flex" className="px-3 flex"><FolderPlus /></Button
click_function={show_new_folder_popup}
disabled={$selected_display_ids.length === 0}><FolderPlus /></Button
> >
<div class="border border-stone-700 my-1"></div> <div class="border border-stone-700 my-1"></div>
<Button <Button title="Datei(en) hochladen" className="px-3 flex"><Upload /></Button>
title="Datei(en) hochladen"
className="px-3 flex"
disabled={$selected_display_ids.length === 0}><Upload /></Button
>
<Button <Button
title="Ausgewählte Datei(en) herunterladen" title="Ausgewählte Datei(en) herunterladen"
className="px-3 flex" className="px-3 flex"
@@ -299,7 +61,6 @@
<Button <Button
title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren" title="Aktuellen Ordner / Ausgewählte Datei(en) zwischen Bildschirmen synchronisieren"
className="px-3 flex gap-3" className="px-3 flex gap-3"
disabled={$selected_display_ids.length === 0}
><RefreshCcw /> ><RefreshCcw />
<span class="hidden 2xl:flex">Synchronisieren</span> <span class="hidden 2xl:flex">Synchronisieren</span>
</Button> </Button>
@@ -310,18 +71,13 @@
className="px-3 flex" className="px-3 flex"
disabled={$selected_file_ids.length === 0}><Scissors /></Button disabled={$selected_file_ids.length === 0}><Scissors /></Button
> >
<Button <Button title="Ausgewählte Datei(en) einfügen" className="px-3 flex"
title="Ausgewählte Datei(en) einfügen" ><ClipboardPaste /></Button
className="!p-0 flex relative"
disabled={$selected_display_ids.length === 0}
> >
<ClipboardPaste />
</Button>
<div class="border border-stone-700 my-1"></div> <div class="border border-stone-700 my-1"></div>
<Button <Button
title="Ausgewählte Datei umbenennen" title="Ausgewählte Datei umbenennen"
className="px-3 flex" className="px-3 flex"
click_function={show_edit_file_popup}
disabled={$selected_file_ids.length !== 1}><Pen /></Button disabled={$selected_file_ids.length !== 1}><Pen /></Button
> >
<Button <Button
@@ -329,43 +85,22 @@
hover_bg="bg-red-400" hover_bg="bg-red-400"
active_bg="bg-red-500" active_bg="bg-red-500"
className="px-3 flex" className="px-3 flex"
disabled={$selected_file_ids.length === 0} disabled={$selected_file_ids.length === 0}><Trash2 /></Button
click_function={delete_folder_element_popup}><Trash2 /></Button
> >
</div> </div>
</div> </div>
</div> </div>
<div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl"> <div class="min-h-0 h-full overflow-y-auto bg-stone-750 rounded-xl">
<div class="flex flex-col gap-2 p-2 min-h-0 max-w-full"> <div class="flex flex-col gap-2 p-2 min-h-0">
{#if $selected_display_ids.length === 0} {#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center"> <section in:slide={{ duration: 100 }} class="outline-none">
Es sind keine Bildschirme ausgewählt. <FolderElementObject file={folder_element} />
</span> </section>
{:else} {/each}
{#each get_current_folder_elements($all_files, $current_file_path, $selected_display_ids) as folder_element (folder_element.id)}
<section in:slide={{ duration: 100 }} class="outline-none">
<FolderElementObject file={folder_element} />
</section>
{/each}
{#if get_current_folder_elements($all_files, $current_file_path, $selected_display_ids).length === 0}
<span class="text-stone-450 px-10 py-6 leading-relaxed text-center max-w-full">
Es existieren keine Dateien auf {$selected_display_ids.length === 1
? 'dem ausgewähltem Bildchirm'
: 'den ausgewählten Bildschirmen'} im aktuellen Ordner. Klicke auf <HighlightedText
bg="bg-stone-700"
fg="text-stone-400"
className="!p-1"><Upload class="inline pb-1" /></HighlightedText
> um Datei(en) hochzuladen.
</span>
{/if}
{/if}
</div> </div>
</div> </div>
<PopUp <!-- <PopUp title="Test" title_icon={Info}>
content={popup_content} <div>Hier kann beliebiges HTML übergeben werden!</div>
close_function={popup_close_function} </PopUp> -->
className="rounded-b-2xl"
snippet_container_class="overflow-hidden min-w-90"
/>
</div> </div>
</div> </div>
@@ -14,12 +14,7 @@
get_shifted_color get_shifted_color
} from '../ts/stores/ui_behavior'; } from '../ts/stores/ui_behavior';
import Button from './Button.svelte'; import Button from './Button.svelte';
import { import { supported_file_types, type FolderElement, type SupportedFileType } from '../ts/types';
supported_file_type_icon,
type FolderElement,
type SupportedFileType
} from '../ts/types';
import { import {
is_selected, is_selected,
select, select,
@@ -33,41 +28,31 @@
get_display_ids_where_file_is_missing get_display_ids_where_file_is_missing
} from '../ts/stores/files'; } from '../ts/stores/files';
import RefreshPlay from './RefreshPlay.svelte'; import RefreshPlay from './RefreshPlay.svelte';
import { get_file_size_display_string, get_file_type } 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 { import { displays, get_display_by_id, update_screenshot } from '../ts/stores/displays';
displays,
get_display_by_id,
run_on_all_selected_displays,
update_screenshot
} from '../ts/stores/displays';
import { get_thumbnail_url } from '../ts/stores/thumbnails';
import { db } from '../ts/indexdb/file_thumbnails.db';
import { onDestroy, onMount } from 'svelte';
import { liveQuery } from 'dexie';
let { file, not_interactable = false } = $props<{ let { file } = $props<{ file: FolderElement }>();
file: FolderElement;
not_interactable?: boolean;
}>();
let thumbnail_url: string | null = $state(null);
// Update thumbnail_url automatically if data is available
const subscription = liveQuery(() => db.thumbnail_blobs.get(file.hash)).subscribe({
next: async () => {
thumbnail_url = await get_thumbnail_url(file.hash);
},
error: (err) => console.error('Dexie subscription error:', err)
});
onDestroy(() => subscription.unsubscribe());
const is_folder = file.type === 'inode/directory'; const is_folder = file.type === 'inode/directory';
function get_created_string(date_object: Date | null, full_string = false) { function get_file_type(file: FolderElement): SupportedFileType | null {
if (!date_object) { for (const key of Object.keys(supported_file_types)) {
return full_string ? 'Verschiedene Daten auf verschiedenen Bildschirmen' : 'versch.'; if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
} }
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
function get_created_string(date_object: Date, full_string = false) {
if (full_string) { if (full_string) {
return ( return (
get_formated_date_string(date_object, true) + ' ' + get_formated_time_string(date_object) get_formated_date_string(date_object, true) + ' ' + get_formated_time_string(date_object)
@@ -91,21 +76,18 @@
} }
function get_grayed_out_text_color_strings(is_selected: boolean): string { function get_grayed_out_text_color_strings(is_selected: boolean): string {
if (not_interactable) return 'text-stone-400';
const color = is_selected ? 'text-stone-600' : 'text-stone-400'; const color = is_selected ? 'text-stone-600' : 'text-stone-400';
const factor = is_selected ? -1 : 1; const factor = is_selected ? -1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`; return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
} }
function get_grayed_out_border_color_strings(is_selected: boolean): string { function get_grayed_out_border_color_strings(is_selected: boolean): string {
if (not_interactable) return 'border-stone-550';
const color = is_selected ? 'border-stone-450' : 'border-stone-550'; const color = is_selected ? 'border-stone-450' : 'border-stone-550';
const factor = is_selected ? 1 : 1; const factor = is_selected ? 1 : 1;
return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`; return `${color} group-hover:${get_shifted_color(color, factor * 100)} group-active:${get_shifted_color(color, factor * 150)}`;
} }
function onclick(e: Event) { function onclick(e: Event) {
if (not_interactable) return;
select(selected_file_ids, file.id); select(selected_file_ids, file.id);
e.stopPropagation(); e.stopPropagation();
} }
@@ -115,59 +97,63 @@
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;
await run_on_all_selected_displays(open_file, true, path_to_file); for (const display_id of $selected_display_ids) {
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>
<div class="flex flex-row h-{$current_height.file} w-full"> <div class="flex flex-row h-{$current_height.file} w-full">
{#if !not_interactable} <div class="h-{$current_height.file} aspect-square max-w-15 flex">
<div class="h-{$current_height.file} aspect-square max-w-15 flex"> <Button
<Button disabled={!is_folder && get_file_type(file) === null}
disabled={!is_folder && get_file_type(file) === null} title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''}
title={!is_folder && get_file_type(file) === null ? 'Dateityp nicht unterstützt' : ''} className="flex rounded-l-lg rounded-r-none {is_folder
className="flex rounded-l-lg rounded-r-none {is_folder ? 'text-stone-450'
? 'text-stone-450' : 'text-stone-800'} w-full"
: 'text-stone-800'} w-full" div_class="w-full"
div_class="w-full" bg={get_selectable_color_classes(
bg={get_selectable_color_classes( !is_folder && get_file_type(file) !== null,
!is_folder && get_file_type(file) !== null, {
{ bg: true
bg: true },
}, -50
-50 )}
)} hover_bg={get_selectable_color_classes(
hover_bg={get_selectable_color_classes( !is_folder,
!is_folder, {
{ bg: true
bg: true },
}, 50
50 )}
)} active_bg={get_selectable_color_classes(
active_bg={get_selectable_color_classes( !is_folder,
!is_folder, {
{ bg: true
bg: true },
}, 100
100 )}
)} click_function={(e) => {
click_function={(e) => { open();
open(); e.stopPropagation();
e.stopPropagation(); }}
}} >
> {#if is_folder}
{#if is_folder} <ArrowRight class="size-full" strokeWidth="3" />
<ArrowRight class="size-full" strokeWidth="3" /> {:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0}
{:else if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[0].length !== 0} <RefreshPlay className="size-full" />
<RefreshPlay className="size-full" /> {:else if get_file_type(file) !== null}
{:else if get_file_type(file) !== null} <Play class="size-full" strokeWidth="3" />
<Play class="size-full" strokeWidth="3" /> {:else}
{:else} <Ban class="size-full" strokeWidth="3" />
<Ban class="size-full" strokeWidth="3" /> {/if}
{/if} </Button>
</Button> </div>
</div>
{/if}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
@@ -175,40 +161,28 @@
if (e.key === 'Enter' || e.key === ' ') onclick(e); if (e.key === 'Enter' || e.key === ' ') onclick(e);
}} }}
{onclick} {onclick}
class="{get_selectable_color_classes( class="{get_selectable_color_classes(is_selected(file.id, $selected_file_ids), {
!not_interactable && is_selected(file.id, $selected_file_ids), bg: true,
{ hover: true,
bg: true, active: true,
hover: !not_interactable, text: true
active: !not_interactable, })} rounded-r-lg transition-colors duration-200 gap-4 flex flex-row justify-between cursor-pointer group w-full min-w-0"
text: true
}
)} {not_interactable
? 'rounded-lg'
: 'rounded-r-lg cursor-pointer'} transition-colors duration-200 gap-4 flex flex-row justify-between group w-full min-w-0"
> >
<div class="flex flex-row gap-2 min-w-0 w-full"> <div class="flex flex-row gap-2 min-w-0 w-full">
<div class="aspect-square rounded-md flex justify-center items-center"> <div class="aspect-square rounded-md flex justify-center items-center p-2">
{#if is_folder} {#if is_folder}
<Folder class="size-full p-2" /> <Folder class="size-full" />
{:else if thumbnail_url} {:else if file.thumbnail}
<img <div></div>
src={thumbnail_url} {:else if get_file_type(file)?.icon}
alt="file_thumbnail" {@const Icon = get_file_type(file)?.icon}
class="object-contain size-full select-none block p-1 rounded-lg" <Icon class="size-full" />
draggable="false"
/>
{:else if supported_file_type_icon[get_file_type(file)?.display_name || '']}
{@const Icon = supported_file_type_icon[get_file_type(file)?.display_name || '']}
<Icon class="size-full p-2" />
{:else} {:else}
<FileIcon class="size-full p-2" /> <FileIcon />
{/if} {/if}
</div> </div>
<div class="content-center truncate select-none w-full" title={file.name}> <div class="content-center truncate select-none w-full" title={file.name}>
{file.name.includes('.') && !is_folder && get_file_type(file) {file.name.includes('.') ? file.name.slice(0, file.name.lastIndexOf('.')) : file.name}
? file.name.slice(0, file.name.lastIndexOf('.'))
: file.name}
</div> </div>
</div> </div>
<div <div
@@ -216,7 +190,7 @@
is_selected(file.id, $selected_file_ids) is_selected(file.id, $selected_file_ids)
)} duration-200 transition-colors" )} duration-200 transition-colors"
> >
<!-- {#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0} {#if get_display_ids_where_file_is_missing($current_file_path, file, $selected_display_ids, $all_files)[1].length !== 0}
<Button <Button
className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100" className="h-8 aspect-square transition-colors duration-200 !p-1.5 text-stone-100"
bg="bg-red-500" bg="bg-red-500"
@@ -242,7 +216,7 @@
> >
<RefreshCcwDot class="size-full" /> <RefreshCcwDot class="size-full" />
</Button> </Button>
{/if} --> {/if}
<div <div
class="w-14 content-center text-center select-none text-xs whitespace-nowrap" class="w-14 content-center text-center select-none text-xs whitespace-nowrap"
title={get_created_string(file.date_created, true)} title={get_created_string(file.date_created, true)}
@@ -1,12 +0,0 @@
<script lang="ts">
let {
children,
bg = 'bg-stone-750',
fg = 'text-stone-200',
className = ''
} = $props<{ children: any; bg?: string; fg?: string; className?: string }>();
</script>
<div class="{bg} {fg} {className} rounded-lg py-0.5 px-1 inline-block truncate min-w-0 max-w-full align-middle my-[1.5px]">
{@render children()}
</div>
@@ -1,70 +0,0 @@
<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>
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { type DisplayStatus } from "../ts/types"; import { display_status_to_info, type DisplayStatus } from "../ts/types";
import { display_status_to_info } from "../ts/utils";
let { selected, status, className = "" } = $props<{ let { selected, status, className = "" } = $props<{
selected: boolean; selected: boolean;
+22 -33
View File
@@ -5,16 +5,9 @@
import type { PopupContent } from '../ts/types'; import type { PopupContent } from '../ts/types';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { let { content, close_function } = $props<{
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() {
@@ -38,41 +31,37 @@
<!-- 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 flex justify-center items-center {className}" class="absolute inset-0 backdrop-blur bg-white/10 flex justify-center items-center"
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%] max-w-[90%] max-h-[85%] flex flex-col shadow-2xl/60 overflow-hidden {content.window_class ?? 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()}
> >
{#if content.title} <div
<div class="font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-8 w-full"> class="text-2xl font-bold bg-stone-700 p-1.5 flex flex-row justify-between gap-6 w-full"
<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 ?? <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 ?? ''}">
''}" {#if content.title_icon}
> {@const Icon = content.title_icon}
{#if content.title_icon} <Icon strokeWidth="2.8" class="flex-shrink-0" />
{@const Icon = content.title_icon} {/if}
<Icon strokeWidth="2" class="flex-shrink-0" /> <div class="flex-shrink-0">
{/if} {content.title}
<div class="flex-shrink-0">
{content.title}
</div>
</div>
<div class="flex aspect-square flex-shrink-0">
{#if content.closable}
<Button className="aspect-square !p-1.5" click_function={try_to_close}>
<X />
</Button>
{/if}
</div> </div>
</div> </div>
{/if} <div class="flex aspect-square flex-shrink-0">
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2 {snippet_container_class}"> {#if content.closable}
<Button className="aspect-square !p-1.5" click_function={try_to_close}>
<X />
</Button>
{/if}
</div>
</div>
<div class="p-2 min-h-0 overflow-auto flex flex-col gap-2">
{@render content.snippet(content.snippet_arg)} {@render content.snippet(content.snippet_arg)}
</div> </div>
</div> </div>
@@ -2,7 +2,6 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { get_shifted_color } from '../ts/stores/ui_behavior'; import { get_shifted_color } from '../ts/stores/ui_behavior';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { selected_display_ids } from '../ts/stores/select';
let { let {
current_value = $bindable(), current_value = $bindable(),
@@ -11,11 +10,7 @@
bg = 'bg-stone-750', bg = 'bg-stone-750',
title, title,
placeholder = '', placeholder = '',
is_valid_function = null, is_valid_function = null
focused_on_start = false,
extension = null,
enter_mode = 'none',
enter_function = null
} = $props<{ } = $props<{
current_value: string; current_value: string;
current_valid: boolean; current_valid: boolean;
@@ -24,16 +19,11 @@
title: string; title: string;
placeholder?: string; placeholder?: string;
is_valid_function?: ((input: string) => [boolean, string]) | null; is_valid_function?: ((input: string) => [boolean, string]) | null;
focused_on_start?: boolean;
extension?: string | null;
enter_mode?: 'none' | 'focus_next' | 'submit';
enter_function?: (() => void) | null;
}>(); }>();
let focus_bg = get_shifted_color(bg, 100); let focus_bg = get_shifted_color(bg, 100);
let focused: boolean = $state(false); let focussed = $state(false);
let current_info = $state(''); let current_info = $state('');
let input_element: HTMLInputElement;
function validate_input() { function validate_input() {
if (!is_valid_function) return; if (!is_valid_function) return;
@@ -43,92 +33,38 @@
function get_highlighting_string(): string { function get_highlighting_string(): string {
if (!is_valid_function) return ''; if (!is_valid_function) return '';
if (current_valid) { if (current_valid) {
return 'focus-within:inset-ring-2 focus-within:inset-ring-green-400'; return 'focus:inset-ring-2 focus:inset-ring-green-400';
} else { } else {
return 'inset-ring-2 inset-ring-red-400'; return 'inset-ring-2 inset-ring-red-400';
} }
} }
function focus_next_element() {
const focusable = Array.from(document.querySelectorAll<HTMLElement>('input')).filter(
(el) => !el.hasAttribute('disabled')
);
const index = focusable.indexOf(input_element);
if (index !== -1 && index < focusable.length - 1) {
focusable[index + 1].focus();
}
}
function handle_keydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
if (enter_mode === 'focus_next') {
event.preventDefault();
focus_next_element();
} else if (enter_mode === 'submit' && enter_function) {
event.preventDefault();
enter_function();
}
}
onMount(() => { onMount(() => {
validate_input(); validate_input();
if (focused_on_start && input_element) input_element.focus();
selected_display_ids.subscribe(() => {
validate_input();
});
}); });
</script> </script>
<div class="flex flex-col {className} relative"> <div class="flex flex-col {className}">
<div class="flex flex-row justify-between text-sm px-1 gap-4"> <div class="flex flex-row justify-between text-sm px-1">
<div class="text-stone-400"> <div class="text-stone-400">
{title}: {title}:
</div> </div>
{#if is_valid_function && focused} {#if is_valid_function && focussed}
<div <div class={current_valid ? "text-green-400" : "text-red-400"} transition:fade={{ duration: 100 }}>
class={current_valid ? 'text-green-400' : 'text-red-400'}
transition:fade={{ duration: 100 }}
>
{current_info} {current_info}
</div> </div>
{/if} {/if}
</div> </div>
<!-- <input <input
bind:this={input_element}
bind:value={current_value} bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
type="text" type="text"
oninput={validate_input} oninput={validate_input}
onfocus={() => {
focussed = true;
}}
onfocusout={() => {
focussed = false;
}}
class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}" class="{bg} focus:{focus_bg} outline-none py-2 px-3 rounded-xl transition-all duration-100 {get_highlighting_string()}"
{placeholder} {placeholder}
/> --> />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={() => {
input_element.focus();
}}
class="{bg} focus-within:{focus_bg} flex items-center rounded-xl {get_highlighting_string()} cursor-text"
>
<input
bind:this={input_element}
bind:value={current_value}
bind:focused
onkeydown={handle_keydown}
oninput={validate_input}
type="text"
class=" outline-none py-2 px-3 transition-all duration-100 flex-grow group"
{placeholder}
/>
{#if extension}
<span class="pr-3 text-stone-400 select-none pointer-events-none">
{extension}
</span>
{/if}
</div>
</div> </div>
@@ -1,256 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import { StarterKit } from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import {
Baseline,
Bold,
Code,
Highlighter,
Italic,
PaintBucket,
QrCode,
Strikethrough
} from 'lucide-svelte';
import Button from './Button.svelte';
import { run_on_all_selected_displays } from '../ts/stores/displays';
import { show_html } from '../ts/api_handler';
import { get_selectable_color_classes } from '../ts/stores/ui_behavior';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-text-style';
import Highlight from '@tiptap/extension-highlight';
type TextEditOption = {
onclick: () => void;
is_selected: () => boolean;
icon: typeof Bold;
title: string;
color?: ColorElement;
};
type ColorElement = {
color_string: () => string;
color_picker: () => void;
};
type ColorState = {
el: HTMLInputElement | null;
value: string;
};
let element = $state<HTMLElement>();
let editor_state = $state<{ editor: Editor | null }>({ editor: null });
let color_states: { text: ColorState; highlight: ColorState; bg: ColorState } = $state({
text: { el: null, value: '#b91c1c' },
highlight: { el: null, value: '#0c4a6e' },
bg: { el: null, value: '#1c1917' }
});
const text_edit_options: TextEditOption[][] = [
[
{
onclick: () => editor_state.editor?.chain().focus().toggleBold().run(),
is_selected: () => editor_state.editor?.isActive('bold') ?? false,
title: 'Fett (STRG+B)',
icon: Bold
},
{
onclick: () => editor_state.editor?.chain().focus().toggleItalic().run(),
is_selected: () => editor_state.editor?.isActive('italic') ?? false,
title: 'Kursiv (STRG+I)',
icon: Italic
},
{
onclick: () => editor_state.editor?.chain().focus().toggleStrike().run(),
is_selected: () => editor_state.editor?.isActive('strike') ?? false,
title: 'Durchgestrichen',
icon: Strikethrough
},
{
onclick: () => {},
is_selected: () => false,
title: 'QR-Code anfügen',
icon: QrCode
}
],
[
{
onclick: () => editor_state.editor?.chain().focus().setColor(color_states.text.value).run(),
is_selected: () =>
editor_state.editor?.isActive('textStyle', { color: color_states.text.value }) ?? false,
icon: Baseline,
title: 'Textfarbe',
color: get_color_element(color_states.text)
},
{
onclick: () =>
editor_state.editor
?.chain()
.focus()
.toggleHighlight({ color: color_states.highlight.value })
.run(),
is_selected: () =>
editor_state.editor?.isActive('highlight', { color: color_states.highlight.value }) ??
false,
icon: Highlighter,
title: 'Markierungsfarbe',
color: get_color_element(color_states.highlight)
},
{
onclick: () => color_states.bg.el?.click(),
is_selected: () => false,
title: 'Hintergrundfarbe',
icon: PaintBucket
}
]
];
function get_color_element(color_state: ColorState) {
return {
color_string: () => color_state.value,
color_picker: () => color_state.el?.click()
};
}
function show_text() {
const html =
editor_state.editor?.getHTML() +
`<style>:root {--background-color: ${color_states.bg.value} !important;}
</style>`;
run_on_all_selected_displays(show_html, true, html);
}
onMount(() => {
editor_state.editor = new Editor({
element: element,
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Text hier eingeben ...'
}),
TextStyle,
Color,
Highlight.configure({
multicolor: true
})
],
content: '',
onTransaction: ({ editor }) => {
// Increment the state signal to force a re-render
editor_state = { editor };
},
autofocus: true
});
});
onDestroy(() => {
editor_state.editor?.destroy();
});
</script>
{#each Object.values(color_states) as color_state}
<input type="color" bind:this={color_state.el} bind:value={color_state.value} class="hidden" />
{/each}
<div class="flex flex-row gap-2 size-full">
<div
class="rounded-xl size-full flex-shrink min-w-0 flex"
style="background-color: {color_states.bg.value};"
>
<div bind:this={element} class="size-full overflow-auto px-3 py-2"></div>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
{#each text_edit_options as edit_row}
<div class="flex flex-row gap-1">
{#each edit_row as option}
<div class="flex flex-row">
<button
title={option.title}
onclick={option.onclick}
class="p-1 {option.color
? 'rounded-l-xl'
: 'rounded-xl'} cursor-pointer duration-200 transition-colors {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
{#if option.icon}
{@const Icon = option.icon}
<Icon class="h-7 w-7" />
{/if}
</button>
{#if option.color}
<button
aria-label="Color"
onclick={option.color.color_picker}
title={option.title}
class="flex p-1 rounded-r-xl cursor-pointer duration-200 transition-colors justify-center items-center {get_selectable_color_classes(
option.is_selected(),
{
bg: true,
hover: true,
active: true,
text: true
},
-100,
50
)}"
>
<span
class="h-7 w-3 rounded-full"
style="background-color: {option.color.color_string()};"
></span>
</button>
{/if}
</div>
{/each}
</div>
{/each}
</div>
<Button click_function={show_text} className="w-full font-bold">Text anzeigen</Button>
</div>
</div>
<style>
:global(.tiptap) {
width: 100%;
height: 100%;
--tw-outline-style: none;
outline-style: none;
}
:global(.tiptap ul),
:global(.tiptap ol) {
padding-left: 1.5rem;
}
:global(.tiptap ul) {
list-style: disc;
}
:global(.tiptap ol) {
list-style: decimal;
}
:global(.tiptap p) {
margin-bottom: 0.5rem;
}
:global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
pointer-events: none;
color: oklch(44.4% 0.011 73.639);
float: left;
height: 0;
opacity: 0.7;
}
+11 -29
View File
@@ -6,7 +6,7 @@
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 { import {
add_display, add_display,
@@ -21,8 +21,6 @@
import { ping_ip } from '../ts/api_handler'; import { ping_ip } from '../ts/api_handler';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { on_start } from '../ts/main'; import { on_start } from '../ts/main';
import { display_status_to_info } from '../ts/utils';
import HighlightedText from '../components/HighlightedText.svelte';
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))$/;
@@ -32,7 +30,6 @@
open: false, open: false,
snippet: null, snippet: null,
title: '', title: '',
title_class: '!text-xl',
closable: true closable: true
}); });
@@ -57,10 +54,10 @@
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;
if (!!existing_display_id) { if (!!existing_display_id) {
await edit_display_data(existing_display_id, ip, mac, name); edit_display_data(existing_display_id, ip, mac, name);
} else { } else {
const status = await ping_ip(text_inputs_valid.ip.value); const status = await ping_ip(text_inputs_valid.ip.value);
await add_display(ip, mac, name, status); add_display(ip, mac, name, status);
} }
popup_close_function(); popup_close_function();
} }
@@ -73,10 +70,9 @@
text_inputs_valid = text_inputs_valid_null_values; text_inputs_valid = text_inputs_valid_null_values;
popup_content = { popup_content = {
open: true, open: true,
snippet: display_popup, 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
}; };
}; };
@@ -87,7 +83,7 @@
snippet: remove_display_popup, snippet: remove_display_popup,
snippet_arg: display_id, snippet_arg: display_id,
title: 'Bildschirm wirklich löschen?', title: 'Bildschirm wirklich löschen?',
title_class: 'text-red-400 !text-xl', title_class: 'text-red-400',
title_icon: Trash2, title_icon: Trash2,
closable: true closable: true
}; };
@@ -103,11 +99,10 @@
} }
popup_content = { popup_content = {
open: true, open: true,
snippet: display_popup, snippet: add_new_display_popup,
snippet_arg: display_id, snippet_arg: display_id,
title: 'Bildschirm bearbeiten', title: 'Bildschirm bearbeiten',
title_icon: Monitor, title_icon: Monitor,
title_class: '!text-xl',
closable: true closable: true
}; };
}; };
@@ -119,10 +114,9 @@
{#snippet remove_display_popup(display_id: string)} {#snippet remove_display_popup(display_id: string)}
<div class="max-w-prose px-2"> <div class="max-w-prose px-2">
Soll der Bildschirm <HighlightedText Soll der Bildschirm "{get_display_by_id(display_id, $displays)?.name || '?'}" wirklich gelöscht
>{get_display_by_id(display_id, $displays)?.name || '?'}</HighlightedText werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation auf dem
> wirklich gelöscht werden? Dadurch wird es von diesem Controller nicht mehr erreichbar. Die Installation Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
auf dem Gerät bleibt bestehen. Mit dem erneuten Hinzufügen des Bildschirms wird er wieder steuerbar.
</div> </div>
<div class="flex flex-row justify-end gap-2"> <div class="flex flex-row justify-end gap-2">
<Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button> <Button className="px-4 font-bold" click_function={popup_close_function}>Abbrechen</Button>
@@ -138,9 +132,8 @@
</div> </div>
{/snippet} {/snippet}
{#snippet display_popup(existing_display_id: string | null = null)} {#snippet add_new_display_popup(existing_display_id: string | null = null)}
<TextInput <TextInput
focused_on_start
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"
@@ -154,7 +147,6 @@
if (is_display_name_taken(input)) return [false, 'Name bereits verwendet']; if (is_display_name_taken(input)) return [false, 'Name bereits verwendet'];
return [true, 'Gültiger Name']; return [true, 'Gültiger Name'];
}} }}
enter_mode="focus_next"
/> />
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<TextInput <TextInput
@@ -168,7 +160,6 @@
: [false, 'Ungültige IP-Adresse']; : [false, 'Ungültige IP-Adresse'];
}} }}
className="grow" className="grow"
enter_mode="focus_next"
/> />
<div class="flex items-end shrink-0"> <div class="flex items-end shrink-0">
<Button <Button
@@ -198,10 +189,6 @@
? [true, 'Gültige MAC-Adresse'] ? [true, 'Gültige MAC-Adresse']
: [false, 'Ungültige MAC-Adresse']; : [false, 'Ungültige MAC-Adresse'];
}} }}
enter_mode="submit"
enter_function={() => {
finalize_add_edit_display(existing_display_id);
}}
/> />
<div class="flex flex-row gap-2 justify-end pt-2"> <div class="flex flex-row gap-2 justify-end pt-2">
{#if !!existing_display_id} {#if !!existing_display_id}
@@ -260,10 +247,5 @@
<FileView /> <FileView />
</div> </div>
</div> </div>
<PopUp <PopUp content={popup_content} close_function={popup_close_function} />
content={popup_content}
close_function={popup_close_function}
className="bg-white/10"
snippet_container_class="min-w-115"
/>
</main> </main>
-1
View File
@@ -201,7 +201,6 @@ module.exports = {
'group-active:border-stone-900', 'group-active:border-stone-900',
'group-active:border-stone-950', 'group-active:border-stone-950',
'h-10',
'h-15', 'h-15',
'h-20', 'h-20',
'h-25', 'h-25',
+52 -180
View File
@@ -1,65 +1,44 @@
import { notifications } from "./stores/notification"; import { notifications } from "./stores/notification";
import { to_display_status, type DisplayStatus, type FolderElement, type RequestResponse, type ShellCommandResponse, type TreeElement } from "./types"; 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): Promise<Blob | null> { export async function get_screenshot(ip: string) {
const options = { method: 'PATCH' }; const options = { method: 'PATCH' };
const response = await request_display(ip, '/takeScreenshot', options); return await request_display(ip, '/takeScreenshot', options);
if (!response.ok || !response.blob) return null;
return response.blob;
} }
export async function open_file(ip: string, path_to_file: string): Promise<void> { export async function open_file(ip: string, path_to_file: string): Promise<boolean> {
const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } }; const options = { method: 'PATCH', headers: { 'content-type': 'application/octet-stream' } };
await request_display(ip, `/file${path_to_file}`, options); const raw_response = await request_display(ip, `/file${path_to_file}`, options);
return !!raw_response;
} }
export async function send_keyboard_input(ip: string, key: string): Promise<void> { export async function get_file_data(ip: string, path: string): Promise<FolderElement[]> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: key,
}),
};
await request_display(ip, '/keyboardInput', options);
}
export async function show_html(ip: string, html: string) {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: html
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_file_data(ip: string, path: string): Promise<FolderElement[] | null> {
interface FileInfo { interface FileInfo {
name: string; name: string;
type: string; type: string;
size: string; size: string;
created: string; created: string;
} }
const command = `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
`
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (json_response.exitCode === 0 && json_response.stdout.trim() === '') return [];
if (handle_shell_error(ip, json_response, command, true)) return null;
const response: FileInfo[] = json_response.stdout.trim() const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: `cd ".${path}" && find . -maxdepth 1 -mindepth 1 -print0 | while IFS= read -r -d '' f; do
typ=$(file -b --mime-type -- "$f")
size=$(stat -c '%s' -- "$f")
created=$(stat -c '%w' -- "$f")
[ "$created" = "-" ] && created=$(stat -c '%y' -- "$f")
jq -n --arg name "$f" --arg type "$typ" --arg size "$size" --arg created "$created" \
'{name:$name, type:$type, size:($size|tostring), created:$created}' | tr -d '\n'
echo
done
` })
};
const raw_response = await request_display(ip, '/shellCommand', options);
if (!raw_response) return [];
const response: FileInfo[] = raw_response.stdout.trim()
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((line: string) => JSON.parse(line) as FileInfo); .map((line: string) => JSON.parse(line) as FileInfo);
@@ -69,14 +48,10 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
for (const response_element of response) { for (const response_element of response) {
// filter hidden files (start with '.' -> './.config') // filter hidden files (start with '.' -> './.config')
if (response_element.name.charAt(2) !== '.') { if (response_element.name.charAt(2) !== '.') {
const truncated = {
...response_element,
created: response_element.created.slice(0, 16) // truncated to YYYY-MM-DD hh-mm -> no (milli)seconds
};
const folder_element: FolderElement = { const folder_element: FolderElement = {
id: get_uuid(), id: get_uuid(),
hash: JSON.stringify(truncated), hash: JSON.stringify(response),
thumbnail_url: null,
name: response_element.name.slice(2), // remove "./" name: response_element.name.slice(2), // remove "./"
type: response_element.type, type: response_element.type,
date_created: new Date(response_element.created), date_created: new Date(response_element.created),
@@ -88,158 +63,55 @@ export async function get_file_data(ip: string, path: string): Promise<FolderEle
return folder_element_list; return folder_element_list;
} }
export async function get_file_tree_data(ip: string, path: string): Promise<TreeElement[] | null> {
const command = `cd ".${path}" && tree -Js`
const raw_response = await run_shell_command(ip, command, true);
if (!raw_response.ok || !raw_response.json) return null;
const json_response = raw_response.json as ShellCommandResponse;
if (handle_shell_error(ip, json_response, command, true)) return null;
return (JSON.parse(json_response.stdout.trim()) as [TreeElement, any])[0].contents || null;
}
export async function create_folders(ip: string, path: string, folder_names: string[]): Promise<void> {
let command = `cd ".${path}"`;
for (const part of folder_names) {
command += ` && mkdir "${part}" && cd "${part}/"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function rename_file(ip: string, path: string, old_file_name: string, new_file_name: string): Promise<void> {
const command: string = `cd ".${path}" && mv "${old_file_name}" "${new_file_name}"`;
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function delete_files(ip: string, current_path: string, file_names: string[]): Promise<void> {
let command: string = `cd ".${current_path}"`;
for (const file_name of file_names) {
command += ` && rm -r "${file_name}"`;
}
const raw_response = await run_shell_command(ip, command);
if (!raw_response.ok || !raw_response.json) return;
const json_response = raw_response.json as ShellCommandResponse;
handle_shell_error(ip, json_response, command, true);
}
export async function show_blackscreen(ip: string): Promise<void> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
html: ``
})
};
await request_display(ip, '/showHTML', options);
}
export async function get_thumbnail_blob(ip: string, path_to_file: string): Promise<Blob | null> {
const raw_response = await request_display(ip, `/file/preview${path_to_file}`, { method: 'GET' }, true, [415]);
if (!raw_response.ok || !raw_response.blob) return null
return raw_response.blob;
}
export async function ping_ip(ip: string): Promise<DisplayStatus> { export async function ping_ip(ip: string): Promise<DisplayStatus> {
const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' }); const raw_response = await request_control(`/ping?ip=${ip}`, { method: 'GET' });
if (!raw_response.ok || !raw_response.json) return null; if (!raw_response) return null;
return raw_response.json.status ? to_display_status(raw_response.json.status) : null; return raw_response.status ? to_display_status(raw_response.status) : null;
} }
async function request_display(ip: string, api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> {
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}`; const url = `http://${ip}:1323/api${api_route}`;
return await request(url, options, log_in_debug, supress_error_handling_http_codes); return await raw_request(url, options);
} }
async function request_control(api_route: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<RequestResponse> { 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}`; const url = `${window.location.origin}/api${api_route}`;
return await request(url, options); return await raw_request(url, options);
} }
async function request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }, log_in_debug: boolean = false, supress_error_handling_http_codes: number[] = []): Promise<RequestResponse> { async function raw_request(url: string, options: { method: string, headers?: Record<string, string>, body?: any }): Promise<null | any> {
try { try {
const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`; const cache_buster = `${url.includes('?') ? '&' : '?'}=${Date.now()}`;
if (log_in_debug) { console.log(url + cache_buster)
console.debug(url + cache_buster, options.body);
} else {
console.log(url + cache_buster, options.body);
}
const response = await fetch(url + cache_buster, options); const response = await fetch(url + cache_buster, options);
if (response.ok || supress_error_handling_http_codes.includes(response.status)) { if (!response.ok) {
const contentType = response.headers.get("content-type") || ""; console.error(`HTTP error! Status: ${response.status}`);
let request_response: RequestResponse; notifications.push("error", `HTTP-Fehler bei API-Anfrage`, `${url}\nHTTP-Status: ${response.status}`);
if (!contentType.includes("application/json")) {
const blob: Blob = await response.blob();
request_response = { ok: response.ok, http_code: response.status, blob: blob };
} else {
const json: Object = await response.json();
request_response = { ok: response.ok, http_code: response.status, json: json };
}
if (log_in_debug) {
console.debug(request_response);
} else {
console.log(request_response);
}
return request_response;
} }
const contentType = response.headers.get("content-type") || "";
let error_description: string; if (!contentType.includes("application/json")) {
try { return await response.blob();
const json: { description: string } = await response.json(); } else {
error_description = json.description; const json = await response.json();
} catch (error_on_json_parsing: any) { if (json.error && json.error !== '') {
error_description = `unknown error: ${error_on_json_parsing}`; notifications.push("error", `Interner Fehler bei API-Anfrage`, `${url}\nJSON: ${JSON.stringify(json)}`);
}
return json;
} }
console.error(url, error_description);
notifications.push("error", `Fehler ${response.status} bei API-Anfrage`, `${url}\nFehler: ${error_description}`);
} catch (error: any) { } catch (error: any) {
if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) { if (error instanceof TypeError && /fetch|NetworkError/i.test(error.message)) {
console.log("Request failed - Is the targeted device online?") console.log("Request failed - Is the targeted device online?")
} else { } else {
console.error(url, error); console.log(typeof error, error instanceof TypeError, error.message);
notifications.push("error", `Fataler Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`); console.error(error);
notifications.push("error", `Fehler bei API-Anfrage`, `${url}\nFehler: ${error}`);
} }
return null;
} }
return { ok: false };
}
function handle_shell_error(ip: string, shell_response: ShellCommandResponse, shell_command: string, command_includs_cd: boolean): boolean {
if (shell_response.exitCode !== 0) {
if (command_includs_cd && shell_response.stderr && /bash: line \d+: cd: .+: No such file or directory/.test(shell_response.stderr)) {
console.log("current file_path does not exist on display:", ip);
return true;
}
console.error(shell_response);
notifications.push("error", `Fehler ${shell_response.exitCode} in API-Shell`, `${ip}\n${shell_command}\nFehler: ${shell_response.stderr}`);
return true;
}
if (shell_response.stdout.trim() === '') return true;
return false;
}
async function run_shell_command(ip: string, command: string, log_in_debug: boolean = false): Promise<RequestResponse> {
const options = {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: command
})
};
return await request_display(ip, '/shellCommand', options, log_in_debug);
} }
@@ -1,20 +0,0 @@
import Dexie, { type EntityTable } from "dexie";
export interface ThumbnailBlobDBEntry {
hash: string;
blob: Blob;
}
export class FileDatabase extends Dexie {
thumbnail_blobs!: EntityTable<ThumbnailBlobDBEntry, "hash">; // (Type, PrimaryKey)
constructor() {
super("FileDatabase");
this.version(1).stores({
thumbnail_blobs: "++hash, blob" // primary key
});
}
}
export const db = new FileDatabase();
+7 -18
View File
@@ -1,34 +1,23 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { displays, run_on_all_selected_displays, update_displays_with_map, update_screenshot } from "./stores/displays" import { displays, update_displays_with_map } from "./stores/displays"
import { ping_ip } from "./api_handler"; import { ping_ip } from "./api_handler";
import type { Display } from "./types"; import type { Display } from "./types";
import { change_file_path, update_folder_elements_recursively } from "./stores/files"; import { change_file_path, update_all_display_files } from "./stores/files";
import { db } from "./indexdb/file_thumbnails.db";
const update_display_status_interval_seconds = 20; const update_display_status_interval_seconds = 20;
export async function on_start() { export function on_start() {
await db.thumbnail_blobs.clear(); update_all_display_status();
await update_all_display_status(); setInterval(update_all_display_status, update_display_status_interval_seconds * 1000);
await setInterval(update_all_display_status, update_display_status_interval_seconds * 1000); update_all_display_files('/');
} }
async function update_all_display_status() { async function update_all_display_status() {
await update_displays_with_map(async (display: Display) => { update_displays_with_map(async (display: Display) => {
const new_status = await ping_ip(display.ip); const new_status = await ping_ip(display.ip);
if (new_status === null && display.status !== null) return display; if (new_status === null && display.status !== null) return display;
if (new_status === "app_online" && display.status !== "app_online") {
await on_display_start(display);
}
return { ...display, status: new_status, }; return { ...display, status: new_status, };
}); });
console.log("Display Status updated") console.log("Display Status updated")
}
async function on_display_start(display: Display) {
await update_folder_elements_recursively(display, '/');
await update_screenshot(display.id);
} }
+5 -25
View File
@@ -3,7 +3,7 @@ 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 { filter_file_selection_for_current_selected_displays } from "./files"; 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(),
@@ -25,16 +25,15 @@ export function add_display(ip: string, mac: string | null, name: string, status
}); });
} }
export async function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) { export function edit_display_data(display_id: string, ip: string, mac: string | null, name: string) {
await update_displays_with_map((display: Display) => { update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display; if (display.id !== display_id) return display;
return { ...display, ip: ip, mac: mac, name: name }; return { ...display, ip: ip, mac: mac, name: name };
}) })
} }
export function remove_display(display_id: string) { export function remove_display(display_id: string) {
select(selected_display_ids, display_id, false); console.log(display_id);
filter_file_selection_for_current_selected_displays();
displays.update((displays: DisplayGroup[]) => { displays.update((displays: DisplayGroup[]) => {
displays = displays.map(display_group => ({ displays = displays.map(display_group => ({
...display_group, ...display_group,
@@ -60,7 +59,6 @@ export function select_all_of_group(display_group: DisplayGroup, new_value: bool
for (const display of display_group.data) { for (const display of display_group.data) {
select(selected_display_ids, display.id, new_value); select(selected_display_ids, display.id, new_value);
} }
filter_file_selection_for_current_selected_displays();
} }
export function set_new_display_group_data(display_group_id: string, new_data: Display[]) { export function set_new_display_group_data(display_group_id: string, new_data: Display[]) {
@@ -113,13 +111,6 @@ export async function update_screenshot(display_id: string, check_type: "first_c
const display_ip = get_display_by_id(display_id, get(displays))?.ip; const display_ip = get_display_by_id(display_id, get(displays))?.ip;
if (!display_ip) return; if (!display_ip) return;
const new_blob = await get_screenshot(display_ip); const new_blob = await get_screenshot(display_ip);
if (!new_blob) {
update_displays_with_map((display: Display) => {
if (display.id !== display_id) return display;
return { ...display, preview_url: null, preview_timeout_id: null };
})
return;
}
const display = get_display_by_id(display_id, get(displays)); const display = get_display_by_id(display_id, get(displays));
let update_needed = check_type === "first_check"; let update_needed = check_type === "first_check";
@@ -128,6 +119,7 @@ export async function update_screenshot(display_id: string, check_type: "first_c
const old_blob = await fetch(display.preview_url).then(r => r.blob()); const old_blob = await fetch(display.preview_url).then(r => r.blob());
const new_hash = await image_content_hash(new_blob); const new_hash = await image_content_hash(new_blob);
const old_hash = await image_content_hash(old_blob); const old_hash = await image_content_hash(old_blob);
console.log(old_hash, new_hash);
update_needed = old_hash !== new_hash; // if different -> update update_needed = old_hash !== new_hash; // if different -> update
} }
} }
@@ -164,18 +156,6 @@ export async function update_displays_with_map(update_function: (display: Displa
displays.set(updated_groups); 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);
}
}
}
}
+41 -155
View File
@@ -1,11 +1,11 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import type { Display, FolderElement, TreeElement } from "../types"; import type { Display, FolderElement } from "../types";
import { displays, get_display_by_id } from "./displays"; import { displays, update_displays_with_map } from "./displays";
import { select, selected_display_ids, selected_file_ids } from "./select"; import { selected_display_ids, selected_file_ids } from "./select";
import { get_file_data, get_file_tree_data } from "../api_handler"; import { get_file_data } from "../api_handler";
import { get_uuid } from "../utils";
import type { Folder } from "lucide-svelte";
import { notifications } from "./notification"; import { notifications } from "./notification";
import { CirclePoundSterling } from "lucide-svelte";
import { deactivate_old_thumbnail_urls, generate_thumbnail } from "./thumbnails";
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[]>>>({});
// { // {
@@ -23,51 +23,18 @@ export const all_files: Writable<Record<string, Record<string, FolderElement[]>>
export const current_file_path: Writable<string> = writable<string>('/'); export const current_file_path: Writable<string> = writable<string>('/');
export async function change_file_path(new_path: string) { export function change_file_path(new_path: string) {
current_file_path.update(() => { current_file_path.update(() => {
return new_path; return new_path;
}); });
selected_file_ids.update(() => { selected_file_ids.update(() => {
return []; return [];
}) })
setTimeout(async () => { await update_all_display_files(new_path) }, 0);
deactivate_old_thumbnail_urls();
for (const display_group of get(displays)) {
for (const display of display_group.data) {
const changed_paths = await get_changed_directory_paths(display, new_path);
if (!changed_paths) continue;
console.log("Update file system from", display.name, ":", changed_paths);
for (const path of changed_paths) {
update_folder_elements_recursively(display, path);
}
}
}
}
export async function filter_file_selection_for_current_selected_displays() {
for (const selected_file_id of get(selected_file_ids)) {
if (!get_file_by_id(selected_file_id, get(all_files), get(current_file_path), true)) {
// file not found in selected displays
select(selected_file_ids, selected_file_id, false);
}
}
}
export async function update_current_folder_on_selected_displays() {
selected_file_ids.update(() => {
return [];
});
const current_path = get(current_file_path);
for (const display_id of get(selected_display_ids)) {
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
update_folder_elements_recursively(display, current_path);
}
} }
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[][] {
if (!all_files.hasOwnProperty(path)) return [selected_display_ids, []]; if (!all_files.hasOwnProperty(path)) return [];
const missing: string[] = []; const missing: string[] = [];
const colliding: string[] = []; const colliding: string[] = [];
Display: Display:
@@ -89,53 +56,16 @@ export function get_display_ids_where_file_is_missing(path: string, file: Folder
return [missing, colliding]; return [missing, colliding];
} }
export function get_display_ids_where_path_does_not_exist(path: string, selected_display_ids: string[], all_files: Record<string, Record<string, FolderElement[]>>): string[] { export async function update_all_display_files(path: string) {
if (!all_files.hasOwnProperty(path)) return selected_display_ids; for (const display_group of get(displays)) {
const out: string[] = []; for (const display of display_group.data) {
for (const selected_display_id of selected_display_ids) { await update_folder_elements_recursively(display, path);
if (!all_files[path].hasOwnProperty(selected_display_id)) {
out.push(selected_display_id);
} }
} }
return out;
} }
async function get_changed_directory_paths(display: Display, file_path: string): Promise<string[] | null> { async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
const current_folder = await get_file_tree_data(display.ip, file_path);
const directory_strings = get_recursive_changed_directory_paths(display, file_path, current_folder, get(all_files));
if (directory_strings.size === 0) return null;
const directory_strings_array = [...directory_strings];
return directory_strings_array.filter((e) => (!directory_strings_array.some((f) => (f !== e && f.startsWith(e)))));
}
function get_recursive_changed_directory_paths(display: Display, current_file_path: string, current_folder_elements: TreeElement[] | null, files: Record<string, Record<string, FolderElement[]>>): Set<string> {
const files_folder: FolderElement[] = files[current_file_path][display.id];
if ((!files_folder || files_folder.length === 0) && (!current_folder_elements || current_folder_elements.length === 0)) {
return new Set([]); // no data -> no update needed
} else if (!files_folder || !current_folder_elements || current_folder_elements.length !== files_folder.length) {
return new Set([current_file_path]); // existing data does not match new data -> update
}
let has_changed: Set<string> = new Set();
for (const tree_folder_element of current_folder_elements) {
const folder_element = files_folder.find(e => e.name === tree_folder_element.name);
if (!folder_element || (tree_folder_element.type !== "directory" && folder_element.size !== tree_folder_element.size)) {
return new Set([current_file_path]);
}
if (tree_folder_element.type === "directory" && tree_folder_element.contents) {
const new_file_path = current_file_path + tree_folder_element.name + '/';
for (const string of get_recursive_changed_directory_paths(display, new_file_path, tree_folder_element.contents, files)) {
has_changed.add(string);
}
}
}
return has_changed;
}
export async function update_folder_elements_recursively(display: Display, file_path: string = '/'): Promise<number> {
const new_folder_elements = await get_file_data(display.ip, file_path); const new_folder_elements = await get_file_data(display.ip, file_path);
if (new_folder_elements === null) return 0;
all_files.update((files: Record<string, Record<string, FolderElement[]>>) => { 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] = {};
@@ -147,13 +77,6 @@ export async function update_folder_elements_recursively(display: Display, file_
const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : []; const existing_folder_elements = files[file_path].hasOwnProperty(display.id) ? files[file_path][display.id] : [];
const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements); const diff = get_folder_elements_difference(existing_folder_elements, new_folder_elements);
// Generate Thumbnails:
setTimeout(async () => {
for (const folder_element of diff.new) {
await generate_thumbnail(display.ip, file_path, folder_element);
}
}, 0)
files[file_path][display.id].push(...diff.new); files[file_path][display.id].push(...diff.new);
return remove_folder_elements_recursively(files, display, diff.deleted, file_path); return remove_folder_elements_recursively(files, display, diff.deleted, file_path);
}) })
@@ -214,6 +137,27 @@ function get_folder_elements_difference(old_elements: FolderElement[], new_eleme
// 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)) return []; if (!all_files.hasOwnProperty(current_file_path)) return [];
@@ -224,21 +168,17 @@ export function get_current_folder_elements(all_files: Record<string, Record<str
FileOnDisplay: FileOnDisplay:
for (const file_on_display of files_on_display_array[key]) { for (const file_on_display of files_on_display_array[key]) {
for (const existing_file of files) { for (const existing_file of files) {
const both_same_folder = file_on_display.type === "inode/directory" && existing_file.type === "inode/directory" && file_on_display.name === existing_file.name; if (file_on_display.name === existing_file.name) {
if (both_same_folder && file_on_display.size !== existing_file.size) { if (file_on_display.hash === existing_file.hash) {
existing_file.size = -1; continue FileOnDisplay;
} }
if (file_on_display.hash === existing_file.hash) {
continue FileOnDisplay;
} else if (both_same_folder) {
existing_file.date_created = null;
continue FileOnDisplay;
} }
} }
files.push({ ...file_on_display }); files.push(file_on_display);
} }
} }
} }
return sort_files(files); return sort_files(files);
} }
@@ -256,62 +196,8 @@ function sort_files(files: FolderElement[]) {
if (nameCompare !== 0) return nameCompare; if (nameCompare !== 0) return nameCompare;
// Wenn name gleich, absteigend nach date_created // Wenn name gleich, absteigend nach date_created
if (!b.date_created || !a.date_created) return -1;
return b.date_created.getTime() - a.date_created.getTime(); return b.date_created.getTime() - a.date_created.getTime();
}); });
return files; return files;
} }
export function get_file_by_id(file_id: string, all_files: Record<string, Record<string, FolderElement[]>>, current_file_path: string, only_from_selected_displays: boolean = false): FolderElement | null {
let current_path_elements: Record<string, FolderElement[]> | undefined = all_files[current_file_path];
if (!current_path_elements) return null;
if (only_from_selected_displays) {
current_path_elements = Object.fromEntries(
Object.entries(current_path_elements).filter(([key]) => get(selected_file_ids).includes(key))
);
}
const all_folder_elements = Object.values(current_path_elements).flat();
const found = all_folder_elements.find(el => el.id === file_id);
if (!found) return null;
return found
}
export async function run_for_selected_files_on_selected_displays(action: (ip: string, file_names: string[]) => Promise<void>): Promise<void> {
const files = get(all_files);
const file_path = get(current_file_path);
const folder_elements: FolderElement[] = get(selected_file_ids)
.map((file_id) => get_file_by_id(file_id, files, file_path))
.filter((element) => element !== null);
const folder_element_hashs = folder_elements
.map((folder_element) => folder_element.hash)
.filter((hash) => hash !== null);
for (const display_id of get(selected_display_ids)) {
if (!files[file_path].hasOwnProperty(display_id)) continue;
const files_on_display = files[file_path][display_id];
const selected_file_names_on_display: string[] = files_on_display
.filter((e) => (e.hash && folder_element_hashs.includes(e.hash)) || (e.type === 'inode/directory' && folder_elements.some(e2 => e2.name === e.name && e2.type === 'inode/directory')))
.map((folder_element) => folder_element.name);
if (selected_file_names_on_display.length === 0) continue;
const display = get_display_by_id(display_id, get(displays));
if (!display) continue;
await action(display.ip, selected_file_names_on_display);
}
}
export function get_longest_existing_path_and_needed_parts(path: string, display_id: string, all_files: Record<string, Record<string, FolderElement[]>>): { existing: string; needed: string[] } {
const path_parts = path.slice(1, path.length - 1).split('/').filter(e => e.length !== 0); // remove front and back / and split after that, then remove empty strings
for (let i = path_parts.length; i > 0; i--) {
const current_path = '/' + [...path_parts].splice(0, i).join('/') + '/';;
if (all_files.hasOwnProperty(current_path)) {
if (all_files[current_path].hasOwnProperty(display_id)) {
return { existing: current_path, needed: [...path_parts].splice(i) };
}
}
}
return { existing: '/', needed: path_parts };
}
@@ -14,7 +14,7 @@ function createNotifications() {
function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") { function push(type: "error" | "success" | "info", title: string, message: string = "", className: string = "") {
const id = Date.now(); const id = Date.now();
const duration = type === "error" ? 16000 : 4000; const duration = type === "error" ? 8000 : 4000;
update((n) => [...n, { id, title, message, duration, className, type }]); update((n) => [...n, { id, title, message, duration, className, type }]);
setTimeout(() => { setTimeout(() => {
update((n) => n.filter((x) => x.id !== id)); update((n) => n.filter((x) => x.id !== id));
@@ -1,38 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import { get_thumbnail_blob } from "../api_handler";
import { type FolderElement } from "../types";
import { db, type ThumbnailBlobDBEntry } from "../indexdb/file_thumbnails.db";
import { get_file_type } from "../utils";
export const active_thumbnail_urls: Writable<string[]> = writable<string[]>([]);
export async function generate_thumbnail(display_ip: string, path: string, folder_element: FolderElement): Promise<void> {
const supported_file_type = get_file_type(folder_element);
if (!supported_file_type || !folder_element.hash) return;
const hash: string = folder_element.hash;
if (await db.thumbnail_blobs.get(hash)) return;
const thumbnail_blob = await get_thumbnail_blob(display_ip, path + folder_element.name);
if (!thumbnail_blob) return;
await db.thumbnail_blobs.add({ hash: hash, blob: thumbnail_blob });
}
export async function get_thumbnail_url(hash: string | null): Promise<string | null> {
if (hash === null) return null;
const thumbnail_blob = await db.thumbnail_blobs.get(hash) as ThumbnailBlobDBEntry;
if (!thumbnail_blob) return null;
const new_url = URL.createObjectURL(thumbnail_blob.blob);
active_thumbnail_urls.update((current: string[]) => {
current.push(new_url);
return current;
})
return new_url;
}
export function deactivate_old_thumbnail_urls() {
const current_urls = get(active_thumbnail_urls);
for (const url of current_urls) {
URL.revokeObjectURL(url);
}
active_thumbnail_urls.update(() => { return []; })
}
+59 -36
View File
@@ -1,46 +1,57 @@
import { FileBox, FileImage, FileText, FileVideoCamera, ImagePlay, type X } from "lucide-svelte"; import { FileBox, FileImage, FileVideoCamera, ImagePlay, type X } from "lucide-svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
export type RequestResponse = {
ok: boolean,
http_code?: number
blob?: Blob,
json?: any,
}
export type ShellCommandResponse = {
stdout: string,
stderr: string,
exitCode: number,
}
export type SupportedFileType = { export type SupportedFileType = {
display_name: string; display_name:
mime_type: string; string; mime_type:
string; icon: typeof X;
}; };
export const supported_file_type_icon: Record<string, typeof X> = {
'MP4': FileVideoCamera, export const supported_file_types: Record<string, SupportedFileType> = {
'JPG': FileImage, '.mp4': {
'PNG': FileImage, display_name: 'MP4',
'GIF': ImagePlay, mime_type: 'video/mp4',
'PPTX': FileBox, icon: FileVideoCamera,
'ODP': FileBox, },
'PDF': FileText '.jpg': {
display_name: 'JPG',
mime_type: 'image/jpg',
icon: FileImage,
},
'.jpeg': {
display_name: 'JPG',
mime_type: 'image/jpeg',
icon: FileImage,
},
'.png': {
display_name: 'PNG',
mime_type: 'image/png',
icon: FileImage,
},
'.gif': {
display_name: 'GIF',
mime_type: 'image/gif',
icon: ImagePlay,
},
'.pptx': {
display_name: 'PPTX',
mime_type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
icon: FileBox,
},
'.odp': {
display_name: 'ODP',
mime_type: 'application/vnd.oasis.opendocument.presentation',
icon: FileBox,
}
} }
export type FolderElement = { export type FolderElement = {
id: string; id?: string;
hash: string; hash: string | null;
thumbnail_url: string | null;
name: string; name: string;
type: string; type: string;
date_created: Date | null; date_created: Date;
size: number;
}
export type TreeElement = {
contents?: TreeElement[];
type: "file" | "directory";
name: string;
size: number; size: number;
} }
@@ -73,10 +84,9 @@ export type PopupContent = {
open: boolean; open: boolean;
snippet: Snippet<[string]> | null; snippet: Snippet<[string]> | null;
snippet_arg?: string; snippet_arg?: string;
title?: string; title: string;
title_class?: string; title_class?: string;
title_icon?: typeof X | null; title_icon?: typeof X | null;
window_class?: string;
closable?: boolean; closable?: boolean;
} }
@@ -86,4 +96,17 @@ export function to_display_status(value: string): DisplayStatus {
return ["host_offline", "app_offline", "app_online"].includes(value) return ["host_offline", "app_offline", "app_online"].includes(value)
? (value as DisplayStatus) ? (value as DisplayStatus)
: null; : 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 '???';
}
} }
+9 -49
View File
@@ -1,41 +1,19 @@
import type { DisplayStatus, FolderElement, SupportedFileType } from "./types";
import supported_file_types_json from './../../../../shared/supported_file_types.json';
const supported_file_types: Record<string, SupportedFileType> = supported_file_types_json as Record<string, SupportedFileType>;
export function get_file_type(file: FolderElement): SupportedFileType | null {
for (const key of Object.keys(supported_file_types)) {
if (file.type === supported_file_types[key].mime_type) {
return supported_file_types[key];
}
}
// Fallback:
const extension = file.name.split('.').pop();
if (extension) {
if (Object.keys(supported_file_types).includes('.' + extension)) {
return supported_file_types['.' + extension];
}
}
return null;
}
export function get_uuid(): string { export function get_uuid(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
export function get_file_size_display_string(size: number, toFixed: number | null = null): string { export function get_file_size_display_string(size: number, toFixed: number|null = null): string {
if (size < 0) return toFixed === null ? "versch." : "Verschiedene Größen auf verschiedenen Bildschirmen"; if (size === 0) return "0 B";
if (size === 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"]; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(size) / Math.log(k)); const i = Math.floor(Math.log(size) / Math.log(k));
const value = size / Math.pow(k, i); const value = size / Math.pow(k, i);
const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`; const size_string = `${value.toFixed(toFixed !== null ? toFixed : Math.max(0, 2 - Math.floor(Math.log10(value))))} ${sizes[i]}`;
return size_string.replace('.', ','); return size_string.replace('.', ',');
} }
@@ -62,21 +40,3 @@ export async function image_content_hash(blob: Blob, size = 32): Promise<number>
return hash >>> 0; // unsigned int return hash >>> 0; // unsigned int
} }
export function is_valid_name(input: string): boolean {
return /^[\p{L}\p{N}\p{M}\-_.+,()[\]{}@!§$%&=~^ ]+$/u.test(input);
}
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 -9
View File
@@ -2,24 +2,16 @@ 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
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a
)
require ( require (
github.com/josharian/native v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.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.38.0 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.11.0 // indirect
-27
View File
@@ -1,11 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
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=
@@ -14,15 +8,6 @@ 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/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 h1:O3p5UmisBhl3V6lgs4Vdfg8HpjzbWJPyOfGLdwVJSmI=
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966/go.mod h1:5s5p/sMJ6sNsFl6uCh85lkFGV8kLuIYJCRJLavVJwvg=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/raw v0.0.0-20190313224157-43dbcdd7739d/go.mod h1:r1fbeITl2xL/zLbVnNHFyOzQJTgr/3fpf1lJX/cjzR8=
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a h1:fpQi42PM6HzbHwNg6a7OvLbh5l49Rgr7GCARozjHwio=
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a/go.mod h1:XX6nvyQpGc8E4KaQm62FyexOYd36ilpyfYf8wjgMDV8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -31,28 +16,16 @@ 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 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=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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 -61
View File
@@ -4,37 +4,16 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"path/filepath"
"plg-mudics/control/frontend" "plg-mudics/control/frontend"
"plg-mudics/shared" "plg-mudics/shared"
"time" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/mdlayher/wol"
) )
var storageFile string
func main() { func main() {
var err error
path, err := getStoragePath()
if err != nil {
slog.Error("Failed to initialize storage path", "error", err)
os.Exit(1)
}
storageFile = filepath.Join(path, "storage.json")
// Ensure storage.json exists
if _, err := os.Stat(storageFile); os.IsNotExist(err) {
if err := os.WriteFile(storageFile, []byte("{}"), 0644); err != nil {
slog.Error("Failed to initialize storage.json", "error", err)
os.Exit(1)
}
}
e := echo.New() e := echo.New()
// Serve the embedded SvelteKit frontend // Serve the embedded SvelteKit frontend
@@ -46,25 +25,14 @@ 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", pingRoute) apiGroup.GET("/ping", pingRoute)
apiGroup.POST("/wakeOnLan", wakeOnLanRoute)
apiGroup.GET("/storage", getStorageRoute)
apiGroup.POST("/storage", setStorageRoute)
port := "8080" port := "8080"
// the order is important, the open browser command exitsts as soon as the winodw is closed go shared.OpenBrowserWindow("http://localhost:"+port, false, false)
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go func() { err := e.Start(":" + port)
err := e.Start(":" + port)
if err != nil {
slog.Error("Failed to start Echo Webserver", "error", err)
os.Exit(1)
}
}()
err = shared.OpenBrowserWindow("http://localhost:"+port, false, false)
if err != nil { if err != nil {
slog.Error("Failed to open browser window", "error", err) slog.Error("Failed to start Echo Webserver", "error", err)
os.Exit(1)
} }
} }
@@ -93,28 +61,3 @@ type PingResponse struct {
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error"` Error string `json:"error"`
} }
type WakeOnLanRequest struct {
MACAddress string `json:"mac_address"`
}
func wakeOnLanRoute(ctx echo.Context) error {
var data WakeOnLanRequest
if err := ctx.Bind(&data); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
mac, err := net.ParseMAC(data.MACAddress)
if err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid MAC address"})
}
client, err := wol.NewClient()
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create Wake-on-LAN client"})
}
if err := client.Wake("255.255.255.255", mac); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send Wake-on-LAN packet"})
}
return ctx.JSON(http.StatusOK, struct{}{})
}
-16
View File
@@ -1,16 +0,0 @@
meta {
name: Get Store
type: http
seq: 2
}
get {
url: http://localhost:8080/api/storage
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
-22
View File
@@ -1,22 +0,0 @@
meta {
name: Set Store
type: http
seq: 3
}
post {
url: http://localhost:8080/api/storage
body: json
auth: inherit
}
body:json {
{
"key": "value"
}
}
settings {
encodeUrl: true
timeout: 0
}
-61
View File
@@ -1,61 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"plg-mudics/shared"
"github.com/labstack/echo/v4"
)
func getStorageRoute(ctx echo.Context) error {
data, err := os.ReadFile(storageFile)
if err != nil {
slog.Error("Could not read storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not read storage file"})
}
var content interface{}
if err := json.Unmarshal(data, &content); err != nil {
slog.Error("Could not parse storage file", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Could not parse storage file"})
}
return ctx.JSON(http.StatusOK, content)
}
func setStorageRoute(ctx echo.Context) error {
var payload interface{}
if err := ctx.Bind(&payload); err != nil {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid JSON payload"})
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to marshal storage file"})
}
if err := os.WriteFile(storageFile, data, 0644); err != nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write storage file"})
}
return ctx.JSON(http.StatusOK, map[string]interface{}{})
}
func getStoragePath() (string, error) {
var storagePath string
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err)
}
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "control")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err)
}
return storagePath, nil
}
+12 -98
View File
@@ -1,102 +1,16 @@
# API # PLG-MuDiCS Display
All response and request bodies are `application/json` if not otherwise specified. If no response schema is specified an empty json object `{}` is returned. ## Runtime Requirements
## Default - Bash
- Chromium
- LibreOffice (presentation view)
- ImageMagick (file preview)
- ffmpeg (file preview)
- Ghostscript (file preview)
### Responses ## Development Setup
#### All Errors - install Go (at least version v1.25)
- build template: `go generate ./..`
If not specified otherwise. - run code: `go run .`
- `description`: string
#### 400
Request uses invalid JSON syntax or does not follow request schema.
#### 500
Something (undefined) on the display side has gone very wrong.
## GET `/ping`
### Responses
#### 200
- `version`: str
## PATCH `/shellCommand`
### Responses
#### 200
Even when the command itself fails.
- `stdout`: string
- `stderr`: string
- `exitCode`: int
## PATCH `/keyboardInput`
### Request Body
- `key`: string for key
- `action`: "press" or "release"
## PATCH `/showHTML`
### Request Body
- `html`: string
## PATCH `/takeScreenshot`
### Responses
#### 200
The screenshot as binary in the response body.
## POST `/file/<path>` - Upload File
### Responses
#### 409 - Conflict
File with the same path and name already exists.
## GET `/file/<path>`
### Responses
#### 404
Requested file was not found at the path.
## PATCH `/file/<path>` - Open File
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available for display.
## GET `/file/preview/<path>`
### Responses
#### 404
Requested file was not found at the path.
#### 415 - Unsupported Media Type
The type of the file is not available preview generation.
-2
View File
@@ -6,8 +6,6 @@ require (
github.com/a-h/templ v0.3.960 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
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/gabriel-vasile/mimetype v1.4.11
) )
require ( require (
-4
View File
@@ -14,8 +14,6 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
@@ -32,8 +30,6 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 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/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=
+1 -3
View File
@@ -24,10 +24,8 @@ func main() {
return return
} }
port := "1323" port := "1323"
// the order is important, the open browser command exitsts as soon as the winodw is closed
// and since its the last action in the main go func all other goroutines (e.g. the webserver) are killed
go web.StartWebServer(VERSION, port) go web.StartWebServer(VERSION, port)
err = shared.OpenBrowserWindow("http://localhost:"+port, true, true) err = shared.OpenBrowserWindow("http://localhost:"+port, true, true)
if err != nil { if err != nil {
slog.Error("Failed to open browser window", "error", err) slog.Error("Failed to open browser window", "error", err)
-63
View File
@@ -1,63 +0,0 @@
package pkg
import (
"fmt"
"os"
"os/exec"
"plg-mudics/shared"
"syscall"
"github.com/gabriel-vasile/mimetype"
)
var FileHandler fileHandler = fileHandler{}
type fileHandler struct {
runningProgram *exec.Cmd
}
func (fh *fileHandler) OpenFile(path string) error {
var err error
mType, err := mimetype.DetectFile(path)
if err != nil {
return fmt.Errorf("failed to detect mime type: %w", err)
}
tempDirPath, err := os.MkdirTemp("", "plg-mudics-program-profile-")
if err != nil {
return fmt.Errorf("failed to create temporary profile directory: %w", err)
}
err = fh.CloseRunningProgram()
if err != nil {
return err
}
switch mType.String() {
case "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
fh.runningProgram = exec.Command("soffice", "--show", path, "--nologo", "--view", "--norestore", fmt.Sprintf("-env:UserInstallation=file://%s", tempDirPath))
case "application/pdf":
fh.runningProgram = exec.Command("xreader", path, "--presentation")
default:
return fmt.Errorf("unsupported file type: %s", mType.String())
}
fh.runningProgram.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
result := shared.RunShellCommandNonBlocking(fh.runningProgram)
if result.ExitCode != 0 {
return fmt.Errorf("could not open pdf: %s (%d)", result.Stderr, result.ExitCode)
}
return nil
}
func (fh *fileHandler) CloseRunningProgram() error {
if fh.runningProgram == nil {
return nil
}
err := syscall.Kill(-fh.runningProgram.Process.Pid, syscall.SIGTERM)
fh.runningProgram = nil
return err
}
+5 -6
View File
@@ -10,9 +10,8 @@ import (
"strings" "strings"
"time" "time"
) )
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview");
var ErrFileTypePreviewNotSupported = errors.New("file type not supported for preview") var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing");
var ErrFilePreviewToolsMissing = errors.New("required tools for file preview are missing")
func GenerateFilePreview(inputPath string) (string, error) { func GenerateFilePreview(inputPath string) (string, error) {
var err error var err error
@@ -37,7 +36,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, "-gravity", "center", "-crop", "1:1", "-thumbnail", "100", "-quality", "50", outputPath) cmd := exec.Command("magick", inputPath, "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
@@ -54,7 +53,7 @@ func generatePDFPreview(inputPath string, outputPath string) error {
return ErrFilePreviewToolsMissing return ErrFilePreviewToolsMissing
} }
cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-gravity", "center", "-crop", "1:1", "-thumbnail", "100x100", "-quality", "50", outputPath) cmd := exec.Command("magick", fmt.Sprintf("%s[0]", inputPath), "-thumbnail", "100x100", "-quality", "50", outputPath)
result := shared.RunShellCommand(cmd) result := shared.RunShellCommand(cmd)
if result.ExitCode != 0 { if result.ExitCode != 0 {
if result.ExitCode == 127 { if result.ExitCode == 127 {
@@ -83,4 +82,4 @@ func generateVideoPreview(inputPath string, outputPath string) error {
} }
return nil return nil
} }
+16 -19
View File
@@ -16,6 +16,8 @@ import (
"github.com/micmonay/keybd_event" "github.com/micmonay/keybd_event"
) )
func GetDeviceIp() (string, error) { func GetDeviceIp() (string, error) {
addrs, err := net.InterfaceAddrs() addrs, err := net.InterfaceAddrs()
if err != nil { if err != nil {
@@ -47,31 +49,24 @@ func GetDeviceMac() (string, error) {
return "", fmt.Errorf("no suitable MAC address found") return "", fmt.Errorf("no suitable MAC address found")
} }
type KeyAction int func OpenPresentation(path string) error {
cmd := exec.Command("bash", "-c", "-r", fmt.Sprintf("soffice --show %s -nologo -norestore", path))
const ( result := shared.RunShellCommand(cmd)
KeyPress KeyAction = iota if result.ExitCode != 0 {
KeyRelease return errors.New(result.Stderr)
) }
return nil
func KeyboardInput(key int, action KeyAction) error { }
var err error
func KeyboardInput(key int) error {
kb, err := keybd_event.NewKeyBonding() kb, err := keybd_event.NewKeyBonding()
if err != nil { if err != nil {
return fmt.Errorf("failed to create key bonding: %w", err) return fmt.Errorf("failed to create key bonding: %w", err)
} }
kb.SetKeys(key) kb.SetKeys(key)
switch action { if err := kb.Launching(); err != nil {
case KeyPress: return fmt.Errorf("failed to launch key event: %w", err)
err = kb.Press()
case KeyRelease:
err = kb.Release()
}
if err != nil {
return fmt.Errorf("failed to run key event: %w", err)
} }
return nil return nil
@@ -95,6 +90,7 @@ 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 GetStoragePath() (string, error) { func GetStoragePath() (string, error) {
var storagePath string var storagePath string
@@ -102,7 +98,7 @@ func GetStoragePath() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("unable to determine user home directory: %w", err) return "", fmt.Errorf("unable to determine user home directory: %w", err)
} }
storagePath = filepath.Join(home, ".local", "share", "plg-mudics", "display") storagePath = filepath.Join(home, ".local", "share", "plg-connect-display")
if err := os.MkdirAll(storagePath, os.ModePerm); err != nil { if err := os.MkdirAll(storagePath, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create local config directory: %w", err) return "", fmt.Errorf("failed to create local config directory: %w", err)
} }
@@ -140,3 +136,4 @@ func ResolveStorageFilePath(pathParam string) (string, bool, error) {
return fullPath, true, nil return fullPath, true, nil
} }
-12
View File
@@ -1,12 +0,0 @@
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var staticDir embed.FS
// BuildDirFS contains the embedded dist directory files (without the "build" prefix)
var StaticDirFS, _ = fs.Sub(staticDir, "static")
+70 -140
View File
@@ -5,26 +5,37 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color"
"io" "io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
shared "plg-mudics/shared" shared "plg-mudics/shared"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/skip2/go-qrcode" "github.com/micmonay/keybd_event"
"plg-mudics/display/pkg" "plg-mudics/display/pkg"
) )
type ErrorResponse struct {
Error string `json:"error"`
}
var version string var version string
var sseConnection chan string var sseConnection chan string
var supportedExtensions = map[string]bool{
".mp4": true,
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".pptx": true,
".odp": true,
}
func StartWebServer(v string, port string) { func StartWebServer(v string, port string) {
version = v version = v
@@ -36,13 +47,6 @@ func StartWebServer(v string, port string) {
e.GET("/splash", func(ctx echo.Context) error { e.GET("/splash", func(ctx echo.Context) error {
return ctx.HTML(http.StatusOK, shared.SplashScreenTemplate) return ctx.HTML(http.StatusOK, shared.SplashScreenTemplate)
}) })
e.GET("/qr", qrRoute)
staticGroup := e.Group("/static")
staticGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(StaticDirFS),
HTML5: true,
}))
apiGroup := e.Group("/api") apiGroup := e.Group("/api")
apiGroup.Use(middleware.CORS()) apiGroup.Use(middleware.CORS())
@@ -90,9 +94,8 @@ func sseRoute(ctx echo.Context) error {
if err != nil { if err != nil {
slog.Error("Failed to get device MAC address", "error", err) slog.Error("Failed to get device MAC address", "error", err)
} }
showQR := !isPortFree(8080)
var status bytes.Buffer var status bytes.Buffer
deviceInfoTemplate(ip, mac, showQR).Render(context.Background(), &status) deviceInfoTemplate(ip, mac).Render(context.Background(), &status)
connectedEvent := Event{ connectedEvent := Event{
Data: status.Bytes(), Data: status.Bytes(),
} }
@@ -121,36 +124,13 @@ func sseRoute(ctx echo.Context) error {
} }
} }
func qrRoute(c echo.Context) error {
data := c.QueryParam("data")
if data == "" {
return c.String(http.StatusBadRequest, "missing data")
}
qr, err := qrcode.New(data, qrcode.Medium)
if err != nil {
return c.String(http.StatusInternalServerError, "could not generate qr")
}
qr.DisableBorder = true
qr.ForegroundColor = color.RGBA{R: 0x1c, G: 0x19, B: 0x17, A: 0xff}
qr.BackgroundColor = color.RGBA{R: 0xe7, G: 0xe5, B: 0xe4, A: 0xff}
png, err := qr.PNG(-1)
if err != nil {
return c.String(http.StatusInternalServerError, "could not encode png")
}
return c.Blob(http.StatusOK, "image/png", png)
}
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc { func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
pathParam := ctx.Param("path") pathParam := ctx.Param("path")
fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam) fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam)
if err != nil { if err != nil {
slog.Warn("Failed to validate file path", "path", pathParam, "error", err) slog.Warn("Failed to validate file path", "path", pathParam, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid file path"})
} }
ctx.Set("fullPath", fullPath) ctx.Set("fullPath", fullPath)
ctx.Set("fileExists", exists) ctx.Set("fileExists", exists)
@@ -164,14 +144,14 @@ func shellCommandRoute(ctx echo.Context) error {
} }
if err := ctx.Bind(&commandInput); err != nil { if err := ctx.Bind(&commandInput); err != nil {
slog.Error("Failed to parse shell command", "error", err) slog.Error("Failed to parse shell command", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
} }
cmd := exec.Command("bash", "-c", commandInput.Command) cmd := exec.Command("bash", "-c", commandInput.Command)
storagePath, err := pkg.GetStoragePath() storagePath, err := pkg.GetStoragePath()
if err != nil { if err != nil {
slog.Error("Failed to get storage path", "error", err) slog.Error("Failed to get storage path", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to get storage path"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
} }
cmd.Dir = storagePath cmd.Dir = storagePath
@@ -186,156 +166,117 @@ func shellCommandRoute(ctx echo.Context) error {
func keyboardInputRoute(ctx echo.Context) error { func keyboardInputRoute(ctx echo.Context) error {
var request struct { var request struct {
Key string `json:"key"` Key string `json:"key"`
Action string `json:"action"`
} }
if err := ctx.Bind(&request); err != nil { if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err) slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
}
if request.Action != "press" && request.Action != "release" {
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
} }
code, ok := pkg.KeyboardEvents[request.Key] code, ok := pkg.KeyboardEvents[request.Key]
if !ok { if !ok {
slog.Error("Unsupported key", "key", request.Key) slog.Error("Unsupported key", "key", request.Key)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Unsupported key: %s", request.Key)}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", request.Key)})
} }
var action pkg.KeyAction err := pkg.KeyboardInput(code)
if request.Action == "press" {
action = pkg.KeyPress
}
if request.Action == "release" {
action = pkg.KeyRelease
}
err := pkg.KeyboardInput(code, action)
if err != nil { if err != nil {
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err) slog.Error("Failed to send keyboard input", "key", request.Key, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
} }
slog.Info("Keyboard input sent", "key", request.Key) slog.Info("Keyboard input sent", "key", request.Key)
return ctx.JSON(http.StatusOK, struct{}{}) return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
} }
func uploadFileRoute(ctx echo.Context) error { func uploadFileRoute(ctx echo.Context) error {
var err error
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
// Ensure parent directories exist // Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
slog.Error("Failed to create storage path", "error", err, "path", fullPath) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
} }
if ctx.Get("fileExists").(bool) { if ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"}) return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
} }
file, err := os.Create(fullPath) ext := strings.ToLower(filepath.Ext(fullPath))
if err != nil { if !supportedExtensions[ext] {
slog.Error("Failed to create file", "file", fullPath, "error", err) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create file"})
}
defer func() {
fileCloseErr := file.Close()
if fileCloseErr != nil {
slog.Error("Failed to close file", "file", fullPath, "error", fileCloseErr)
}
if err != nil {
os.Remove(fullPath)
}
}()
_, err = io.Copy(file, ctx.Request().Body)
if err != nil {
slog.Error("Failed to write file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write file"})
} }
err = file.Sync() // ensure data is flushed to disk data, err := io.ReadAll(ctx.Request().Body)
if err != nil { if err != nil {
slog.Error("Failed to sync file to disk", "file", fullPath, "error", err) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"}) }
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
} }
slog.Info("File uploaded successfully", "path", fullPath) slog.Info("File uploaded successfully", "path", fullPath)
return ctx.JSON(http.StatusOK, struct{}{}) return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
} }
func downloadFileRoute(ctx echo.Context) error { func downloadFileRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) { if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"}) return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
} }
slog.Info("Serving file for download", "path", fullPath) err := ctx.File(fullPath)
file, err := os.Open(fullPath)
if err != nil { if err != nil {
slog.Error("Failed to open file", "file", fullPath, "error", err) slog.Error("Failed to serve file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
} }
defer file.Close()
return ctx.Stream(http.StatusOK, "application/octet-stream", file) slog.Info("File downloaded successfully", "path", fullPath)
return nil
} }
func openFileRoute(ctx echo.Context) error { func openFileRoute(ctx echo.Context) error {
var err error
pathParam := ctx.Param("path") pathParam := ctx.Param("path")
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) { if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"}) return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
} }
if sseConnection == nil { if sseConnection == nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Cant connect to display browser client"})
} }
err = resetView() err := resetView()
if err != nil { if err != nil {
slog.Error("Failed to reset view", "error", err) slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
} }
mType, err := mimetype.DetectFile(fullPath) ext := strings.ToLower(filepath.Ext(fullPath))
if err != nil { switch ext {
slog.Error("Failed to detect mime type", "file", pathParam, "error", err) case ".mp4":
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to detect file type"})
}
switch mType.String() {
case "video/mp4":
var templateBuffer bytes.Buffer var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer) videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String() sseConnection <- templateBuffer.String()
case "image/jpeg", "image/png", "image/gif": case ".jpg", ".jpeg", ".png", ".gif":
var templateBuffer bytes.Buffer var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer) imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String() sseConnection <- templateBuffer.String()
case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation": case ".pptx", ".odp":
err = pkg.FileHandler.OpenFile(fullPath) err := pkg.OpenPresentation(fullPath)
if err != nil {
slog.Error("Failed to open presentation", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to open presentation"})
}
default: default:
slog.Info("Unsupported file type", "type", mType) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type: " + mType.String()})
}
if err != nil {
slog.Error("Failed to open file", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
} }
slog.Info("Successfully run file", "file", pathParam) slog.Info("Successfully run file", "file", pathParam)
return ctx.JSON(http.StatusOK, struct{}{}) return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
} }
func showHTMLRoute(ctx echo.Context) error { func showHTMLRoute(ctx echo.Context) error {
@@ -344,19 +285,19 @@ func showHTMLRoute(ctx echo.Context) error {
} }
if err := ctx.Bind(&request); err != nil { if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse request", "error", err) slog.Error("Failed to parse request", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
} }
err := resetView() err := resetView()
if err != nil { if err != nil {
slog.Error("Failed to reset view", "error", err) slog.Error("Failed to reset view", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to reset view"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
} }
sseConnection <- request.HTML sseConnection <- request.HTML
slog.Info("HTML content sent to client") slog.Info("HTML content sent to client")
return ctx.JSON(http.StatusOK, struct{}{}) return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "Success"})
} }
func pingRoute(ctx echo.Context) error { func pingRoute(ctx echo.Context) error {
@@ -371,13 +312,13 @@ func takeScreenshotRoute(ctx echo.Context) error {
screenshotPath, err := pkg.TakeScreenshot() screenshotPath, err := pkg.TakeScreenshot()
if err != nil { if err != nil {
slog.Error("Failed to take screenshot", "error", err) slog.Error("Failed to take screenshot", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to take screenshot"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
} }
err = ctx.File(screenshotPath) err = ctx.File(screenshotPath)
if err != nil { if err != nil {
slog.Error("Failed to serve file", "file", screenshotPath, "error", err) slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
} }
return nil return nil
@@ -387,19 +328,19 @@ func previewRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string) fullPath := ctx.Get("fullPath").(string)
exists := ctx.Get("fileExists").(bool) exists := ctx.Get("fileExists").(bool)
if !exists { if !exists {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"}) return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
} }
outputFilePath, err := pkg.GenerateFilePreview(fullPath) outputFilePath, err := pkg.GenerateFilePreview(fullPath)
if err != nil { if err != nil {
slog.Error("Failed to generate preview", "file", fullPath, "error", err) slog.Error("Failed to generate preview", "file", fullPath, "error", err)
if errors.Is(err, pkg.ErrFileTypePreviewNotSupported) { if errors.Is(err, pkg.ErrFileTypePreviewNotSupported) {
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "File type not supported for preview"}) return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "File type not supported for preview"})
} }
if errors.Is(err, pkg.ErrFilePreviewToolsMissing) { if errors.Is(err, pkg.ErrFilePreviewToolsMissing) {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Required tools for file preview are missing"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Required tools for file preview are missing"})
} }
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to generate preview"}) return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to generate preview"})
} }
return ctx.File(outputFilePath) return ctx.File(outputFilePath)
@@ -407,22 +348,11 @@ func previewRoute(ctx echo.Context) error {
// Reset previous file views so they dont collide with the new one // Reset previous file views so they dont collide with the new one
func resetView() error { func resetView() error {
err := pkg.FileHandler.CloseRunningProgram() err := pkg.KeyboardInput(keybd_event.VK_ESC)
if err != nil { if err != nil {
return fmt.Errorf("failed to close running program: %w", err) return fmt.Errorf("failed to send ESC key: %w", err)
} }
sseConnection <- "" sseConnection <- ""
return nil return nil
} }
func isPortFree(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}
+66 -105
View File
@@ -1,120 +1,81 @@
package web package web
templ indexTemplate() { templ indexTemplate() {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8"/>
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>PLG Connect Display</title>
<title>PLG Connect Display</title> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
<script src="/static/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2" crossorigin="anonymous"></script>
<script src="/static/htmx-ext-sse.min.js"></script> <style>
<style> body {
:root { display: flex;
--background-color: black; justify-content: center; /* centers horizontally */
--foreground-color: oklch(92.3% 0.003 48.717); align-items: center; /* centers vertically */
--font-size: 5rem; width: 100vw; /* Viewport width */
} height: 100vh; /* Viewport height */
margin: 0;
body { padding: 0;
display: flex; overflow: hidden;
justify-content: center; background-color: hsl(256, 10%, 10%);
/* centers horizontally */ color: hsl(256, 90%, 95%);
align-items: center;
/* centers vertically */
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
margin: 0;
padding: 0;
overflow: hidden;
background-color: var(--background-color);
color: var(--foreground-color);
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
video,
img,
iframe {
width: 100vw;
/* Viewport width */
height: 100vh;
/* Viewport height */
object-fit: contain;
}
p,
li {
font-size: var(--font-size);
line-height: 1.2;
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
margin: 0 0 calc(var(--font-size) * 0.4) 0;
}
ul,
ol {
padding-left: calc(var(--font-size)*1.5);
}
</style>
<script>
document.addEventListener('keydown', function (event) {
if (event.code === 'Space') {
event.preventDefault();
var video = document.querySelector('video');
if (video) {
if (video.paused) {
video.play();
} else {
video.pause();
}
} }
}
});
</script>
</head>
<bod>
<div hx-get="/splash" hx-trigger="load"></div>
<main hx-ext="sse" sse-connect="/sse" sse-swap="message"></main>
</bod>
</html> video, img, iframe {
width: 100vw; /* Viewport width */
height: 100vh; /* Viewport height */
object-fit: contain;
}
p {
font-size: 6rem;
line-height: 1;
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
text-wrap: balance;
max-width: 90vw;
word-break: break-word;
display: block;
}
</style>
<script>
document.addEventListener('keydown', function(event) {
if (event.code === 'Space') {
event.preventDefault();
var video = document.querySelector('video');
if (video) {
if (video.paused) {
video.play();
} else {
video.pause();
}
}
}
});
</script>
</head>
<bod>
<div hx-get="/splash" hx-trigger="load"></div>
<main hx-ext="sse" sse-connect="/sse" sse-swap="message"></main>
</bod>
</html>
} }
templ videoTemplate(path string) { templ videoTemplate(path string) {
<video autoplay> <video autoplay>
<source src={ "/api/file/" + path } type="video/mp4" /> <source src={ "/api/file/" + path } type="video/mp4"/>
</video> </video>
} }
templ imageTemplate(path string) { templ imageTemplate(path string) {
<img src={ "/api/file/" + path } /> <img src={ "/api/file/" + path }/>
} }
templ deviceInfoTemplate(ip string, mac string, showQR bool) { templ deviceInfoTemplate(ip string, mac string) {
<div style="width: 100vw; height: 100vh; display: flex; flex-direction: row; justify-content: space-between;"> <p>
<div
style="display: flex; flex-direction: column; gap: 1rem; justify-content: end; padding: 2rem; font-size: 4rem;">
{ ip } { ip }
<br/>
<span style="text-transform: uppercase;">{ mac }</span> <span style="text-transform: uppercase;">{ mac }</span>
</div> </p>
if showQR { }
<div style="display: flex; justify-content: end; align-items: end; padding: 2rem;">
<div style="padding: 1rem; background-color: var(--foreground-color); border-radius: 1rem;">
<img style="height: 30vh; width: auto; image-rendering: pixelated; image-rendering: crisp-edges;"
src={ "/qr?data=http://" + ip + ":8080" } alt="QR-Code" />
</div>
</div>
}
</div>
<style>
:root {
--splash-bg: transparent !important;
--splash-fade-out-state: paused !important;
--background-color: oklch(21.6% 0.006 56.043);
}
</style>
}
-1
View File
@@ -1 +0,0 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
File diff suppressed because one or more lines are too long
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765779637,
"narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1306659b587dc277866c7b69eb97e5f07864d8c4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-24
View File
@@ -1,24 +0,0 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
pkgs = nixpkgs.legacyPackages."x86_64-linux";
in {
devShells."x86_64-linux".default = pkgs.mkShell {
packages = with pkgs; [
libreoffice
ungoogled-chromium
xreader
imagemagick
ffmpeg
ghostscript
gnome-screenshot
];
};
};
}
-164
View File
@@ -1,164 +0,0 @@
{
inputs,
config,
pkgs,
...
}: {
imports = [
inputs.home-manager.nixosModules.default
./hardware-configuration.nix
];
## System Config
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
hardware.steam-hardware.enable = 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 = "mudics";
environment.systemPackages = with pkgs; [
# Programs
libreoffice
#rustdesk
ungoogled-chromium
xfce.thunar-archive-plugin
git
nushell
unzip
iputils
xreader
# Libraries
imagemagick
ffmpeg
ghostscript
];
home-manager.users.mudics = {
xfconf.settings = {
xfce4-power-manager."xfce4-power-manager/dpms-enabled" = false;
xfce4-screensaver."saver/enabled" = false;
};
home.stateVersion = "25.05";
};
systemd.services.update-mudics = {
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
path = config.environment.systemPackages;
script = "nu ${./update.sh}";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics";
User = "mudics";
Type = "oneshot";
};
};
systemd.services.run-mudics = {
wantedBy = ["default.target"];
after = ["update-mudics.service" "graphical.target"];
wants = ["graphical.target"];
path = config.environment.systemPackages;
script = "./plg-mudics-display";
serviceConfig = {
WorkingDirectory = "/home/mudics/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 .#mudics";
serviceConfig = {
WorkingDirectory = "/home/mudics/mudics/nixos";
};
};
systemd.tmpfiles.rules = [
"d /home/mudics/mudics 0755 mudics - -"
];
networking.firewall = {
enable = true;
allowedTCPPorts = [
1323 # display
8080 # control
];
};
}
-49
View File
@@ -1,49 +0,0 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763992789,
"narHash": "sha256-WHkdBlw6oyxXIra/vQPYLtqY+3G8dUVZM8bEXk0t8x4=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "44831a7eaba4360fb81f2acc5ea6de5fde90aaa3",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.05",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"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": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-27
View File
@@ -1,27 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
nixosConfigurations.mudics = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
inputs.home-manager.nixosModules.default
];
};
};
}
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env nu
print "Checking if we have an internet connection ..."
if (ping google.com -c 5 -W 10 -s 1400 | complete | get exit_code) != 0 {
print "No internet connection. Exiting."
exit 1
}
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 | 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 | 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
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"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"
}
+1 -35
View File
@@ -2,10 +2,8 @@ package shared
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"os/exec" "os/exec"
"time"
) )
type CommandResponse struct { type CommandResponse struct {
@@ -14,11 +12,7 @@ type CommandResponse struct {
ExitCode int `json:"exitCode"` ExitCode int `json:"exitCode"`
} }
type ErrorResponse struct {
Description string `json:"description"`
}
var BadRequestDescription string = "Request uses invalid JSON syntax or does not follow request schema."
func RunShellCommand(cmd *exec.Cmd) CommandResponse { func RunShellCommand(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -26,34 +20,6 @@ func RunShellCommand(cmd *exec.Cmd) CommandResponse {
cmd.Stderr = &stderr cmd.Stderr = &stderr
err := cmd.Run() err := cmd.Run()
return parseCmdResult(cmd, stdout, stderr, err)
}
func RunShellCommandNonBlocking(cmd *exec.Cmd) CommandResponse {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Start()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
return CommandResponse{}
case err := <-done:
return parseCmdResult(cmd, stdout, stderr, err)
}
}
func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) CommandResponse {
commandOutput := CommandResponse{ commandOutput := CommandResponse{
Stdout: stdout.String(), Stdout: stdout.String(),
Stderr: stderr.String(), Stderr: stderr.String(),
@@ -69,4 +35,4 @@ func parseCmdResult(cmd *exec.Cmd, stdout, stderr bytes.Buffer, err error) Comma
} }
return commandOutput return commandOutput
} }
+30 -18
View File
@@ -40,7 +40,15 @@
<text class="splash-text" x="850" y="66.25" font-size="90">S</text> <text class="splash-text" x="850" y="66.25" font-size="90">S</text>
<!-- Controller --> <!-- Controller -->
<rect x="325" y="350" width="300" height="168.75" rx="8" ry="8" class="splash-control-monitor" /> <rect
x="325"
y="350"
width="300"
height="168.75"
rx="8"
ry="8"
class="splash-control-monitor"
/>
<rect x="335" y="390" width="135" height="120" rx="8" ry="8" class="splash-other-button" /> <rect x="335" y="390" width="135" height="120" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="390" width="135" height="55" rx="8" ry="8" class="splash-other-button" /> <rect x="480" y="390" width="135" height="55" rx="8" ry="8" class="splash-other-button" />
<rect x="480" y="455" width="135" height="55" rx="8" ry="8" class="splash-start-button" /> <rect x="480" y="455" width="135" height="55" rx="8" ry="8" class="splash-start-button" />
@@ -51,15 +59,30 @@
<path class="window-controls" fill="none" d="M 575 360 H 590 V 375 H 575 V 360" /> <path class="window-controls" fill="none" d="M 575 360 H 590 V 375 H 575 V 360" />
<path class="window-controls" d="M 550 375 H 565" /> <path class="window-controls" d="M 550 375 H 565" />
<svg class="cursor" width="50" height="50" viewBox="0 0 24 24" x="510" y="480" aria-hidden="true"> <svg
<g stroke="#ffffff" fill="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> class="cursor"
width="50"
height="50"
viewBox="0 0 24 24"
x="510"
y="480"
aria-hidden="true"
>
<g
stroke="#ffffff"
fill="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="#000000" d="M 6.5 10 H 18 V 19 H 6" /> <path stroke="#000000" d="M 6.5 10 H 18 V 19 H 6" />
<path d="M22 14a8 8 0 0 1-8 8" /> <path d="M22 14a8 8 0 0 1-8 8" />
<path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /> <path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
<path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1" /> <path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1" />
<path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10" /> <path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10" />
<path <path
d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" /> d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"
/>
</g> </g>
</svg> </svg>
</svg> </svg>
@@ -67,9 +90,6 @@
<style> <style>
:root { :root {
--splash-fade-out-delay: 0s;
--splash-fade-out-state: running;
--base-stroke: #444444; --base-stroke: #444444;
--base-stroke-active: #109e00; --base-stroke-active: #109e00;
@@ -98,20 +118,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; z-index: 9999;
/* animation: splash-fade 1s ease 4s forwards; */ animation: splash-fade 1s ease 4s forwards;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
animation-name: splash-fade;
animation-duration: 1s;
animation-timing-function: ease;
animation-fill-mode: forwards;
animation-delay: var(--splash-fade-out-delay);
animation-play-state: var(--splash-fade-out-state);
} }
#splash-svg { #splash-svg {
max-height: 50vh; max-height: 40vh;
max-width: 80vw; max-width: 80vw;
opacity: 0; opacity: 0;
animation: fade-in 1s ease 500ms forwards; animation: fade-in 1s ease 500ms forwards;
@@ -170,7 +183,6 @@
dominant-baseline: middle; dominant-baseline: middle;
fill: var(--monitor-dark); fill: var(--monitor-dark);
user-select: none; user-select: none;
font-family: sans-serif;
} }
.splash-monitor { .splash-monitor {
@@ -264,4 +276,4 @@
.cursor * { .cursor * {
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
} }
</style> </style>
-34
View File
@@ -1,34 +0,0 @@
{
".mp4": {
"display_name": "MP4",
"mime_type": "video/mp4"
},
".jpg": {
"display_name": "JPG",
"mime_type": "image/jpg"
},
".jpeg": {
"display_name": "JPG",
"mime_type": "image/jpeg"
},
".png": {
"display_name": "PNG",
"mime_type": "image/png"
},
".gif": {
"display_name": "GIF",
"mime_type": "image/gif"
},
".pptx": {
"display_name": "PPTX",
"mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
".odp": {
"display_name": "ODP",
"mime_type": "application/vnd.oasis.opendocument.presentation"
},
".pdf": {
"display_name": "PDF",
"mime_type": "application/pdf"
}
}