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('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 { 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string) ); const items = files.map((file, index) => { const escName = esc(file.filename); return ``; }).join(''); return ` Send To Mobile

HPL Toolbox

${items} `; } function mobilePreviewPage(file: SharedFile): string { const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script'); const title = file.filename.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string) ); return ` ${title}
Loading preview...
`; } 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 `

Send To Mobile

Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.

Inputs

(no files selected)
`; }