diff --git a/port_ext/background.js b/port_ext/background.js index 6a788b3..85e5e04 100644 --- a/port_ext/background.js +++ b/port_ext/background.js @@ -12,11 +12,18 @@ const BADGE_RED_TRANSLUCENT = '#ea4335cc'; const BADGE_GREEN = '#34a853'; // new port detected const BADGE_GREEN_TRANSLUCENT = '#34a853cc'; -let knownActivePorts = new Set(); // last known active ports, used to detect "new" +let knownActivePorts = new Set(); let serverDownFlashTimer = null; let newPortFlashTimer = null; -let flashBlinkInterval = null; // drives the translucent pulse while flashing -let currentFlashMode = null; // 'red' | 'green' | null +let flashBlinkInterval = null; +let currentFlashMode = null; +let badgeEnabled = true; // default on; updated from storage + +// Load badge preference at startup +chrome.storage.local.get(['badgeEnabled'], (result) => { + badgeEnabled = result.badgeEnabled !== false; + if (!badgeEnabled) chrome.action.setBadgeText({ text: '' }); +}); chrome.runtime.onInstalled.addListener(() => { console.log("Localhost Manager installed."); @@ -37,6 +44,7 @@ function initBadge() { // --- Badge rendering --- function updateBadgeCount(count) { + if (!badgeEnabled) { chrome.action.setBadgeText({ text: '' }); return; } chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' }); if (currentFlashMode === null) { chrome.action.setBadgeBackgroundColor({ color: BADGE_NEUTRAL }); @@ -44,9 +52,7 @@ function updateBadgeCount(count) { } function startFlash(mode, durationMs) { - // A new flash of the same or higher-priority mode resets the timer. - // Red (server down) takes priority over green (new port) since it's - // a problem state, not a celebratory one. + if (!badgeEnabled) return; if (currentFlashMode === 'red' && mode === 'green') return; stopFlash(); @@ -88,7 +94,8 @@ async function pollOnce() { // 1. Check active ports among knownPorts (lightweight — no title/favicon fetch here, // that detail work stays in popup.js; background only needs up/down + count) const activeNow = new Set(); - await Promise.all(knownPorts.map(async (port) => { + const validPorts = knownPorts.filter(p => Number.isInteger(p) && p > 0 && p <= 65535); + await Promise.all(validPorts.map(async (port) => { try { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 800); @@ -126,6 +133,18 @@ async function pollOnce() { // popup.js calls this after its own scan completes so the badge updates // immediately rather than waiting for the next background poll cycle. chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === "setBadgeEnabled") { + badgeEnabled = request.enabled; + if (!badgeEnabled) { + stopFlash(); + chrome.action.setBadgeText({ text: '' }); + } else { + // Immediately restore by re-running a poll + pollOnce(); + } + sendResponse({ ok: true }); + return true; + } if (request.action === "getPorts") { sendResponse({ status: "Background worker is active" }); return true; diff --git a/port_ext/scripts/popup.js b/port_ext/scripts/popup.js index 6d58d96..0a63b88 100644 --- a/port_ext/scripts/popup.js +++ b/port_ext/scripts/popup.js @@ -688,6 +688,7 @@ document.getElementById('addPortBtn').addEventListener('click', () => { }); document.getElementById('hostBtn').addEventListener('click', () => htmlFileInput.click()); +document.getElementById('hostProjectBtn').addEventListener('click', () => document.getElementById('projectFolder').click()); htmlFileInput.addEventListener('change', function (e) { const file = e.target.files[0]; @@ -696,4 +697,133 @@ htmlFileInput.addEventListener('change', function (e) { reader.onload = (event) => hostFile(event.target.result); reader.readAsText(file); htmlFileInput.value = ''; +}); + +// Multi-file project hosting +// Reads every file in the selected folder, encodes binary files as base64, +// and sends them all to /start-multi on the control server. +document.getElementById('projectFolder').addEventListener('change', async function (e) { + const files = [...e.target.files]; + if (!files.length) return; + + const ready = await controlServerReady(); + if (!ready) { setError('Server offline — run: node server.js'); return; } + + const candidates = [9000, 9001, 9002, 9003, 9004, 9005]; + const port = candidates.find(p => !activePortSet.has(p) && !hostedPortSet.has(p)); + if (port === undefined) { setError('No free port in range 9000–9005'); return; } + + setStatus('scanning', `Reading ${files.length} files…`); + + const TEXT_TYPES = /\.(html?|css|js|mjs|json|svg|txt|xml|md|ts|jsx|tsx|map|yaml|yml|toml|ini)$/i; + + // Step 1: read all files, keeping the raw webkitRelativePath + const fileData = []; + for (const file of files) { + const rawPath = file.webkitRelativePath || file.name; + + const result = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + if (TEXT_TYPES.test(file.name)) { + reader.onload = (ev) => resolve({ rawPath, content: ev.target.result, encoding: 'utf8' }); + reader.readAsText(file, 'UTF-8'); + } else { + reader.onload = (ev) => { + const raw = ev.target.result || ''; + const commaIdx = raw.indexOf(','); + resolve({ rawPath, content: commaIdx !== -1 ? raw.slice(commaIdx + 1) : '', encoding: 'base64' }); + }; + reader.readAsDataURL(file); + } + }).catch(err => { console.warn(`Skipping ${file.name}:`, err); return null; }); + + if (result) fileData.push(result); + } + + if (fileData.length === 0) { + setError('No readable files found in folder'); + document.getElementById('projectFolder').value = ''; + return; + } + + // DEBUG: log all raw paths to see exactly what Chrome gives us + console.log('[LM] Raw paths from webkitRelativePath:'); + fileData.forEach(f => console.log(' ', JSON.stringify(f.rawPath))); + + // Read the entry file name — default to index.html if blank + const entryFileName = (document.getElementById('entryFile').value.trim() || 'index.html').toLowerCase(); + + // Find the entry file to determine root + const indexFile = fileData.find(f => + f.rawPath.toLowerCase().endsWith('/' + entryFileName) || + f.rawPath.toLowerCase() === entryFileName + ); + + console.log('[LM] entry file:', entryFileName, '| found at:', indexFile ? indexFile.rawPath : 'NOT FOUND'); + + let rootPrefix = ''; + if (indexFile) { + const lastSlash = indexFile.rawPath.lastIndexOf('/'); + rootPrefix = lastSlash !== -1 ? indexFile.rawPath.slice(0, lastSlash + 1) : ''; + } else { + const firstSlash = fileData[0].rawPath.indexOf('/'); + rootPrefix = firstSlash !== -1 ? fileData[0].rawPath.slice(0, firstSlash + 1) : ''; + } + + console.log('[LM] rootPrefix to strip:', JSON.stringify(rootPrefix)); + + const strippedFiles = fileData + .map(f => ({ + path: f.rawPath.startsWith(rootPrefix) + ? f.rawPath.slice(rootPrefix.length) + : f.rawPath, + content: f.content, + encoding: f.encoding + })) + .filter(f => f.path); + + console.log('[LM] Stripped paths being sent to server:'); + strippedFiles.forEach(f => console.log(' ', JSON.stringify(f.path))); + + // Warn clearly if no entry file was found + if (!indexFile) { + setError(`No ${entryFileName} found — pick your project root folder`); + document.getElementById('projectFolder').value = ''; + return; + } + + if (strippedFiles.length === 0) { + setError('Could not determine project root'); + document.getElementById('projectFolder').value = ''; + return; + } + + setStatus('scanning', `Starting server on port ${port} · ${strippedFiles.length} files…`); + + let result; + try { + const res = await fetch(`${CONTROL_SERVER}/start-multi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ port, files: strippedFiles, entryFile: entryFileName }) + }); + result = await res.json(); + } catch { + setError('Server offline — run: node server.js'); + document.getElementById('projectFolder').value = ''; + return; + } + + if (!result.ok) { setError(`Failed: ${result.error}`); return; } + + hostedPortSet.add(port); + if (!knownPorts.includes(port)) knownPorts.push(port); + chrome.storage.local.set({ knownPorts, hostedPorts: [...hostedPortSet] }); + const entryUrl = entryFileName === 'index.html' + ? `http://localhost:${port}` + : `http://localhost:${port}/${entryFileName}`; + chrome.tabs.create({ url: entryUrl }); + await scanPorts(); + document.getElementById('projectFolder').value = ''; }); \ No newline at end of file diff --git a/port_ext/scripts/settings.js b/port_ext/scripts/settings.js index 01a3a90..03b9d41 100644 --- a/port_ext/scripts/settings.js +++ b/port_ext/scripts/settings.js @@ -89,6 +89,19 @@ serverFlashSlider.addEventListener('input', () => { }); }); +// --- BADGE TOGGLE --- +function setBadgeSubEnabled(enabled) { + document.getElementById('badgeSubSettings').classList.toggle('disabled', !enabled); +} + +document.getElementById('badgeToggle').addEventListener('change', (e) => { + const enabled = e.target.checked; + chrome.storage.local.set({ badgeEnabled: enabled }); + setBadgeSubEnabled(enabled); + // Tell background to immediately clear or restore the badge + chrome.runtime.sendMessage({ action: 'setBadgeEnabled', enabled }); +}); + // --- AUTO SCAN --- document.getElementById('autoScanToggle').addEventListener('change', (e) => { const enabled = e.target.checked; @@ -217,7 +230,7 @@ document.getElementById('shortcutBtn').addEventListener('click', () => { document.getElementById('backBtn').addEventListener('click', () => window.close()); // --- LOAD SAVED SETTINGS --- -chrome.storage.local.get(['theme', 'viewMode', 'networkScan', 'networkRange', 'autoScan', 'autoScanIntervalIndex', 'accentColor', 'serverDownFlashIndex'], (result) => { +chrome.storage.local.get(['theme', 'viewMode', 'networkScan', 'networkRange', 'autoScan', 'autoScanIntervalIndex', 'accentColor', 'serverDownFlashIndex', 'badgeEnabled'], (result) => { const theme = result.theme || 'auto'; const radio = document.querySelector(`input[name="theme"][value="${theme}"]`); if (radio) radio.checked = true; @@ -262,4 +275,9 @@ chrome.storage.local.get(['theme', 'viewMode', 'networkScan', 'networkRange', 'a const flashIndex = result.serverDownFlashIndex ?? 8; serverFlashSlider.value = flashIndex; updateServerFlashLabel(flashIndex); + + // Restore badge toggle; default true (enabled) + const badgeOn = result.badgeEnabled !== false; + document.getElementById('badgeToggle').checked = badgeOn; + setBadgeSubEnabled(badgeOn); }); \ No newline at end of file diff --git a/port_ext/servers/server.js b/port_ext/servers/server.js index f832c58..3beb6c0 100644 --- a/port_ext/servers/server.js +++ b/port_ext/servers/server.js @@ -1,5 +1,5 @@ /** - * Localhost Manager Pro — Control Server + * Localhost Manager — Control Server * Run once in the background: node server.js * * Exposes a local REST API on http://127.0.0.1:8765 that the @@ -17,6 +17,8 @@ const path = require('path'); const os = require('os'); const CONTROL_PORT = 8765; +const BLUE_TEXT = "\x1b[96m"; +const RESET = "\x1b[0m"; // Registry of active hosted servers // Maps port number → { server: http.Server, tmpdir: string } @@ -62,22 +64,143 @@ function startHostedServer(port, htmlContent) { fs.writeFileSync(path.join(tmpdir, 'index.html'), htmlContent, 'utf8'); const server = http.createServer((req, res) => { - // Serve index.html for any request (single-file host) const filePath = path.join(tmpdir, 'index.html'); + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(data); + }); + }); + + // Track every open socket so we can destroy them instantly on stop. + // Without this, server.close() waits for keep-alive connections to + // timeout naturally — which can take 20+ seconds. + const sockets = new Set(); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + server.listen(port, '127.0.0.1', () => { + hostedServers[port] = { server, tmpdir, sockets }; + console.log(`[+] Hosting on http://localhost:${port}`); + resolve({ ok: true, port, message: `Serving on http://localhost:${port}` }); + }); + + server.on('error', (e) => { + fs.rmSync(tmpdir, { recursive: true, force: true }); + resolve({ ok: false, error: `Could not bind port ${port}: ${e.message}` }); + }); + }); +} + +// ── Start a multi-file project server ──────────────────────────────────────── + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2':'font/woff2', + '.ttf': 'font/ttf', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml', + '.pdf': 'application/pdf', +}; + +function getMime(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return MIME_TYPES[ext] || 'application/octet-stream'; +} + +function startMultiServer(port, files, entryFile = 'index.html') { + return new Promise((resolve) => { + if (hostedServers[port]) { + return resolve({ ok: false, error: `Port ${port} is already hosted` }); + } + + // Write every file to the temp directory, creating subdirs as needed + const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm_pro_')); + console.log(`[MULTI] Writing ${files.length} files to: ${tmpdir}`); + try { + for (const file of files) { + const safePath = file.path.replace(/\.\.\//g, '').replace(/^\/+/, ''); + const dest = path.join(tmpdir, safePath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + if (file.encoding === 'base64') { + fs.writeFileSync(dest, Buffer.from(file.content, 'base64')); + } else { + fs.writeFileSync(dest, file.content, 'utf8'); + } + console.log(`[MULTI] wrote: ${safePath}`); + } + } catch (e) { + fs.rmSync(tmpdir, { recursive: true, force: true }); + return resolve({ ok: false, error: `Failed to write files: ${e.message}` }); + } + + const server = http.createServer((req, res) => { + let urlPath = req.url.split('?')[0]; + try { urlPath = decodeURIComponent(urlPath); } catch {} + urlPath = urlPath.replace(/\.\.\//g, '').replace(/^\/+/, ''); + + let filePath = path.join(tmpdir, urlPath); + console.log(`[REQ] ${req.method} ${req.url} -> ${filePath}`); + + try { + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + filePath = path.join(filePath, entryFile); + console.log(`[DIR] resolved to: ${filePath}`); + } + } catch { /* not a directory or doesn't exist */ } + fs.readFile(filePath, (err, data) => { if (err) { - res.writeHead(404); - res.end('Not found'); + console.log(`[404] not found: ${filePath}, falling back to ${entryFile}`); + const rootIndex = path.join(tmpdir, entryFile); + fs.readFile(rootIndex, (err2, fallbackData) => { + if (err2) { + console.log(`[404] root index.html also missing!`); + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end(`404 Not Found: /${urlPath}`); + return; + } + res.writeHead(200, { 'Content-Type': getMime(rootIndex) }); + res.end(fallbackData); + }); return; } - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + console.log(`[200] ${filePath}`); + res.writeHead(200, { 'Content-Type': getMime(filePath) }); res.end(data); }); }); + // Track open sockets for instant stop + const sockets = new Set(); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + server.listen(port, '127.0.0.1', () => { - hostedServers[port] = { server, tmpdir }; - console.log(`[+] Hosting on http://localhost:${port}`); + hostedServers[port] = { server, tmpdir, sockets }; + console.log(`[+] Hosting project (${files.length} files) on http://localhost:${port}`); resolve({ ok: true, port, message: `Serving on http://localhost:${port}` }); }); @@ -97,6 +220,12 @@ function stopHostedServer(port) { return resolve({ ok: false, error: `No server running on port ${port}` }); } + // Destroy all open sockets immediately — this is what makes stop instant. + // server.close() alone waits for keep-alive connections which can linger + // for 20+ seconds if a browser tab is still open on that port. + entry.sockets.forEach(s => s.destroy()); + entry.sockets.clear(); + entry.server.close(() => { fs.rmSync(entry.tmpdir, { recursive: true, force: true }); delete hostedServers[port]; @@ -143,6 +272,24 @@ const controlServer = http.createServer(async (req, res) => { return; } + if (req.method === 'POST' && url === '/start-multi') { + const body = await readBody(req); + const { port, files, entryFile = 'index.html' } = body; + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + send(res, 400, { ok: false, error: 'Invalid port number' }); + return; + } + if (!Array.isArray(files) || files.length === 0) { + send(res, 400, { ok: false, error: 'No files provided' }); + return; + } + + const result = await startMultiServer(port, files, entryFile); + send(res, result.ok ? 200 : 400, result); + return; + } + if (req.method === 'POST' && url === '/stop') { const body = await readBody(req); const { port } = body; @@ -162,12 +309,12 @@ const controlServer = http.createServer(async (req, res) => { controlServer.listen(CONTROL_PORT, '127.0.0.1', () => { console.log(''); - console.log('┌──────────────────────────────────────────┐'); - console.log('│ Localhost Manager Pro — Control Server │'); - console.log(`│ Listening on http://127.0.0.1:${CONTROL_PORT} │`); - console.log('│ Keep this terminal open while using │'); - console.log('│ the extension. Ctrl+C to quit. │'); - console.log('└──────────────────────────────────────────┘'); + console.log(`${BLUE_TEXT}┌───────────────────────────────────────┐${RESET}`); + console.log(`${BLUE_TEXT}│ Localhost Manager — Control Server │${RESET}`); + console.log(`${BLUE_TEXT}│ Listening on http://127.0.0.1:${CONTROL_PORT} │${RESET}`); + console.log(`${BLUE_TEXT}│ Keep this terminal open while using │${RESET}`); + console.log(`${BLUE_TEXT}│ the extension. Ctrl+C to quit. │${RESET}`); + console.log(`${BLUE_TEXT}└───────────────────────────────────────┘${RESET}`); console.log(''); }); diff --git a/port_ext/servers/server.py b/port_ext/servers/server.py index bdb9162..8dacdaf 100644 --- a/port_ext/servers/server.py +++ b/port_ext/servers/server.py @@ -30,6 +30,9 @@ CONTROL_PORT = 8765 +BLUE_TEXT = "\033[96m" +RESET = "\033[0m" + # Registry of active hosted servers. # Maps port number (int) -> dict with keys: server, thread, tmpdir, sockets hosted_servers = {} @@ -79,6 +82,135 @@ def force_close_all_sockets(self): pass +MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2':'font/woff2', + '.ttf': 'font/ttf', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml', + '.pdf': 'application/pdf', +} + +def _get_mime(file_path): + ext = os.path.splitext(file_path)[1].lower() + return MIME_TYPES.get(ext, 'application/octet-stream') + + +def _make_multi_handler(tmpdir, entry_file='index.html'): + """Returns a handler that serves a full file tree with correct MIME types.""" + + class MultiFileHandler(BaseHTTPRequestHandler): + def do_GET(self): + self._serve() + + def do_HEAD(self): + self._serve(head_only=True) + + def _serve(self, head_only=False): + import urllib.parse + url_path = self.path.split('?')[0] + try: + url_path = urllib.parse.unquote(url_path) + except Exception: + pass + # Strip leading slashes and prevent path traversal + url_path = url_path.lstrip('/').replace('../', '') + file_path = os.path.join(tmpdir, url_path) + + # Directory → look for entry file + if os.path.isdir(file_path): + file_path = os.path.join(file_path, entry_file) + + def send_file(fp): + try: + with open(fp, 'rb') as f: + data = f.read() + mime = _get_mime(fp) + self.send_response(200) + self.send_header('Content-Type', mime) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + if not head_only: + self.wfile.write(data) + return True + except (FileNotFoundError, OSError): + return False + + if not send_file(file_path): + # Fall back to entry file (handles client-side routing) + root_index = os.path.join(tmpdir, entry_file) + if not send_file(root_index): + self.send_response(404) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + if not head_only: + self.wfile.write(f'404 Not Found: /{url_path}'.encode()) + + def log_message(self, fmt, *args): + pass + + return MultiFileHandler + + +def start_multi_server(port, files, entry_file='index.html'): + """Write a list of {path, content, encoding} dicts to a temp directory, + then serve the whole tree on the given port.""" + with hosted_servers_lock: + if port in hosted_servers: + return {"ok": False, "error": f"Port {port} is already hosted"} + + tmpdir = tempfile.mkdtemp(prefix='lm_pro_') + try: + for file in files: + # Sanitise path — no traversal, no absolute paths + safe_path = file['path'].replace('../', '').lstrip('/') + dest = os.path.join(tmpdir, safe_path) + os.makedirs(os.path.dirname(dest), exist_ok=True) + if file.get('encoding') == 'base64': + import base64 as _b64 + with open(dest, 'wb') as f: + f.write(_b64.b64decode(file['content'])) + else: + with open(dest, 'w', encoding='utf-8') as f: + f.write(file['content']) + except Exception as e: + shutil.rmtree(tmpdir, ignore_errors=True) + return {"ok": False, "error": f"Failed to write files: {e}"} + + handler_cls = _make_multi_handler(tmpdir, entry_file) + try: + server = _SocketTrackingServer(('127.0.0.1', port), handler_cls) + except OSError as e: + shutil.rmtree(tmpdir, ignore_errors=True) + return {"ok": False, "error": f"Could not bind port {port}: {e}"} + + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + with hosted_servers_lock: + hosted_servers[port] = {"server": server, "thread": thread, "tmpdir": tmpdir} + + print(f"[+] Hosting project ({len(files)} files) on http://localhost:{port}") + return {"ok": True, "port": port, "message": f"Serving on http://localhost:{port}"} + + def _make_hosted_handler(tmpdir): """Returns a request handler class bound to the given temp directory, always serving index.html regardless of the requested path.""" @@ -237,6 +369,23 @@ def do_POST(self): self._send_json(200 if result["ok"] else 400, result) return + if path == '/start-multi': + body = self._read_json_body() + port = body.get('port') + files = body.get('files') + entry_file = body.get('entryFile', 'index.html') or 'index.html' + + if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535): + self._send_json(400, {"ok": False, "error": "Invalid port number"}) + return + if not isinstance(files, list) or len(files) == 0: + self._send_json(400, {"ok": False, "error": "No files provided"}) + return + + result = start_multi_server(port, files, entry_file) + self._send_json(200 if result["ok"] else 400, result) + return + if path == '/stop': body = self._read_json_body() port = body.get('port') @@ -275,12 +424,12 @@ def main(): control_server = ControlServer(('127.0.0.1', CONTROL_PORT), ControlHandler) print() - print('┌───────────────────────────────────────┐') - print('│ Localhost Manager — Control Server │') - print(f'│ Listening on http://127.0.0.1:{CONTROL_PORT} │') - print('│ Keep this terminal open while using │') - print('│ the extension. Ctrl+C to quit. │') - print('└───────────────────────────────────────┘') + print(f'{BLUE_TEXT}┌───────────────────────────────────────┐{RESET}') + print(f'{BLUE_TEXT}│ Localhost Manager — Control Server │{RESET}') + print(f'{BLUE_TEXT}│ Listening on http://127.0.0.1:{CONTROL_PORT} │{RESET}') + print(f'{BLUE_TEXT}│ Keep this terminal open while using │{RESET}') + print(f'{BLUE_TEXT}│ the extension. Ctrl+C to quit. │{RESET}') + print(f'{BLUE_TEXT}└───────────────────────────────────────┘{RESET}') print() control_server.serve_forever() diff --git a/port_ext/templates/popup.html b/port_ext/templates/popup.html index 131c68c..66a0560 100644 --- a/port_ext/templates/popup.html +++ b/port_ext/templates/popup.html @@ -157,7 +157,8 @@ border-color: var(--accent-hover); } - input[type="number"] { + input[type="number"], + input[type="text"] { flex: 1; height: 32px; padding: 0 10px; @@ -170,11 +171,13 @@ background: var(--chrome-white); transition: border-color 0.15s; } - input[type="number"]:focus { + input[type="number"]:focus, + input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(26,115,232,0.2); } - input[type="number"]::placeholder { color: var(--chrome-text-secondary); } + input[type="number"]::placeholder, + input[type="text"]::placeholder { color: var(--chrome-text-secondary); } /* ── Status bar ── */ #status-bar { @@ -445,6 +448,7 @@ Rescan + + + +