Add standalone Go build alongside VSCode extension

Ports all five tools to a single 7.5 MB Windows exe with an embedded
WebView2 window, file-based config, single-instance focus, and clean
shutdown. Adds build.ps1 to build both targets from one command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 01:15:39 +08:00
parent 859c7d9e7c
commit f123cbfc4a
17 changed files with 2156 additions and 0 deletions

132
build.ps1 Normal file
View File

@@ -0,0 +1,132 @@
<#
.SYNOPSIS
Builds both the VSCode extension and the standalone Windows exe.
.DESCRIPTION
- Extension: tsc compile -> out/extension.js
- Standalone: go build -> standalone/hpl-toolbox.exe (single static binary)
Optionally packages the extension as a .vsix with -Package.
.EXAMPLE
.\build.ps1
.\build.ps1 -Package
.\build.ps1 -Standalone # Skip the extension; build only the exe
.\build.ps1 -Extension # Skip the standalone; build only the extension
#>
[CmdletBinding()]
param(
[switch]$Package,
[switch]$Extension,
[switch]$Standalone
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $repoRoot
# If neither was specified, build both.
if (-not $Extension -and -not $Standalone) {
$Extension = $true
$Standalone = $true
}
function Write-Step($msg) {
Write-Host ""
Write-Host "==> $msg" -ForegroundColor Cyan
}
function Get-FileSize($path) {
if (-not (Test-Path $path)) { return $null }
$bytes = (Get-Item $path).Length
if ($bytes -ge 1MB) { return "{0:N2} MB" -f ($bytes / 1MB) }
if ($bytes -ge 1KB) { return "{0:N1} KB" -f ($bytes / 1KB) }
return "$bytes B"
}
# Ensure Go is on PATH (winget install doesn't update current session)
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
$goBin = "C:\Program Files\Go\bin"
if (Test-Path "$goBin\go.exe") {
$env:Path = "$goBin;$env:Path"
} else {
Write-Error "go not found on PATH. Install Go from https://go.dev/dl/"
exit 1
}
}
$summary = @()
# ----------------------------------------------------------------------
# VSCode extension
# ----------------------------------------------------------------------
if ($Extension) {
Write-Step "Building VSCode extension (tsc)"
if (-not (Test-Path "node_modules")) {
Write-Host " node_modules missing - running npm install" -ForegroundColor Yellow
npm install
if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed"; exit 1 }
}
npm run compile
if ($LASTEXITCODE -ne 0) { Write-Error "tsc compile failed"; exit 1 }
$extOut = Join-Path $repoRoot "out\extension.js"
$summary += [pscustomobject]@{
Artifact = "Extension (out/extension.js)"
Size = Get-FileSize $extOut
}
if ($Package) {
Write-Step "Packaging .vsix"
if (-not (Get-Command vsce -ErrorAction SilentlyContinue)) {
Write-Host " vsce not found - installing via npx (one-time)" -ForegroundColor Yellow
npx --yes @vscode/vsce package
} else {
vsce package
}
if ($LASTEXITCODE -ne 0) { Write-Error "vsce package failed"; exit 1 }
$vsix = Get-ChildItem -Path $repoRoot -Filter "*.vsix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($vsix) {
$summary += [pscustomobject]@{
Artifact = "Extension ($($vsix.Name))"
Size = Get-FileSize $vsix.FullName
}
}
}
}
# ----------------------------------------------------------------------
# Standalone Windows exe
# ----------------------------------------------------------------------
if ($Standalone) {
Write-Step "Building standalone exe (go build)"
Push-Location (Join-Path $repoRoot "standalone")
try {
# If rsrc.syso is missing (e.g. fresh clone before icon embedding), regenerate.
if (-not (Test-Path "rsrc.syso") -and (Test-Path "hpl.ico")) {
if (Get-Command rsrc -ErrorAction SilentlyContinue) {
Write-Host " Regenerating rsrc.syso" -ForegroundColor Yellow
rsrc -ico hpl.ico -o rsrc.syso
} else {
Write-Host " rsrc tool missing - exe will build without embedded icon" -ForegroundColor Yellow
Write-Host " (install with: go install github.com/akavel/rsrc@latest)" -ForegroundColor Yellow
}
}
go build -ldflags="-s -w -H windowsgui" -trimpath -o hpl-toolbox.exe .
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
$exePath = Join-Path (Get-Location) "hpl-toolbox.exe"
$summary += [pscustomobject]@{
Artifact = "Standalone (standalone/hpl-toolbox.exe)"
Size = Get-FileSize $exePath
}
} finally {
Pop-Location
}
}
# ----------------------------------------------------------------------
# Summary
# ----------------------------------------------------------------------
Write-Step "Build complete"
$summary | Format-Table -AutoSize | Out-Host

