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 0000000..3416da7 Binary files /dev/null and b/standalone/hpl.ico differ diff --git a/standalone/layout.go b/standalone/layout.go new file mode 100644 index 0000000..07fffe8 --- /dev/null +++ b/standalone/layout.go @@ -0,0 +1,73 @@ +package main + +import "strings" + +const SharedCSS = ` + :root { color-scheme: dark; } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1e1e1e; color: #ddd; + margin: 0; padding: 0; + } + .topbar { + display: flex; align-items: center; gap: 8px; + padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; + font-size: 13px; + } + .topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; } + .topbar a:hover { background: #2a2d2e; } + .topbar a.active { background: #094771; color: #fff; } + .content { padding: 16px; max-width: 980px; margin: 0 auto; } + h2 { margin-top: 0; font-weight: 500; } + input[type=text], input[type=date], select, textarea { + box-sizing: border-box; padding: 6px 8px; + background: #3c3c3c; color: #ddd; + border: 1px solid #3c3c3c; border-radius: 2px; + font-family: inherit; font-size: 13px; + } + input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus { + outline: 1px solid #007fd4; border-color: #007fd4; + } + button { + background: #0e639c; color: #fff; + border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px; + } + button:hover { background: #1177bb; } + button.secondary { background: #3a3d41; color: #ddd; } + button.secondary:hover { background: #45494e; } + button:disabled { opacity: 0.5; cursor: not-allowed; } + .err { color: #f48771; white-space: pre-wrap; } + .ok { color: #89d185; } + .hint { font-size: 12px; opacity: 0.7; } +` + +type navItem struct{ Path, Label string } + +var navItems = []navItem{ + {"/", "Home"}, + {"/plec", "PLEC Upload"}, + {"/applovin", "AppLovin Upload"}, + {"/base64", "Base64 Scanner"}, + {"/daily", "Daily Update"}, + {"/mobile", "Send To Mobile"}, +} + +func Page(activePath, title, body string) string { + var tabs strings.Builder + for _, n := range navItems { + cls := "" + if n.Path == activePath { + cls = " class=\"active\"" + } + tabs.WriteString(`` + n.Label + ``) + } + return ` + + +` + title + ` — HPL Toolbox + + +
` + tabs.String() + `
+
` + body + `
+` +} diff --git a/standalone/main.go b/standalone/main.go new file mode 100644 index 0000000..e506e15 --- /dev/null +++ b/standalone/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "sync/atomic" + "syscall" + "time" + "unsafe" + + webview "github.com/jchv/go-webview2" +) + +var currentHwnd atomic.Uintptr + +func setupFileLogging() { + logPath := filepath.Join(appDir(), "debug.log") + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return + } + log.SetOutput(f) + log.SetFlags(log.LstdFlags | log.Lshortfile) +} + +func main() { + setupFileLogging() + log.Printf("HPL Toolbox starting (pid=%d)", os.Getpid()) + + if focusExistingInstance() { + log.Printf("Another instance is running; focused it and exiting.") + return + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + log.Printf("Server listening on 127.0.0.1:%d", port) + + mux := buildMux() + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + } + }() + + if err := writeLockFile(port); err != nil { + log.Printf("Failed to write lock file: %v", err) + } + defer removeLockFile() + + w := webview.NewWithOptions(webview.WebViewOptions{ + Debug: false, + AutoFocus: true, + WindowOptions: webview.WindowOptions{ + Title: "HPL Toolbox", + Width: 1100, + Height: 720, + IconId: 1, + Center: true, + }, + }) + if w == nil { + log.Fatalln("Failed to create webview.") + } + defer w.Destroy() + + // Stash HWND for the /api/focus handler. + currentHwnd.Store(uintptr(w.Window())) + + w.SetSize(1100, 720, webview.HintNone) + w.Navigate(fmt.Sprintf("http://127.0.0.1:%d/", port)) + w.Run() + + // Window closed — shut the server down cleanly. + log.Printf("Window closed; shutting down server.") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} + +func buildMux() *http.ServeMux { + mux := http.NewServeMux() + + // Pages + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/home" { + http.NotFound(w, r) + return + } + HomePage(w, r) + }) + mux.HandleFunc("GET /plec", PlecPage) + mux.HandleFunc("GET /applovin", ApplovinPage) + mux.HandleFunc("GET /base64", Base64Page) + mux.HandleFunc("GET /daily", DailyPage) + mux.HandleFunc("GET /mobile", MobilePage) + mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript) + + // Shared API + mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint) + mux.HandleFunc("POST /api/open", OpenEndpoint) + mux.HandleFunc("POST /api/focus", FocusEndpoint) + + // Daily Update + mux.HandleFunc("POST /api/daily/submit", DailySubmit) + + // PLEC + mux.HandleFunc("POST /api/plec/pick", PlecPick) + mux.HandleFunc("POST /api/plec/upload", PlecUpload) + + // AppLovin + mux.HandleFunc("POST /api/applovin/pick", ApplovinPick) + mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload) + + // Base64 + mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder) + mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles) + mux.HandleFunc("POST /api/base64/scan", Base64Scan) + + // Send To Mobile + mux.HandleFunc("POST /api/mobile/pick", MobilePick) + mux.HandleFunc("POST /api/mobile/start", MobileStart) + mux.HandleFunc("POST /api/mobile/stop", MobileStop) + + return mux +} + +// FocusEndpoint brings the webview window to the foreground. Used by a second +// instance that wants to focus the running one before exiting. +func FocusEndpoint(w http.ResponseWriter, r *http.Request) { + hwnd := currentHwnd.Load() + if hwnd != 0 { + restoreAndFocus(hwnd) + } + w.WriteHeader(http.StatusOK) +} + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procShowWindow = user32.NewProc("ShowWindow") + procSetForeground = user32.NewProc("SetForegroundWindow") + procIsIconic = user32.NewProc("IsIconic") +) + +const ( + swRestore = 9 + swShow = 5 +) + +func restoreAndFocus(hwnd uintptr) { + // If minimized, restore. Otherwise just ensure it's shown. + iconic, _, _ := procIsIconic.Call(hwnd) + if iconic != 0 { + _, _, _ = procShowWindow.Call(hwnd, swRestore) + } else { + _, _, _ = procShowWindow.Call(hwnd, swShow) + } + _, _, _ = procSetForeground.Call(hwnd) +} + +// Silence "imported and not used" complaints if any. +var _ = unsafe.Sizeof(int(0)) diff --git a/standalone/mobile.go b/standalone/mobile.go new file mode 100644 index 0000000..f806cd6 --- /dev/null +++ b/standalone/mobile.go @@ -0,0 +1,390 @@ +package main + +import ( + "context" + _ "embed" + "encoding/base64" + "encoding/json" + "crypto/rand" + "fmt" + "html" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed assets/qrcode.min.js +var qrcodeJS []byte + +type activeShare struct { + server *http.Server + listener net.Listener + port int + token string + fileBuf []byte + filename string +} + +var ( + activeMu sync.Mutex + active *activeShare +) + +func stopActiveShare() { + activeMu.Lock() + defer activeMu.Unlock() + if active != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = active.server.Shutdown(ctx) + active = nil + } +} + +func MobileQrScript(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write(qrcodeJS) +} + +func MobilePage(w http.ResponseWriter, r *http.Request) { + body := ` +

