From f123cbfc4a50b1129a531737d8dd1b7658ecc3e7 Mon Sep 17 00:00:00 2001 From: hesukastro Date: Tue, 12 May 2026 01:15:39 +0800 Subject: [PATCH] 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) --- build.ps1 | 132 +++++++++++ standalone/.gitignore | 4 + standalone/applovin.go | 302 +++++++++++++++++++++++++ standalone/assets/qrcode.min.js | 8 + standalone/base64.go | 289 +++++++++++++++++++++++ standalone/daily.go | 131 +++++++++++ standalone/go.mod | 9 + standalone/go.sum | 7 + standalone/home.go | 19 ++ standalone/hpl.ico | Bin 0 -> 41086 bytes standalone/layout.go | 73 ++++++ standalone/main.go | 171 ++++++++++++++ standalone/mobile.go | 390 ++++++++++++++++++++++++++++++++ standalone/platform.go | 177 +++++++++++++++ standalone/plec.go | 376 ++++++++++++++++++++++++++++++ standalone/rsrc.syso | Bin 0 -> 41350 bytes standalone/single_instance.go | 68 ++++++ 17 files changed, 2156 insertions(+) create mode 100644 build.ps1 create mode 100644 standalone/.gitignore create mode 100644 standalone/applovin.go create mode 100644 standalone/assets/qrcode.min.js create mode 100644 standalone/base64.go create mode 100644 standalone/daily.go create mode 100644 standalone/go.mod create mode 100644 standalone/go.sum create mode 100644 standalone/home.go create mode 100644 standalone/hpl.ico create mode 100644 standalone/layout.go create mode 100644 standalone/main.go create mode 100644 standalone/mobile.go create mode 100644 standalone/platform.go create mode 100644 standalone/plec.go create mode 100644 standalone/rsrc.syso create mode 100644 standalone/single_instance.go diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..1f50c84 --- /dev/null +++ b/build.ps1 @@ -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 diff --git a/standalone/.gitignore b/standalone/.gitignore new file mode 100644 index 0000000..d0b9509 --- /dev/null +++ b/standalone/.gitignore @@ -0,0 +1,4 @@ +hpl-toolbox.exe +config.json +.hpltoolbox.lock +debug.log diff --git a/standalone/applovin.go b/standalone/applovin.go new file mode 100644 index 0000000..c327cc8 --- /dev/null +++ b/standalone/applovin.go @@ -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 := ` +

AppLovin Playable Preview (QR)

+

Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.

+ +
+ + (no files) +
+
+ +
+ +
+
+ + + + +` + 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"}) +} diff --git a/standalone/assets/qrcode.min.js b/standalone/assets/qrcode.min.js new file mode 100644 index 0000000..c1215c7 --- /dev/null +++ b/standalone/assets/qrcode.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/qrcode-generator@1.4.4/qrcode.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u=0?h.getAt(s):0}}var v=0;for(g=0;gn)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+="
"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+=''+y(n.text)+"":"",l+=e.text?''+y(e.text)+"":"",l+='',l+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<=0;)r^=n<5&&(e+=3+i-5)}for(n=0;n=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e>>7-r%8&1)},put:function(t,r){for(var n=0;n>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<>>=8-c,g=0,c=0;g|=t<0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a>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 0000000000000000000000000000000000000000..3416da75487f1be04272150f317b71246a34ca86 GIT binary patch literal 41086 zcmeI1J&z?v6^83FW^ZD4AVEf2(E$k|hyXjGe*!2G( zoFGqXOBUP0(oSNr7mdW;W%_pUzUS0e-;chQCs=}0>*=nlr%u&5?>XJ~&e*vX{yFz; z{5bcE51soi?my&!)8F9j4UU}qP_9QV-Z}i|yw@A9=;Ft_zV88o{O3Yo4?pfGJ=Km;$DNDPRhi0;Yf|U<#N5rhqA63YY?>fGJ=Km;(1z zK>wHbeRuiV_N&)?{?~Tg3{3%3z!WeAOaW8C6fgx$0aL&fFa=BjQ@|831xx``z!WeA zOaW8C6fgx$0aL&fFa=BjQ@|831xx``z!WeAOaW8C6fgx$0aL&fFa=BjQ@|831xx`` zz!WeAOaW8C6fgx$0aL&fFa=BjQ@|831xx``z!WeAOaW8C6fgx$0aL&fFa=BjQ@|83 z1xx``z!WeAOaW8C6fgx$0aL&fFa=BjQ{ewff$)0U(=*4n_v6zU`|-^7=lH8<%NyKY zo#D+g|G2#$|9!V?|L_cdbJ=`)bU!}6wQRqAJMmrW%w?5;s5Ps zAP)K;q}jVR_3FD{r_tTh)T{4>hiCPC>br~7JIbeuU+0yjGgkaXna;g!;nPi?EuD&o z_sVqcZCs8o+~(QR87ltQGM#%h|4W`N9begR%5?5|cbWUg5?J$3ySy}aTxI|HA)jjg zWt+`XXLPPf{V=wt(P%H-6f>3rYR{68g>v|yIEXi7FY)cKPw;5cfA0uKcH@zg`wqBoaH(1ef8$n|_sQ9~2AtDqy*!5f9`7@E z%J&ist|>#HMExH3IvoNW6~32XxUUv)gq+*@38^GQB#)L?om*Ti|MIy^)^9`Lm+CU4Q3b9$w` z|H`~hFum5X&-S{XdYGb;JctleJWu%$tpWo%!8jPxnegR4Aq{%?syMQNljykplRP4QDg4sAIp<)ekz z_mB#ORRj2{Y@ef&W+Gy zugsqdA7CEkQ7)B1`yuo6HWGU|47-L+j3kF2?K?fG;<8aXUlN3!x{Ykh^II@B-M`UZ zMr<7i=uzZPMkYl~tw=k^ShkeD@lWW8gNds1L|253%5A09#M)-x; zWB-(0+F#Nh@xuNhNZIy9KJbS>0_TCk*+C_AxJlc(?~`*w;q1)$qhEnjKwhN-g9c-1 zTla{sh|IY&#+CkW!!Z%ixi|6THFQ+`5&O}-!}SzEIV>^E`5)bH3V*1d9)|&A#0{@H7LhoZ0vO#5^`Jvo)cqswlmpng z7tk0@^lv2zJTPTw_vBi+p!Q#~6L=1AbAPumIoJx4?$_9=D5W3G9oE~)gX(kJrfCCU(pED?Y5Qq!7d>g|= z6hJrylm-qrHA6w#$o)k?E&zuNolIaJZTkQh%xPm*{479uW6bz~T{zKTE$l^TU)gW8 z7GjAWa`~+c25Yg`uU$iBFTWM`DMR{3OaV&OFhCe+hKF~>i^?7WR01fM3cwn%?-3vs z>1a7T<~0Qk!uvB98*JoDzY2t^iJnKQL)!Kxz8fZzm~0;B#01zlYf}GR<3TH-iL@H&A8SUepE05SaI+UwsD1@fP6~Sj2kl&E>PaXqKk%VBc zs(r+h>2plrT%ZsTUN2{o%OllP)`5TH@sjnFz?pzy>R5*K2_MR8KMMeuN|$HwUH2Ovppssa$TV3!($YRF>&%md)TQWU~_^!LxF(-80^r&DkZ z%d|kGTGHXXdl5+ZfVHyL8E%FZhjZO=ou5j+pLtF3%u|N>atg&ip zj{dAMf!Nc-p4|igqkw*Wf>S^UL@oukzklXe%DdU=q71e57grQXiea`wCM!OJ9MGtR z{k7U3a1Y+#1;Ixh?SS`~yjG-lKb9Pt{haALaY?uNCNiEBp^4)!q&gAohU$ z2hQcc50_P7Y+r$Y>fBF@UFvx;r#<*D(`>m9%6}fhm(IN?^QkBA9p}LDUF`^`0BDyX zJa_I{vMTo&pz^lNr(WSN56h2>eA@q|Ka?N5PyF^c9}>Uwhv|X62GHQ6yPv(@fTT%X z^T9pArLOqkyc+hOKHb36fsPy^9BBhN=Er|FN2oeT&&HdhSPGvfpW;-oA^+mIJryLM zPH~h#hp$B^d2s6h-==p@?9XX(263u!|Me+|dGh8opZJ%Y1-@f!9ez%wl6vt{0&;m?2f(c=Eo sZkdNOe0TTf#r?zG%`(4ze1@N~-&`--ziz%g` + 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) +
+ +
+ +
+ + +
+ + + + + +
+ + + +` + 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 FileIteration 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 0000000000000000000000000000000000000000..9633fbd52b4120a98e7901ba0cd7347b08401000 GIT binary patch literal 41350 zcmeI0L5n0w7008d$0>!idj$_G4CaCtPmAKgS&&V?fqnpQBg`CS=AfdIF)VnLJ?=s5 z*^^iI6HLN_dy(zy3X7AtFnw8^k_M;7uFmlPe=j03vMMWUp%3HXMR#Ssh+n*j_x|y+ zs=9yv`-Nls#Rtx*b#w97gNG09%BWu61p5)ppW)7S1Fs*;FMFxoa7QrTbj}~+{!`p> zE80T*?o5n-?dHxP{1y_={T;M$oc|tXJjUxjFR^_U@qIIOaQ`mt1zrev;o!vfSgsQn zuRD3~VBv)y4&l*G|ILBr5Al~z=NGs*J;1AZ{&9Uh{)cAX|M&oZZQi}VeLcQ;W8Qy# zC-JLfjPC32r|$GYbw5aaos7|a;Qwt#Aa>gCq|wVdS>@d?Q}6Ogvda6w!_#s++3qM= zle{nZS)MrQp2czKO;V_)7mCN~si#$3QUBN#bhO<@jzvR)u@P+?s z7|xb=C)u7lz>m*d?o+UF4mgL=T6qlHE#7ACly4m{xE>i2C2F_0*WnNlsPL@=M)>N1KxlL^ z8b-tht`T*Ue2_P8v>N+U-nv|9H9o*B9~a_AtMLI+zJ~tkoVIuePR#UNCHETF+)|vC1lf@gHk?Ph%IjVDbaIzt*#NTMqJ#bypn$v;@1fJN3~?D zigQ~;ylH4%k;lhPxTHecY zryuuEc!Lo(v>Cl{DG!$PIiYe@?{r5F7p zuh!z{hw#ZUongVh+9aOBkDSYapKK&9_}3Vawss5n(+y8@0dwaxN&G#+cv!6*vXtRSY;(=u2C>+kA`2oGYVW=zj-+iGa?%h9|FLpc0Sx zZ`|9QPXUy}6Wx6N8~2-mAM0m%iwWY4%f>yF$64HN@qJg~&k2%qzVKxusWD*eCvD^I zg${k>ji?$Hkp!3$*tm1*!GJKS`A1wSCvfGSLt`}2zm`MbhUtW^9-S!{RR2p>0?!F9 z&RCfUh^~=$C4Z{C!mOj3TF9f61z4PlV60toxYpuOYkqy=3uLhnqzP7$Qwfh%*}$;@ zQup};`&*5En#qJJU<0Jq<`Wd&3B(0lzKvlZ3Lu;UN&`ojdO|_khU<%foB$3P8X3Si z+WHnQ7}Lg6@uLLg9b?2htipi`bKx&S`@(;vwGc~mm&;#ea9E1Je(mZCfBCDhO(&%9 zh$%s-8YTz_J>l-IL{a!7fkFV~QURDF{w)%uA`Lyed%UIug?EwHBx7F55K^aq%}#sH zYdYbI_6j@;^!FDW8u1NMCzs9m)gG-wAFZNI&N{?3gvg@+HHSnXpDTbv5)WKz=}?+> z^w&vBP&oza_%w3%DKA>$$-jj(*iiNlPXxyVYUy;!V+AV5dT5PwC;$Y?k01v%fr0Zx zv_~psixIW)o-2>o+uD_@o=^yvGFJtR({&cN$Bz6{DhW8+pan;*kc)tvU#qKJ5#Zu; zFm||*2bU9ya}e&ERoq+s>r2q2hu!|fB)+*XZ+?g@Q0~`w!0@7<`PjY#rs=_<) ze>`4JJssdkz|c1=L&k&;W%VB=fTKax@KDL+z{d$_6*a{Hk17C;6iql5hPj=99BNe* zfT#t()RAxPmv5 z>vsH7b(lBH8}OTr7C>3yT%ViP;J0FdAem8P)s!6esPO=?riVYxo%o}Gez}KJKnO%G z1-89@=BLV=(e9!Qwe?4*6iJF!#i+% zjikSax#LgO7N{TP+x4#%=zc5wJ&|f_1xXNV!2f;c^8bg2Szv5mf`97V4+p!1IKr@2~GjfPC|I*+|%SW++%{u>tQ@u1Annwew^gf z`Y-&h{NQck7t_2;{L=3p5BxQO3Lo9==&P*_`DZmjH9$tTIiJK*_}qCPr-Bvv=hOaFki6f=Q34IV7VYK1Edyff@9tTj!{kh2 zU*r1AeGud1)qdXdKRGk}in%p-?P)LDJs=KWJeud{@6PjQbGgIyleyf%|9J9bc7rz# z@b{-nFR$q<2t3n#{qYRNL;B7Wqy@pA6|W9M#&<>iV`P=}9EX-Dp@2M-_IjXnAt IxtqBD53Bq){{R30 literal 0 HcmV?d00001 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()) +}