// 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 = `
Timetrack — Android App
Time tracking — offline-first, cross-platform
⬇ APK herunterladen
Nach dem Download: Datei öffnen → Installieren.
Einmalig: Einstellungen → Sicherheit →
Browser/Dateimanager als Installationsquelle erlauben.
{{.SHA256}}
`
// ── 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()
}