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
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,
},
})