fix(pwa): resolve proxy connection reset and manifest same-origin warnings
CI / Build & Test (push) Successful in 2m47s

Fix "Proxy error: connection reset by peer" caused by dashboard-server.js
looking for files in native-host/ instead of extension/dashboard/. Remove
the devBasePath parameter and resolve DEV_DASHBOARD_DIR directly from
__dirname so dev and distribution mode both find the correct files.

Fix all manifest warnings (start_url/id/scope/protocol_handlers ignored)
by reverting manifest.webmanifest to relative/extension-origin URLs for
the chrome-extension:// context, and having dashboard-server.js dynamically
generate a manifest at /manifest.webmanifest with absolute
https://my.dash.board/ URLs — satisfying Chrome's same-origin requirement
for PWA installability.

Also serve extension icons at https://my.dash.board/icons/<file> with
correct bundle key mapping, and embed them in the distributable binary
via build-distributable.js.
This commit is contained in:
Raven Scott
2026-03-01 03:10:11 -05:00
parent a838c3a345
commit e6658d4421
4 changed files with 90 additions and 29 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
{
"id": "https://my.dash.board/",
"id": "/dashboard/dashboard.html",
"name": "Holesail Browser",
"short_name": "Holesail",
"description": "P2P Holesail tunnels in the browser",
"start_url": "https://my.dash.board/",
"scope": "https://my.dash.board/",
"start_url": "dashboard.html",
"scope": ".",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#0f1117",
@@ -12,7 +12,7 @@
"protocol_handlers": [
{
"protocol": "web+holesail",
"url": "https://my.dash.board/?lookup=%s"
"url": "dashboard.html?lookup=%s"
}
],
"screenshots": [
+62 -19
View File
@@ -18,6 +18,11 @@ const path = require('bare-path');
const { allocateTunnelPort, releaseTunnelPort } = require('./holesail-manager/port-allocator.js');
// Resolve the extension/dashboard directory for dev-mode file serving.
// In dev mode, __dirname is native-host/ so the dashboard is one level up.
// In distribution mode this path is never used (files come from the bundle).
const DEV_DASHBOARD_DIR = path.join(__dirname, '..', 'extension', 'dashboard');
const DASHBOARD_HOSTNAME = 'my.dash.board';
const MIME_TYPES = {
@@ -35,7 +40,6 @@ const MIME_TYPES = {
let _server = null;
let _port = null;
let _baseDir = null; // fallback for dev mode
function _getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
@@ -43,51 +47,46 @@ function _getMimeType(filePath) {
}
/**
* 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').
* Read a file by its bundle key and disk-relative path.
* In distribution mode, reads from the bare-bundle asset embedded at build time.
* In development mode, reads from disk relative to DEV_DASHBOARD_DIR.
* @param {string} bundleKey - Key in the bare-bundle (e.g. '/dashboard/dashboard.html').
* @param {string} diskRelPath - Path relative to DEV_DASHBOARD_DIR (e.g. 'dashboard.html' or '../icons/48.png').
* @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(/^[./]+/, '');
function _readFileByKey(bundleKey, diskRelPath) {
// 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);
const buf = b.read(bundleKey);
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);
const fullPath = path.join(DEV_DASHBOARD_DIR, diskRelPath);
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) {
function start() {
return new Promise((resolve, reject) => {
if (_server) {
resolve({ port: _port });
return;
}
_baseDir = devBasePath || null;
_port = allocateTunnelPort();
const tcpServer = tcp.createServer();
@@ -98,9 +97,55 @@ function start(devBasePath) {
let urlPath = (req.url || '/').split('?')[0];
if (urlPath === '/') urlPath = '/dashboard.html';
// Serve a dynamically generated manifest with absolute https://my.dash.board/ URLs
// so Chrome accepts start_url, id, scope, and protocol_handlers (same-origin requirement).
if (urlPath === '/manifest.webmanifest') {
const manifest = {
id: 'https://' + DASHBOARD_HOSTNAME + '/',
name: 'Holesail Browser',
short_name: 'Holesail',
description: 'P2P Holesail tunnels in the browser',
start_url: 'https://' + DASHBOARD_HOSTNAME + '/',
scope: 'https://' + DASHBOARD_HOSTNAME + '/',
display: 'standalone',
display_override: ['window-controls-overlay', 'standalone'],
background_color: '#0f1117',
theme_color: '#0f1117',
protocol_handlers: [
{ protocol: 'web+holesail', url: 'https://' + DASHBOARD_HOSTNAME + '/?lookup=%s' }
],
screenshots: [
{ src: 'https://' + DASHBOARD_HOSTNAME + '/screenshots/wide.png', sizes: '1024x515', type: 'image/png', form_factor: 'wide', label: 'Holesail Browser Dashboard' },
{ src: 'https://' + DASHBOARD_HOSTNAME + '/screenshots/narrow.png', sizes: '390x844', type: 'image/png', form_factor: 'narrow', label: 'Holesail Browser Dashboard' }
],
icons: [
{ src: 'https://' + DASHBOARD_HOSTNAME + '/icons/48.png', sizes: '48x48', type: 'image/png', purpose: 'any' },
{ src: 'https://' + DASHBOARD_HOSTNAME + '/icons/128.png', sizes: '128x128', type: 'image/png', purpose: 'any' },
{ src: 'https://' + DASHBOARD_HOSTNAME + '/icons/192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'https://' + DASHBOARD_HOSTNAME + '/icons/512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }
]
};
const body = Buffer.from(JSON.stringify(manifest, null, 2));
res.statusCode = 200;
res.setHeader('Content-Type', 'application/manifest+json; charset=utf-8');
res.setHeader('Content-Length', body.length);
res.end(body);
return;
}
// Strip leading slash for file lookup
const relPath = urlPath.replace(/^\/+/, '');
const buf = _readFile(relPath);
let relPath = urlPath.replace(/^\/+/, '');
// Icons are stored in extension/icons/ (one level up from dashboard/).
// Remap /icons/<file> → ../icons/<file> for the bundle key and disk path.
let bundleKey = '/dashboard/' + relPath;
let diskRelPath = relPath;
if (relPath.startsWith('icons/')) {
bundleKey = '/' + relPath; // stored as /icons/<file> in bundle
diskRelPath = '../icons/' + relPath.slice('icons/'.length);
}
const buf = _readFileByKey(bundleKey, diskRelPath);
if (!buf) {
res.statusCode = 404;
@@ -112,8 +157,6 @@ function start(devBasePath) {
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', () => {});
+1 -2
View File
@@ -5,7 +5,6 @@
*/
const { log, debugLog } = require('./logger.js');
const { BASE_DIR } = require('./paths.js');
const dashboardServer = require('../dashboard-server.js');
const PROXY_PORT = 8443;
@@ -118,7 +117,7 @@ function initStartup(holesailManager, certificateAuthority, httpsProxy, connectP
// 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);
const { port: dashPort } = await dashboardServer.start();
holesailManager.setLocalVirtualHost(dashboardServer.getHostname(), dashPort);
log('Dashboard server ready on port', dashPort, '→ https://' + dashboardServer.getHostname() + '/');
} catch (e) {
+19
View File
@@ -37,6 +37,7 @@ 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 EXTENSION_ICONS_DIR = path.join(ROOT, 'extension', 'icons')
const ALL_HOSTS = [
'darwin-arm64',
@@ -281,6 +282,24 @@ module.exports = EventEmitter;
} else {
console.warn(' WARNING: extension/dashboard not found — dashboard assets not embedded')
}
// ── Embed extension icons as bundle assets ───────────────────────────────────
// Icons are served at https://my.dash.board/icons/<file> by dashboard-server.js.
// They live in extension/icons/ (outside the dashboard dir) so they need a
// separate embedding pass with keys like /icons/<file>.
if (fs.existsSync(EXTENSION_ICONS_DIR)) {
const iconFiles = walkDir(EXTENSION_ICONS_DIR)
let iconCount = 0
for (const file of iconFiles) {
const rel = path.relative(EXTENSION_ICONS_DIR, file).replace(/\\/g, '/')
const key = '/icons/' + rel
bundle.write(key, fs.readFileSync(file), { asset: true })
iconCount++
}
console.log(` Embedded ${iconCount} icon assets into bundle`)
} else {
console.warn(' WARNING: extension/icons not found — icon assets not embedded')
}
}
async function build(hosts, doPackage) {