Send To Mobile

+

+ Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. + The phone gets a choice of View or Download. +

+ +
+
+ + (no file) +
+ +
+ +
+ + +
+ + + + + +
+ + + +` + 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 0000000..9633fbd Binary files /dev/null and b/standalone/rsrc.syso differ diff --git a/standalone/single_instance.go b/standalone/single_instance.go new file mode 100644 index 0000000..24107c4 --- /dev/null +++ b/standalone/single_instance.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func lockFilePath() string { + return filepath.Join(appDir(), ".hpltoolbox.lock") +} + +// focusExistingInstance returns true if another instance was found and +// successfully asked to focus its window. In that case the caller should exit. +// Returns false if no live instance exists (and clears any stale lock). +func focusExistingInstance() bool { + data, err := os.ReadFile(lockFilePath()) + if err != nil { + return false + } + parts := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(parts) != 2 { + _ = os.Remove(lockFilePath()) + return false + } + pid, errPid := strconv.Atoi(strings.TrimSpace(parts[0])) + port, errPort := strconv.Atoi(strings.TrimSpace(parts[1])) + if errPid != nil || errPort != nil { + _ = os.Remove(lockFilePath()) + return false + } + + // Quick liveness check: PID must exist. + if proc, err := os.FindProcess(pid); err != nil || proc == nil { + _ = os.Remove(lockFilePath()) + return false + } + + // Ask the running instance to focus its window. + client := &http.Client{Timeout: 1500 * time.Millisecond} + url := fmt.Sprintf("http://127.0.0.1:%d/api/focus", port) + resp, err := client.Post(url, "application/json", bytes.NewReader(nil)) + if err != nil { + // Lock file is stale (process died but didn't clean up). + _ = os.Remove(lockFilePath()) + return false + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _ = os.Remove(lockFilePath()) + return false + } + return true +} + +func writeLockFile(port int) error { + content := fmt.Sprintf("%d\n%d\n", os.Getpid(), port) + return os.WriteFile(lockFilePath(), []byte(content), 0644) +} + +func removeLockFile() { + _ = os.Remove(lockFilePath()) +}