standalone

This commit is contained in:
2026-05-28 01:25:53 +08:00
parent ca9cc62b8c
commit 16847a78ca
12 changed files with 859 additions and 443 deletions

View File

@@ -2,10 +2,10 @@ package main
import (
"context"
"crypto/rand"
_ "embed"
"encoding/base64"
"encoding/json"
"crypto/rand"
"fmt"
"html"
"net"
@@ -27,7 +27,11 @@ type activeShare struct {
listener net.Listener
port int
token string
fileBuf []byte
files []sharedFile
}
type sharedFile struct {
buf []byte
filename string
}
@@ -54,81 +58,143 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
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>
<header class="tool-header">
<h2 class="tool-title">Send To Mobile</h2>
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
</header>
<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>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body">
<div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-list"></div>
</div>
<div class="action-row">
<button id="start">Start</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="status" class="status-panel"></div>
</div>
<input type="hidden" id="filePath" />
</section>
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
<div class="control-label">Network Interfaces</div>
<div id="ifaceList"></div>
</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 id="shareInfo" class="result-card" style="display:none;">
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<div class="muted" style="margin-top:8px;font-size:12px;">
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 PAGE_SIZE = 5;
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath');
const fileListEl = document.getElementById('fileList');
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;
let selectedFiles = [];
let selectedPaths = [];
let selectedPage = 0;
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedFiles.length) {
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
return;
}
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((file, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.index);
selectedFiles.splice(index, 1);
selectedPaths.splice(index, 1);
renderSelection();
});
});
if (selectedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addFiles(files) {
const existing = new Set(selectedFiles.map(f => f.path));
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
renderSelection();
pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
const j = await r.json();
addFiles(j.files || []);
});
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;
}
addFiles(j.files || []);
});
clearBtn.addEventListener('click', () => {
selectedFiles = [];
selectedPaths = [];
selectedPage = 0;
statusEl.textContent = '';
renderSelection();
});
startBtn.addEventListener('click', async () => {
statusEl.innerHTML = '';
if (!filePathEl.value) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one 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 }),
body: JSON.stringify({ paths: selectedPaths }),
});
const j = await r.json();
if (j.error) {
@@ -154,18 +220,8 @@ stopBtn.addEventListener('click', async () => {
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);
@@ -182,19 +238,18 @@ function renderIfaces(urls) {
ifaceList.innerHTML = '';
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
ifaceWrap.style.display = '';
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
const tbody = ifaceList.querySelector('tbody');
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', () => {
const row = document.createElement('tr');
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
row.querySelector('input').addEventListener('change', () => {
selectedIdx = i;
renderQr(urls[i].url);
});
ifaceList.appendChild(label);
tbody.appendChild(row);
});
}
</script>
@@ -207,35 +262,65 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
picked := PickFiles(
"Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
false, "",
true, "",
)
if len(picked) == 0 {
writeJSON(w, map[string]any{"path": nil})
writeJSON(w, map[string]any{"files": []any{}})
return
}
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
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 MobilePickFolder(w http.ResponseWriter, r *http.Request) {
dir := PickFolder("Select folder", "")
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare()
var req struct {
Path string `json:"path"`
Path string `json:"path"`
Paths []string `json:"paths"`
}
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) {
paths := req.Paths
if len(paths) == 0 && req.Path != "" {
paths = []string{req.Path}
}
if len(paths) == 0 {
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
files := make([]sharedFile, 0, len(paths))
for _, p := range paths {
if p == "" || !fileExists(p) {
writeJSON(w, map[string]any{"error": "File missing."})
return
}
fileBuf, err := os.ReadFile(p)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
return
}
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
}
filename := filepath.Base(req.Path)
// 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6)
@@ -257,8 +342,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
listener: listener,
port: port,
token: token,
fileBuf: fileBuf,
filename: filename,
files: files,
}
share.server = &http.Server{Handler: shareHandler(share)}
@@ -288,6 +372,10 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
})
}
filename := fmt.Sprintf("%d files", len(files))
if len(files) == 1 {
filename = files[0].filename
}
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
}
@@ -308,19 +396,53 @@ func shareHandler(s *activeShare) http.Handler {
case "", "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(chooserPage(s.filename)))
_, _ = w.Write([]byte(chooserPage(s.files)))
case "/view":
fallthrough
case "/view/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf)
_, _ = w.Write(s.files[0].buf)
case "/file":
safeName := safeFilename(s.filename)
fallthrough
case "/file/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
safeName := safeFilename(s.files[0].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("Content-Length", strconv.Itoa(len(s.files[0].buf)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf)
_, _ = w.Write(s.files[0].buf)
default:
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 2 {
idx, err := strconv.Atoi(parts[1])
if err == nil && idx >= 0 && idx < len(s.files) {
file := s.files[idx]
if parts[0] == "view" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
safeName := safeFilename(file.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(file.buf)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
}
}
http.NotFound(w, r)
}
})
@@ -330,8 +452,14 @@ 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)
func chooserPage(files []sharedFile) string {
var items strings.Builder
for i, file := range files {
escName := html.EscapeString(file.filename)
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
}
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
@@ -341,7 +469,8 @@ func chooserPage(filename string) string {
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; }
.item { margin-bottom: 26px; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; 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; }
@@ -349,9 +478,7 @@ func chooserPage(filename string) string {
</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>
` + items.String() + `
</body></html>`
}