feat(dashboard): native host onboarding when disconnected
CI / Build & Test (push) Successful in 3m19s
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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user