Initial commit: CTmine-client (ContainerMine Client)
Docker-WebApp, die einen echten Minecraft Java-Client (Prism Launcher) in einem Container mit virtuellem Display betreibt und per noVNC im Browser anzeigt. Gedacht, um Accounts AFK an Farmen zu stellen. - Multi-Stage Dockerfile: Vite/Vue-Build + Ubuntu-Runtime (Xvfb, x11vnc, websockify, noVNC, openbox, nginx, Prism Launcher 11.0.3) - Vue 3 + Vite + TypeScript Dashboard (noVNC-iframe, Start/Stop, Status-Anzeige, Schnellstart-Anleitung) - Node-Status-API ohne externe Dependencies (/api/status, /api/mc/*) - docker-compose.yml mit PUID/PGID, konfigurierbarer Auflösung, shm_size - Persistente Accounts/Instanzen in /config (Volume)
This commit is contained in:
commit
b8a99e6e9e
25 changed files with 1766 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Alles was nicht ins Image soll
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
config/
|
||||||
|
data/
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
29
.env.example
Normal file
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# In .env kopieren und anpassen. Wird von docker-compose.yml eingelesen.
|
||||||
|
|
||||||
|
# Benutzer/Gruppe, als der Container-Prozess läuft (entspricht deinem Host-User,
|
||||||
|
# damit Dateien in ./config dir "gehören"). Mit `id -u` / `id -g` herausfinden.
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# Zeitzone
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# Auflösung des virtuellen Displays (MC läuft darin).
|
||||||
|
DISPLAY_WIDTH=1280
|
||||||
|
DISPLAY_HEIGHT=720
|
||||||
|
|
||||||
|
# Bildwiederholrate des virtuellen Displays.
|
||||||
|
DISPLAY_REFRESH=60
|
||||||
|
|
||||||
|
# Farbe pro Pixel (24 = 8-bit RGB, ausreichend und schnell).
|
||||||
|
DISPLAY_DEPTH=24
|
||||||
|
|
||||||
|
# Web-Port des Dashboards (Browser → http://localhost:<PORT>).
|
||||||
|
WEB_PORT=8080
|
||||||
|
|
||||||
|
# Optionales VNC-Passwort. Leer = ohne Passwort (nur für lokales Netz gedacht).
|
||||||
|
VNC_PASSWORD=
|
||||||
|
|
||||||
|
# noVNC-Pfad. Leer = Dashboard startet direkt im VNC-Viewer.
|
||||||
|
# Z.B. "vnc.html" für die standalone noVNC-Seite ohne Dashboard-Frame.
|
||||||
|
NOVNC_PATH=
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Dependencies
|
||||||
|
web/node_modules/
|
||||||
|
docker/mc-api/node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# Local env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Container runtime state (wird ins Volume gemountet, nie einchecken)
|
||||||
|
config/
|
||||||
|
data/
|
||||||
165
Dockerfile
Normal file
165
Dockerfile
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# =============================================================================
|
||||||
|
# CTmine-client · ContainerMine Client
|
||||||
|
# Multi-Stage Dockerfile:
|
||||||
|
# Stage 1 (web-build): baut das Vue 3 + Vite Dashboard
|
||||||
|
# Stage 2 (final): Ubuntu mit Xvfb/x11vnc/websockify/openbox/nginx,
|
||||||
|
# Prism Launcher, echtem Minecraft-Client, Node-API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Stage 1: Vue Dashboard bauen
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
FROM node:22-slim AS web-build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Erst nur Abhängigkeiten (besserer Layer-Cache).
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
# Quellen kopieren und Produktions-Build erstellen.
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Stage 2: Runtime
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
TZ=Europe/Berlin \
|
||||||
|
DISPLAY=:0 \
|
||||||
|
CONFIG_DIR=/config \
|
||||||
|
PRISM_DIR=/config/prism \
|
||||||
|
WEB_ROOT=/var/www/ctmine \
|
||||||
|
MC_API_PORT=3000 \
|
||||||
|
PUID=1000 \
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# --- Systempakete ----------------------------------------------------------
|
||||||
|
# Xvfb virtuelles Display
|
||||||
|
# x11vnc VNC-Server auf Xvfb
|
||||||
|
# websockify VNC → WebSocket für noVNC
|
||||||
|
# novnc Browser-VNC-Client (wird hier nur als Fallback gehostet)
|
||||||
|
# openbox leichter Windowmanager (sonst kein Fokus/Rahmen für MC)
|
||||||
|
# nginx liefert Dashboard + proxyt API/Websockify
|
||||||
|
# nodejs Status-API
|
||||||
|
# supervisord Prozess-Manager
|
||||||
|
# gosu Prozess als Runtime-Benutzer starten
|
||||||
|
# xdpyinfo Auflösung auslesen (für /api/status)
|
||||||
|
# Prism Launcher benötigt Qt6 + multimediale Libs.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
xz-utils \
|
||||||
|
\
|
||||||
|
xvfb \
|
||||||
|
x11vnc \
|
||||||
|
python3 \
|
||||||
|
python3-websockify \
|
||||||
|
novnc \
|
||||||
|
openbox \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
gosu \
|
||||||
|
x11-utils \
|
||||||
|
\
|
||||||
|
nodejs \
|
||||||
|
\
|
||||||
|
qt6-base-dev \
|
||||||
|
qt6-wayland \
|
||||||
|
libqt6core6 \
|
||||||
|
libqt6gui6 \
|
||||||
|
libqt6network6 \
|
||||||
|
libqt6widgets6 \
|
||||||
|
libqt6svg6 \
|
||||||
|
libqt6opengl6 \
|
||||||
|
libqt6multimedia6 \
|
||||||
|
libgl1 \
|
||||||
|
libglx-mesa0 \
|
||||||
|
libegl1 \
|
||||||
|
libegl-mesa0 \
|
||||||
|
mesa-utils \
|
||||||
|
libopenal1 \
|
||||||
|
libpulse0 \
|
||||||
|
libglfw3 \
|
||||||
|
libflac12 \
|
||||||
|
libvorbisfile3 \
|
||||||
|
libopengl0 \
|
||||||
|
locales \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Locale generieren (Qt verlangt ein funktionierendes utf8-Locale).
|
||||||
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
ENV LANG=en_US.UTF-8 \
|
||||||
|
LANGUAGE=en_US:en \
|
||||||
|
LC_ALL=en_US.UTF-8
|
||||||
|
|
||||||
|
# --- Runtime-Benutzer (UID/GID 1000) ---------------------------------------
|
||||||
|
# Ubuntu hat bereits eine Gruppe/einen User mit ID 1000 (»ubuntu«). Wir nutzen
|
||||||
|
# diese, statt sie neu anzulegen: bestehende Gruppe/User werden umbenannt.
|
||||||
|
RUN if getent group 1000 >/dev/null; then \
|
||||||
|
existing_group=$(getent group 1000 | cut -d: -f1) && \
|
||||||
|
groupmod -n ctmine "$existing_group"; \
|
||||||
|
else \
|
||||||
|
groupadd -g 1000 ctmine; \
|
||||||
|
fi && \
|
||||||
|
if getent passwd 1000 >/dev/null; then \
|
||||||
|
existing_user=$(getent passwd 1000 | cut -d: -f1) && \
|
||||||
|
usermod -l ctmine -d /home/ctmine -m -s /bin/bash "$existing_user" && \
|
||||||
|
usermod -g 1000 ctmine; \
|
||||||
|
else \
|
||||||
|
useradd -m -u 1000 -g 1000 -s /bin/bash ctmine; \
|
||||||
|
fi && \
|
||||||
|
mkdir -p /config /var/www/ctmine /app/mc-api && \
|
||||||
|
chown -R ctmine:ctmine /config /app
|
||||||
|
|
||||||
|
# --- Prism Launcher installieren -------------------------------------------
|
||||||
|
# Wir laden den offiziellen portable Linux-Build (Qt6) herunter. Prism ist
|
||||||
|
# eine portable Qt-App — kein systemweites Installieren nötig. Das Archiv
|
||||||
|
# entpackt *flach* (Dateien liegen direkt im Root, kein Unterverzeichnis).
|
||||||
|
ARG PRISM_VERSION=11.0.3
|
||||||
|
RUN mkdir -p /opt/prism && cd /opt/prism && \
|
||||||
|
wget -q "https://github.com/PrismLauncher/PrismLauncher/releases/download/${PRISM_VERSION}/PrismLauncher-Linux-Qt6-Portable-${PRISM_VERSION}.tar.gz" -O prism.tar.gz && \
|
||||||
|
tar -xzf prism.tar.gz && \
|
||||||
|
rm prism.tar.gz && \
|
||||||
|
chmod +x /opt/prism/PrismLauncher /opt/prism/bin/prismlauncher
|
||||||
|
|
||||||
|
# --- Vue-Dashboard aus Stage 1 übernehmen ----------------------------------
|
||||||
|
COPY --from=web-build /build/dist/ /var/www/ctmine/
|
||||||
|
|
||||||
|
# --- Node-API übernehmen ---------------------------------------------------
|
||||||
|
COPY docker/mc-api/ /app/mc-api/
|
||||||
|
|
||||||
|
# --- Docker-Konfigurationsdateien ------------------------------------------
|
||||||
|
COPY docker/supervisor.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh /opt/prism/PrismLauncher
|
||||||
|
|
||||||
|
# --- Prism beim ersten Start ins Volume entpacken --------------------------
|
||||||
|
# So liegen Accounts, Instanzen und Launcher persistent in /config und
|
||||||
|
# überstehen Container-Neustarts. Prism erwartet $HOME als Datenverzeichnis.
|
||||||
|
RUN echo '#!/usr/bin/env bash' > /opt/bootstrap-prism.sh && \
|
||||||
|
echo 'set -e' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo 'if [ ! -x "${PRISM_DIR}/PrismLauncher" ]; then' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo ' mkdir -p "${PRISM_DIR}"' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo ' cp -r /opt/prism/* "${PRISM_DIR}/"' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo ' chmod +x "${PRISM_DIR}/PrismLauncher"' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo ' chown -R ctmine:ctmine "${PRISM_DIR}"' >> /opt/bootstrap-prism.sh && \
|
||||||
|
echo 'fi' >> /opt/bootstrap-prism.sh && \
|
||||||
|
chmod +x /opt/bootstrap-prism.sh
|
||||||
|
|
||||||
|
# Volumes: persistente Daten (Accounts, Instanzen, .minecraft).
|
||||||
|
VOLUME ["/config"]
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Healthcheck: ist das Dashboard erreichbar?
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
198
README.md
Normal file
198
README.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# ⛏ CTmine-client · ContainerMine Client
|
||||||
|
|
||||||
|
Eine Docker-WebApp, die einen **echten Minecraft Java-Client** im Container
|
||||||
|
laufen lässt und ihn per **noVNC direkt im Browser** anzeigt. Ideal, um einen
|
||||||
|
Account AFK an eine Farm zu stellen — ohne lokalen Minecraft-Client oder
|
||||||
|
Java-Installation.
|
||||||
|
|
||||||
|
Das Dashboard ist eine **Vite + Vue 3**-Anwendung, die im selben Container
|
||||||
|
mit ausgeliefert wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──HTTP/WS──▶ nginx (:8080)
|
||||||
|
├── / → Vue-Dashboard (statisch)
|
||||||
|
├── /api/* → Node-Status-API (127.0.0.1:3000)
|
||||||
|
└── /websockify → websockify (127.0.0.1:6080)
|
||||||
|
│
|
||||||
|
supervisord verwaltet ▼
|
||||||
|
Xvfb :0 → x11vnc → websockify → Browser
|
||||||
|
▲
|
||||||
|
Prism Launcher + echter Minecraft-Client
|
||||||
|
(rendert per Software-OpenGL/llvmpipe auf Xvfb)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum Prism Launcher statt offiziellem Mojang-Launcher?** Der Mojang-Launcher
|
||||||
|
nutzt für den Microsoft-Login ein eingebettetes Webview, das in einem headlessen
|
||||||
|
Container regelmäßig Probleme macht. Prism startet denselben Vanilla-Client,
|
||||||
|
ist aber container-freundlich und nutzt den **Device-Code-Flow** für den Login:
|
||||||
|
Code notieren → `microsoft.com/link` auf irgendeinem Gerät öffnen → fertig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- **Docker** (mit BuildKit) und **Docker Compose v2**
|
||||||
|
- ~2 GB freier Speicher für das Image
|
||||||
|
- Einen **gültigen Microsoft-Account** mit Minecraft-Lizenz
|
||||||
|
- Chromium/Firefox (für WebGL & WebSocket in noVNC)
|
||||||
|
|
||||||
|
> Lokales Bauen ohne Docker ist nicht vorgesehen. Wenn du nur das Dashboard
|
||||||
|
> entwickeln willst, siehe [Entwicklung](#dashboard-entwickeln).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Env-Datei erzeugen und anpassen
|
||||||
|
cp .env.example .env
|
||||||
|
# PUID/PGID mit `id -u` / `id -g` setzen, damit ./config dir gehört.
|
||||||
|
|
||||||
|
# 2. Image bauen und starten
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 3. Dashboard öffnen
|
||||||
|
# http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erster Login (einmalig)
|
||||||
|
|
||||||
|
1. **Dashboard öffnen** → http://localhost:8080
|
||||||
|
2. Auf **„Instanz starten“** klicken. Prism Launcher öffnet sich im VNC-Fenster.
|
||||||
|
3. In Prism oben rechts: **Konto → Konto hinzufügen → Microsoft**.
|
||||||
|
4. Prism zeigt einen **Code** an. Auf *irgendeinem* Gerät
|
||||||
|
`https://microsoft.com/link` öffnen, Code eingeben, mit Microsoft einloggen.
|
||||||
|
5. Zurück in Prism: im Hauptfenster die gewünschte **Instanz** wählen und auf
|
||||||
|
**„Spielen“** klicken. Prism lädt die passende Minecraft-Version + Java
|
||||||
|
automatisch herunter.
|
||||||
|
6. Im Minecraft-Hauptmenü: **Mehrspieler → Server hinzufügen** → Adresse der
|
||||||
|
Farm eingeben → **Verbinden**.
|
||||||
|
|
||||||
|
Ab jetzt ist der Account auf der Farm. Der Login liegt persistent in
|
||||||
|
`./config/prism` und bleibt auch nach `docker compose down` erhalten.
|
||||||
|
|
||||||
|
> **Tipp:** Bevor du den Container Neustartest, kannst du in Minecraft über
|
||||||
|
> *Optionen → Steuerelemente* auch schon die korrekte Farm-Anbindung
|
||||||
|
> (z. B. eine Trade/Anti-AFK-Makro-Mod) konfigurieren — sie bleibt erhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AFK-Farm-Setup
|
||||||
|
|
||||||
|
Die konkrete Einrichtung hängt von deiner Farm ab. Typische Schritte:
|
||||||
|
|
||||||
|
- **Anti-AFK**: periodisch springen/bewegen, damit der Server dich nicht kickt.
|
||||||
|
Du kannst die Vanilla-Funktion (z. B. Wasserstrom, der dich ständig leicht
|
||||||
|
bewegt) nutzen oder eine Client-Mod wie *Mouse Tweaks* / *Inventory Profiles*
|
||||||
|
installieren. Mods legst du über Prism in die jeweilige Instanz.
|
||||||
|
- **Auto-Reconnect**: manche Server trennen nach Stunden. Nutze ggf. eine
|
||||||
|
*Reconnect*-Mod.
|
||||||
|
- **Fenster offen lassen**: Solange das Dashboard geöffnet ist, siehst du den
|
||||||
|
Client live. Du kannst den Tab schließen — Minecraft läuft im Container
|
||||||
|
weiter. Nur der Container muss laufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration (`.env`)
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ------------------- | ------------- | ------------------------------------------------- |
|
||||||
|
| `PUID` / `PGID` | `1000` | UID/GID des Container-Benutzers. Mit `id -u`/`id -g` deines Host-Users setzen, damit `./config` dir gehört. |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone des Containers. |
|
||||||
|
| `DISPLAY_WIDTH` | `1280` | Breite des virtuellen Displays (MC-Auflösung). |
|
||||||
|
| `DISPLAY_HEIGHT` | `720` | Höhe des virtuellen Displays. |
|
||||||
|
| `DISPLAY_DEPTH` | `24` | Farbtiefe (24 = 8-bit RGB). |
|
||||||
|
| `DISPLAY_REFRESH` | `60` | Bildwiederholrate des virtuellen Displays. |
|
||||||
|
| `WEB_PORT` | `8080` | Browser-Port. |
|
||||||
|
| `VNC_PASSWORD` | *(leer)* | Optional. Leer = ohne Auth (nur lokales Netz!). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
| ------- | ---------------- | ---------------------------------------- |
|
||||||
|
| `GET` | `/api/status` | Status von Display, VNC und Minecraft. |
|
||||||
|
| `POST` | `/api/mc/start` | Startet Prism Launcher. |
|
||||||
|
| `POST` | `/api/mc/stop` | Beendet Prism + Minecraft. |
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/mc/start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard entwickeln
|
||||||
|
|
||||||
|
Für die Vue-Entwicklung ohne jedes Mal neu zu bauen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev # → http://localhost:5173 (proxyt /api + /websockify nach :8080)
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit das Dev-Dashboard funktioniert, muss der Container laufen
|
||||||
|
(`docker compose up -d`), damit der VNC-Stream und die API erreichbar sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance-Hinweise
|
||||||
|
|
||||||
|
- Minecraft rendert im Container per **llvmpipe (CPU-Software-OpenGL)**.
|
||||||
|
Für AFK-Farmen absolut ausreichend (~1–2 CPU-Kerne), aber kein flüssiges PVP.
|
||||||
|
- `shm_size: 1gb` ist gesetzt, damit OpenGL genug Shared Memory hat.
|
||||||
|
- Bei verfübarer GPU kannst du `/dev/dri` durchreichen (in `docker-compose.yml`
|
||||||
|
auskommentiert). MC nutzt dann Hardware-Rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
CTmine-client/
|
||||||
|
├── Dockerfile # Multi-Stage: Vue-Build + Runtime
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
├── docker/
|
||||||
|
│ ├── entrypoint.sh # PUID/PGID, /config, Xvfb/x11vnc-Conf generieren
|
||||||
|
│ ├── supervisor.conf # Prozess-Manager für alle Dienste
|
||||||
|
│ ├── nginx.conf # Dashboard + /api + /websockify Proxy
|
||||||
|
│ └── mc-api/ # Node-Status-API (ohne externe Dependencies)
|
||||||
|
│ ├── server.js
|
||||||
|
│ └── package.json
|
||||||
|
└── web/ # Vite + Vue 3 + TypeScript Dashboard
|
||||||
|
├── package.json · vite.config.ts · tsconfig.json
|
||||||
|
└── src/
|
||||||
|
├── App.vue · main.ts · api.ts · types.ts
|
||||||
|
├── styles/main.css
|
||||||
|
└── components/
|
||||||
|
├── VncViewer.vue # noVNC-Integration
|
||||||
|
├── ControlPanel.vue # MC Start/Stop, Schnellstart-Anleitung
|
||||||
|
└── StatusBar.vue # Status-Indikatoren
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- **Sound** ist im MVP nicht konfiguriert (für AFK-Farmen irrelevant).
|
||||||
|
- **Erster Login interaktiv**: der Microsoft-Login muss einmalig im VNC-Fenster
|
||||||
|
per Device-Code durchgeführt werden. Danach persistent.
|
||||||
|
- **EULA/Nutzungsbedingungen**: Dieses Projekt stellt nur die Infrastruktur
|
||||||
|
bereit. Du bist selbst für die Einhaltung der Mojang/Minecraft-Nutzungs-
|
||||||
|
bedingungen und der Regeln deines Zielservers verantwortlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT — siehe `LICENSE` falls beigefügt. Minecraft ist Eigentum von Mojang/Microsoft;
|
||||||
|
dieses Projekt steht nicht in offizieller Verbindung.
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# CTmine-client · ContainerMine Client
|
||||||
|
# Start: docker compose up -d --build
|
||||||
|
# Web: http://localhost:8080
|
||||||
|
|
||||||
|
services:
|
||||||
|
ctmine-client:
|
||||||
|
image: ctmine-client:latest
|
||||||
|
container_name: ctmine-client
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
environment:
|
||||||
|
PUID: ${PUID:-1000}
|
||||||
|
PGID: ${PGID:-1000}
|
||||||
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
|
DISPLAY_WIDTH: ${DISPLAY_WIDTH:-1280}
|
||||||
|
DISPLAY_HEIGHT: ${DISPLAY_HEIGHT:-720}
|
||||||
|
DISPLAY_REFRESH: ${DISPLAY_REFRESH:-60}
|
||||||
|
DISPLAY_DEPTH: ${DISPLAY_DEPTH:-24}
|
||||||
|
VNC_PASSWORD: ${VNC_PASSWORD:-}
|
||||||
|
NOVNC_PATH: ${NOVNC_PATH:-}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Persistente Daten: Microsoft-Account, Prism-Instanzen, .minecraft
|
||||||
|
- ./config:/config
|
||||||
|
|
||||||
|
# Software-Rendering braucht Shared Memory für OpenGL.
|
||||||
|
shm_size: "1gb"
|
||||||
|
|
||||||
|
#Optionale GPU-Beschleunigung (im Software-Rendering-Modus nicht nötig):
|
||||||
|
# devices:
|
||||||
|
# - /dev/dri:/dev/dri
|
||||||
98
docker/entrypoint.sh
Normal file
98
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# CTmine-client Entrypoint.
|
||||||
|
# Richtet den Runtime-Benutzer, /config und die display-bezogenen Konfigurationen
|
||||||
|
# ein und startet anschließend supervisord, der alle Prozesse verwaltet.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PUID="${PUID:-1000}"
|
||||||
|
PGID="${PGID:-1000}"
|
||||||
|
TZ="${TZ:-Europe/Berlin}"
|
||||||
|
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
||||||
|
WEB_ROOT="${WEB_ROOT:-/var/www/ctmine}"
|
||||||
|
|
||||||
|
# TimeZone setzen, sofern tzdata vorhanden.
|
||||||
|
if [ -f "/usr/share/zoneinfo/${TZ}" ]; then
|
||||||
|
ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime
|
||||||
|
echo "${TZ}" > /etc/timezone
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] PUID=${PUID} PGID=${PGID} TZ=${TZ}"
|
||||||
|
|
||||||
|
# --- /config anlegen und dem Runtime-Benutzer übergeben ------------------
|
||||||
|
mkdir -p "${CONFIG_DIR}/prism" "${CONFIG_DIR}/.local"
|
||||||
|
|
||||||
|
# Gruppe/Benutzer an PUID/PGID anpassen (der 'ctmine'-Benutzer wird im
|
||||||
|
# Dockerfile mit UID 1000 angelegt). So gehören Dateien im Volume dem
|
||||||
|
# Host-Benutzer.
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
groupmod -o -g "${PGID}" ctmine 2>/dev/null || true
|
||||||
|
usermod -o -u "${PUID}" -g "${PGID}" -d "${CONFIG_DIR}" ctmine 2>/dev/null || true
|
||||||
|
chown -R ctmine:ctmine "${CONFIG_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Display-Auflösung in Supervisor-Config schreiben --------------------
|
||||||
|
# supervisord kann keine ENV-Substitution, darum generieren wir Xvfb-Start
|
||||||
|
# und x11vnc-Optionen hier als feste Dateien.
|
||||||
|
DISPLAY_WIDTH="${DISPLAY_WIDTH:-1280}"
|
||||||
|
DISPLAY_HEIGHT="${DISPLAY_HEIGHT:-720}"
|
||||||
|
DISPLAY_REFRESH="${DISPLAY_REFRESH:-60}"
|
||||||
|
DISPLAY_DEPTH="${DISPLAY_DEPTH:-24}"
|
||||||
|
|
||||||
|
cat > /tmp/xvfb.conf <<EOF
|
||||||
|
[program:xvfb]
|
||||||
|
command=/usr/bin/Xvfb :0 -screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x${DISPLAY_DEPTH} -ac -nolisten tcp
|
||||||
|
priority=10
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
environment=HOME="/config"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# VNC-Passwort-Datei (optional). Ohne Passwort läuft x11vnc ohne Auth —
|
||||||
|
# nur für vertrauenswürdige lokale Netze gedacht.
|
||||||
|
VNC_PASSWORD="${VNC_PASSWORD:-}"
|
||||||
|
VNC_PWFILE="${CONFIG_DIR}/.vncpasswd"
|
||||||
|
if [ -n "${VNC_PASSWORD}" ]; then
|
||||||
|
printf '%s\n' "${VNC_PASSWORD}" | vncpasswd -f > "${VNC_PWFILE}"
|
||||||
|
chmod 600 "${VNC_PWFILE}"
|
||||||
|
chown ctmine:ctmine "${VNC_PWFILE}"
|
||||||
|
VNC_PWFLAG="-rfbauth ${VNC_PWFILE}"
|
||||||
|
else
|
||||||
|
rm -f "${VNC_PWFILE}"
|
||||||
|
VNC_PWFLAG="-nopw"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > /tmp/x11vnc.conf <<EOF
|
||||||
|
[program:x11vnc]
|
||||||
|
command=/usr/bin/x11vnc -display :0 -forever -shared ${VNC_PWFLAG} -rfbport 5900
|
||||||
|
priority=20
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
environment=HOME="/config"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# nginx braucht /run für PID + Sockets, sicherstellen dass es existiert.
|
||||||
|
mkdir -p /run /var/lib/nginx /var/log/nginx
|
||||||
|
chown -R ctmine:ctmine /var/lib/nginx /var/log/nginx 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wenn Web-Root existiert, Rechte setzen.
|
||||||
|
if [ -d "${WEB_ROOT}" ]; then
|
||||||
|
chown -R ctmine:ctmine "${WEB_ROOT}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Prism beim ersten Start ins persistente Volume kopieren --------------
|
||||||
|
# /opt/prism enthält die frische Launcher-Installation; beim ersten Start
|
||||||
|
# (oder nach Updates) wird sie nach /config/prism kopiert, damit Accounts
|
||||||
|
# und Instanzen persistent bleiben.
|
||||||
|
/opt/bootstrap-prism.sh
|
||||||
|
echo "[entrypoint] Prism Launcher unter ${CONFIG_DIR}/prism bereit."
|
||||||
|
|
||||||
|
echo "[entrypoint] Starte supervisord …"
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
14
docker/mc-api/package.json
Normal file
14
docker/mc-api/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "ctmine-client-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Status- und Steuerungs-API für CTmine-client",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
docker/mc-api/server.js
Normal file
178
docker/mc-api/server.js
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// CTmine-client Status- und Steuerungs-API.
|
||||||
|
// Absichtlich ohne externe Abhängigkeiten (nur Node-Built-ins), damit der
|
||||||
|
// Container schlank bleibt und kein npm install im Runtime-Stage nötig ist.
|
||||||
|
//
|
||||||
|
// Endpunkte:
|
||||||
|
// GET /api/status → { display, vnc, minecraft, resolution, timestamp }
|
||||||
|
// POST /api/mc/start → startet Prism Launcher
|
||||||
|
// POST /api/mc/stop → beendet Prism + Minecraft
|
||||||
|
//
|
||||||
|
// nginx proxyt /api zu diesem Server (127.0.0.1:3000 im Container).
|
||||||
|
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { exec, execFile, spawn } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
const PORT = Number(process.env.MC_API_PORT || 3000)
|
||||||
|
|
||||||
|
// --- Konfiguration aus Environment ---------------------------------------
|
||||||
|
const CONFIG_DIR = process.env.CONFIG_DIR || '/config'
|
||||||
|
const PRISM_DIR = process.env.PRISM_DIR || `${CONFIG_DIR}/prism`
|
||||||
|
const DISPLAY = process.env.DISPLAY || ':0'
|
||||||
|
const ACCOUNT = process.env.MC_ACCOUNT || '' // Profilname in Prism
|
||||||
|
const MC_VERSION = process.env.MC_VERSION || '' // optional, z.B. "1.21"
|
||||||
|
|
||||||
|
// --- Hilfsfunktionen -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert true, wenn mindestens ein Prozess auf das Muster passt.
|
||||||
|
* Wichtig: pgrep -f per Shell matched sonst auf die Shell selbst, deren
|
||||||
|
* Kommandozeile das Pattern enthält. Wir filtern deshalb die Shell-basierten
|
||||||
|
* Aufrufe (sh/bash -c …) heraus und prüfen nur „echte“ Treffer.
|
||||||
|
*/
|
||||||
|
async function pgrep(pattern) {
|
||||||
|
try {
|
||||||
|
// -a gibt die volle Kommandozeile aus, sodass wir filtern können.
|
||||||
|
const { stdout } = await execAsync(`pgrep -fa '${pattern}' || true`)
|
||||||
|
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||||
|
// Nur Treffer zählen, die nicht themselves die Such-Shell sind.
|
||||||
|
const real = lines.filter((line) => !/\b(?:sh|bash) -c .*pgrep/.test(line))
|
||||||
|
return real.length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Führt ein Kommando als der Runtime-Benutzer aus, falls PUID gesetzt ist. */
|
||||||
|
function asUser(cmd) {
|
||||||
|
const uid = process.env.PUID
|
||||||
|
const gid = process.env.PGID
|
||||||
|
// Wenn wir root sind und PUID gesetzt ist → per runpuuids als Benutzer laufen.
|
||||||
|
if (process.getuid && process.getuid() === 0 && uid && gid) {
|
||||||
|
return `gosu ${uid}:${gid} bash -lc '${cmd.replace(/'/g, "'\\''")}'`
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicheres JSON-Antwort-Helfer. */
|
||||||
|
function sendJson(res, status, body) {
|
||||||
|
const payload = JSON.stringify(body)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
})
|
||||||
|
res.end(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status --------------------------------------------------------------
|
||||||
|
|
||||||
|
async function readResolution() {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('xdpyinfo -display ' + DISPLAY + ' 2>/dev/null | grep dimensions')
|
||||||
|
const m = /(\d+)x(\d+)/.exec(stdout)
|
||||||
|
if (m) return { width: Number(m[1]), height: Number(m[2]) }
|
||||||
|
} catch {
|
||||||
|
/* xdpyinfo nicht verfügbar */
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatus() {
|
||||||
|
const [display, vnc, minecraft, resolution] = await Promise.all([
|
||||||
|
pgrep('Xvfb ' + DISPLAY),
|
||||||
|
pgrep('x11vnc'),
|
||||||
|
// Prism portable Binary heißt 'prismlauncher' (klein); der offizielle Build
|
||||||
|
// 'PrismLauncher'. Minecraft selbst läuft als java-Prozess mit net.minecraft.
|
||||||
|
pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft'),
|
||||||
|
readResolution(),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
vnc,
|
||||||
|
minecraft,
|
||||||
|
resolution,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Minecraft-Steuerung -------------------------------------------------
|
||||||
|
|
||||||
|
async function startMinecraft() {
|
||||||
|
if (await pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft')) {
|
||||||
|
return { ok: true, message: 'Minecraft läuft bereits.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrismLauncher im Headless-CLI-Modus gibt es nicht zuverlässig über alle
|
||||||
|
// Versionen. Wir starten daher die GUI-Anwendung auf dem virtuellen Display;
|
||||||
|
// der Benutzer interagiert dann per VNC. Das ist der robuste Weg.
|
||||||
|
//
|
||||||
|
// Die API läuft bereits als ctmine-Benutzer (via gosu in supervisor.conf),
|
||||||
|
// daher starten wir Prism direkt ohne weiteren gosu-Wrapper.
|
||||||
|
const child = spawn(PRISM_DIR + '/PrismLauncher', [], {
|
||||||
|
cwd: PRISM_DIR,
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DISPLAY,
|
||||||
|
HOME: CONFIG_DIR,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
child.on('error', () => {
|
||||||
|
/* wird über Status-Polling sichtbar */
|
||||||
|
})
|
||||||
|
child.unref()
|
||||||
|
|
||||||
|
return { ok: true, message: 'Prism Launcher wird gestartet. Im VNC-Fenster sichtbar.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopMinecraft() {
|
||||||
|
// Zuerst sauber Prism schließen, dann Restprozesse killen.
|
||||||
|
try {
|
||||||
|
await execAsync("pkill -INT -f 'prismlauncher|PrismLauncher' || true")
|
||||||
|
await new Promise((r) => setTimeout(r, 1500))
|
||||||
|
await execAsync("pkill -TERM -f 'prismlauncher|PrismLauncher|net.minecraft' || true")
|
||||||
|
} catch {
|
||||||
|
/* pkill liefert !=0 wenn nichts läuft — egal */
|
||||||
|
}
|
||||||
|
return { ok: true, message: 'Minecraft wurde beendet.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP-Server ---------------------------------------------------------
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
// CORS frei für lokales Netz (Dashboard läuft gleicher Origin, aber sicher).
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204)
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
||||||
|
return sendJson(res, 200, await getStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/mc/start') {
|
||||||
|
return sendJson(res, 200, await startMinecraft())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/mc/stop') {
|
||||||
|
return sendJson(res, 200, await stopMinecraft())
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 404, { ok: false, message: 'Not found' })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
sendJson(res, 500, { ok: false, message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[ctmine-api] lauscht auf 127.0.0.1:${PORT}`)
|
||||||
|
})
|
||||||
66
docker/nginx.conf
Normal file
66
docker/nginx.conf
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# CTmine-client nginx-Konfiguration.
|
||||||
|
# Liefert das gebaute Vue-Dashboard aus und proxyt API + VNC-WebSocket.
|
||||||
|
|
||||||
|
user ctmine;
|
||||||
|
worker_processes 1;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
listen [::]:8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/ctmine;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# --- Vue SPA: Fallback auf index.html ---------------------------
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- noVNC-Webclient (vom novnc-Debian-Paket unter /usr/share/novnc) -
|
||||||
|
# Der VncViewer-iframe lädt /novnc/vnc.html und verbindet sich dann
|
||||||
|
# selbsttätig über /websockify (s.u.).
|
||||||
|
location /novnc/ {
|
||||||
|
alias /usr/share/novnc/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Status-/Steuerungs-API → Node ------------------------------
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- VNC über WebSocket (websockify lauscht auf :6080) ----------
|
||||||
|
# Wichtig: Upgrade-Header weiterreichen, sonst kein WS-Handshake.
|
||||||
|
location /websockify {
|
||||||
|
proxy_pass http://127.0.0.1:6080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
docker/supervisor.conf
Normal file
79
docker/supervisor.conf
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
; CTmine-client — Haupt-Supervisor-Konfiguration.
|
||||||
|
; supervisord läuft als ROOT, damit es /dev/fd/1 (Container-stdout) nutzen und
|
||||||
|
; die Programme sauber verwalten kann. Jedes Programm startet seinen Prozess
|
||||||
|
; selbst als ctmine-Benutzer (per gosu), so dass die eigentliche Arbeit nie
|
||||||
|
; als root läuft.
|
||||||
|
;
|
||||||
|
; Die Xvfb- und x11vnc-Programmblöcke werden vom entrypoint.sh als
|
||||||
|
; /tmp/xvfb.conf und /tmp/x11vnc.conf generiert (wegen ENV-Substitution)
|
||||||
|
; und hier per include gezogen.
|
||||||
|
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
|
[unix_http_server]
|
||||||
|
file=/var/run/supervisor.sock
|
||||||
|
chmod=0700
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///var/run/supervisor.sock
|
||||||
|
|
||||||
|
; Von entrypoint.sh generierte Programmblöcke (Xvfb, x11vnc):
|
||||||
|
[include]
|
||||||
|
files = /tmp/xvfb.conf /tmp/x11vnc.conf
|
||||||
|
|
||||||
|
; --- Windowmanager (sonst hat MC keinen Rahmen / Fokus) -------------------
|
||||||
|
[program:openbox]
|
||||||
|
command=/usr/sbin/gosu ctmine /usr/bin/openbox
|
||||||
|
environment=DISPLAY=":0"
|
||||||
|
priority=30
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; --- websockify: VNC :5900 → WebSocket, von nginx unter /websockify exposed -
|
||||||
|
[program:websockify]
|
||||||
|
command=/usr/sbin/gosu ctmine /usr/bin/python3 -m websockify 0.0.0.0:6080 localhost:5900 --web=/usr/share/novnc
|
||||||
|
priority=40
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; --- Node Status-/Steuerungs-API ------------------------------------------
|
||||||
|
[program:mc-api]
|
||||||
|
command=/usr/sbin/gosu ctmine /usr/bin/node /app/mc-api/server.js
|
||||||
|
directory=/app/mc-api
|
||||||
|
environment=DISPLAY=":0",HOME="/config",NODE_ENV="production"
|
||||||
|
priority=50
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; --- nginx: liefert Vue-Dashboard + proxyt /api und /websockify -----------
|
||||||
|
; nginx muss als root starten (Port-Bind + Config), wechselt intern per
|
||||||
|
; 'user ctmine;' in der nginx.conf in den Worker-Prozessen.
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
priority=60
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CTmine-client · ContainerMine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
web/package.json
Normal file
23
web/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "ctmine-client-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Dashboard für CTmine-client (ContainerMine Client)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.38"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.5.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.1.2",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"vue-tsc": "^2.0.29"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
web/src/App.vue
Normal file
136
web/src/App.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import VncViewer from './components/VncViewer.vue'
|
||||||
|
import ControlPanel from './components/ControlPanel.vue'
|
||||||
|
import StatusBar from './components/StatusBar.vue'
|
||||||
|
import { getStatus } from './api'
|
||||||
|
import type { ContainerStatus } from './types'
|
||||||
|
|
||||||
|
const status = ref<ContainerStatus>({
|
||||||
|
display: false,
|
||||||
|
vnc: false,
|
||||||
|
minecraft: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
const loadingStatus = ref(false)
|
||||||
|
const lastError = ref<string | null>(null)
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadingStatus.value = true
|
||||||
|
try {
|
||||||
|
status.value = await getStatus()
|
||||||
|
lastError.value = null
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loadingStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
// Alle 5s den Backend-Status aktualisieren (Display/VNC/MC).
|
||||||
|
pollTimer = setInterval(refresh, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="app__header">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand__mark">⛏</span>
|
||||||
|
<div>
|
||||||
|
<div class="brand__title">CTmine-client</div>
|
||||||
|
<div class="brand__sub">ContainerMine Client</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar
|
||||||
|
:status="status"
|
||||||
|
:loading="loadingStatus"
|
||||||
|
:error="lastError"
|
||||||
|
@refresh="refresh"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app__main">
|
||||||
|
<section class="viewer-wrap">
|
||||||
|
<VncViewer />
|
||||||
|
</section>
|
||||||
|
<aside class="panel-wrap">
|
||||||
|
<ControlPanel :status="status" @changed="refresh" />
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__mark {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__sub {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-wrap {
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-wrap {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app__main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
web/src/api.ts
Normal file
41
web/src/api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { ContainerStatus, McActionResponse } from './types'
|
||||||
|
|
||||||
|
/** Relativer Basis-Pfad — im Container liefert nginx /api aus. */
|
||||||
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = `${res.status} ${res.statusText}`
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
if (body?.message) detail = body.message
|
||||||
|
} catch {
|
||||||
|
/* kein JSON-Body */
|
||||||
|
}
|
||||||
|
throw new Error(detail)
|
||||||
|
}
|
||||||
|
// 204 → kein Body
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatus(): Promise<ContainerStatus> {
|
||||||
|
return request<ContainerStatus>('/api/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet Prism Launcher (falls nicht schon offen). */
|
||||||
|
export function startMinecraft(): Promise<McActionResponse> {
|
||||||
|
return request<McActionResponse>('/api/mc/start', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beendet alle Minecraft-/Prism-Prozesse. */
|
||||||
|
export function stopMinecraft(): Promise<McActionResponse> {
|
||||||
|
return request<McActionResponse>('/api/mc/stop', { method: 'POST' })
|
||||||
|
}
|
||||||
212
web/src/components/ControlPanel.vue
Normal file
212
web/src/components/ControlPanel.vue
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { startMinecraft, stopMinecraft } from '../api'
|
||||||
|
import type { ContainerStatus, McActionResponse } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{ status: ContainerStatus }>()
|
||||||
|
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||||
|
|
||||||
|
const busy = ref<null | 'start' | 'stop'>(null)
|
||||||
|
const toast = ref<{ type: 'ok' | 'err'; text: string } | null>(null)
|
||||||
|
const showHelp = ref(true)
|
||||||
|
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function flashToast(type: 'ok' | 'err', text: string) {
|
||||||
|
toast.value = { type, text }
|
||||||
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
|
toastTimer = setTimeout(() => (toast.value = null), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<McActionResponse>, label: 'start' | 'stop') {
|
||||||
|
busy.value = label
|
||||||
|
try {
|
||||||
|
const res = await fn()
|
||||||
|
flashToast(res.ok ? 'ok' : 'err', res.message)
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
flashToast('err', e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
busy.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStart() {
|
||||||
|
run(startMinecraft, 'start')
|
||||||
|
}
|
||||||
|
function onStop() {
|
||||||
|
if (confirm('Minecraft / Prism Launcher wirklich beenden?')) {
|
||||||
|
run(stopMinecraft, 'stop')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Minecraft</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Prism Launcher startet den echten Vanilla-Client im Container. Der
|
||||||
|
erste Microsoft-Login erfolgt einmalig im VNC-Fenster (Device-Code)
|
||||||
|
und wird in <code>/config</code> gespeichert.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
@click="onStart"
|
||||||
|
:disabled="busy !== null || props.status.minecraft"
|
||||||
|
>
|
||||||
|
{{ busy === 'start' ? 'Starte…' : 'Instanz starten' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
@click="onStop"
|
||||||
|
:disabled="busy !== null || !props.status.minecraft"
|
||||||
|
>
|
||||||
|
{{ busy === 'stop' ? 'Stoppe…' : 'Beenden' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showHelp" class="card">
|
||||||
|
<div class="card__head">
|
||||||
|
<h2>Schnellstart</h2>
|
||||||
|
<button class="link" @click="showHelp = false">ausblenden</button>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>Auf <strong>„Instanz starten“</strong> klicken — Prism öffnet sich im VNC-Fenster links.</li>
|
||||||
|
<li>Oben rechts in Prism: <strong>„Konto hinzufügen“</strong> → Microsoft → Code notieren.</li>
|
||||||
|
<li>Auf einem beliebigen Gerät <code>microsoft.com/link</code> öffnen, Code eingeben, einloggen.</li>
|
||||||
|
<li>Zurück in Prism: <strong>Instanz wählen</strong> → <strong>„Spielen“</strong>.</li>
|
||||||
|
<li>Im Minecraft-Hauptmenü: <strong>Mehrspieler</strong> → <strong>Server hinzufügen</strong> → verbinden.</li>
|
||||||
|
<li>Account ist nun AFK auf der Farm. Fenster kann offen bleiben.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Tastenkürzel im VNC-Fenster</h2>
|
||||||
|
<ul class="kbds">
|
||||||
|
<li><kbd>Strg</kbd>+<kbd>Alt</kbd>+<kbd>Umschalt</kbd> — Maus aus VNC freigeben</li>
|
||||||
|
<li><kbd>Strg</kbd>+<kbd>Alt</kbd>+<kbd>End</kbd> — Steuerbefehl senden (Toolbar)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">
|
||||||
|
Tipp: Minecraft-Auflösung folgt <code>DISPLAY_WIDTH</code>×<code>DISPLAY_HEIGHT</code>
|
||||||
|
aus der <code>.env</code>. Für AFK-Farmen genügen 1280×720.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="toast" class="toast" :class="toast.type">{{ toast.text }}</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps,
|
||||||
|
.kbds {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps li,
|
||||||
|
.kbds li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 100;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.ok {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.err {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
web/src/components/StatusBar.vue
Normal file
63
web/src/components/StatusBar.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContainerStatus } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
status: ContainerStatus
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}>()
|
||||||
|
defineEmits<{ (e: 'refresh'): void }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="statusbar">
|
||||||
|
<span class="item" :title="'Virtuelles Display (Xvfb)'">
|
||||||
|
<span class="dot" :class="status.display ? 'on' : 'off'"></span>
|
||||||
|
Display
|
||||||
|
</span>
|
||||||
|
<span class="item" :title="'VNC-Server (x11vnc)'">
|
||||||
|
<span class="dot" :class="status.vnc ? 'on' : 'off'"></span>
|
||||||
|
VNC
|
||||||
|
</span>
|
||||||
|
<span class="item" :title="'Minecraft / Prism Launcher'">
|
||||||
|
<span class="dot" :class="status.minecraft ? 'on' : 'off'"></span>
|
||||||
|
MC
|
||||||
|
</span>
|
||||||
|
<button class="icon" @click="$emit('refresh')" :disabled="loading" title="Aktualisieren">
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
<span v-if="error" class="err" :title="error">⚠ API</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
web/src/components/VncViewer.vue
Normal file
183
web/src/components/VncViewer.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { ConnectionState } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* noVNC wird als <iframe> eingebettet. nginx liefert die noVNC-Web-App
|
||||||
|
* statisch unter /novnc/ aus (vom Debian-Paket). Der iframe öffnet
|
||||||
|
* vnc.html und verbindet sich automatisch mit dem websockify-Endpunkt
|
||||||
|
* auf derselben Origin unter /websockify.
|
||||||
|
*
|
||||||
|
* Das ist deutlich robuster als der Versuch, noVNCs interne ES-Module
|
||||||
|
* (die nicht offiziell als Public-API exportiert werden) zu bündeln.
|
||||||
|
*/
|
||||||
|
const state = ref<ConnectionState>('disconnected')
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
const iframeEl = ref<HTMLIFrameElement | null>(null)
|
||||||
|
const iframeKey = ref(0)
|
||||||
|
|
||||||
|
// noVNC-URL: autoconnect=1, resize=scaling, Pfad zum websockify.
|
||||||
|
const novncUrl = computed(() => {
|
||||||
|
const host = window.location.hostname
|
||||||
|
const port = window.location.port || String(window.location.port)
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
autoconnect: '1',
|
||||||
|
resize: 'scaling',
|
||||||
|
// noVNC spricht relativ 'websockify' an → nginx mappt /websockify.
|
||||||
|
path: 'websockify',
|
||||||
|
// show_dot_cursor hilft beim Zielen im VNC-Fenster.
|
||||||
|
show_dot: '1',
|
||||||
|
// host/port für die Anzeige im noVNC-eigenen UI; iframe nutzt aber path.
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
return `/novnc/vnc.html?${params.toString()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
state.value = 'connecting'
|
||||||
|
errorMsg.value = null
|
||||||
|
// iframe neu laden per Key-Wechsel.
|
||||||
|
iframeKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIframeLoad() {
|
||||||
|
// Wenn der iframe geladen hat, betrachten wir die Verbindung als aktiv.
|
||||||
|
// (noVNC zeigt intern Fehler selbst an — wir liefern nur den Rahmen.)
|
||||||
|
state.value = 'connected'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
state.value = 'connecting'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vnc">
|
||||||
|
<iframe
|
||||||
|
ref="iframeEl"
|
||||||
|
:key="iframeKey"
|
||||||
|
class="vnc__frame"
|
||||||
|
:src="novncUrl"
|
||||||
|
title="Minecraft via noVNC"
|
||||||
|
allow="clipboard-read; clipboard-write; fullscreen"
|
||||||
|
@load="onIframeLoad"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
|
<div v-if="state !== 'connected'" class="vnc__overlay">
|
||||||
|
<div class="vnc__overlay-inner">
|
||||||
|
<p class="vnc__state">
|
||||||
|
<span class="dot" :class="state === 'connecting' ? 'warn' : 'off'"></span>
|
||||||
|
{{
|
||||||
|
state === 'connecting'
|
||||||
|
? 'Lade noVNC-Client…'
|
||||||
|
: state === 'error'
|
||||||
|
? (errorMsg ?? 'Verbindungsfehler')
|
||||||
|
: 'Getrennt'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<button class="primary" @click="reload">Neu verbinden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vnc__toolbar">
|
||||||
|
<span class="vnc__badge" :class="state">
|
||||||
|
{{
|
||||||
|
state === 'connected'
|
||||||
|
? '● Live'
|
||||||
|
: state === 'connecting'
|
||||||
|
? 'Verbinde…'
|
||||||
|
: 'Offline'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="vnc__hint">
|
||||||
|
Maus im Fenster festhalten? Im noVNC-Seitenmenge „Clip to Window“.
|
||||||
|
</span>
|
||||||
|
<button @click="reload">Aktualisieren</button>
|
||||||
|
<button
|
||||||
|
@click="iframeEl?.requestFullscreen()"
|
||||||
|
:disabled="state !== 'connected'"
|
||||||
|
>
|
||||||
|
Vollbild
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vnc {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__frame {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__overlay-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__state {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__badge {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__hint {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__badge.connected {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0f1115;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__badge.connecting {
|
||||||
|
background: var(--warn);
|
||||||
|
color: #0f1115;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vnc__badge.disconnected,
|
||||||
|
.vnc__badge.error {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
web/src/main.ts
Normal file
5
web/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
5
web/src/shims-vue.d.ts
vendored
Normal file
5
web/src/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
109
web/src/styles/main.css
Normal file
109
web/src/styles/main.css
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--bg-elev: #161a22;
|
||||||
|
--bg-elev-2: #1d222d;
|
||||||
|
--border: #2a3140;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--text-dim: #9aa3b2;
|
||||||
|
--accent: #4ea34e;
|
||||||
|
--accent-hover: #5cbf5c;
|
||||||
|
--danger: #d4564f;
|
||||||
|
--warn: #d9a441;
|
||||||
|
--radius: 8px;
|
||||||
|
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #262d3c;
|
||||||
|
border-color: #3a4253;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #0f1115;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.off {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.warn {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
20
web/src/types.ts
Normal file
20
web/src/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Status-Modell, das die Backend-API (/api/status) liefert.
|
||||||
|
export interface ContainerStatus {
|
||||||
|
/** Läuft das virtuelle Display? */
|
||||||
|
display: boolean
|
||||||
|
/** Läuft der VNC-Server? */
|
||||||
|
vnc: boolean
|
||||||
|
/** Läuft Prism Launcher / ein Minecraft-Prozess? */
|
||||||
|
minecraft: boolean
|
||||||
|
/** Letzte bekannte Bildschirmauflösung. */
|
||||||
|
resolution?: { width: number; height: number }
|
||||||
|
/** UNIX-Zeitstempel der letzten Aktualisierung. */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
|
export interface McActionResponse {
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
27
web/tsconfig.json
Normal file
27
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
31
web/vite.config.ts
Normal file
31
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// Beim Entwickeln (außerhalb des Containers) läuft Vite unter :5173 und
|
||||||
|
// erwartet das Backend (nginx + websockify) unter :8080. In der Produktion
|
||||||
|
// liefert nginx das gebaute dist/ direkt aus, sodass keine Proxys nötig sind.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/websockify': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue