>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}((function(){return qrcode}));
+//# sourceMappingURL=/sm/26b4b0d0b1e283d6b3ec9857ac597d7a60c76ac17be1ef4c965f03086de426bb.map
\ No newline at end of file
diff --git a/standalone/base64.go b/standalone/base64.go
new file mode 100644
index 0000000..951fd97
--- /dev/null
+++ b/standalone/base64.go
@@ -0,0 +1,289 @@
+package main
+
+import (
+ "encoding/json"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+func Base64Page(w http.ResponseWriter, r *http.Request) {
+ body := `
+Base64 Asset Scanner
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(Page("/base64", "Base64 Scanner", body)))
+}
+
+func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
+ p := PickFolder("Select folder to scan", "")
+ writeJSON(w, map[string]any{"path": p})
+}
+
+func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
+ paths := PickFiles(
+ "Select HTML files",
+ []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
+ true, "",
+ )
+ writeJSON(w, map[string]any{"paths": paths})
+}
+
+type scanResult struct {
+ File string `json:"file"`
+ OK bool `json:"ok"`
+ Assets []string `json:"assets"`
+}
+
+func Base64Scan(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Folder string `json:"folder"`
+ Files []string `json:"files"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
+ return
+ }
+
+ var targets []string
+ var baseDir string
+
+ if len(req.Files) > 0 {
+ for _, f := range req.Files {
+ if fileExists(f) {
+ targets = append(targets, f)
+ }
+ }
+ if len(targets) > 0 {
+ baseDir = commonBaseDir(targets)
+ }
+ } else if req.Folder != "" && fileExists(req.Folder) {
+ targets = collectHTMLFiles(req.Folder)
+ baseDir = req.Folder
+ } else {
+ writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
+ return
+ }
+
+ results := []scanResult{}
+ for _, file := range targets {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ continue
+ }
+ offenders := findNonBase64Assets(string(data))
+ display := file
+ if baseDir != "" {
+ rel, err := filepath.Rel(baseDir, file)
+ if err == nil && rel != "" {
+ display = rel
+ } else {
+ display = filepath.Base(file)
+ }
+ }
+ results = append(results, scanResult{
+ File: display,
+ OK: len(offenders) == 0,
+ Assets: offenders,
+ })
+ }
+ writeJSON(w, map[string]any{"results": results})
+}
+
+func commonBaseDir(files []string) string {
+ if len(files) == 0 {
+ return ""
+ }
+ if len(files) == 1 {
+ return filepath.Dir(files[0])
+ }
+ split := make([][]string, len(files))
+ for i, f := range files {
+ split[i] = strings.Split(filepath.Dir(f), string(filepath.Separator))
+ }
+ first := split[0]
+ i := 0
+ for i < len(first) {
+ match := true
+ for _, parts := range split {
+ if i >= len(parts) || parts[i] != first[i] {
+ match = false
+ break
+ }
+ }
+ if !match {
+ break
+ }
+ i++
+ }
+ return strings.Join(first[:i], string(filepath.Separator))
+}
+
+func collectHTMLFiles(root string) []string {
+ var out []string
+ htmlRx := regexp.MustCompile(`(?i)\.html?$`)
+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ name := d.Name()
+ if name == "node_modules" || name == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if htmlRx.MatchString(d.Name()) {
+ out = append(out, path)
+ }
+ return nil
+ })
+ return out
+}
+
+var (
+ scriptBlockRx = regexp.MustCompile(`(?is)`)
+ attrRx = regexp.MustCompile(`(?i)\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')`)
+ cssUrlRx = regexp.MustCompile(`(?i)url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)`)
+ base64DataRx = regexp.MustCompile(`(?i)^data:[^;,]*;base64,`)
+ mailTelRx = regexp.MustCompile(`(?i)^(mailto:|tel:)`)
+)
+
+func findNonBase64Assets(htmlSrc string) []string {
+ markup := scriptBlockRx.ReplaceAllString(htmlSrc, "")
+ seen := map[string]bool{}
+ var out []string
+ add := func(u string) {
+ if u == "" || seen[u] {
+ return
+ }
+ seen[u] = true
+ out = append(out, u)
+ }
+
+ for _, m := range attrRx.FindAllStringSubmatch(markup, -1) {
+ url := m[2]
+ if url == "" {
+ url = m[3]
+ }
+ url = strings.TrimSpace(url)
+ if url == "" {
+ continue
+ }
+ if isAssetReference(url) && !base64DataRx.MatchString(url) {
+ add(url)
+ }
+ }
+ for _, m := range cssUrlRx.FindAllStringSubmatch(markup, -1) {
+ url := m[1]
+ if url == "" {
+ url = m[2]
+ }
+ if url == "" {
+ url = m[3]
+ }
+ url = strings.TrimSpace(url)
+ if url == "" {
+ continue
+ }
+ if !base64DataRx.MatchString(url) {
+ add(url)
+ }
+ }
+ if out == nil {
+ out = []string{}
+ }
+ return out
+}
+
+func isAssetReference(url string) bool {
+ if strings.HasPrefix(url, "#") || strings.HasPrefix(strings.ToLower(url), "javascript:") {
+ return false
+ }
+ if mailTelRx.MatchString(url) {
+ return false
+ }
+ return true
+}
diff --git a/standalone/daily.go b/standalone/daily.go
new file mode 100644
index 0000000..3ac3e3f
--- /dev/null
+++ b/standalone/daily.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+ "encoding/json"
+ "html"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+func DailyPage(w http.ResponseWriter, r *http.Request) {
+ cfg := LoadConfig()
+ today := time.Now().Format("2006-01-02")
+ lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
+
+ body := `
+Daily Update
+Submit copies the formatted text to your clipboard.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
+}
+
+type dailySubmitReq struct {
+ Date string `json:"date"`
+ Status string `json:"status"`
+ Project string `json:"project"`
+ Remarks string `json:"remarks"`
+}
+
+func DailySubmit(w http.ResponseWriter, r *http.Request) {
+ var req dailySubmitReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ text := formatDailyMessage(req)
+ if err := CopyToClipboard(text); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ cfg := LoadConfig()
+ cfg.DailyUpdate.LastProject = req.Project
+ _ = SaveConfig(cfg)
+ writeJSON(w, map[string]any{"ok": true, "text": text})
+}
+
+func formatDailyMessage(p dailySubmitReq) string {
+ bullets := []string{}
+ for _, line := range strings.Split(p.Remarks, "\n") {
+ t := strings.TrimRight(line, "\r")
+ t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
+ t = strings.TrimSpace(t)
+ if t != "" {
+ bullets = append(bullets, "- "+t)
+ }
+ }
+ lines := []string{
+ "Date: " + formatDailyDate(p.Date),
+ "Status: " + p.Status,
+ "Project: " + p.Project,
+ "Remarks:",
+ strings.Join(bullets, "\n"),
+ }
+ if p.Status == "For OT" {
+ lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
+ }
+ return strings.Join(lines, "\n")
+}
+
+func formatDailyDate(iso string) string {
+ t, err := time.Parse("2006-01-02", iso)
+ if err != nil {
+ return iso
+ }
+ return t.Format("01/02/2006")
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/standalone/go.mod b/standalone/go.mod
new file mode 100644
index 0000000..dd13611
--- /dev/null
+++ b/standalone/go.mod
@@ -0,0 +1,9 @@
+module hpltoolbox
+
+go 1.22
+
+require (
+ github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 // indirect
+ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
+ golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e // indirect
+)
diff --git a/standalone/go.sum b/standalone/go.sum
new file mode 100644
index 0000000..3e70c30
--- /dev/null
+++ b/standalone/go.sum
@@ -0,0 +1,7 @@
+github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
+github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e h1:f5mksnk+hgXHnImpZoWj64ja99j9zV7YUgrVG95uFE4=
+golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/standalone/home.go b/standalone/home.go
new file mode 100644
index 0000000..bdf5d53
--- /dev/null
+++ b/standalone/home.go
@@ -0,0 +1,19 @@
+package main
+
+import "net/http"
+
+func HomePage(w http.ResponseWriter, r *http.Request) {
+ body := `
+HPL Toolbox
+Standalone build. Pick a tool from the top bar.
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(Page("/", "Home", body)))
+}
diff --git a/standalone/hpl.ico b/standalone/hpl.ico
new file mode 100644
index 0000000..3416da7
Binary files /dev/null and b/standalone/hpl.ico differ
diff --git a/standalone/layout.go b/standalone/layout.go
new file mode 100644
index 0000000..07fffe8
--- /dev/null
+++ b/standalone/layout.go
@@ -0,0 +1,73 @@
+package main
+
+import "strings"
+
+const SharedCSS = `
+ :root { color-scheme: dark; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #1e1e1e; color: #ddd;
+ margin: 0; padding: 0;
+ }
+ .topbar {
+ display: flex; align-items: center; gap: 8px;
+ padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
+ font-size: 13px;
+ }
+ .topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
+ .topbar a:hover { background: #2a2d2e; }
+ .topbar a.active { background: #094771; color: #fff; }
+ .content { padding: 16px; max-width: 980px; margin: 0 auto; }
+ h2 { margin-top: 0; font-weight: 500; }
+ input[type=text], input[type=date], select, textarea {
+ box-sizing: border-box; padding: 6px 8px;
+ background: #3c3c3c; color: #ddd;
+ border: 1px solid #3c3c3c; border-radius: 2px;
+ font-family: inherit; font-size: 13px;
+ }
+ input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus {
+ outline: 1px solid #007fd4; border-color: #007fd4;
+ }
+ button {
+ background: #0e639c; color: #fff;
+ border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px;
+ }
+ button:hover { background: #1177bb; }
+ button.secondary { background: #3a3d41; color: #ddd; }
+ button.secondary:hover { background: #45494e; }
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
+ .err { color: #f48771; white-space: pre-wrap; }
+ .ok { color: #89d185; }
+ .hint { font-size: 12px; opacity: 0.7; }
+`
+
+type navItem struct{ Path, Label string }
+
+var navItems = []navItem{
+ {"/", "Home"},
+ {"/plec", "PLEC Upload"},
+ {"/applovin", "AppLovin Upload"},
+ {"/base64", "Base64 Scanner"},
+ {"/daily", "Daily Update"},
+ {"/mobile", "Send To Mobile"},
+}
+
+func Page(activePath, title, body string) string {
+ var tabs strings.Builder
+ for _, n := range navItems {
+ cls := ""
+ if n.Path == activePath {
+ cls = " class=\"active\""
+ }
+ tabs.WriteString(`` + n.Label + ``)
+ }
+ return `
+
+
+` + title + ` — HPL Toolbox
+
+
+` + tabs.String() + `
+` + body + `
+`
+}
diff --git a/standalone/main.go b/standalone/main.go
new file mode 100644
index 0000000..e506e15
--- /dev/null
+++ b/standalone/main.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+
+ webview "github.com/jchv/go-webview2"
+)
+
+var currentHwnd atomic.Uintptr
+
+func setupFileLogging() {
+ logPath := filepath.Join(appDir(), "debug.log")
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ return
+ }
+ log.SetOutput(f)
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+}
+
+func main() {
+ setupFileLogging()
+ log.Printf("HPL Toolbox starting (pid=%d)", os.Getpid())
+
+ if focusExistingInstance() {
+ log.Printf("Another instance is running; focused it and exiting.")
+ return
+ }
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ log.Fatal(err)
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+ log.Printf("Server listening on 127.0.0.1:%d", port)
+
+ mux := buildMux()
+ srv := &http.Server{Handler: mux}
+ go func() {
+ if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Printf("Server error: %v", err)
+ }
+ }()
+
+ if err := writeLockFile(port); err != nil {
+ log.Printf("Failed to write lock file: %v", err)
+ }
+ defer removeLockFile()
+
+ w := webview.NewWithOptions(webview.WebViewOptions{
+ Debug: false,
+ AutoFocus: true,
+ WindowOptions: webview.WindowOptions{
+ Title: "HPL Toolbox",
+ Width: 1100,
+ Height: 720,
+ IconId: 1,
+ Center: true,
+ },
+ })
+ if w == nil {
+ log.Fatalln("Failed to create webview.")
+ }
+ defer w.Destroy()
+
+ // Stash HWND for the /api/focus handler.
+ currentHwnd.Store(uintptr(w.Window()))
+
+ w.SetSize(1100, 720, webview.HintNone)
+ w.Navigate(fmt.Sprintf("http://127.0.0.1:%d/", port))
+ w.Run()
+
+ // Window closed — shut the server down cleanly.
+ log.Printf("Window closed; shutting down server.")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+}
+
+func buildMux() *http.ServeMux {
+ mux := http.NewServeMux()
+
+ // Pages
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" && r.URL.Path != "/home" {
+ http.NotFound(w, r)
+ return
+ }
+ HomePage(w, r)
+ })
+ mux.HandleFunc("GET /plec", PlecPage)
+ mux.HandleFunc("GET /applovin", ApplovinPage)
+ mux.HandleFunc("GET /base64", Base64Page)
+ mux.HandleFunc("GET /daily", DailyPage)
+ mux.HandleFunc("GET /mobile", MobilePage)
+ mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
+
+ // Shared API
+ mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
+ mux.HandleFunc("POST /api/open", OpenEndpoint)
+ mux.HandleFunc("POST /api/focus", FocusEndpoint)
+
+ // Daily Update
+ mux.HandleFunc("POST /api/daily/submit", DailySubmit)
+
+ // PLEC
+ mux.HandleFunc("POST /api/plec/pick", PlecPick)
+ mux.HandleFunc("POST /api/plec/upload", PlecUpload)
+
+ // AppLovin
+ mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
+ mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
+
+ // Base64
+ mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)
+ mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
+ mux.HandleFunc("POST /api/base64/scan", Base64Scan)
+
+ // Send To Mobile
+ mux.HandleFunc("POST /api/mobile/pick", MobilePick)
+ mux.HandleFunc("POST /api/mobile/start", MobileStart)
+ mux.HandleFunc("POST /api/mobile/stop", MobileStop)
+
+ return mux
+}
+
+// FocusEndpoint brings the webview window to the foreground. Used by a second
+// instance that wants to focus the running one before exiting.
+func FocusEndpoint(w http.ResponseWriter, r *http.Request) {
+ hwnd := currentHwnd.Load()
+ if hwnd != 0 {
+ restoreAndFocus(hwnd)
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ procShowWindow = user32.NewProc("ShowWindow")
+ procSetForeground = user32.NewProc("SetForegroundWindow")
+ procIsIconic = user32.NewProc("IsIconic")
+)
+
+const (
+ swRestore = 9
+ swShow = 5
+)
+
+func restoreAndFocus(hwnd uintptr) {
+ // If minimized, restore. Otherwise just ensure it's shown.
+ iconic, _, _ := procIsIconic.Call(hwnd)
+ if iconic != 0 {
+ _, _, _ = procShowWindow.Call(hwnd, swRestore)
+ } else {
+ _, _, _ = procShowWindow.Call(hwnd, swShow)
+ }
+ _, _, _ = procSetForeground.Call(hwnd)
+}
+
+// Silence "imported and not used" complaints if any.
+var _ = unsafe.Sizeof(int(0))
diff --git a/standalone/mobile.go b/standalone/mobile.go
new file mode 100644
index 0000000..f806cd6
--- /dev/null
+++ b/standalone/mobile.go
@@ -0,0 +1,390 @@
+package main
+
+import (
+ "context"
+ _ "embed"
+ "encoding/base64"
+ "encoding/json"
+ "crypto/rand"
+ "fmt"
+ "html"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+//go:embed assets/qrcode.min.js
+var qrcodeJS []byte
+
+type activeShare struct {
+ server *http.Server
+ listener net.Listener
+ port int
+ token string
+ fileBuf []byte
+ filename string
+}
+
+var (
+ activeMu sync.Mutex
+ active *activeShare
+)
+
+func stopActiveShare() {
+ activeMu.Lock()
+ defer activeMu.Unlock()
+ if active != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = active.server.Shutdown(ctx)
+ active = nil
+ }
+}
+
+func MobileQrScript(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ _, _ = w.Write(qrcodeJS)
+}
+
+func MobilePage(w http.ResponseWriter, r *http.Request) {
+ body := `
+Send To Mobile
+
+ Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
+ The phone gets a choice of View or Download.
+
+
+
+
+
+ (no file)
+
+
+
+
+
+
+
+
+
+
+
Multiple network interfaces found. Pick the one your phone can reach:
+
+
+
+
+
+
+
+
+
+
+
+ First run on Windows may show a firewall prompt — allow access for "Private networks".
+
+
+
+
+
+
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(Page("/mobile", "Send To Mobile", body)))
+}
+
+func MobilePick(w http.ResponseWriter, r *http.Request) {
+ picked := PickFiles(
+ "Select HTML",
+ []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
+ false, "",
+ )
+ if len(picked) == 0 {
+ writeJSON(w, map[string]any{"path": nil})
+ return
+ }
+ writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
+}
+
+func MobileStart(w http.ResponseWriter, r *http.Request) {
+ stopActiveShare()
+
+ var req struct {
+ Path string `json:"path"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if req.Path == "" || !fileExists(req.Path) {
+ writeJSON(w, map[string]any{"error": "File missing."})
+ return
+ }
+ fileBuf, err := os.ReadFile(req.Path)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
+ return
+ }
+ filename := filepath.Base(req.Path)
+
+ // 6 random bytes -> base64url, no padding
+ tokenBytes := make([]byte, 6)
+ if _, err := rand.Read(tokenBytes); err != nil {
+ writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
+ return
+ }
+ token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
+
+ cfgPort := LoadConfig().SendToMobile.Port
+ listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
+ return
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+
+ share := &activeShare{
+ listener: listener,
+ port: port,
+ token: token,
+ fileBuf: fileBuf,
+ filename: filename,
+ }
+ share.server = &http.Server{Handler: shareHandler(share)}
+
+ go func() { _ = share.server.Serve(listener) }()
+
+ ips := getLanIPs()
+ if len(ips) == 0 {
+ _ = share.server.Close()
+ writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
+ return
+ }
+
+ activeMu.Lock()
+ active = share
+ activeMu.Unlock()
+
+ type urlInfo struct {
+ IP string `json:"ip"`
+ Iface string `json:"iface"`
+ URL string `json:"url"`
+ }
+ urls := make([]urlInfo, 0, len(ips))
+ for _, ip := range ips {
+ urls = append(urls, urlInfo{
+ IP: ip.address,
+ Iface: ip.iface,
+ URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
+ })
+ }
+ writeJSON(w, map[string]any{"urls": urls, "filename": filename})
+}
+
+func MobileStop(w http.ResponseWriter, r *http.Request) {
+ stopActiveShare()
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func shareHandler(s *activeShare) http.Handler {
+ prefix := "/s/" + s.token
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, prefix) {
+ http.NotFound(w, r)
+ return
+ }
+ rest := strings.TrimPrefix(r.URL.Path, prefix)
+ switch rest {
+ case "", "/":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write([]byte(chooserPage(s.filename)))
+ case "/view":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write(s.fileBuf)
+ case "/file":
+ safeName := safeFilename(s.filename)
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
+ w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write(s.fileBuf)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+}
+
+var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
+
+func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
+
+func chooserPage(filename string) string {
+ escName := html.EscapeString(filename)
+ return `
+
+
+
+Send To Mobile
+
+
+ HPL Toolbox
+ ` + escName + `
+ View in browser
+ Download .html
+`
+}
+
+type lanIP struct {
+ address string
+ iface string
+}
+
+func getLanIPs() []lanIP {
+ var out []lanIP
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return out
+ }
+ for _, iface := range ifaces {
+ if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ addrs, err := iface.Addrs()
+ if err != nil {
+ continue
+ }
+ for _, a := range addrs {
+ ipNet, ok := a.(*net.IPNet)
+ if !ok {
+ continue
+ }
+ ip4 := ipNet.IP.To4()
+ if ip4 == nil || ip4.IsLoopback() {
+ continue
+ }
+ out = append(out, lanIP{address: ip4.String(), iface: iface.Name})
+ }
+ }
+ return out
+}
diff --git a/standalone/platform.go b/standalone/platform.go
new file mode 100644
index 0000000..e51c6a6
--- /dev/null
+++ b/standalone/platform.go
@@ -0,0 +1,177 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+type PlecConfig struct {
+ UploadUrl string `json:"uploadUrl"`
+ OriginUrl string `json:"originUrl"`
+ PreviewBase string `json:"previewBase"`
+ SkipExistsCheck bool `json:"skipExistsCheck"`
+}
+
+type ApplovinConfig struct {
+ Cookie string `json:"cookie"`
+}
+
+type SendToMobileConfig struct {
+ Port int `json:"port"`
+}
+
+type DailyUpdateConfig struct {
+ LastProject string `json:"lastProject"`
+}
+
+type AppConfig struct {
+ Plec PlecConfig `json:"plec"`
+ Applovin ApplovinConfig `json:"applovin"`
+ SendToMobile SendToMobileConfig `json:"sendToMobile"`
+ DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
+}
+
+var configMu sync.Mutex
+
+func defaultConfig() AppConfig {
+ return AppConfig{
+ Plec: PlecConfig{
+ UploadUrl: "http://167.99.227.249/src/upload_HTML_Experimental.php",
+ OriginUrl: "http://167.99.227.249",
+ PreviewBase: "https://playable.applovindemo.com/phaser/",
+ SkipExistsCheck: false,
+ },
+ Applovin: ApplovinConfig{Cookie: ""},
+ SendToMobile: SendToMobileConfig{Port: 0},
+ DailyUpdate: DailyUpdateConfig{LastProject: ""},
+ }
+}
+
+func appDir() string {
+ exe, err := os.Executable()
+ if err != nil {
+ wd, _ := os.Getwd()
+ return wd
+ }
+ // `go run` puts the exe in a temp dir; fall back to cwd in that case.
+ resolved, err := filepath.EvalSymlinks(exe)
+ if err == nil {
+ exe = resolved
+ }
+ dir := filepath.Dir(exe)
+ if strings.Contains(strings.ToLower(dir), "go-build") {
+ wd, _ := os.Getwd()
+ return wd
+ }
+ return dir
+}
+
+func configPath() string {
+ return filepath.Join(appDir(), "config.json")
+}
+
+func LoadConfig() AppConfig {
+ configMu.Lock()
+ defer configMu.Unlock()
+ cfg := defaultConfig()
+ data, err := os.ReadFile(configPath())
+ if err != nil {
+ return cfg
+ }
+ _ = json.Unmarshal(data, &cfg)
+ return cfg
+}
+
+func SaveConfig(cfg AppConfig) error {
+ configMu.Lock()
+ defer configMu.Unlock()
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(configPath(), data, 0644)
+}
+
+func OpenInBrowser(url string) {
+ // `start` is a cmd builtin. Quoted empty arg is the window title.
+ cmd := exec.Command("cmd", "/c", "start", "", url)
+ _ = cmd.Start()
+}
+
+func CopyToClipboard(text string) error {
+ cmd := exec.Command("clip")
+ cmd.Stdin = strings.NewReader(text)
+ return cmd.Run()
+}
+
+type FileFilter struct {
+ Name string
+ Extensions []string
+}
+
+func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
+
+func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
+ filterStr := "All files|*.*"
+ if len(filters) > 0 {
+ parts := make([]string, 0, len(filters))
+ for _, f := range filters {
+ exts := make([]string, 0, len(f.Extensions))
+ for _, e := range f.Extensions {
+ exts = append(exts, "*."+e)
+ }
+ parts = append(parts, fmt.Sprintf("%s|%s", f.Name, strings.Join(exts, ";")))
+ }
+ filterStr = strings.Join(parts, "|")
+ }
+ multiStr := "false"
+ if multi {
+ multiStr = "true"
+ }
+ script := fmt.Sprintf(`
+Add-Type -AssemblyName System.Windows.Forms
+$dlg = New-Object System.Windows.Forms.OpenFileDialog
+$dlg.Title = '%s'
+$dlg.Filter = '%s'
+$dlg.Multiselect = $%s
+if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
+if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
+ $dlg.FileNames | ForEach-Object { Write-Output $_ }
+}
+`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
+ out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
+ if err != nil {
+ return nil
+ }
+ lines := strings.Split(string(out), "\n")
+ result := make([]string, 0, len(lines))
+ for _, l := range lines {
+ t := strings.TrimSpace(l)
+ if t != "" {
+ result = append(result, t)
+ }
+ }
+ return result
+}
+
+func PickFolder(title, initialDir string) string {
+ script := fmt.Sprintf(`
+Add-Type -AssemblyName System.Windows.Forms
+$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
+$dlg.Description = '%s'
+if ('%s'.Length -gt 0) { $dlg.SelectedPath = '%s' }
+if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
+ Write-Output $dlg.SelectedPath
+}
+`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
+ out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
diff --git a/standalone/plec.go b/standalone/plec.go
new file mode 100644
index 0000000..05f3917
--- /dev/null
+++ b/standalone/plec.go
@@ -0,0 +1,376 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ urlpkg "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+func PlecPage(w http.ResponseWriter, r *http.Request) {
+ body := `
+PLEC Upload
+Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.
+
+
+
+ | HTML File |
+ Iteration Name |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(Page("/plec", "PLEC Upload", body)))
+}
+
+func PlecPick(w http.ResponseWriter, r *http.Request) {
+ picked := PickFiles(
+ "Select HTML",
+ []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
+ false, "",
+ )
+ if len(picked) == 0 {
+ writeJSON(w, map[string]any{"path": nil})
+ return
+ }
+ writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
+}
+
+type plecRow struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Iteration string `json:"iteration"`
+}
+
+func PlecUpload(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Rows []plecRow `json:"rows"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ cfg := LoadConfig().Plec
+
+ type rowErr struct {
+ ID string `json:"id"`
+ Message string `json:"message"`
+ }
+ var rowErrors []rowErr
+ var valid []plecRow
+ htmlRx := regexp.MustCompile(`(?i)\.html?$`)
+
+ for _, row := range req.Rows {
+ if row.Path == "" || !fileExists(row.Path) {
+ rowErrors = append(rowErrors, rowErr{row.ID, "File missing"})
+ } else if strings.TrimSpace(row.Iteration) == "" {
+ rowErrors = append(rowErrors, rowErr{row.ID, "Iteration name required"})
+ } else if !htmlRx.MatchString(row.Path) {
+ rowErrors = append(rowErrors, rowErr{row.ID, "Must be .html"})
+ } else {
+ valid = append(valid, row)
+ }
+ }
+ if len(rowErrors) > 0 {
+ writeJSON(w, map[string]any{"rowErrors": rowErrors})
+ return
+ }
+ if len(valid) == 0 {
+ writeJSON(w, map[string]any{"error": "Nothing to upload."})
+ return
+ }
+
+ stamp := time.Now().Format("20060102150405")
+
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ for i, row := range valid {
+ idx := i + 1
+ data, err := os.ReadFile(row.Path)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
+ return
+ }
+ stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
+ fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if _, err := fw.Write(data); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
+ // Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
+ iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
+ if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ }
+ if err := mw.Close(); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+
+ httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ httpReq.Header.Set("Content-Type", mw.FormDataContentType())
+ httpReq.Header.Set("Origin", cfg.OriginUrl)
+ httpReq.Header.Set("Referer", cfg.OriginUrl+"/")
+ httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
+ httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
+ httpReq.Header.Set("Accept", "*/*")
+
+ resp, err := http.DefaultClient.Do(httpReq)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
+ return
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
+ return
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(respBody, &parsed); err != nil {
+ writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
+ return
+ }
+ var preview any
+ if v, ok := parsed["preview"]; ok && v != nil {
+ preview = v
+ } else if v, ok := parsed["previewURL"]; ok {
+ preview = v
+ }
+ writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
+}
+
+func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Text string `json:"text"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ if err := CopyToClipboard(req.Text); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func OpenEndpoint(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ URL string `json:"url"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ OpenInBrowser(req.URL)
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func fileExists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
+
+func appendTimestamp(filename, stamp string) string {
+ ext := filepath.Ext(filename)
+ base := filename
+ if ext != "" {
+ base = filename[:len(filename)-len(ext)]
+ }
+ return base + "_" + stamp + ext
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n]
+}
+
+// createFormFileWithType is like multipart.Writer.CreateFormFile but lets us
+// override the Content-Type (CreateFormFile hardcodes "application/octet-stream").
+func createFormFileWithType(mw *multipart.Writer, field, filename, contentType string) (io.Writer, error) {
+ h := make(map[string][]string)
+ h["Content-Disposition"] = []string{
+ fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename),
+ }
+ h["Content-Type"] = []string{contentType}
+ return mw.CreatePart(h)
+}
+
+// encodeURICompatible mimics JS encodeURI() — keeps A-Za-z0-9 and these
+// reserved/mark chars: ;,/?:@&=+$-_.!~*'()#
+func encodeURICompatible(s string) string {
+ keep := func(c byte) bool {
+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
+ return true
+ }
+ switch c {
+ case ';', ',', '/', '?', ':', '@', '&', '=', '+', '$',
+ '-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
+ return true
+ }
+ return false
+ }
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if keep(c) {
+ b.WriteByte(c)
+ } else {
+ b.WriteString(fmt.Sprintf("%%%02X", c))
+ }
+ }
+ return b.String()
+}
diff --git a/standalone/rsrc.syso b/standalone/rsrc.syso
new file mode 100644
index 0000000..9633fbd
Binary files /dev/null and b/standalone/rsrc.syso differ
diff --git a/standalone/single_instance.go b/standalone/single_instance.go
new file mode 100644
index 0000000..24107c4
--- /dev/null
+++ b/standalone/single_instance.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func lockFilePath() string {
+ return filepath.Join(appDir(), ".hpltoolbox.lock")
+}
+
+// focusExistingInstance returns true if another instance was found and
+// successfully asked to focus its window. In that case the caller should exit.
+// Returns false if no live instance exists (and clears any stale lock).
+func focusExistingInstance() bool {
+ data, err := os.ReadFile(lockFilePath())
+ if err != nil {
+ return false
+ }
+ parts := strings.Split(strings.TrimSpace(string(data)), "\n")
+ if len(parts) != 2 {
+ _ = os.Remove(lockFilePath())
+ return false
+ }
+ pid, errPid := strconv.Atoi(strings.TrimSpace(parts[0]))
+ port, errPort := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if errPid != nil || errPort != nil {
+ _ = os.Remove(lockFilePath())
+ return false
+ }
+
+ // Quick liveness check: PID must exist.
+ if proc, err := os.FindProcess(pid); err != nil || proc == nil {
+ _ = os.Remove(lockFilePath())
+ return false
+ }
+
+ // Ask the running instance to focus its window.
+ client := &http.Client{Timeout: 1500 * time.Millisecond}
+ url := fmt.Sprintf("http://127.0.0.1:%d/api/focus", port)
+ resp, err := client.Post(url, "application/json", bytes.NewReader(nil))
+ if err != nil {
+ // Lock file is stale (process died but didn't clean up).
+ _ = os.Remove(lockFilePath())
+ return false
+ }
+ _ = resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ _ = os.Remove(lockFilePath())
+ return false
+ }
+ return true
+}
+
+func writeLockFile(port int) error {
+ content := fmt.Sprintf("%d\n%d\n", os.Getpid(), port)
+ return os.WriteFile(lockFilePath(), []byte(content), 0644)
+}
+
+func removeLockFile() {
+ _ = os.Remove(lockFilePath())
+}