572 lines
20 KiB
Go
572 lines
20 KiB
Go
// serve — Timetrack HTTP server
|
|
// Portable Go HTTP server for WLAN APK distribution and Flutter Web serving.
|
|
// No external dependencies — standard library only.
|
|
//
|
|
// Usage:
|
|
//
|
|
// ./serve [--dir build/apk] [--port 8888] [--debug] # APK mode (default)
|
|
// ./serve --web [--dir build/web] [--port 8080] [--debug] # Web mode
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ── HTML template ─────────────────────────────────────────────────────────────
|
|
|
|
const htmlTpl = `<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Timetrack — Android App</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background: #0F172A;
|
|
color: #E2E8F0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
}
|
|
.card {
|
|
background: #1E293B;
|
|
border-radius: 20px;
|
|
padding: 36px 32px;
|
|
max-width: 480px;
|
|
width: 100%;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
}
|
|
.logo-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.logo-icon {
|
|
width: 56px; height: 56px;
|
|
background: #2563EB;
|
|
border-radius: 14px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 28px; flex-shrink: 0;
|
|
}
|
|
h1 { font-size: 28px; font-weight: 700; color: #93C5FD; }
|
|
.tagline { font-size: 14px; color: #94A3B8; margin-bottom: 28px; margin-top: 4px; }
|
|
.meta-grid {
|
|
display: grid; grid-template-columns: 1fr 1fr;
|
|
gap: 12px; margin-bottom: 28px;
|
|
}
|
|
.meta-item { background: #0F172A; border-radius: 10px; padding: 12px 14px; }
|
|
.meta-label { font-size: 11px; color: #64748B; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
|
.meta-value { font-size: 15px; font-weight: 600; color: #E2E8F0; }
|
|
.download-btn {
|
|
display: block; width: 100%; padding: 16px;
|
|
background: #2563EB; color: white;
|
|
text-align: center; text-decoration: none;
|
|
border-radius: 12px; font-size: 17px; font-weight: 600;
|
|
letter-spacing: 0.3px; transition: background 0.2s; margin-bottom: 20px;
|
|
}
|
|
.download-btn:hover, .download-btn:active { background: #1D4ED8; }
|
|
.download-btn .icon { margin-right: 8px; }
|
|
.sha-section { background: #0F172A; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
|
|
.sha-header {
|
|
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
font-size: 13px; font-weight: 600; color: #64748B;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.sha-expected {
|
|
font-family: "SF Mono", "Fira Code", monospace;
|
|
font-size: 11px; color: #93C5FD; word-break: break-all;
|
|
line-height: 1.6; margin-bottom: 12px;
|
|
}
|
|
.sha-check-area { display: none; }
|
|
.sha-check-area.visible { display: block; }
|
|
.file-input-label {
|
|
display: block; border: 2px dashed #2563EB; border-radius: 8px;
|
|
padding: 14px; text-align: center; font-size: 13px; color: #64748B;
|
|
cursor: pointer; transition: border-color 0.2s, color 0.2s; margin-bottom: 10px;
|
|
}
|
|
.file-input-label:hover { border-color: #60A5FA; color: #E2E8F0; }
|
|
#fileInput { display: none; }
|
|
.progress-bar-wrap { background: #1E293B; border-radius: 6px; height: 6px; margin-bottom: 10px; display: none; }
|
|
.progress-bar { height: 100%; background: #2563EB; border-radius: 6px; width: 0%; transition: width 0.1s; }
|
|
.sha-result {
|
|
border-radius: 8px; padding: 12px 14px; font-size: 13px; font-weight: 600;
|
|
display: none; align-items: center; gap: 8px;
|
|
}
|
|
.sha-result.match { background: #14532D; color: #86EFAC; border: 1px solid #16A34A; }
|
|
.sha-result.mismatch { background: #7F1D1D; color: #FCA5A5; border: 1px solid #DC2626; }
|
|
.sha-computed { font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; color: #64748B; word-break: break-all; margin-top: 6px; display: none; }
|
|
.toggle-verify {
|
|
background: none; border: 1px solid #2563EB; color: #64748B;
|
|
border-radius: 8px; padding: 8px 14px; font-size: 12px;
|
|
cursor: pointer; width: 100%; transition: background 0.2s;
|
|
}
|
|
.toggle-verify:hover { background: #0F172A; color: #E2E8F0; }
|
|
.install-hint { font-size: 12px; color: #475569; text-align: center; margin-bottom: 16px; line-height: 1.5; }
|
|
.install-hint strong { color: #64748B; }
|
|
.footer { text-align: center; font-size: 11px; color: #334155; margin-top: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="logo-row">
|
|
<div class="logo-icon">⏱</div>
|
|
<h1>Timetrack</h1>
|
|
</div>
|
|
<p class="tagline">Time tracking — offline-first, cross-platform</p>
|
|
<div class="meta-grid">
|
|
<div class="meta-item">
|
|
<div class="meta-label">Version</div>
|
|
<div class="meta-value">{{.Version}}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Größe</div>
|
|
<div class="meta-value">{{.SizeMB}} MB</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Build</div>
|
|
<div class="meta-value">{{.BuildDate}}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Plattform</div>
|
|
<div class="meta-value">Android</div>
|
|
</div>
|
|
</div>
|
|
<a class="download-btn" href="/download">
|
|
<span class="icon">⬇</span> APK herunterladen
|
|
</a>
|
|
<p class="install-hint">
|
|
Nach dem Download: Datei öffnen → Installieren.<br>
|
|
<strong>Einmalig:</strong> Einstellungen → Sicherheit →<br>
|
|
Browser/Dateimanager als Installationsquelle erlauben.
|
|
</p>
|
|
<div class="sha-section">
|
|
<div class="sha-header"><span>🔒</span> SHA256-Prüfsumme</div>
|
|
<div class="sha-expected" id="expectedHash">{{.SHA256}}</div>
|
|
<button class="toggle-verify" onclick="toggleVerify()">Heruntergeladene Datei prüfen</button>
|
|
<div class="sha-check-area" id="verifyArea">
|
|
<br>
|
|
<label class="file-input-label" for="fileInput">📁 APK-Datei auswählen zum Vergleichen</label>
|
|
<input type="file" id="fileInput" accept=".apk" onchange="verifyFile(this)">
|
|
<div class="progress-bar-wrap" id="progressWrap"><div class="progress-bar" id="progressBar"></div></div>
|
|
<div class="sha-result" id="shaResult"><span id="shaIcon"></span><span id="shaText"></span></div>
|
|
<div class="sha-computed" id="shaComputed"></div>
|
|
</div>
|
|
</div>
|
|
<div class="footer">Timetrack · Lokaler Testserver · {{.Host}}</div>
|
|
</div>
|
|
<script>
|
|
function toggleVerify() {
|
|
document.getElementById('verifyArea').classList.toggle('visible');
|
|
}
|
|
async function verifyFile(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const expected = document.getElementById('expectedHash').textContent.trim().toLowerCase();
|
|
const progressWrap = document.getElementById('progressWrap');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const result = document.getElementById('shaResult');
|
|
const computed = document.getElementById('shaComputed');
|
|
result.style.display = 'none'; computed.style.display = 'none';
|
|
progressWrap.style.display = 'block'; progressBar.style.width = '0%';
|
|
const CHUNK = 4 * 1024 * 1024;
|
|
let offset = 0; const parts = [];
|
|
while (offset < file.size) {
|
|
const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
|
|
parts.push(new Uint8Array(buf));
|
|
offset += CHUNK;
|
|
progressBar.style.width = Math.min(100, Math.round(offset / file.size * 100)) + '%';
|
|
}
|
|
const total = parts.reduce((s, p) => s + p.length, 0);
|
|
const merged = new Uint8Array(total);
|
|
let pos = 0; for (const p of parts) { merged.set(p, pos); pos += p.length; }
|
|
const hashBuf = await crypto.subtle.digest('SHA-256', merged);
|
|
const actual = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
progressWrap.style.display = 'none'; result.style.display = 'flex';
|
|
if (actual === expected) {
|
|
result.className = 'sha-result match';
|
|
document.getElementById('shaIcon').textContent = '✓';
|
|
document.getElementById('shaText').textContent = 'Prüfsumme stimmt überein — Datei ist unverändert.';
|
|
} else {
|
|
result.className = 'sha-result mismatch';
|
|
document.getElementById('shaIcon').textContent = '✗';
|
|
document.getElementById('shaText').textContent = 'Prüfsummen stimmen NICHT überein!';
|
|
computed.textContent = 'Berechnet: ' + actual; computed.style.display = 'block';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
// ── Data structures ───────────────────────────────────────────────────────────
|
|
|
|
type pageData struct {
|
|
Version string
|
|
SizeMB string
|
|
BuildDate string
|
|
SHA256 string
|
|
Host string
|
|
}
|
|
|
|
type server struct {
|
|
apkPath string
|
|
sha256 string
|
|
sizeMB string
|
|
date string
|
|
version string
|
|
etag string
|
|
debug bool
|
|
tpl *template.Template
|
|
}
|
|
|
|
// ── APK discovery ─────────────────────────────────────────────────────────────
|
|
|
|
// apkVersion parses version info from filenames like timetrack-0.1.0+3.apk
|
|
// Returns the full version string (e.g. "0.1.0+3") or the filename stem.
|
|
var apkRe = regexp.MustCompile(`timetrack-([0-9]+\.[0-9]+\.[0-9]+\+[0-9]+)\.apk$`)
|
|
|
|
func parseVersion(name string) string {
|
|
m := apkRe.FindStringSubmatch(name)
|
|
if len(m) == 2 {
|
|
return m[1]
|
|
}
|
|
return strings.TrimSuffix(name, ".apk")
|
|
}
|
|
|
|
// findLatestAPK returns the path to the newest APK in dir.
|
|
// Prefers timetrack.apk symlink, falls back to newest versioned file.
|
|
func findLatestAPK(dir string) (string, error) {
|
|
symlink := filepath.Join(dir, "timetrack.apk")
|
|
if _, err := os.Stat(symlink); err == nil {
|
|
resolved, err := filepath.EvalSymlinks(symlink)
|
|
if err == nil {
|
|
return resolved, nil
|
|
}
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot read dir %s: %w", dir, err)
|
|
}
|
|
|
|
var apks []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".apk") && e.Name() != "timetrack.apk" {
|
|
apks = append(apks, e.Name())
|
|
}
|
|
}
|
|
if len(apks) == 0 {
|
|
return "", fmt.Errorf("no .apk files found in %s", dir)
|
|
}
|
|
sort.Strings(apks)
|
|
return filepath.Join(dir, apks[len(apks)-1]), nil
|
|
}
|
|
|
|
// ── SHA256 sidecar ────────────────────────────────────────────────────────────
|
|
|
|
func readSHA256(apkPath string) string {
|
|
data, err := os.ReadFile(apkPath + ".sha256")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) > 0 {
|
|
return fields[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ── Local IP ──────────────────────────────────────────────────────────────────
|
|
|
|
func localIP() string {
|
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
|
if err != nil {
|
|
return "127.0.0.1"
|
|
}
|
|
defer conn.Close()
|
|
return conn.LocalAddr().(*net.UDPAddr).IP.String()
|
|
}
|
|
|
|
// ── QR code ───────────────────────────────────────────────────────────────────
|
|
|
|
func printQR(url string) {
|
|
if _, err := exec.LookPath("qrencode"); err != nil {
|
|
fmt.Println(" (qrencode nicht verfügbar — URL manuell eingeben)")
|
|
return
|
|
}
|
|
cmd := exec.Command("qrencode", "-t", "UTF8", url)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
_ = cmd.Run()
|
|
}
|
|
|
|
// ── HTTP handlers ─────────────────────────────────────────────────────────────
|
|
|
|
func (s *server) noCache(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
w.Header().Set("ETag", s.etag)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
host := r.Host
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
data := pageData{
|
|
Version: s.version,
|
|
SizeMB: s.sizeMB,
|
|
BuildDate: s.date,
|
|
SHA256: s.sha256,
|
|
Host: host,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tpl.Execute(w, data); err != nil && s.debug {
|
|
fmt.Fprintf(os.Stderr, "template error: %v\n", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
f, err := os.Open(s.apkPath)
|
|
if err != nil {
|
|
http.Error(w, "APK not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
http.Error(w, "stat error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
name := filepath.Base(s.apkPath)
|
|
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
|
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
buf := make([]byte, 256*1024)
|
|
io.CopyBuffer(w, f, buf) //nolint:errcheck
|
|
}
|
|
|
|
func (s *server) handleSHA256(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
|
|
"sha256": s.sha256,
|
|
"filename": filepath.Base(s.apkPath),
|
|
})
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
dir := flag.String("dir", "", "Verzeichnis mit den Dateien (Standard: build/apk oder build/web)")
|
|
port := flag.Int("port", 8888, "HTTP-Port")
|
|
debug := flag.Bool("debug", false, "Ausführliche Request-Logs aktivieren")
|
|
web := flag.Bool("web", false, "Web-Modus: Flutter-Web-App als Static Files ausliefern")
|
|
flag.Parse()
|
|
|
|
if *web {
|
|
runWebMode(*dir, *port, *debug)
|
|
} else {
|
|
runAPKMode(*dir, *port, *debug)
|
|
}
|
|
}
|
|
|
|
// ── Web mode ──────────────────────────────────────────────────────────────────
|
|
|
|
func runWebMode(dir string, port int, debug bool) {
|
|
if dir == "" {
|
|
dir = "build/web"
|
|
}
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil || !dirExists(absDir) {
|
|
fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_web' ausführen.\n", dir)
|
|
}
|
|
|
|
ip := localIP()
|
|
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
url := fmt.Sprintf("http://%s:%d", ip, port)
|
|
|
|
printWebBanner(url, absDir, debug)
|
|
printQR(url)
|
|
printWebFooter()
|
|
|
|
var handler http.Handler = http.FileServer(http.Dir(absDir))
|
|
if debug {
|
|
handler = logMiddleware(handler)
|
|
}
|
|
|
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
fatalf("Server-Fehler: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// ── APK mode ──────────────────────────────────────────────────────────────────
|
|
|
|
func runAPKMode(dir string, port int, debug bool) {
|
|
if dir == "" {
|
|
dir = "build/apk"
|
|
}
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil || !dirExists(absDir) {
|
|
fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_android' ausführen.\n", dir)
|
|
}
|
|
|
|
apkPath, err := findLatestAPK(absDir)
|
|
if err != nil {
|
|
fatalf("FEHLER: %v\n → Zuerst 'make build_android' ausführen.\n", err)
|
|
}
|
|
|
|
info, _ := os.Stat(apkPath)
|
|
sizeMB := fmt.Sprintf("%.1f", float64(info.Size())/(1024*1024))
|
|
buildDate := time.Unix(info.ModTime().Unix(), 0).Format("02.01.2006")
|
|
sha256 := readSHA256(apkPath)
|
|
version := parseVersion(filepath.Base(apkPath))
|
|
etag := fmt.Sprintf(`"%d"`, info.ModTime().Unix())
|
|
|
|
tpl, err := template.New("page").Parse(htmlTpl)
|
|
if err != nil {
|
|
fatalf("FEHLER: Template-Fehler: %v\n", err)
|
|
}
|
|
|
|
srv := &server{
|
|
apkPath: apkPath,
|
|
sha256: sha256,
|
|
sizeMB: sizeMB,
|
|
date: buildDate,
|
|
version: version,
|
|
etag: etag,
|
|
debug: debug,
|
|
tpl: tpl,
|
|
}
|
|
|
|
ip := localIP()
|
|
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
url := fmt.Sprintf("http://%s:%d", ip, port)
|
|
|
|
printBanner(filepath.Base(apkPath), float64(info.Size())/(1024*1024), url, sha256, debug)
|
|
printQR(url)
|
|
printFooter()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", srv.handleIndex)
|
|
mux.HandleFunc("/download", srv.handleDownload)
|
|
mux.HandleFunc("/sha256", srv.handleSHA256)
|
|
|
|
var handler http.Handler = srv.noCache(mux)
|
|
if debug {
|
|
handler = logMiddleware(handler)
|
|
}
|
|
|
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
fatalf("Server-Fehler: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func fatalf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, format, args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func printBanner(apkName string, sizeMB float64, url, sha256 string, dbg bool) {
|
|
sep := strings.Repeat("═", 50)
|
|
fmt.Println()
|
|
fmt.Println(sep)
|
|
fmt.Println(" Timetrack APK — WLAN-Download")
|
|
fmt.Println(sep)
|
|
fmt.Printf(" Datei : %s (%.1f MB)\n", apkName, sizeMB)
|
|
fmt.Printf(" URL : %s\n", url)
|
|
if dbg {
|
|
fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)")
|
|
}
|
|
if sha256 != "" {
|
|
prefix := sha256
|
|
if len(prefix) > 16 {
|
|
prefix = prefix[:16]
|
|
}
|
|
fmt.Printf(" SHA256: %s…\n", prefix)
|
|
} else {
|
|
fmt.Println(" SHA256: keine .sha256-Datei gefunden")
|
|
}
|
|
fmt.Println()
|
|
fmt.Println(" QR-Code scannen:")
|
|
fmt.Println()
|
|
}
|
|
|
|
func printFooter() {
|
|
sep := strings.Repeat("═", 50)
|
|
fmt.Println(sep)
|
|
fmt.Println(" Handy: QR scannen → Seite öffnen → Herunterladen")
|
|
fmt.Println(" SHA256-Prüfung direkt im Browser verfügbar")
|
|
fmt.Println(" Stoppen mit Ctrl+C")
|
|
fmt.Println(sep)
|
|
fmt.Println()
|
|
}
|
|
|
|
func logMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
fmt.Printf("[%s] %s %s %s\n",
|
|
time.Now().Format("15:04:05"),
|
|
r.Method, r.URL.Path,
|
|
time.Since(start).Round(time.Millisecond),
|
|
)
|
|
})
|
|
}
|
|
|
|
func printWebBanner(url, dir string, dbg bool) {
|
|
sep := strings.Repeat("═", 50)
|
|
fmt.Println()
|
|
fmt.Println(sep)
|
|
fmt.Println(" Timetrack Web — WLAN-Zugriff")
|
|
fmt.Println(sep)
|
|
fmt.Printf(" Verzeichnis : %s\n", dir)
|
|
fmt.Printf(" URL : %s\n", url)
|
|
if dbg {
|
|
fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)")
|
|
}
|
|
fmt.Println()
|
|
fmt.Println(" QR-Code scannen:")
|
|
fmt.Println()
|
|
}
|
|
|
|
func printWebFooter() {
|
|
sep := strings.Repeat("═", 50)
|
|
fmt.Println(sep)
|
|
fmt.Println(" Handy: QR scannen → App direkt im Browser öffnen")
|
|
fmt.Println(" Stoppen mit Ctrl+C")
|
|
fmt.Println(sep)
|
|
fmt.Println()
|
|
}
|