Files
vsix-hpl-toolbox/src/tools/sendToMobile.ts
2026-06-08 22:31:02 +08:00

617 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as os from 'os';
import * as crypto from 'crypto';
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
interface ActiveShare {
server: http.Server;
port: number;
token: string;
files: SharedFile[];
}
interface SharedFile {
buf: Buffer;
filename: string;
}
export function openSendToMobile(context: vscode.ExtensionContext) {
if (store.panel) {
store.panel.reveal();
return;
}
const panel = vscode.window.createWebviewPanel(
'hplToolbox.sendToMobile',
'Send To Mobile',
vscode.ViewColumn.Active,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'media')],
}
);
store.panel = panel;
let active: ActiveShare | null = null;
const stopActive = () => {
if (active) {
try { active.server.close(); } catch { /* noop */ }
active = null;
}
};
panel.onDidDispose(() => {
stopActive();
store.panel = null;
});
const qrUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(context.extensionUri, 'media', 'qrcode.min.js')
);
panel.webview.html = getHtml(qrUri.toString());
panel.webview.onDidReceiveMessage(async (msg) => {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'fileSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked.length) {
panel.webview.postMessage({
type: 'fileSelected',
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
});
}
return;
}
if (msg.type === 'startShare') {
stopActive();
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
if (!filePaths.length) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return;
}
const files: SharedFile[] = [];
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!filePath || !fs.existsSync(filePath)) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return;
}
panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${path.basename(filePath)}` });
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
}
panel.webview.postMessage({ type: 'status', message: 'Starting server...' });
const token = crypto.randomBytes(6).toString('base64url');
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
const configuredPort = cfg.get<number>('port') ?? 0;
const share: ActiveShare = {
server: http.createServer(),
port: 0,
token,
files,
};
share.server.on('request', (req, res) => handleRequest(req, res, share));
share.server.on('error', (err) => {
panel.webview.postMessage({ type: 'error', message: 'Server error: ' + err.message });
stopActive();
});
share.server.listen(configuredPort, '0.0.0.0', () => {
const addr = share.server.address();
if (!addr || typeof addr === 'string') {
panel.webview.postMessage({ type: 'error', message: 'Failed to bind server.' });
return;
}
share.port = addr.port;
active = share;
const ips = getLanIps();
if (ips.length === 0) {
panel.webview.postMessage({
type: 'error',
message: 'No non-loopback IPv4 interface found. Are you on a network?',
});
stopActive();
return;
}
const urls = ips.map((ip) => ({
ip: ip.address,
iface: ip.iface,
url: `http://${ip.address}:${share.port}/s/${token}/`,
}));
panel.webview.postMessage({
type: 'sharing',
urls,
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
});
});
return;
}
if (msg.type === 'stopShare') {
stopActive();
panel.webview.postMessage({ type: 'stopped' });
return;
}
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
}
});
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
if (req.method !== 'GET' || !req.url) {
res.statusCode = 404;
res.end();
return;
}
const prefix = `/s/${share.token}`;
if (!req.url.startsWith(prefix)) {
res.statusCode = 404;
res.end();
return;
}
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
if (rest === '' || rest === '/') {
const body = chooserPage(share.files);
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(body);
return;
}
const viewMatch = rest.match(/^\/view\/(\d+)$/);
if (rest === '/view' || viewMatch) {
const index = viewMatch ? Number(viewMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(mobilePreviewPage(file));
return;
}
const fileMatch = rest.match(/^\/file\/(\d+)$/);
if (rest === '/file' || fileMatch) {
const index = fileMatch ? Number(fileMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${safeName}"`,
'Content-Length': String(file.buf.length),
'Cache-Control': 'no-store',
});
res.end(file.buf);
return;
}
res.statusCode = 404;
res.end();
}
function chooserPage(files: SharedFile[]): string {
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
);
const items = files.map((file, index) => {
const escName = esc(file.filename);
return `<div class="item">
<div class="file">${escName}</div>
<a class="btn view" href="view/${index}">View in browser</a>
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
</div>`;
}).join('');
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; }
.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; }
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
</style></head>
<body>
<h1>HPL Toolbox</h1>
${items}
</body></html>`;
}
function mobilePreviewPage(file: SharedFile): string {
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
const title = file.filename.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
);
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>${title}</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
</style>
</head><body>
<div id="boot" class="boot">Loading preview...</div>
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
<script>
const playableHtml = ${playableJson};
setTimeout(() => {
const frame = document.getElementById('playable');
document.getElementById('boot').style.display = 'none';
frame.srcdoc = playableHtml;
}, 750);
</script>
</body></html>`;
}
interface LanIp { address: string; iface: string; }
function getLanIps(): LanIp[] {
const out: LanIp[] = [];
const ifaces = os.networkInterfaces();
for (const [name, list] of Object.entries(ifaces)) {
if (!list) continue;
for (const info of list) {
if (info.family === 'IPv4' && !info.internal) {
out.push({ address: info.address, iface: name });
}
}
}
return out;
}
function getPickerDefaultUri(): vscode.Uri | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return undefined;
const root = folders[0].uri.fsPath;
const dist = path.join(root, 'dist');
if (fs.existsSync(dist) && fs.statSync(dist).isDirectory()) {
return vscode.Uri.file(dist);
}
return vscode.Uri.file(root);
}
function getHtml(qrScriptUri: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
${getToolWebviewStyles()}
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
#qr svg, #qr img { display: block; }
.url {
margin-top: 10px; padding: 8px;
background: var(--vscode-textBlockQuote-background);
border-left: 3px solid var(--vscode-textBlockQuote-border);
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
}
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.share-panel { padding: 12px; }
.iface-radio { text-align: center; }
#qr { margin-top: 0; }
.selected-list { margin-top: 10px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
<script src="${qrScriptUri}"></script>
</head>
<body>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Send To Mobile</h2>
<p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
</header>
<section class="tool-panel input-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 class="file-name" id="fileName" 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>
</section>
<div id="outputPanel" class="is-hidden">
<div id="ifaceWrap" class="control-group" style="display:none;">
<div class="control-label">Network Interfaces</div>
<p class="section-description">Pick the interface your phone can reach.</p>
<div class="iface-list" id="ifaceList"></div>
</div>
<div id="shareInfo" class="result-card share-panel" style="display:none;">
<div id="qr"></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".
Sharing stops automatically when this panel is closed.
</div>
</div>
</div>
</main>
<script>
const vscode = acquireVsCodeApi();
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 fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const outputPanel = document.getElementById('outputPanel');
const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr');
const PAGE_SIZE = 5;
let currentUrls = [];
let selectedIdx = 0;
let selectedPaths = [];
let selectedFiles = [];
let selectedPage = 0;
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
clearBtn.addEventListener('click', () => {
selectedPaths = [];
selectedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
});
startBtn.addEventListener('click', () => {
statusEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
});
stopBtn.addEventListener('click', () => {
vscode.postMessage({ type: 'stopShare' });
});
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 visibleFiles = 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>' +
visibleFiles.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(); });
}
}
renderSelection();
function renderQr(url) {
qrEl.innerHTML = '';
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 = '';
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 row = document.createElement('tr');
row.innerHTML =
'<td class="iface-radio"><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);
});
tbody.appendChild(row);
});
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'fileSelected') {
const existing = new Set(selectedFiles.map(f => f.path));
selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = 0;
renderSelection();
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'sharing') {
statusEl.classList.remove('is-busy');
currentUrls = m.urls;
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
outputPanel.classList.remove('is-hidden');
shareInfo.style.display = '';
renderIfaces(m.urls);
renderQr(m.urls[0].url);
startBtn.disabled = true;
stopBtn.disabled = false;
} else if (m.type === 'stopped') {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
ifaceWrap.style.display = 'none';
outputPanel.classList.add('is-hidden');
currentUrls = [];
startBtn.disabled = false;
stopBtn.disabled = true;
} else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>';
startBtn.disabled = false;
}
});
</script>
</body>
</html>`;
}