feat(pwa): experimental — add my.dash.board virtual host for secure PWA install
CI / Build & Test (push) Successful in 2m51s

Serve the dashboard over a local HTTPS virtual host (my.dash.board) so
Chrome treats it as a secure origin and shows the PWA install prompt.

- Add native-host/dashboard-server.js: bare-http1 static file server
  backed by bare-bundle assets in distribution mode, disk fallback in dev
- Add setLocalVirtualHost() to virtual-hosts.js; type:local entries are
  protected from removal and filtered out of saveState persistence
- Export setLocalVirtualHost from holesail-manager/index.js
- Start dashboard server in startup.js after proxies are ready and
  register my.dash.board as a local virtual host
- Stop dashboard server in message-router.js cleanup()
- Update manifest.webmanifest: start_url, id, scope → https://my.dash.board/
- Embed all extension/dashboard/ files as bare-bundle assets in
  build-distributable.js patchBundle() — no installer copy step needed
- Hide checkbox, hs:// URL, Reconnect, and Remove controls for built-in
  my.dash.board row in the virtual hosts table UI
This commit is contained in:
Raven Scott
2026-03-01 02:58:52 -05:00
parent cbc663cacb
commit a838c3a345
8 changed files with 255 additions and 14 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
{
"id": "/dashboard/dashboard.html",
"id": "https://my.dash.board/",
"name": "Holesail Browser",
"short_name": "Holesail",
"description": "P2P Holesail tunnels in the browser",
"start_url": "dashboard.html",
"scope": ".",
"start_url": "https://my.dash.board/",
"scope": "https://my.dash.board/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#0f1117",
@@ -12,7 +12,7 @@
"protocol_handlers": [
{
"protocol": "web+holesail",
"url": "dashboard.html?lookup=%s"
"url": "https://my.dash.board/?lookup=%s"
}
],
"screenshots": [
+11 -6
View File
@@ -54,18 +54,23 @@ function updateConnectionsTable(state) {
const openUrl = `https://${hostname}`;
const safeHostname = hostname.replace(/"/g, '"');
const needsReconnect = v.state === 'error' || v.state === 'closed';
const isBuiltIn = v.type === 'local';
return `
<tr>
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
<td style="width:32px;">${isBuiltIn ? '' : `<input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}">`}</td>
<td>
<span class="mono-chip">${escapeHtml(hostname)}</span>
${isBuiltIn ? '<span style="font-size:10px;color:var(--text4);margin-left:4px;">built-in</span>' : ''}
</td>
<td>
${isBuiltIn ? '<span style="color:var(--text4);font-size:11px;">—</span>' : `
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(hsUrl)}" style="color:var(--text3);font-size:11px;">${truncate(hsUrl, 28)}</span>
<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// URL" style="flex-shrink:0;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>
</div>
</div>`}
</td>
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
<td>
@@ -78,14 +83,14 @@ function updateConnectionsTable(state) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open
</a>
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" title="Reconnect tunnel">
${!isBuiltIn && needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" title="Reconnect tunnel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-danger btn-sm" data-remove-vhost="${safeHostname}">
${isBuiltIn ? '' : `<button class="btn btn-danger btn-sm" data-remove-vhost="${safeHostname}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
Remove
</button>
</button>`}
</div>
</td>
</tr>`;
+159
View File
@@ -0,0 +1,159 @@
/**
* Local HTTP server that serves the Holesail dashboard as a static site.
*
* In distribution mode (running as a standalone Bare binary), dashboard files
* are read from the bare-bundle assets embedded at build time via `require.bundle()`.
*
* In development mode (`bare index.mjs`), `require.bundle()` is not available,
* so files are read from disk relative to the source directory.
*
* The server is registered as the backend for the `my.dash.board` virtual host
* so the HTTPS proxy can serve the dashboard over a trusted HTTPS origin,
* satisfying Chrome's PWA installability requirement.
*/
const http1 = require('bare-http1');
const tcp = require('bare-tcp');
const path = require('bare-path');
const { allocateTunnelPort, releaseTunnelPort } = require('./holesail-manager/port-allocator.js');
const DASHBOARD_HOSTNAME = 'my.dash.board';
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml; charset=utf-8',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
let _server = null;
let _port = null;
let _baseDir = null; // fallback for dev mode
function _getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}
/**
* Read a dashboard file. In distribution mode, reads from the bare-bundle asset
* embedded at build time. In development mode, reads from disk.
* @param {string} relPath - Relative path within the dashboard directory (e.g. 'dashboard.html').
* @returns {Buffer|null} File contents or null if not found.
*/
function _readFile(relPath) {
// Normalise path separators and strip leading slashes/dots
const safe = relPath.replace(/\\/g, '/').replace(/^[./]+/, '');
// Distribution mode: read from bare-bundle assets
try {
if (typeof require.bundle === 'function') {
const b = require.bundle();
if (b) {
const buf = b.read('/dashboard/' + safe);
if (buf !== null) return buf;
}
}
} catch (_) {}
// Development mode: read from disk
if (_baseDir) {
try {
const fs = require('bare-fs');
const fullPath = path.join(_baseDir, safe);
return fs.readFileSync(fullPath);
} catch (_) {}
}
return null;
}
/**
* Start the dashboard HTTP server.
* @param {string} [devBasePath] - Base directory for dev-mode file serving (optional).
* @returns {Promise<{port: number}>}
*/
function start(devBasePath) {
return new Promise((resolve, reject) => {
if (_server) {
resolve({ port: _port });
return;
}
_baseDir = devBasePath || null;
_port = allocateTunnelPort();
const tcpServer = tcp.createServer();
tcpServer.on('connection', (socket) => {
const conn = new http1.ServerConnection(socket);
conn.on('request', (req, res) => {
let urlPath = (req.url || '/').split('?')[0];
if (urlPath === '/') urlPath = '/dashboard.html';
// Strip leading slash for file lookup
const relPath = urlPath.replace(/^\/+/, '');
const buf = _readFile(relPath);
if (!buf) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found: ' + relPath);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', _getMimeType(relPath));
res.setHeader('Content-Length', buf.length);
// Allow the extension's chrome.* APIs to work when loaded in a PWA window
res.setHeader('Access-Control-Allow-Origin', '*');
res.end(buf);
});
conn.on('error', () => {});
});
tcpServer.on('error', (err) => {
releaseTunnelPort(_port);
_port = null;
_server = null;
reject(err);
});
tcpServer.listen(_port, '127.0.0.1', () => {
_server = tcpServer;
if (process.stderr) {
process.stderr.write('[dashboard-server] listening on 127.0.0.1:' + _port + '\n');
}
resolve({ port: _port });
});
});
}
/**
* Stop the dashboard HTTP server and release its port.
*/
function stop() {
if (_server) {
try { _server.close(); } catch (_) {}
_server = null;
}
if (_port != null) {
releaseTunnelPort(_port);
_port = null;
}
}
/** @returns {string} The fixed hostname for the dashboard virtual host. */
function getHostname() { return DASHBOARD_HOSTNAME; }
/** @returns {number|null} The port the server is listening on, or null if not started. */
function getPort() { return _port; }
module.exports = { start, stop, getHostname, getPort };
+4 -3
View File
@@ -29,9 +29,9 @@ function saveState() {
const serversList = serversModule.getServers().map(s => ({
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
}));
const virtualHostsList = vhostsModule.getVirtualHosts().map(v => ({
hostname: v.hostname, hsUrl: v.hsUrl
}));
const virtualHostsList = vhostsModule.getVirtualHosts()
.filter(v => v.type !== 'local') // never persist built-in local hosts
.map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl }));
const serviceTunnelsList = svcModule.getServiceTunnels().map(t => ({
id: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort
}));
@@ -125,6 +125,7 @@ module.exports = {
getServers: serversModule.getServers,
// Virtual hosts
setVirtualHost: vhostsModule.setVirtualHost,
setLocalVirtualHost: vhostsModule.setLocalVirtualHost,
removeVirtualHost: vhostsModule.removeVirtualHost,
getVirtualHosts: vhostsModule.getVirtualHosts,
getLocalPortForHostname: vhostsModule.getLocalPortForHostname,
+25 -1
View File
@@ -129,6 +129,29 @@ async function setVirtualHost(payload) {
}
}
/**
* Register a built-in local virtual host that is served by an internal HTTP
* server rather than a Holesail P2P tunnel. The entry is marked `type: 'local'`
* so it is never persisted to state.json and cannot be removed by the user.
* @param {string} hostname - The hostname (e.g. `my.dash.board`).
* @param {number} localPort - The local port the internal server is listening on.
*/
function setLocalVirtualHost(hostname, localPort) {
virtualHosts.set(hostname, {
hostname,
hsUrl: null,
holesail: null,
localHost: TUNNEL_HOST,
localPort,
state: 'ready',
type: 'local',
createdAt: Date.now(),
reconnectTimer: null,
reconnectDelay: 0
});
debugLog('setLocalVirtualHost: hostname=', hostname, 'localPort=', localPort);
}
/**
* Remove a virtual host, close its tunnel, and release its local port.
* @param {object} payload
@@ -140,6 +163,7 @@ async function removeVirtualHost(payload) {
debugLog('removeVirtualHost: hostname=', hostname);
const entry = virtualHosts.get(hostname);
if (!entry) return { ok: false, error: 'Virtual host not found' };
if (entry.type === 'local') return { ok: false, error: 'Built-in host cannot be removed' };
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
if (entry.localPort) releaseTunnelPort(entry.localPort);
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
@@ -205,4 +229,4 @@ async function cleanupVirtualHosts() {
virtualHosts.clear();
}
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts, RECONNECT_BASE_MS, RECONNECT_MAX_MS };
module.exports = { init, setVirtualHost, setLocalVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts, RECONNECT_BASE_MS, RECONNECT_MAX_MS };
+2
View File
@@ -16,6 +16,7 @@ const certificateAuthority = require('../certificate-authority.js');
const httpsProxy = require('../https-proxy.js');
const connectProxy = require('../connect-proxy.js');
const sshManager = require('../ssh-manager.js');
const dashboardServer = require('../dashboard-server.js');
const backupManager = require('../backup-manager.js');
backupManager.setStoragePath(STORAGE_PATH);
backupManager.setCertsPath(certificateAuthority.getCertsDir());
@@ -343,6 +344,7 @@ async function handleMessageAsync(send, msg) {
function cleanup() {
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
dashboardServer.stop();
httpsProxy.stop(() => {});
connectProxy.stop(() => {});
sshManager.cleanup();
+14
View File
@@ -5,6 +5,8 @@
*/
const { log, debugLog } = require('./logger.js');
const { BASE_DIR } = require('./paths.js');
const dashboardServer = require('../dashboard-server.js');
const PROXY_PORT = 8443;
const CONNECT_PROXY_PORT = 8442;
@@ -111,6 +113,18 @@ function initStartup(holesailManager, certificateAuthority, httpsProxy, connectP
}
log('Proxies startup complete: HTTPS', savedProxyPort, 'CONNECT', connectProxy.getPort() ?? 'FAILED');
// Start the dashboard HTTP server and register it as the my.dash.board
// local virtual host so the HTTPS proxy can serve it over a trusted
// HTTPS origin (satisfying Chrome's PWA installability requirement).
try {
const { port: dashPort } = await dashboardServer.start(BASE_DIR);
holesailManager.setLocalVirtualHost(dashboardServer.getHostname(), dashPort);
log('Dashboard server ready on port', dashPort, '→ https://' + dashboardServer.getHostname() + '/');
} catch (e) {
log('Dashboard server startup failed:', e.message);
}
// Assign tunnelsRestoredPromise BEFORE resolving proxiesReadyPromise so
// that any getState call awaiting proxiesReadyPromise immediately sees the
// non-null promise rather than the one-microtask window where it is null.
+36
View File
@@ -36,6 +36,7 @@ const ROOT = path.join(__dirname, '..')
const NATIVE_HOST_DIR = path.join(ROOT, 'native-host')
const RELEASES_DIR = path.join(ROOT, 'releases')
const ENTRY = path.join(NATIVE_HOST_DIR, 'index.mjs')
const EXTENSION_DASHBOARD_DIR = path.join(ROOT, 'extension', 'dashboard')
const ALL_HOSTS = [
'darwin-arm64',
@@ -98,6 +99,24 @@ function getPlatformModule(host) {
}
}
/**
* Recursively list all files under a directory.
* @param {string} dir - Absolute path to the directory.
* @returns {string[]} Absolute paths to all files.
*/
function walkDir(dir) {
const results = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
results.push(...walkDir(full))
} else {
results.push(full)
}
}
return results
}
/**
* Patch bare-build's apple/sign.js to skip codesign when not on macOS.
* bare-build always calls `codesign --sign -` (ad-hoc) even when sign:false,
@@ -245,6 +264,23 @@ module.exports = EventEmitter;
bundle.write(eventsKey, Buffer.from(eventShim))
console.log(' Patched events module for node-rdpjs-2 compatibility')
}
// ── Embed dashboard static files as bundle assets ───────────────────────────
// The dashboard-server.js reads these via require.bundle().read('/dashboard/<rel>')
// at runtime, so no separate file copy step is needed in the installer.
if (fs.existsSync(EXTENSION_DASHBOARD_DIR)) {
const dashFiles = walkDir(EXTENSION_DASHBOARD_DIR)
let dashCount = 0
for (const file of dashFiles) {
const rel = path.relative(EXTENSION_DASHBOARD_DIR, file).replace(/\\/g, '/')
const key = '/dashboard/' + rel
bundle.write(key, fs.readFileSync(file), { asset: true })
dashCount++
}
console.log(` Embedded ${dashCount} dashboard assets into bundle`)
} else {
console.warn(' WARNING: extension/dashboard not found — dashboard assets not embedded')
}
}
async function build(hosts, doPackage) {