617 lines
21 KiB
TypeScript
617 lines
21 KiB
TypeScript
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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, '<') + '</span>';
|
||
startBtn.disabled = false;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|