Ports all five tools to a single 7.5 MB Windows exe with an embedded WebView2 window, file-based config, single-instance focus, and clean shutdown. Adds build.ps1 to build both targets from one command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
391 lines
11 KiB
Go
391 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"html"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed assets/qrcode.min.js
|
|
var qrcodeJS []byte
|
|
|
|
type activeShare struct {
|
|
server *http.Server
|
|
listener net.Listener
|
|
port int
|
|
token string
|
|
fileBuf []byte
|
|
filename string
|
|
}
|
|
|
|
var (
|
|
activeMu sync.Mutex
|
|
active *activeShare
|
|
)
|
|
|
|
func stopActiveShare() {
|
|
activeMu.Lock()
|
|
defer activeMu.Unlock()
|
|
if active != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = active.server.Shutdown(ctx)
|
|
active = nil
|
|
}
|
|
}
|
|
|
|
func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
_, _ = w.Write(qrcodeJS)
|
|
}
|
|
|
|
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
|
body := `
|
|
<h2>Send To Mobile</h2>
|
|
<p class="hint" style="margin-top:0;">
|
|
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
|
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
|
</p>
|
|
|
|
<div style="margin-bottom:14px;">
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<button id="pick" class="secondary">Choose...</button>
|
|
<span id="fileName" class="hint">(no file)</span>
|
|
</div>
|
|
<input type="hidden" id="filePath" />
|
|
</div>
|
|
|
|
<div style="margin-bottom:14px;">
|
|
<button id="start">Start sharing</button>
|
|
<button id="stop" class="secondary" disabled>Stop</button>
|
|
</div>
|
|
|
|
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
|
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
|
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
|
</div>
|
|
|
|
<div id="shareInfo" style="display:none;">
|
|
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
|
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
|
<div style="margin-top:6px;display:flex;gap:6px;">
|
|
<button id="copyUrl" class="secondary">Copy URL</button>
|
|
<button id="openUrl" class="secondary">Open in browser</button>
|
|
</div>
|
|
<div class="hint" style="margin-top:8px;">
|
|
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
|
|
|
<script src="/assets/qrcode.min.js"></script>
|
|
<script>
|
|
const pickBtn = document.getElementById('pick');
|
|
const startBtn = document.getElementById('start');
|
|
const stopBtn = document.getElementById('stop');
|
|
const fileNameEl = document.getElementById('fileName');
|
|
const filePathEl = document.getElementById('filePath');
|
|
const statusEl = document.getElementById('status');
|
|
const shareInfo = document.getElementById('shareInfo');
|
|
const ifaceWrap = document.getElementById('ifaceWrap');
|
|
const ifaceList = document.getElementById('ifaceList');
|
|
const qrEl = document.getElementById('qr');
|
|
const urlTextEl = document.getElementById('urlText');
|
|
|
|
let currentUrls = [];
|
|
let selectedIdx = 0;
|
|
|
|
pickBtn.addEventListener('click', async () => {
|
|
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
|
const j = await r.json();
|
|
if (j.path) {
|
|
filePathEl.value = j.path;
|
|
fileNameEl.textContent = j.name;
|
|
}
|
|
});
|
|
|
|
startBtn.addEventListener('click', async () => {
|
|
statusEl.innerHTML = '';
|
|
if (!filePathEl.value) {
|
|
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
|
return;
|
|
}
|
|
startBtn.disabled = true;
|
|
statusEl.textContent = 'Starting server...';
|
|
const r = await fetch('/api/mobile/start', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ path: filePathEl.value }),
|
|
});
|
|
const j = await r.json();
|
|
if (j.error) {
|
|
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
|
startBtn.disabled = false;
|
|
return;
|
|
}
|
|
currentUrls = j.urls;
|
|
selectedIdx = 0;
|
|
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
|
shareInfo.style.display = '';
|
|
renderIfaces(j.urls);
|
|
renderQr(j.urls[0].url);
|
|
stopBtn.disabled = false;
|
|
});
|
|
|
|
stopBtn.addEventListener('click', async () => {
|
|
await fetch('/api/mobile/stop', { method:'POST' });
|
|
statusEl.textContent = 'Stopped.';
|
|
shareInfo.style.display = 'none';
|
|
currentUrls = [];
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
});
|
|
|
|
document.getElementById('copyUrl').addEventListener('click', () => {
|
|
const url = currentUrls[selectedIdx]?.url || '';
|
|
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
|
|
});
|
|
document.getElementById('openUrl').addEventListener('click', () => {
|
|
const url = currentUrls[selectedIdx]?.url || '';
|
|
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
|
});
|
|
|
|
function renderQr(url) {
|
|
qrEl.innerHTML = '';
|
|
urlTextEl.textContent = url;
|
|
try {
|
|
const qr = qrcode(0, 'M');
|
|
qr.addData(url);
|
|
qr.make();
|
|
qrEl.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 2, scalable: true });
|
|
const svg = qrEl.querySelector('svg');
|
|
if (svg) { svg.setAttribute('width','240'); svg.setAttribute('height','240'); }
|
|
} catch (e) {
|
|
qrEl.textContent = 'QR render failed: ' + e.message;
|
|
}
|
|
}
|
|
|
|
function renderIfaces(urls) {
|
|
ifaceList.innerHTML = '';
|
|
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
|
ifaceWrap.style.display = '';
|
|
urls.forEach((u, i) => {
|
|
const id = 'iface_' + i;
|
|
const label = document.createElement('label');
|
|
label.style.fontSize = '12px';
|
|
label.style.cursor = 'pointer';
|
|
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
|
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
|
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
|
label.querySelector('input').addEventListener('change', () => {
|
|
selectedIdx = i;
|
|
renderQr(urls[i].url);
|
|
});
|
|
ifaceList.appendChild(label);
|
|
});
|
|
}
|
|
</script>
|
|
`
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(Page("/mobile", "Send To Mobile", body)))
|
|
}
|
|
|
|
func MobilePick(w http.ResponseWriter, r *http.Request) {
|
|
picked := PickFiles(
|
|
"Select HTML",
|
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
|
false, "",
|
|
)
|
|
if len(picked) == 0 {
|
|
writeJSON(w, map[string]any{"path": nil})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
|
}
|
|
|
|
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
|
stopActiveShare()
|
|
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.Path == "" || !fileExists(req.Path) {
|
|
writeJSON(w, map[string]any{"error": "File missing."})
|
|
return
|
|
}
|
|
fileBuf, err := os.ReadFile(req.Path)
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
|
return
|
|
}
|
|
filename := filepath.Base(req.Path)
|
|
|
|
// 6 random bytes -> base64url, no padding
|
|
tokenBytes := make([]byte, 6)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
|
|
return
|
|
}
|
|
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
|
|
|
|
cfgPort := LoadConfig().SendToMobile.Port
|
|
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
|
|
if err != nil {
|
|
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
|
|
return
|
|
}
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
share := &activeShare{
|
|
listener: listener,
|
|
port: port,
|
|
token: token,
|
|
fileBuf: fileBuf,
|
|
filename: filename,
|
|
}
|
|
share.server = &http.Server{Handler: shareHandler(share)}
|
|
|
|
go func() { _ = share.server.Serve(listener) }()
|
|
|
|
ips := getLanIPs()
|
|
if len(ips) == 0 {
|
|
_ = share.server.Close()
|
|
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
|
|
return
|
|
}
|
|
|
|
activeMu.Lock()
|
|
active = share
|
|
activeMu.Unlock()
|
|
|
|
type urlInfo struct {
|
|
IP string `json:"ip"`
|
|
Iface string `json:"iface"`
|
|
URL string `json:"url"`
|
|
}
|
|
urls := make([]urlInfo, 0, len(ips))
|
|
for _, ip := range ips {
|
|
urls = append(urls, urlInfo{
|
|
IP: ip.address,
|
|
Iface: ip.iface,
|
|
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
|
})
|
|
}
|
|
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
|
|
}
|
|
|
|
func MobileStop(w http.ResponseWriter, r *http.Request) {
|
|
stopActiveShare()
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
func shareHandler(s *activeShare) http.Handler {
|
|
prefix := "/s/" + s.token
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, prefix) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
rest := strings.TrimPrefix(r.URL.Path, prefix)
|
|
switch rest {
|
|
case "", "/":
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write([]byte(chooserPage(s.filename)))
|
|
case "/view":
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(s.fileBuf)
|
|
case "/file":
|
|
safeName := safeFilename(s.filename)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(s.fileBuf)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
|
|
|
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
|
|
|
func chooserPage(filename string) string {
|
|
escName := html.EscapeString(filename)
|
|
return `<!DOCTYPE html>
|
|
<html><head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Send To Mobile</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
margin: 0; padding: 24px; background: #111; color: #eee; }
|
|
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
|
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
|
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
|
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
|
a.view { background: #2d7dff; color: white; }
|
|
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
|
|
</style></head>
|
|
<body>
|
|
<h1>HPL Toolbox</h1>
|
|
<div class="file">` + escName + `</div>
|
|
<a class="btn view" href="view">View in browser</a>
|
|
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
|
</body></html>`
|
|
}
|
|
|
|
type lanIP struct {
|
|
address string
|
|
iface string
|
|
}
|
|
|
|
func getLanIPs() []lanIP {
|
|
var out []lanIP
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, a := range addrs {
|
|
ipNet, ok := a.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ip4 := ipNet.IP.To4()
|
|
if ip4 == nil || ip4.IsLoopback() {
|
|
continue
|
|
}
|
|
out = append(out, lanIP{address: ip4.String(), iface: iface.Name})
|
|
}
|
|
}
|
|
return out
|
|
}
|