4
standalone/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
hpl-toolbox.exe
config.json
.hpltoolbox.lock
debug.log

302
standalone/applovin.go Normal file
View File

@@ -0,0 +1,302 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"regexp"
)
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>AppLovin Playable Preview (QR)</h2>
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<button id="pick" class="secondary">Choose files...</button>
<span id="fileName" class="hint">(no files)</span>
</div>
<div style="display:flex;gap:8px;">
<button id="upload">Upload to AppLovin</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
<style>
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
</style>
<script>
const pickBtn = document.getElementById('pick');
const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
let selectedPaths = [];
pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pick', { method: 'POST' });
const j = await r.json();
if (j.files && j.files.length) {
selectedPaths = j.files.map(f => f.path);
const names = j.files.map(f => f.name);
fileNameEl.textContent = names.length === 1
? names[0]
: names.length + ' files: ' + names.slice(0,3).join(', ') + (names.length > 3 ? '...' : '');
}
});
uploadBtn.addEventListener('click', async () => {
statusEl.textContent = '';
resultsEl.innerHTML = '';
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
uploadBtn.disabled = true;
try {
const res = await fetch('/api/applovin/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: selectedPaths }),
});
if (!res.ok || !res.body) {
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
uploadBtn.disabled = false;
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
if (!line.trim()) continue;
handle(JSON.parse(line));
}
}
} catch (e) {
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
} finally {
uploadBtn.disabled = false;
}
});
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function handle(m) {
if (m.type === 'status') {
statusEl.textContent = m.message;
} else if (m.type === 'fileError') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
resultsEl.appendChild(wrap);
} else if (m.type === 'fileResult') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML =
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
'<div style="text-align:center;margin-bottom:8px;">' +
'<img src="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'</div>' +
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copyHash = document.createElement('button');
copyHash.textContent = 'Copy Hash';
copyHash.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.hash})});
const copyUrl = document.createElement('button');
copyUrl.textContent = 'Copy URL';
copyUrl.className = 'secondary';
copyUrl.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.previewUrl})});
const openBtn = document.createElement('button');
openBtn.textContent = 'Open';
openBtn.className = 'secondary';
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
actions.appendChild(copyHash); actions.appendChild(copyUrl); actions.appendChild(openBtn);
wrap.appendChild(actions);
resultsEl.appendChild(wrap);
} else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
}
}
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
}
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
picked := PickFiles(
"Select HTML files",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, "",
)
files := make([]map[string]string, 0, len(picked))
for _, p := range picked {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
Paths []string `json:"paths"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cookie := LoadConfig().Applovin.Cookie
w.Header().Set("Content-Type", "application/x-ndjson")
flusher, _ := w.(http.Flusher)
send := func(obj any) {
data, _ := json.Marshal(obj)
_, _ = w.Write(append(data, '\n'))
if flusher != nil {
flusher.Flush()
}
}
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
buildForm := func(filePath string) (*bytes.Buffer, string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, "", err
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := createFormFileWithType(mw, "file", filepath.Base(filePath), "text/html")
if err != nil {
return nil, "", err
}
if _, err := fw.Write(data); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return &buf, mw.FormDataContentType(), nil
}
doPost := func(url string, body *bytes.Buffer, contentType string) (int, string, error) {
req, err := http.NewRequest("POST", url, body)
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
req.Header.Set("Origin", "https://p.applov.in")
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
if cookie != "" {
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(out), nil
}
for i, filePath := range req.Paths {
fileName := filepath.Base(filePath)
idx := i + 1
fileErr := func(msg string) {
send(map[string]any{"type": "fileError", "name": fileName, "message": msg})
}
if filePath == "" || !fileExists(filePath) {
fileErr("File missing")
continue
}
if !htmlRx.MatchString(filePath) {
fileErr("Must be .html")
continue
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
body, ct, err := buildForm(filePath)
if err != nil {
fileErr("Read failed: " + err.Error())
continue
}
status, respText, err := doPost("https://p.applov.in/validateHTMLContent", body, ct)
if err != nil {
fileErr("Network (validate): " + err.Error())
continue
}
if status < 200 || status >= 300 {
fileErr(fmt.Sprintf("Validate HTTP %d: %s", status, truncate(respText, 300)))
continue
}
var vJSON map[string]any
_ = json.Unmarshal([]byte(respText), &vJSON)
if code, ok := vJSON["code"]; ok {
if codeNum, isNum := code.(float64); isNum && codeNum != 200 {
fileErr("Validation failed: " + truncate(respText, 300))
continue
}
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)})
body, ct, err = buildForm(filePath)
if err != nil {
fileErr("Read failed: " + err.Error())
continue
}
status, respText, err = doPost("https://p.applov.in/getCachedAdURL", body, ct)
if err != nil {
fileErr("Network (cache): " + err.Error())
continue
}
if status < 200 || status >= 300 {
fileErr(fmt.Sprintf("Cache HTTP %d: %s", status, truncate(respText, 300)))
continue
}
var cJSON map[string]any
if err := json.Unmarshal([]byte(respText), &cJSON); err != nil {
fileErr("Non-JSON: " + truncate(respText, 300))
continue
}
hash, _ := cJSON["results"].(string)
if hash == "" {
fileErr("No hash in response: " + truncate(respText, 300))
continue
}
send(map[string]any{
"type": "fileResult",
"name": fileName,
"hash": hash,
"previewUrl": "https://p.applov.in/getPreviewHTML?preview=" + hash,
"pageUrl": "https://p.applov.in/playablePreview?preview=" + hash + "&qr=1",
"qrImg": "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=" + urlpkg.QueryEscape(hash),
})
}
send(map[string]any{"type": "done"})
}

8
standalone/assets/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

289
standalone/base64.go Normal file
View File

@@ -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 := `
<h2>Base64 Asset Scanner</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
<button id="pickFolder">Browse Folder...</button>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button>
</div>
<div id="selection" class="hint"></div>
<div id="status" style="margin-top:8px;"></div>
<div id="results" style="margin-top:8px;"></div>
<style>
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
.mark { width:14px; flex-shrink:0; font-weight:bold; }
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
.file-name { word-break:break-all; }
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
.summary { opacity:0.8; margin-bottom:6px; }
</style>
<script>
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection');
let pickedFiles = [];
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
folderEl.addEventListener('input', clearFiles);
document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); }
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
const j = await r.json();
if (j.paths && j.paths.length) {
pickedFiles = j.paths;
folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
}
});
document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
let payload;
if (pickedFiles.length) payload = { files: pickedFiles };
else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
payload = { folder };
}
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
resultsEl.innerHTML = '';
for (const rr of j.results) {
const row = document.createElement('div');
row.className = 'row-result';
const mark = document.createElement('span');
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
mark.textContent = rr.ok ? '✓' : '✗';
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = rr.file;
row.appendChild(mark); row.appendChild(name);
if (!rr.ok) {
const a = document.createElement('span');
a.className = 'assets';
a.textContent = '— ' + rr.assets.join(', ');
row.appendChild(a);
}
resultsEl.appendChild(row);
}
});
</script>
`
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)<script\b[^>]*>.*?</script>`)
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
}

