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:
Tronax 2026-08-08 21:08:51 +02:00
commit b8a99e6e9e
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
25 changed files with 1766 additions and 0 deletions

13
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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 (~12 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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' })
}

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

27
web/tsconfig.json Normal file
View 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
View 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,
},
})