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

Timetrack

Time tracking — offline-first, cross-platform

Version
{{.Version}}
Größe
{{.SizeMB}} MB
Build
{{.BuildDate}}
Plattform
Android
APK herunterladen

Nach dem Download: Datei öffnen → Installieren.
Einmalig: Einstellungen → Sicherheit →
Browser/Dateimanager als Installationsquelle erlauben.

🔒 SHA256-Prüfsumme
{{.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() }