131
standalone/daily.go Normal file
View File

@@ -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 := `
<h2>Daily Update</h2>
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
<div style="max-width:520px;">
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
<input type="date" id="date" value="` + today + `" style="width:100%;" />
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
<select id="status" style="width:100%;">
<option>Started</option><option>Progress</option><option>Finished</option>
<option>Client Feedback</option><option>For OT</option>
</select>
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
<button id="submit" style="margin-top:16px;">Submit</button>
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
</div>
<script>
document.getElementById('submit').addEventListener('click', async () => {
const payload = {
date: document.getElementById('date').value,
status: document.getElementById('status').value,
project: document.getElementById('project').value.trim(),
remarks: document.getElementById('remarks').value.trim(),
};
const statusEl = document.getElementById('status-msg');
statusEl.textContent = 'Copying...';
try {
const res = await fetch('/api/daily/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const j = await res.json();
if (j.ok) {
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
} else {
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
}
} catch (e) {
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
}
});
</script>
`
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)
}

9
standalone/go.mod Normal file
View File

@@ -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
)

7
standalone/go.sum Normal file
View File

@@ -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=

19
standalone/home.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import "net/http"
func HomePage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>HPL Toolbox</h2>
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
<ul style="line-height:1.9;">
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose &amp; copy a daily status</li>
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
</ul>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/", "Home", body)))
}

BIN
standalone/hpl.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

73
standalone/layout.go Normal file
View File

