feat(dashboard): native host onboarding when disconnected
CI / Build & Test (push) Successful in 3m19s

- Add install-command helper (getInstallCommand + platform detection)
  with OS-specific one-liners and configurable base URL
- Show onboarding card on Overview when !hostConnected: copy button,
  platform switcher (macOS/Linux / Windows), and "Check again" button
- "Check again" triggers refresh and shows success/disconnected toast
- refresh() returns state so callers can react to hostConnected
- Add unit tests for install-commands (all platforms, base URL)
- Document "Native host not found" flow in README and INSTALLATION
This commit is contained in:
Raven Scott
2026-03-15 01:12:29 -04:00
parent 02094e8605
commit 410fd87357
7 changed files with 215 additions and 3 deletions
+2
View File
@@ -136,6 +136,8 @@ The native host has no active tunnel for that hostname. Possible causes:
**Native host not connecting / `hostConnected: false`**
If the extension shows **Native host not found** in the sidebar or on the Overview page, open the dashboard — an onboarding card will show the one-liner install command for your OS. Run it in a terminal, then click **Check again** in the card.
- Make sure you haven't started `holesail-browser-host` manually from a terminal — only Chrome should spawn it via native messaging
- Check `~/.holesail-browser/holesail-browser.log` for errors
- Try reloading the extension at `chrome://extensions`
+2
View File
@@ -18,6 +18,8 @@ irm https://git.ssh.surf/snxraven/holesail-browser/raw/branch/main/scripts/insta
Or download `install.ps1` from the [latest release](https://git.ssh.surf/snxraven/holesail-browser/releases/tag/latest-main) and run it from PowerShell.
If the extension shows **Native host not found**, open the dashboard — the Overview page will show the one-liner install command for your OS. Run it in a terminal, then click **Check again** in the onboarding card.
---
## What the installer does
+23
View File
@@ -171,6 +171,28 @@
</div>
</div>
<!-- Onboarding: shown when native host is not connected -->
<div id="onboardingNativeHost" class="card" style="display:none;margin-bottom:14px;flex-shrink:0;border-color:var(--amber-mid);">
<div class="card-body">
<div class="section-heading" style="color:var(--amber);">Native host not found</div>
<p style="font-size:13px;color:var(--text2);margin-bottom:12px;">The extension needs the native host to be installed. Run the command below in a terminal for your operating system, then click <strong>Check again</strong>.</p>
<div style="margin-bottom:8px;font-size:12px;color:var(--text3);" id="onboardingPlatformLabel"></div>
<div style="display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap;">
<pre id="onboardingCommand" style="flex:1;min-width:0;margin:0;padding:10px 12px;background:var(--elevated);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:12px;font-family:'JetBrains Mono',monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-all;"></pre>
<button type="button" class="btn btn-secondary" id="onboardingCopyBtn" title="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><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>
Copy
</button>
</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<button type="button" class="btn btn-primary" id="onboardingCheckAgainBtn">Check again</button>
<div style="font-size:12px;color:var(--text4);align-self:center;">Or switch:</div>
<button type="button" class="btn btn-ghost btn-sm" id="onboardingSwitchMacLinux" data-platform="macos">macOS / Linux</button>
<button type="button" class="btn btn-ghost btn-sm" id="onboardingSwitchWin" data-platform="win32">Windows</button>
</div>
</div>
</div>
<!-- Stat cards — 6 across -->
<div class="stats-grid" style="grid-template-columns:repeat(6,1fr);margin-bottom:14px;flex-shrink:0;">
<div class="stat-card">
@@ -1284,6 +1306,7 @@
<!-- Data layer -->
<script src="data/tlds.js"></script>
<script src="data/hostname-validator.js"></script>
<script src="data/install-commands.js"></script>
<!-- Core utilities and state -->
<script src="core/utils.js"></script>
@@ -0,0 +1,47 @@
/**
* One-liner install commands for the native host, by platform.
* Used by the onboarding card when the native host is not found.
* Testable from Node (see test/install-commands.test.js).
*/
const INSTALL_SCRIPT_BASE_URL = 'https://git.ssh.surf/snxraven/holesail-browser/raw/branch/main/scripts/';
/**
* @param {string} baseUrl - Base URL for scripts (no trailing slash; we add / before filename).
* @param {'macos'|'linux'|'win32'} platform
* @returns {{ label: string, command: string }}
*/
function getInstallCommand(baseUrl, platform) {
const base = baseUrl.replace(/\/?$/, '/');
if (platform === 'win32') {
return {
label: 'Windows (PowerShell)',
command: `irm ${base}install.ps1 | iex`
};
}
if (platform === 'macos' || platform === 'linux') {
return {
label: platform === 'macos' ? 'macOS' : 'Linux',
command: `curl -fsSL ${base}web-installer.sh | bash`
};
}
return {
label: 'macOS / Linux',
command: `curl -fsSL ${base}web-installer.sh | bash`
};
}
/**
* Detect platform from navigator (browser only). Returns 'win32' | 'macos' | 'linux'.
*/
function detectPlatform() {
if (typeof navigator === 'undefined' || !navigator.platform) return 'linux';
const p = navigator.platform.toLowerCase();
if (p.includes('win')) return 'win32';
if (p.includes('mac')) return 'macos';
return 'linux';
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getInstallCommand, detectPlatform, INSTALL_SCRIPT_BASE_URL };
}
+67 -2
View File
@@ -2,8 +2,9 @@
* Overview page — summary stats, quick-action cards, and virtualized connection lists.
* Uses the OvList class for infinite-scroll, searchable tables of connections and servers.
* Depends on: core/utils.js ($, escapeHtml), ui/state-tag.js (stateTag),
* ui/modal.js (openModal), pages/ssh.js (openAddSshModal),
* pages/rdp.js (openAddRdpModal), core/state.js (sshConnections, rdpConnections)
* ui/modal.js (openModal), ui/toast.js (copyToClipboard, showToast),
* pages/ssh.js (openAddSshModal), pages/rdp.js (openAddRdpModal),
* core/state.js (sshConnections, rdpConnections), data/install-commands.js (getInstallCommand, detectPlatform, INSTALL_SCRIPT_BASE_URL)
*/
const OV_PAGE = 20; // rows per page
@@ -89,6 +90,22 @@ let _ovSvc = null;
let _ovSsh = null;
let _ovRdp = null;
/** Current platform for onboarding card (win32 | macos | linux). Set by detectPlatform or switcher. */
let _onboardingPlatform = null;
/**
* Update the onboarding card command and label for the given platform.
* @param {'macos'|'linux'|'win32'} platform
*/
function _updateOnboardingCommand(platform) {
if (typeof getInstallCommand === 'undefined' || typeof INSTALL_SCRIPT_BASE_URL === 'undefined') return;
const { label, command } = getInstallCommand(INSTALL_SCRIPT_BASE_URL, platform);
const labelEl = $('onboardingPlatformLabel');
const cmdEl = $('onboardingCommand');
if (labelEl) labelEl.textContent = label;
if (cmdEl) cmdEl.textContent = command;
}
/**
* Create and wire up the OvList instances for virtual hosts and server tunnels.
* Called once during dashboard initialisation.
@@ -188,6 +205,41 @@ function initOvLists() {
$('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null));
$('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null));
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
// Onboarding card (native host not found): Copy, Check again, platform switcher
const copyBtn = $('onboardingCopyBtn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const cmdEl = $('onboardingCommand');
if (cmdEl && typeof copyToClipboard === 'function') copyToClipboard(cmdEl.textContent, copyBtn);
});
}
const checkAgainBtn = $('onboardingCheckAgainBtn');
if (checkAgainBtn) {
checkAgainBtn.addEventListener('click', async () => {
if (typeof refresh !== 'function') return;
const state = await refresh();
if (state?.hostConnected) {
if (typeof showToast === 'function') showToast('Native host connected', 'success');
} else {
if (typeof showToast === 'function') showToast('Still disconnected. Install the native host using the command above.', 'default');
}
});
}
const switchMacLinux = $('onboardingSwitchMacLinux');
if (switchMacLinux) {
switchMacLinux.addEventListener('click', () => {
_onboardingPlatform = 'macos';
_updateOnboardingCommand('macos');
});
}
const switchWin = $('onboardingSwitchWin');
if (switchWin) {
switchWin.addEventListener('click', () => {
_onboardingPlatform = 'win32';
_updateOnboardingCommand('win32');
});
}
}
/**
@@ -251,6 +303,19 @@ function updateDashboard(state) {
const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
// Onboarding card: show when native host is disconnected
const onboardingEl = $('onboardingNativeHost');
if (onboardingEl) {
if (!state.hostConnected) {
onboardingEl.style.display = 'block';
if (_onboardingPlatform == null && typeof detectPlatform === 'function') _onboardingPlatform = detectPlatform();
_updateOnboardingCommand(_onboardingPlatform || 'linux');
} else {
onboardingEl.style.display = 'none';
_onboardingPlatform = null;
}
}
setText('swarmCount', servers.length);
setText('connCount', virtualHosts.length);
setText('serviceTunnelCount', serviceTunnels.length);
+2 -1
View File
@@ -63,7 +63,7 @@ function _pingLatencyBadges() {
* Fetch the latest extension state and update all dashboard page renderers.
* Also pings latency badges and syncs settings defaults.
* Called on a 2-second interval by `init.js`.
* @returns {Promise<void>}
* @returns {Promise<object|null>} The fetched state, or null.
*/
async function refresh() {
const state = await fetchState();
@@ -106,4 +106,5 @@ async function refresh() {
const sshCountEl = $('sshCount');
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
refreshBackups();
return state ?? null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Unit tests for extension/dashboard/data/install-commands.js
* Run with: node --test test/install-commands.test.js
*/
const path = require('path');
const { getInstallCommand, INSTALL_SCRIPT_BASE_URL } = require(path.join(__dirname, '../extension/dashboard/data/install-commands.js'));
const { test, describe } = require('node:test');
const assert = require('node:assert');
const BASE = 'https://example.com/scripts/';
describe('getInstallCommand', () => {
test('returns non-empty label and command for macos', () => {
const { label, command } = getInstallCommand(BASE, 'macos');
assert.ok(typeof label === 'string' && label.length > 0);
assert.ok(typeof command === 'string' && command.length > 0);
assert.ok(command.includes('web-installer.sh'), 'macos command should use web-installer.sh');
assert.ok(command.includes('example.com/scripts'), 'command should contain base URL');
});
test('returns non-empty label and command for linux', () => {
const { label, command } = getInstallCommand(BASE, 'linux');
assert.ok(typeof label === 'string' && label.length > 0);
assert.ok(typeof command === 'string' && command.length > 0);
assert.ok(command.includes('web-installer.sh'), 'linux command should use web-installer.sh');
assert.ok(command.includes('example.com/scripts'), 'command should contain base URL');
});
test('macos and linux use curl one-liner', () => {
const mac = getInstallCommand(BASE, 'macos');
const lin = getInstallCommand(BASE, 'linux');
assert.ok(mac.command.includes('curl -fsSL'));
assert.ok(lin.command.includes('curl -fsSL'));
assert.ok(mac.command.includes('| bash'));
assert.ok(lin.command.includes('| bash'));
});
test('returns non-empty label and command for win32', () => {
const { label, command } = getInstallCommand(BASE, 'win32');
assert.ok(typeof label === 'string' && label.length > 0);
assert.ok(typeof command === 'string' && command.length > 0);
assert.ok(command.includes('install.ps1'), 'win32 command should use install.ps1');
assert.ok(command.includes('example.com/scripts'), 'command should contain base URL');
});
test('win32 uses PowerShell irm pattern', () => {
const { command } = getInstallCommand(BASE, 'win32');
assert.ok(command.includes('irm '));
assert.ok(command.includes('| iex'));
});
test('normalizes base URL with or without trailing slash', () => {
const withSlash = getInstallCommand('https://x.org/scripts/', 'macos');
const noSlash = getInstallCommand('https://x.org/scripts', 'macos');
assert.ok(withSlash.command.includes('x.org/scripts/web-installer.sh'));
assert.ok(noSlash.command.includes('x.org/scripts/web-installer.sh'));
});
test('unknown platform falls back to macOS/Linux curl command', () => {
const { label, command } = getInstallCommand(BASE, 'other');
assert.ok(command.includes('web-installer.sh'));
assert.ok(command.includes('curl -fsSL'));
});
});
describe('INSTALL_SCRIPT_BASE_URL', () => {
test('is defined and points to scripts directory', () => {
assert.ok(typeof INSTALL_SCRIPT_BASE_URL === 'string');
assert.ok(INSTALL_SCRIPT_BASE_URL.length > 0);
assert.ok(INSTALL_SCRIPT_BASE_URL.includes('scripts'));
});
});