feat(dashboard, host): cross-repo parity — a11y, messaging, validation, tooling
CI / Build & Test (push) Has been cancelled
CI / Build & Test (push) Has been cancelled
- Dashboard: skip link, main landmark, focus-visible, toast live region, keyboard shortcuts modal (?), aria-current on nav, shared state-panel CSS, theme spacing tokens - messaging: sendToNativeResult + notifyNativeFailure; SSH/RDP use them for failures - Host: validate setVirtualHost/removeVirtualHost/startServer/stopServer in message-router; log on handler errors; logWarn in logger - SW: skipWaiting + clients.claim; register-sw controllerchange hook - Tests: test/paths.js + env HOLESAIL_HOST_ROOT / HOLESAIL_DASH_DATA_ROOT; mirror tests in Holesail; data/package.json for CJS under ESM root; eslint.config.cjs + lint script - Data: install-commands cross-repo note; extension/dashboard/data package.json (Browser)
This commit is contained in:
@@ -73,6 +73,7 @@ async function init() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
setupNavigation();
|
setupNavigation();
|
||||||
|
setupKeyboardShortcutsHelp();
|
||||||
setupEvents();
|
setupEvents();
|
||||||
setupCertValidator();
|
setupCertValidator();
|
||||||
setupSshEvents();
|
setupSshEvents();
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ function sendToNative(type, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like sendToNative but resolves with a normalized result (checks chrome.runtime.lastError).
|
||||||
|
* @returns {Promise<{ ok: true, data: object } | { ok: false, error: string }>}
|
||||||
|
*/
|
||||||
|
function sendToNativeResult(type, payload) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||||||
|
(response) => {
|
||||||
|
const lastErr = chrome.runtime.lastError;
|
||||||
|
if (lastErr) {
|
||||||
|
resolve({ ok: false, error: lastErr.message || 'Extension messaging failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response) {
|
||||||
|
resolve({ ok: false, error: 'No response from extension' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.error != null && response.ok !== true) {
|
||||||
|
resolve({ ok: false, error: String(response.error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.ok === false) {
|
||||||
|
resolve({ ok: false, error: response.error ? String(response.error) : 'Request failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ ok: true, data: response });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast for a failed native call (user-initiated actions).
|
||||||
|
* @param {string} [context] - Short label, e.g. "SSH"
|
||||||
|
* @param {string} [error] - Error message
|
||||||
|
*/
|
||||||
|
function notifyNativeFailure(context, error) {
|
||||||
|
const msg = error && String(error).trim() ? String(error) : 'Something went wrong';
|
||||||
|
const prefix = context && String(context).trim() ? String(context).trim() + ': ' : '';
|
||||||
|
if (typeof showToast === 'function') showToast(prefix + msg, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch the full extension state from the background service worker. */
|
/** Fetch the full extension state from the background service worker. */
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
@@ -25,16 +25,44 @@ const PAGE_TITLES = {
|
|||||||
* @param {string} page - Page key (e.g. `'dashboard'`, `'connections'`, `'ssh'`).
|
* @param {string} page - Page key (e.g. `'dashboard'`, `'connections'`, `'ssh'`).
|
||||||
*/
|
*/
|
||||||
function navigateTo(page) {
|
function navigateTo(page) {
|
||||||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(i => {
|
||||||
|
i.classList.remove('active');
|
||||||
|
i.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
|
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||||||
if (navItem) navItem.classList.add('active');
|
if (navItem) {
|
||||||
|
navItem.classList.add('active');
|
||||||
|
navItem.setAttribute('aria-current', 'page');
|
||||||
|
}
|
||||||
const pageEl = $(`page-${page}`);
|
const pageEl = $(`page-${page}`);
|
||||||
if (pageEl) pageEl.classList.add('active');
|
if (pageEl) pageEl.classList.add('active');
|
||||||
const titleEl = $('topbarTitle');
|
const titleEl = $('topbarTitle');
|
||||||
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
|
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the keyboard shortcuts dialog with ? (Shift+/ on US layouts) when focus is not in a field.
|
||||||
|
* Depends on modal.js (openModal, closeModal, topmostOpenModalBackdrop) — call after scripts load.
|
||||||
|
*/
|
||||||
|
function setupKeyboardShortcutsHelp() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (e.key !== '?' && !(e.shiftKey && e.key === '/')) return;
|
||||||
|
const t = e.target;
|
||||||
|
if (t && typeof t.closest === 'function' && t.closest('input, textarea, select, [contenteditable="true"]')) return;
|
||||||
|
const open = typeof topmostOpenModalBackdrop === 'function' ? topmostOpenModalBackdrop() : null;
|
||||||
|
if (open && open.id !== 'modal-keyboardShortcuts') return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (open && open.id === 'modal-keyboardShortcuts') {
|
||||||
|
closeModal('modal-keyboardShortcuts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal('modal-keyboardShortcuts');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach click and keyboard listeners to all `.nav-item` elements.
|
* Attach click and keyboard listeners to all `.nav-item` elements.
|
||||||
* Called once during dashboard initialisation.
|
* Called once during dashboard initialisation.
|
||||||
@@ -51,4 +79,6 @@ function setupNavigation() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const initial = document.querySelector('.nav-item.active[data-page]');
|
||||||
|
if (initial) initial.setAttribute('aria-current', 'page');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
--sidebar-w: 224px;
|
--sidebar-w: 224px;
|
||||||
--transition: 0.15s ease;
|
--transition: 0.15s ease;
|
||||||
--terminal-bg: #0d0d0f;
|
--terminal-bg: #0d0d0f;
|
||||||
|
/* Vertical rhythm: use for section/card gaps (theme / density tuning) */
|
||||||
|
--space-section: 16px;
|
||||||
|
--space-card-gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
@@ -76,6 +79,40 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skip link (visible on focus) */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
left: 12px;
|
||||||
|
top: 12px;
|
||||||
|
outline: 2px solid var(--cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visibility for keyboard / assistive tech */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
.btn:focus-visible,
|
||||||
|
.nav-item:focus-visible,
|
||||||
|
input:not([type="hidden"]):focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid var(--cyan-mid);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Scrollbar ─────────────────────────────────── */
|
/* ── Scrollbar ─────────────────────────────────── */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -628,6 +665,21 @@
|
|||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
|
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
|
||||||
|
.modal.modal--narrow { max-width: 360px; }
|
||||||
|
.keyboard-shortcuts-body { padding-top: 8px; }
|
||||||
|
.keyboard-shortcuts-hint { font-size: 13px; color: var(--text2); line-height: 1.5; margin: 0; }
|
||||||
|
.keyboard-shortcuts-list { margin: 12px 0 0; padding: 0 0 0 1.2em; }
|
||||||
|
.keyboard-shortcuts-list li { margin: 8px 0; font-size: 13px; color: var(--text2); line-height: 1.45; }
|
||||||
|
.kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -648,6 +700,34 @@
|
|||||||
}
|
}
|
||||||
.modal-error { font-size: 12px; color: var(--red); display: none; }
|
.modal-error { font-size: 12px; color: var(--red); display: none; }
|
||||||
|
|
||||||
|
/* ── Dashboard state panels (empty / loading / error) ─ */
|
||||||
|
.dashboard-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--elevated);
|
||||||
|
}
|
||||||
|
.dashboard-state__title { font-weight: 600; color: var(--text2); font-size: 14px; }
|
||||||
|
.dashboard-state__actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.dashboard-state--error { color: var(--red); border-color: var(--red-mid); border-style: solid; }
|
||||||
|
.dashboard-spinner {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 2px solid var(--border2);
|
||||||
|
border-top-color: var(--cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dashboard-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes dashboard-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* ── Confirm inline ────────────────────────────── */
|
/* ── Confirm inline ────────────────────────────── */
|
||||||
.confirm-inline {
|
.confirm-inline {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<script type="module" src="../vendor/novnc.js"></script>
|
<script type="module" src="../vendor/novnc.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
<!-- Boot splash: removed by init.js after first state refresh (sync-aware messaging). -->
|
<!-- Boot splash: removed by init.js after first state refresh (sync-aware messaging). -->
|
||||||
<div id="bootSplash" class="boot-splash" role="status" aria-live="polite" aria-busy="true">
|
<div id="bootSplash" class="boot-splash" role="status" aria-live="polite" aria-busy="true">
|
||||||
<div class="boot-splash__glow" aria-hidden="true"></div>
|
<div class="boot-splash__glow" aria-hidden="true"></div>
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── Main ─────────────────────────────────────── -->
|
<!-- ── Main ─────────────────────────────────────── -->
|
||||||
<main class="main">
|
<main class="main" id="main-content" tabindex="-1">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<span class="topbar-title" id="topbarTitle">Overview</span>
|
<span class="topbar-title" id="topbarTitle">Overview</span>
|
||||||
<div class="topbar-actions" id="topbarActions"></div>
|
<div class="topbar-actions" id="topbarActions"></div>
|
||||||
@@ -947,8 +948,31 @@
|
|||||||
</main>
|
</main>
|
||||||
</div><!-- /layout -->
|
</div><!-- /layout -->
|
||||||
|
|
||||||
<!-- ── Toast ─────────────────────────────────────────── -->
|
<!-- ── Toast (aria-live for screen reader announcements) ─ -->
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
<!-- ── Keyboard shortcuts (toggle with ? outside inputs) ─ -->
|
||||||
|
<div class="modal-backdrop" id="modal-keyboardShortcuts" aria-hidden="true">
|
||||||
|
<div class="modal modal--narrow" role="dialog" aria-modal="true" aria-labelledby="modal-keyboardShortcuts-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="modal-keyboardShortcuts-title">Keyboard shortcuts</span>
|
||||||
|
<button type="button" class="btn-icon" data-close-modal="modal-keyboardShortcuts" aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body keyboard-shortcuts-body">
|
||||||
|
<p class="keyboard-shortcuts-hint">Press <kbd class="kbd">?</kbd> to open or close this panel (when not typing in a field).</p>
|
||||||
|
<ul class="keyboard-shortcuts-list">
|
||||||
|
<li><kbd class="kbd">Enter</kbd> / <kbd class="kbd">Space</kbd> Activate sidebar item</li>
|
||||||
|
<li><kbd class="kbd">Escape</kbd> Close topmost dialog</li>
|
||||||
|
<li><kbd class="kbd">Tab</kbd> Move focus inside dialogs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-close-modal="modal-keyboardShortcuts">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Modal: Add Virtual Host ───────────────────────── -->
|
<!-- ── Modal: Add Virtual Host ───────────────────────── -->
|
||||||
<div class="modal-backdrop" id="modal-addVhost">
|
<div class="modal-backdrop" id="modal-addVhost">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* One-liner install commands for the native host, by platform.
|
* One-liner install commands for the native host, by platform.
|
||||||
* Used by the onboarding card when the native host is not found.
|
* Used by the onboarding card when the native host is not found.
|
||||||
|
* Mirrored in Holesail (`renderer/dashboard/data/install-commands.js`).
|
||||||
* Testable from Node (see test/install-commands.test.js).
|
* Testable from Node (see test/install-commands.test.js).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Remote Desktop page — connection grid, add/edit modal, VNC viewer (noVNC over WebSocket),
|
* Remote Desktop page — connection grid, add/edit modal, VNC viewer (noVNC over WebSocket),
|
||||||
* and RDP bitmap viewer (node-rdpjs-2 over WebSocket with offscreen canvas rendering).
|
* and RDP bitmap viewer (node-rdpjs-2 over WebSocket with offscreen canvas rendering).
|
||||||
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
|
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative, sendToNativeResult, notifyNativeFailure),
|
||||||
* ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
|
* ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ async function connectRdp(conn) {
|
|||||||
|
|
||||||
await disconnectRdp();
|
await disconnectRdp();
|
||||||
|
|
||||||
const result = await sendToNative('startRdpSession', {
|
const r = await sendToNativeResult('startRdpSession', {
|
||||||
type: conn.type,
|
type: conn.type,
|
||||||
hsUrl: conn.hsUrl,
|
hsUrl: conn.hsUrl,
|
||||||
port: conn.port,
|
port: conn.port,
|
||||||
@@ -407,14 +407,15 @@ async function connectRdp(conn) {
|
|||||||
label: conn.label || ''
|
label: conn.label || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result || !result.ok) {
|
if (!r.ok) {
|
||||||
|
notifyNativeFailure('Remote desktop', r.error);
|
||||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||||
$('rdpStateDisplay').textContent = 'Failed: ' + ((result && result.error) || 'Unknown error');
|
$('rdpStateDisplay').textContent = 'Failed: ' + (r.error || 'Unknown error');
|
||||||
$('rdpStateDisplay').style.color = 'var(--red)';
|
$('rdpStateDisplay').style.color = 'var(--red)';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, wsPort } = result;
|
const { sessionId, wsPort } = r.data;
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
|
|
||||||
if (conn.type === 'vnc') {
|
if (conn.type === 'vnc') {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Session lifecycle: start Holesail tunnel → open WebSocket → spawn SSH via PTY on native host
|
* Session lifecycle: start Holesail tunnel → open WebSocket → spawn SSH via PTY on native host
|
||||||
* → bridge PTY ↔ WebSocket ↔ xterm.js in the browser.
|
* → bridge PTY ↔ WebSocket ↔ xterm.js in the browser.
|
||||||
* Handles auto-reconnect with exponential backoff and PTY resize via ResizeObserver.
|
* Handles auto-reconnect with exponential backoff and PTY resize via ResizeObserver.
|
||||||
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
|
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative, sendToNativeResult, notifyNativeFailure),
|
||||||
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ async function _connectSshImpl(conn) {
|
|||||||
|
|
||||||
const cols = term.cols || 80;
|
const cols = term.cols || 80;
|
||||||
const rows = term.rows || 24;
|
const rows = term.rows || 24;
|
||||||
const result = await sendToNative('startSshSession', {
|
const r = await sendToNativeResult('startSshSession', {
|
||||||
hsUrl: conn.hsUrl,
|
hsUrl: conn.hsUrl,
|
||||||
username: conn.username,
|
username: conn.username,
|
||||||
password: conn.password || '',
|
password: conn.password || '',
|
||||||
@@ -269,8 +269,9 @@ async function _connectSshImpl(conn) {
|
|||||||
label: conn.label || conn.username
|
label: conn.label || conn.username
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result || !result.ok) {
|
if (!r.ok) {
|
||||||
const errMsg = (result && result.error) || 'Unknown error';
|
notifyNativeFailure('SSH', r.error);
|
||||||
|
const errMsg = r.error || 'Unknown error';
|
||||||
term.writeln('\x1b[31mFailed to start session: ' + errMsg + '\x1b[0m');
|
term.writeln('\x1b[31mFailed to start session: ' + errMsg + '\x1b[0m');
|
||||||
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
||||||
$('termStateDisplay').textContent = 'Error';
|
$('termStateDisplay').textContent = 'Error';
|
||||||
@@ -279,7 +280,7 @@ async function _connectSshImpl(conn) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, wsPort } = result;
|
const { sessionId, wsPort } = r.data;
|
||||||
term.writeln('\x1b[90mTunnel ready — connecting SSH…\x1b[0m');
|
term.writeln('\x1b[90mTunnel ready — connecting SSH…\x1b[0m');
|
||||||
|
|
||||||
let ws;
|
let ws;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
// Registers the PWA service worker required for installability.
|
// Registers the PWA service worker required for installability.
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('sw.js').catch(() => {});
|
navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
// New worker took control after reload; optional hook for future UX.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
// Minimal service worker required for PWA installability.
|
// Minimal service worker for PWA installability.
|
||||||
// No fetch handler needed — the extension bundle is already local
|
// skipWaiting + clients.claim so a new SW activates when the dashboard tab reloads.
|
||||||
// and Chrome does not require one for the install prompt since v112.
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function openModal(id) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
_modalPreviousFocus = document.activeElement;
|
_modalPreviousFocus = document.activeElement;
|
||||||
el.classList.add('open');
|
el.classList.add('open');
|
||||||
|
el.setAttribute('aria-hidden', 'false');
|
||||||
el.setAttribute('aria-modal', 'true');
|
el.setAttribute('aria-modal', 'true');
|
||||||
el.setAttribute('role', 'dialog');
|
el.setAttribute('role', 'dialog');
|
||||||
const modalTitle = el.querySelector('.modal-title');
|
const modalTitle = el.querySelector('.modal-title');
|
||||||
@@ -40,6 +41,7 @@ function closeModal(id) {
|
|||||||
const el = $(id);
|
const el = $(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.classList.remove('open');
|
el.classList.remove('open');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
el.removeAttribute('aria-modal');
|
el.removeAttribute('aria-modal');
|
||||||
el.removeAttribute('role');
|
el.removeAttribute('role');
|
||||||
el.removeAttribute('aria-labelledby');
|
el.removeAttribute('aria-labelledby');
|
||||||
@@ -61,6 +63,7 @@ function showModalError(modalId, errorId, msg) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
el.setAttribute('role', 'alert');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modals on backdrop click or close button
|
// Close modals on backdrop click or close button
|
||||||
|
|||||||
@@ -44,4 +44,12 @@ function debugLog(...args) {
|
|||||||
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { log, debugLog };
|
/** Non-fatal issues (also written to log file when available). */
|
||||||
|
function logWarn(...args) {
|
||||||
|
const msg = '[host:warn] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||||
|
if (process.stderr) process.stderr.write(msg + '\n');
|
||||||
|
const stream = getLogStream();
|
||||||
|
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { log, debugLog, logWarn };
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
|
|
||||||
const { STORAGE_PATH } = require('./paths.js');
|
const { STORAGE_PATH } = require('./paths.js');
|
||||||
const { log, debugLog } = require('./logger.js');
|
const { log, debugLog } = require('./logger.js');
|
||||||
|
const {
|
||||||
|
validateSetVirtualHost,
|
||||||
|
validateRemoveVirtualHost,
|
||||||
|
validateStartServer,
|
||||||
|
validateStopServer
|
||||||
|
} = require('./payload-schemas.js');
|
||||||
|
|
||||||
|
const PAYLOAD_VALIDATORS = new Map([
|
||||||
|
['setVirtualHost', validateSetVirtualHost],
|
||||||
|
['removeVirtualHost', validateRemoveVirtualHost],
|
||||||
|
['startServer', validateStartServer],
|
||||||
|
['stopServer', validateStopServer]
|
||||||
|
]);
|
||||||
const { initStartup, getProxiesReadyPromise, getTunnelsRestoredPromise, setTunnelsRestoredPromise, restorePersistedTunnels } = require('./startup.js');
|
const { initStartup, getProxiesReadyPromise, getTunnelsRestoredPromise, setTunnelsRestoredPromise, restorePersistedTunnels } = require('./startup.js');
|
||||||
const { buildHandlers } = require('./handlers/index.js');
|
const { buildHandlers } = require('./handlers/index.js');
|
||||||
|
|
||||||
@@ -112,12 +125,23 @@ async function handleMessageAsync(send, msg) {
|
|||||||
|
|
||||||
const handle = _handlers.get(type);
|
const handle = _handlers.get(type);
|
||||||
if (handle) {
|
if (handle) {
|
||||||
|
const validator = PAYLOAD_VALIDATORS.get(type);
|
||||||
|
if (validator) {
|
||||||
|
const v = validator(payload);
|
||||||
|
if (!v.ok) {
|
||||||
|
reply({ ok: false, error: v.error });
|
||||||
|
} else {
|
||||||
await handle(payload, reply);
|
await handle(payload, reply);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await handle(payload, reply);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
reply({ ok: false, error: `Unknown command: ${type}` });
|
reply({ ok: false, error: `Unknown command: ${type}` });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply({ ok: false, error: err.message });
|
log('handleMessage error:', type, err && err.message);
|
||||||
|
reply({ ok: false, error: err.message || String(err) });
|
||||||
if (process.stderr) {
|
if (process.stderr) {
|
||||||
process.stderr.write(`[holesail-browser-host] ${err.stack}\n`);
|
process.stderr.write(`[holesail-browser-host] ${err.stack}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
* Run with: node --test test/hostname-validator.test.js
|
* Run with: node --test test/hostname-validator.test.js
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { DASH_DATA_ROOT } = require('./paths.js');
|
||||||
require(path.join(__dirname, 'setup-validator.js'));
|
require(path.join(__dirname, 'setup-validator.js'));
|
||||||
const { isValidVhostHostname, extractBaseDomain, extractActiveTlds } = require(path.join(__dirname, '../extension/dashboard/data/hostname-validator.js'));
|
const { isValidVhostHostname, extractBaseDomain, extractActiveTlds } = require(path.join(DASH_DATA_ROOT, 'hostname-validator.js'));
|
||||||
const { test, describe } = require('node:test');
|
const { test, describe } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Run with: node --test test/install-commands.test.js
|
* Run with: node --test test/install-commands.test.js
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { getInstallCommand, INSTALL_SCRIPT_BASE_URL } = require(path.join(__dirname, '../extension/dashboard/data/install-commands.js'));
|
const { DASH_DATA_ROOT } = require('./paths.js');
|
||||||
|
const { getInstallCommand, INSTALL_SCRIPT_BASE_URL } = require(path.join(DASH_DATA_ROOT, 'install-commands.js'));
|
||||||
const { test, describe } = require('node:test');
|
const { test, describe } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
* Uses mocked dependencies so we don't need the full Bare runtime or real managers.
|
* Uses mocked dependencies so we don't need the full Bare runtime or real managers.
|
||||||
* Run with: node --test test/message-router.test.js
|
* Run with: node --test test/message-router.test.js
|
||||||
*
|
*
|
||||||
* Note: This test loads native-host/host/handlers/index.js and the handler modules.
|
* Note: This test loads host/host/handlers/index.js (see test/paths.js) and the handler modules.
|
||||||
* It does NOT load message-router.js or the real managers (holesail-manager, etc.),
|
* It does NOT load message-router.js or the real managers (holesail-manager, etc.),
|
||||||
* so we test the handler registry and handler logic with mocks.
|
* so we test the handler registry and handler logic with mocks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { buildHandlers } = require(path.join(__dirname, '../native-host/host/handlers/index.js'));
|
const { HOST_ROOT } = require('./paths.js');
|
||||||
|
const { buildHandlers } = require(path.join(HOST_ROOT, 'host/handlers/index.js'));
|
||||||
const { test, describe } = require('node:test');
|
const { test, describe } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Resolves native host and dashboard data roots for tests.
|
||||||
|
* Holesail (Pear) sets HOLESAIL_HOST_ROOT=host and HOLESAIL_DASH_DATA_ROOT=renderer/dashboard/data.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const HOST_ROOT = path.join(__dirname, '..', process.env.HOLESAIL_HOST_ROOT || 'native-host');
|
||||||
|
const DASH_DATA_ROOT = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
process.env.HOLESAIL_DASH_DATA_ROOT || 'extension/dashboard/data'
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = { HOST_ROOT, DASH_DATA_ROOT };
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
* Run with: node --test test/payload-schemas.test.js
|
* Run with: node --test test/payload-schemas.test.js
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { HOST_ROOT } = require('./paths.js');
|
||||||
const {
|
const {
|
||||||
validateSetVirtualHost,
|
validateSetVirtualHost,
|
||||||
validateRemoveVirtualHost,
|
validateRemoveVirtualHost,
|
||||||
validateStartServer,
|
validateStartServer,
|
||||||
validateStopServer
|
validateStopServer
|
||||||
} = require(path.join(__dirname, '../native-host/host/payload-schemas.js'));
|
} = require(path.join(HOST_ROOT, 'host/payload-schemas.js'));
|
||||||
const { test, describe } = require('node:test');
|
const { test, describe } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Must be required before hostname-validator so REAL_TLDS/REAL_SLD_TLDS are set.
|
* Must be required before hostname-validator so REAL_TLDS/REAL_SLD_TLDS are set.
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const tlds = require(path.join(__dirname, '../extension/dashboard/data/tlds.js'));
|
const { DASH_DATA_ROOT } = require('./paths.js');
|
||||||
|
const tlds = require(path.join(DASH_DATA_ROOT, 'tlds.js'));
|
||||||
global.REAL_TLDS = tlds.REAL_TLDS;
|
global.REAL_TLDS = tlds.REAL_TLDS;
|
||||||
global.REAL_SLD_TLDS = tlds.REAL_SLD_TLDS;
|
global.REAL_SLD_TLDS = tlds.REAL_SLD_TLDS;
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
* Run with: node --test test/tlds.test.js
|
* Run with: node --test test/tlds.test.js
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { REAL_TLDS, REAL_SLD_TLDS } = require(path.join(__dirname, '../extension/dashboard/data/tlds.js'));
|
const { DASH_DATA_ROOT } = require('./paths.js');
|
||||||
|
const { REAL_TLDS, REAL_SLD_TLDS } = require(path.join(DASH_DATA_ROOT, 'tlds.js'));
|
||||||
const { test, describe } = require('node:test');
|
const { test, describe } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user