@@ -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(`<a href="` + n.Path + `"` + cls + `>` + n.Label + `</a>`)
}
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
</head><body>
<div class="topbar">` + tabs.String() + `</div>
<div class="content">` + body + `</div>
</body></html>`
}

171
standalone/main.go Normal file
View File

@@ -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))

390
standalone/mobile.go Normal file
View File

@@ -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 := `
<h2>Send To Mobile</h2>
<p class="hint" style="margin-top:0;">
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
</p>
<div style="margin-bottom:14px;">
<div style="display:flex;align-items:center;gap:8px;">
<button id="pick" class="secondary">Choose...</button>
<span id="fileName" class="hint">(no file)</span>
</div>
<input type="hidden" id="filePath" />
</div>
<div style="margin-bottom:14px;">
<button id="start">Start sharing</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
</div>
<div id="shareInfo" style="display:none;">
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
</div>
<div class="hint" style="margin-top:8px;">
First run on Windows may show a firewall prompt — allow access for "Private networks".
</div>
</div>
<div id="status" style="margin-top:12px;min-height:18px;"></div>
<script src="/assets/qrcode.min.js"></script>
<script>
const pickBtn = document.getElementById('pick');
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath');
const statusEl = document.getElementById('status');
const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
let currentUrls = [];
let selectedIdx = 0;
pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pick', { method:'POST' });
const j = await r.json();
if (j.path) {
filePathEl.value = j.path;
fileNameEl.textContent = j.name;
}
});
startBtn.addEventListener('click', async () => {
statusEl.innerHTML = '';
if (!filePathEl.value) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
return;
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ path: filePathEl.value }),
});
const j = await r.json();
if (j.error) {
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
startBtn.disabled = false;
return;
}
currentUrls = j.urls;
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
shareInfo.style.display = '';
renderIfaces(j.urls);
renderQr(j.urls[0].url);
stopBtn.disabled = false;
});
stopBtn.addEventListener('click', async () => {
await fetch('/api/mobile/stop', { method:'POST' });
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
currentUrls = [];
startBtn.disabled = false;
stopBtn.disabled = true;
});
document.getElementById('copyUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
});
document.getElementById('openUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
});
function renderQr(url) {
qrEl.innerHTML = '';
urlTextEl.textContent = url;
try {
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
qrEl.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 2, scalable: true });
const svg = qrEl.querySelector('svg');
if (svg) { svg.setAttribute('width','240'); svg.setAttribute('height','240'); }
} catch (e) {
qrEl.textContent = 'QR render failed: ' + e.message;
}
}
function renderIfaces(urls) {
ifaceList.innerHTML = '';
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
ifaceWrap.style.display = '';
urls.forEach((u, i) => {
const id = 'iface_' + i;
const label = document.createElement('label');
label.style.fontSize = '12px';
label.style.cursor = 'pointer';
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
(i === selectedIdx ? ' checked' : '') + ' /> ' +
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
label.querySelector('input').addEventListener('change', () => {
selectedIdx = i;
renderQr(urls[i].url);
});
ifaceList.appendChild(label);
});
}
</script>
`
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 `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Send To Mobile</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; }
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
</style></head>
<body>
<h1>HPL Toolbox</h1>
<div class="file">` + escName + `</div>
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
</body></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
}

177
standalone/platform.go Normal file
View File

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

376
standalone/plec.go Normal file
View File

@@ -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 := `
<h2>PLEC Upload</h2>
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
<thead><tr>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
<th></th>
</tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:8px;">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results"></div>
<style>
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
.file-cell { display:flex; align-items:center; gap:8px; }
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
.result a { color:#9cdcfe; }
.result-actions { margin-top:8px; display:flex; gap:8px; }
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
</style>
<script>
let nextId = 1;
const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
function addRow() {
const id = 'r' + (nextId++);
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = ` + "`" + `
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
` + "`" + `;
tr.querySelector('.pick').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
if (j.path) {
tr.querySelector('[data-path]').value = j.path;
tr.querySelector('[data-name]').textContent = j.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = j.name.replace(/\.html?$/i, '');
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
if (!tbody.children.length) addRow();
});
tbody.appendChild(tr);
}
document.getElementById('addRow').addEventListener('click', addRow);
document.getElementById('upload').addEventListener('click', async () => {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
statusEl.textContent = '';
resultsEl.innerHTML = '';
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
return;
}
statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
const j = await res.json();
if (j.rowErrors) {
for (const re of j.rowErrors) {
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
if (tr) tr.querySelector('[data-error]').textContent = re.message;
}
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return;
}
if (j.error) {
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
return;
}
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div');
wrap.className = 'result';
if (j.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = j.rawText;
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
});
addRow();
</script>
`
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()
}

BIN
standalone/rsrc.syso Normal file

Binary file not shown.

View File

@@ -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())
}