efactor(extension): modularize dashboard, background, and docs
CI / Build & Test (push) Failing after 28s
CI / Build & Test (push) Failing after 28s
Split monolithic dashboard.js (2753 lines) into 18 focused modules
under dashboard/{core,data,ui,pages}/ with refresh.js and events.js
as orchestrators. Extracted ~900-line inline <style> into dashboard.css
and moved dashboard.html to dashboard/dashboard.html.
Split background.js (609 lines) into background/{logs,state,proxy,
native-messaging,tab-lifecycle,message-router}.js with a thin entry
point using importScripts().
Deleted dead files: wrong-domain.js (duplicate of inline script).
Updated manifest.json web_accessible_resources for new paths.
Updated docs/ARCHITECTURE.md to reflect the new file structure.
No functionality changed. No build step introduced.
This commit is contained in:
+65
-4
@@ -9,7 +9,7 @@ Holesail Browser is composed of three parts: a browser extension, a native host
|
||||
│ Browser (Chrome / Firefox) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
|
||||
│ │ background.js│ │ dashboard.html / dashboard.js │ │
|
||||
│ │ background.js│ │ dashboard/dashboard.html │ │
|
||||
│ │ (service │◄──►│ (management UI — virtual hosts, │ │
|
||||
│ │ worker) │ │ SSH, RDP, backups, settings, logs) │ │
|
||||
│ └──────┬───────┘ └──────────────────────────────────────┘ │
|
||||
@@ -70,14 +70,75 @@ Holesail Browser is composed of three parts: a browser extension, a native host
|
||||
|
||||
### Extension (`extension/`)
|
||||
|
||||
#### Top-level files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `manifest.json` | Manifest V3. Permissions: `nativeMessaging`, `proxy`, `declarativeNetRequest`, `tabs`, `notifications`. Optional host permissions requested at runtime for custom TLDs. |
|
||||
| `background.js` | Service worker. Connects to native host via `connectNative`. Sets PAC script dynamically — includes `*.hole.sail` plus any custom TLDs from virtual hosts. Reconnects with exponential backoff (100ms → 30s). Routes dashboard requests to native host. Broadcasts tunnel events to subscribed tabs. |
|
||||
| `background.js` | Service worker entry point. Declares constants (`DEBUG_VERBOSE`, `browser` shim), then loads all background modules via `importScripts()` in dependency order, applies the initial PAC script, and connects to the native host. |
|
||||
| `content.js` | Minimal content script. Relays `holesail-host-disconnect` to the page as a `CustomEvent`. |
|
||||
| `dashboard.html/js` | Full management UI. 10 pages: Overview, Virtual Hosts, Server Tunnels, Service Tunnels, Proxy & CA, SSH, Remote Desktop, Backups, Logs, Settings. Each page title is shown only in the sticky topbar. The Overview page uses a flex layout: status bar → stat cards → equal-height scrollable list rows (Virtual Hosts + Server Tunnels on row 1; Service Tunnels + SSH + Remote Desktop on row 2) → sticky Quick Actions pinned at the bottom. |
|
||||
| `wrong-domain.html` | Error page for `*.host.test` (common typo), redirected via `declarativeNetRequest`. |
|
||||
|
||||
#### `background/` — service worker modules
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `logs.js` | In-memory log ring buffer (500 entries), `log()`/`debugLog()` helpers, `broadcastLogs()` to open dashboard tabs, `dashboardTabs` set. |
|
||||
| `state.js` | All shared mutable state: `extensionState`, `activeConnections`, `tabSwarms`, `swarmRefCount`, `pacConfirmedActive`, `notifyOnDisconnect`, port defaults. |
|
||||
| `proxy.js` | `applyPAC()` — builds and installs the PAC script; `clearProxy()`; `getActiveTlds()` helper; `proxy.settings.onChange` listener to re-apply if overridden. |
|
||||
| `native-messaging.js` | `connect()`, `send()`, `scheduleReconnect()`, `retryGetStateForConnectProxy()`. Owns the `port` reference, `pending` map, `subscribedTabs` set, and all `port.onMessage`/`port.onDisconnect` logic. |
|
||||
| `tab-lifecycle.js` | `tabs.onRemoved` listener — decrements swarm ref counts, destroys swarms when their last tab closes, cleans up `subscribedTabs` and `dashboardTabs`. |
|
||||
| `message-router.js` | `runtime.onMessage` dispatcher — handles `registerSwarm`, `send`, `subscribe`/`unsubscribe`, `registerDashboard`/`unregisterDashboard`, and `getState` actions. |
|
||||
|
||||
#### `dashboard/` — management UI
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `dashboard.html` | HTML shell + ordered `<script>` tags. Links `dashboard.css` and vendor scripts. No inline styles or logic. |
|
||||
| `dashboard.css` | All dashboard styles (~900 lines), extracted from the original inline `<style>` block. |
|
||||
| `refresh.js` | Top-level `refresh()` orchestrator — fetches state from background, syncs SSH/RDP connections and settings, calls all page `update*` functions. |
|
||||
| `events.js` | `setupEvents()` — wires toggle switches, settings save/reset, and calls each page's `setup*Events()` function. |
|
||||
|
||||
**`dashboard/core/`**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `utils.js` | `$()`, `log()`, `timeAgo()`, `formatUptime()`, `truncate()`, `escapeHtml()` |
|
||||
| `state.js` | All dashboard-level state variables: `currentState`, `settings`, `SETTINGS_DEFAULTS` |
|
||||
| `messaging.js` | `sendToNative()` and `fetchState()` — wraps `chrome.runtime.sendMessage` |
|
||||
| `navigation.js` | `PAGE_TITLES`, `navigateTo()`, `setupNavigation()` |
|
||||
| `init.js` | Entry point. Dashboard context guard, manifest version display, calls all `setup*` functions, starts polling interval. |
|
||||
|
||||
**`dashboard/data/`**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `tlds.js` | `REAL_TLDS` and `REAL_SLD_TLDS` sets (~300 lines of data) |
|
||||
| `hostname-validator.js` | `isValidVhostHostname()`, `extractBaseDomain()`, `extractActiveTlds()` |
|
||||
|
||||
**`dashboard/ui/`**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `toast.js` | `showToast()`, `copyToClipboard()` |
|
||||
| `modal.js` | `openModal()`, `closeModal()`, `showModalError()`, global close/escape handlers |
|
||||
| `state-tag.js` | `stateTag()` — renders coloured state badge HTML |
|
||||
|
||||
**`dashboard/pages/`**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `overview.js` | `OvList` class, `initOvLists()`, `updateDashboard()`, quick action button handlers |
|
||||
| `virtual-hosts.js` | `updateConnectionsTable()`, `setupVirtualHostEvents()` |
|
||||
| `servers.js` | `updateSwarmsTable()`, `setupServerEvents()` |
|
||||
| `service-tunnels.js` | `updateServiceTunnelsTable()`, `setupServiceTunnelEvents()` |
|
||||
| `proxy-ca.js` | `updateTabsTable()`, cert validator, `setupCertValidator()`, CA install logic |
|
||||
| `backups.js` | `updateBackupsTable()`, `refreshBackups()`, `setupBackupEvents()` |
|
||||
| `settings.js` | `updateSettingsUI()`, `saveSettings()` |
|
||||
| `ssh.js` | SSH state, `renderSshGrid()`, `connectSsh()`, xterm.js lifecycle, `setupSshEvents()` |
|
||||
| `rdp.js` | RDP/VNC state, `renderRdpGrid()`, `initVncViewer()`, `initRdpViewer()`, `setupRdpEvents()` |
|
||||
| `logs.js` | Log buffer, `updateLogsDisplay()`, filter/auto-scroll, `setupLogsEvents()` |
|
||||
|
||||
## Proxy architecture
|
||||
|
||||
The browser cannot connect directly to a custom HTTPS server via a proxy — it sends a `CONNECT` request instead. This requires two proxy layers:
|
||||
@@ -85,7 +146,7 @@ The browser cannot connect directly to a custom HTTPS server via a proxy — it
|
||||
```
|
||||
Browser navigates to https://myapp.hole.sail/
|
||||
│
|
||||
│ PAC script (applied by background.js):
|
||||
│ PAC script (applied by background/proxy.js):
|
||||
│ *.hole.sail → PROXY 127.0.0.1:8442
|
||||
│ *.custom.tld → PROXY 127.0.0.1:8442 (custom TLDs)
|
||||
│ everything else → DIRECT
|
||||
|
||||
+15
-570
@@ -1,367 +1,30 @@
|
||||
/**
|
||||
* Background service worker: maintains native messaging port and routes messages
|
||||
* between content scripts and the native host. Reconnects with backoff on disconnect.
|
||||
* Background service worker entry point.
|
||||
* Loads modules in dependency order via importScripts().
|
||||
*/
|
||||
|
||||
const HOST_NAME = 'com.holesail.browser';
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const INITIAL_RECONNECT_DELAY = 100;
|
||||
const SW_VERSION = 3; // increment to force service worker reload detection
|
||||
|
||||
/** Set to true for super detailed debug logging (every message, payload, storage, PAC). Set to false to reduce console noise. */
|
||||
/** Set to true for super detailed debug logging. */
|
||||
const DEBUG_VERBOSE = false;
|
||||
|
||||
function log(...args) {
|
||||
console.log('[Holesail-bg]', ...args);
|
||||
// Store logs for dashboard
|
||||
const timestamp = Date.now();
|
||||
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||
logs.push({ timestamp, message, level: 'info' });
|
||||
if (logs.length > MAX_LOGS) logs.shift();
|
||||
broadcastLogs();
|
||||
}
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_VERBOSE) return;
|
||||
log('[debug]', ...args);
|
||||
}
|
||||
|
||||
const logs = [];
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
function broadcastLogs() {
|
||||
// Send all buffered logs to any dashboard that's open
|
||||
for (const tabId of dashboardTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-logs', logs: logs.slice(0) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Track dashboard tabs
|
||||
const dashboardTabs = new Set();
|
||||
|
||||
const browser = (typeof chrome !== 'undefined' && chrome.runtime?.connectNative)
|
||||
? chrome
|
||||
: (globalThis.browser ?? chrome);
|
||||
|
||||
let port = null;
|
||||
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
let reconnectTimer = null;
|
||||
|
||||
// Pending requests: id -> { resolve, reject }
|
||||
const pending = new Map();
|
||||
|
||||
// Tabs that have subscribed to native host events (content script registered)
|
||||
const subscribedTabs = new Set();
|
||||
|
||||
// Track which swarmIds belong to which tab: tabId -> Set<swarmId>
|
||||
const tabSwarms = new Map();
|
||||
// Track how many tabs are using each swarm: swarmId -> count
|
||||
const swarmRefCount = new Map();
|
||||
|
||||
// Track active connections from native host: connId -> { swarmId, peerKey, createdAt }
|
||||
const activeConnections = new Map();
|
||||
|
||||
let notifyOnDisconnect = false;
|
||||
|
||||
// Extension state for the UI
|
||||
let pacConfirmedActive = false; // true once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
|
||||
const extensionState = {
|
||||
hostConnected: false,
|
||||
proxyPort: 8443,
|
||||
connectProxyPort: null, // PAC uses this when set (CONNECT proxy); else proxyPort
|
||||
servers: [], // Holesail server tunnels
|
||||
virtualHosts: [], // virtual hostname -> hsUrl mappings
|
||||
stats: {
|
||||
totalConnections: 0,
|
||||
uptime: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_PORT = 8443;
|
||||
/** CONNECT proxy port (browser must send CONNECT here, not to HTTPS port). Use when host omits connectProxyPort. */
|
||||
const DEFAULT_CONNECT_PROXY_PORT = 8442;
|
||||
|
||||
/** Extract unique two-label base domains from virtualHosts array, always including hole.sail */
|
||||
function getActiveTlds(virtualHosts) {
|
||||
const seen = new Set(['hole.sail']);
|
||||
for (const v of (virtualHosts || [])) {
|
||||
if (v && v.hostname) {
|
||||
const parts = v.hostname.split('.');
|
||||
if (parts.length >= 3) seen.add(parts.slice(-2).join('.'));
|
||||
}
|
||||
}
|
||||
return Array.from(seen).map(b => '.' + b);
|
||||
}
|
||||
|
||||
function applyPAC(tlds) {
|
||||
if (!browser.proxy || !browser.proxy.settings) {
|
||||
log('applyPAC: SKIPPED - no browser.proxy.settings API');
|
||||
return;
|
||||
}
|
||||
const port = extensionState.connectProxyPort ?? DEFAULT_CONNECT_PROXY_PORT ?? extensionState.proxyPort ?? DEFAULT_PROXY_PORT;
|
||||
const activeTlds = tlds || getActiveTlds(extensionState.virtualHosts);
|
||||
log('applyPAC: setting PAC port=', port, 'tlds=', activeTlds);
|
||||
|
||||
const clauses = activeTlds.map(t =>
|
||||
` if (dnsDomainIs(host, "${t}")) return "PROXY 127.0.0.1:${port}";`
|
||||
).join('\n');
|
||||
const pacData = `function FindProxyForURL(url, host) {\n${clauses}\n return "DIRECT";\n}`;
|
||||
|
||||
browser.proxy.settings.set(
|
||||
{ value: { mode: 'pac_script', pacScript: { data: pacData, mandatory: false } }, scope: 'regular' },
|
||||
() => {
|
||||
if (browser.runtime.lastError) {
|
||||
log('applyPAC: ERROR:', browser.runtime.lastError.message);
|
||||
pacConfirmedActive = false;
|
||||
return;
|
||||
}
|
||||
browser.proxy.settings.get({}, (details) => {
|
||||
if (browser.runtime.lastError) {
|
||||
log('applyPAC: get ERROR:', browser.runtime.lastError.message);
|
||||
return;
|
||||
}
|
||||
const mode = details && details.value && details.value.mode;
|
||||
const loc = details && details.levelOfControl;
|
||||
log('applyPAC: mode=', mode, 'levelOfControl=', loc);
|
||||
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
|
||||
pacConfirmedActive = true;
|
||||
log('applyPAC: ACTIVE');
|
||||
} else {
|
||||
pacConfirmedActive = false;
|
||||
log('applyPAC: WARNING not active - mode=', mode, 'loc=', loc);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Re-apply PAC whenever proxy settings change (e.g. another extension or system override)
|
||||
if (browser.proxy && browser.proxy.settings && browser.proxy.settings.onChange) {
|
||||
browser.proxy.settings.onChange.addListener((details) => {
|
||||
const loc = details && details.levelOfControl;
|
||||
const mode = details && details.value && details.value.mode;
|
||||
log('proxy.settings.onChange: levelOfControl=', loc, 'mode=', mode);
|
||||
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
|
||||
pacConfirmedActive = true;
|
||||
return;
|
||||
}
|
||||
pacConfirmedActive = false;
|
||||
log('proxy settings changed externally - reapplying PAC');
|
||||
applyPAC();
|
||||
});
|
||||
}
|
||||
|
||||
function clearProxy() {
|
||||
debugLog('clearProxy');
|
||||
if (!browser.proxy || !browser.proxy.settings) return;
|
||||
browser.proxy.settings.set({ value: { mode: 'direct' }, scope: 'regular' }).catch(() => {});
|
||||
}
|
||||
|
||||
function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
|
||||
const maxAttempts = 3;
|
||||
if (attempt >= maxAttempts || !port) return;
|
||||
const delayMs = delaySeconds * (attempt + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
if (!port) return;
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
if (payload && payload.ok && payload.connectProxyPort != null) {
|
||||
extensionState.connectProxyPort = payload.connectProxyPort;
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || extensionState.virtualHosts;
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
} else if (payload && payload.ok && attempt + 1 < maxAttempts) {
|
||||
retryGetStateForConnectProxy(delaySeconds, attempt + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function updateExtensionState() {
|
||||
extensionState.hostConnected = !!port;
|
||||
extensionState.stats.totalConnections = activeConnections.size;
|
||||
}
|
||||
|
||||
|
||||
function connect() {
|
||||
debugLog('connect: attempting connectNative', HOST_NAME);
|
||||
try {
|
||||
port = browser.runtime.connectNative(HOST_NAME);
|
||||
log('Connected to native host');
|
||||
debugLog('connect: connected, fetching getState then apply/clear PAC');
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
if (payload && payload.ok) {
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || [];
|
||||
// Sync notifyOnDisconnect from persisted settings
|
||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||
}
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
|
||||
} else {
|
||||
applyPAC(); // Apply PAC even if getState fails - use default port
|
||||
}
|
||||
})
|
||||
.catch(() => applyPAC()); // Apply PAC even on error - use default port
|
||||
} catch (e) {
|
||||
log('connectNative failed:', e);
|
||||
debugLog('connect: failed', e.message, e.stack);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
port.onMessage.addListener((msg) => {
|
||||
debugLog('onMessage: full msg=', JSON.stringify(msg));
|
||||
log('Received from native:', msg.type, msg.event || '');
|
||||
|
||||
// Track connection events
|
||||
if (msg.type === 'event') {
|
||||
const payload = msg.payload || {};
|
||||
if (msg.event === 'connection') {
|
||||
const connId = payload.connId;
|
||||
const swarmId = payload.swarmId;
|
||||
const peerInfo = payload.peerInfo || {};
|
||||
log('Connection established:', connId, 'peer:', peerInfo.publicKey?.slice(0, 8));
|
||||
activeConnections.set(connId, {
|
||||
connId,
|
||||
swarmId,
|
||||
peerKey: peerInfo.publicKey || '',
|
||||
createdAt: Date.now()
|
||||
});
|
||||
updateExtensionState();
|
||||
} else if (msg.event === 'error') {
|
||||
const connId = payload.connId;
|
||||
const errorMsg = payload.message || 'Unknown error';
|
||||
log('Connection ERROR:', connId, errorMsg);
|
||||
activeConnections.delete(connId);
|
||||
updateExtensionState();
|
||||
} else if (msg.event === 'end') {
|
||||
const connId = payload.connId;
|
||||
log('Connection ended:', connId);
|
||||
activeConnections.delete(connId);
|
||||
updateExtensionState();
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'response' && msg.id != null) {
|
||||
debugLog('onMessage: response id=', msg.id, 'payloadKeys=', msg.payload && Object.keys(msg.payload), 'payload=', msg.payload && JSON.stringify(msg.payload).slice(0, 300));
|
||||
const p = pending.get(msg.id);
|
||||
if (p) {
|
||||
pending.delete(msg.id);
|
||||
if (msg.payload && msg.payload.error) {
|
||||
p.reject(new Error(msg.payload.error));
|
||||
} else {
|
||||
p.resolve(msg.payload);
|
||||
}
|
||||
} else {
|
||||
debugLog('onMessage: response id=', msg.id, 'no pending handler');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'event') {
|
||||
const p = msg.payload || {};
|
||||
const eventSwarmId = p.swarmId;
|
||||
const isTunnelEvent = msg.event === 'tunnelReady' || msg.event === 'tunnelClosed' || msg.event === 'tunnelError';
|
||||
if (isTunnelEvent) {
|
||||
log('Broadcasting event to tabs:', msg.event, 'hostname:', p.hostname);
|
||||
} else {
|
||||
log('Broadcasting event to tabs:', msg.event, 'connId:', p.connId, 'swarmId:', eventSwarmId);
|
||||
}
|
||||
if (eventSwarmId != null) {
|
||||
for (const [tabId, swarmIds] of tabSwarms) {
|
||||
if (swarmIds.has(eventSwarmId)) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTunnelEvent) {
|
||||
for (const tabId of subscribedTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
debugLog('onDisconnect');
|
||||
log('Native host disconnected');
|
||||
extensionState.hostConnected = false;
|
||||
activeConnections.clear();
|
||||
updateExtensionState();
|
||||
clearProxy();
|
||||
port = null;
|
||||
if (notifyOnDisconnect && browser.notifications) {
|
||||
browser.notifications.create('holesail-host-disconnect', {
|
||||
type: 'basic',
|
||||
title: 'Holesail Browser',
|
||||
message: 'Native host disconnected.',
|
||||
iconUrl: browser.runtime.getURL('icons/48.png'),
|
||||
}).catch(() => {});
|
||||
}
|
||||
const err = new Error('Native host disconnected');
|
||||
for (const [id, p] of pending) {
|
||||
p.reject(err);
|
||||
}
|
||||
pending.clear();
|
||||
for (const tabId of subscribedTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-host-disconnect' }).catch(() => {});
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
function send(msg) {
|
||||
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
msg.id = id;
|
||||
debugLog('send: id=', id, 'type=', msg.type, 'payload=', msg.payload && JSON.stringify(msg.payload));
|
||||
log('Sending to native:', msg.type, msg.payload?.swarmId || '', msg.payload?.connId || '');
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!port) {
|
||||
debugLog('send: no port, rejecting');
|
||||
reject(new Error('Native host not connected'));
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
debugLog('send: timeout for id=', id, 'type=', msg.type);
|
||||
reject(new Error('Request timed out: ' + msg.type));
|
||||
}
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
pending.set(id, {
|
||||
resolve: (v) => { clearTimeout(timeoutId); resolve(v); },
|
||||
reject: (e) => { clearTimeout(timeoutId); reject(e); }
|
||||
});
|
||||
try {
|
||||
port.postMessage(msg);
|
||||
} catch (e) {
|
||||
pending.delete(id);
|
||||
clearTimeout(timeoutId);
|
||||
debugLog('send: postMessage threw', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
importScripts(
|
||||
'background/logs.js',
|
||||
'background/state.js',
|
||||
'background/proxy.js',
|
||||
'background/native-messaging.js',
|
||||
'background/tab-lifecycle.js',
|
||||
'background/message-router.js'
|
||||
);
|
||||
|
||||
log('background: SW started v' + SW_VERSION);
|
||||
applyPAC(); // Apply PAC immediately on startup using default port; updated once native host connects
|
||||
|
||||
// Apply PAC immediately on startup using default port; updated once native host connects
|
||||
applyPAC();
|
||||
connect();
|
||||
|
||||
// Redirect *.host.test (and similar typos) to extension page so we can show "use .hole.sail"
|
||||
@@ -385,225 +48,7 @@ if (browser.declarativeNetRequest && browser.runtime.getURL) {
|
||||
}).catch((err) => log('declarativeNetRequest rule failed:', err.message));
|
||||
}
|
||||
|
||||
// Handle tab closure - destroy swarms only when last tab using them closes
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
log('Tab closed:', tabId);
|
||||
const swarmIds = tabSwarms.get(tabId);
|
||||
if (swarmIds) {
|
||||
for (const swarmId of swarmIds) {
|
||||
const count = (swarmRefCount.get(swarmId) || 1) - 1;
|
||||
swarmRefCount.set(swarmId, count);
|
||||
log('Swarm', swarmId, 'refcount now:', count);
|
||||
// Only destroy when last tab closes
|
||||
if (count <= 0) {
|
||||
log('Last tab for swarm', swarmId, '- destroying');
|
||||
send({ type: 'destroy', payload: { swarmId } }).catch(() => {});
|
||||
swarmRefCount.delete(swarmId);
|
||||
}
|
||||
}
|
||||
tabSwarms.delete(tabId);
|
||||
}
|
||||
subscribedTabs.delete(tabId);
|
||||
dashboardTabs.delete(tabId);
|
||||
});
|
||||
|
||||
// Content script talks to background via runtime.sendMessage / onMessage
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
let responded = false;
|
||||
function reply(value) {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
try { sendResponse(value); } catch (_) {}
|
||||
}
|
||||
|
||||
if (message.target !== 'holesail-native') {
|
||||
reply({ error: 'Unknown target' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle registerSwarm locally (legacy) - don't forward to native host
|
||||
if (message.action === 'send' && message.payload?.type === 'registerSwarm') {
|
||||
const swarmId = message.payload?.payload?.swarmId;
|
||||
if (swarmId && sender.tab?.id != null) {
|
||||
if (!tabSwarms.has(sender.tab.id)) {
|
||||
tabSwarms.set(sender.tab.id, new Set());
|
||||
}
|
||||
tabSwarms.get(sender.tab.id).add(swarmId);
|
||||
// Increment reference count
|
||||
const count = swarmRefCount.get(swarmId) || 0;
|
||||
swarmRefCount.set(swarmId, count + 1);
|
||||
log('Registered swarm', swarmId, 'for tab', sender.tab.id, '- refcount:', count + 1);
|
||||
}
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'send') {
|
||||
send(message.payload)
|
||||
.then((r) => {
|
||||
// After a successful setVirtualHost or removeVirtualHost, refresh virtualHosts
|
||||
// and re-apply the PAC so the new TLD is routed immediately
|
||||
if (r && r.ok && (
|
||||
message.payload?.type === 'setVirtualHost' ||
|
||||
message.payload?.type === 'removeVirtualHost'
|
||||
)) {
|
||||
send({ type: 'getState', payload: {} }).then((state) => {
|
||||
if (state && state.ok && Array.isArray(state.virtualHosts)) {
|
||||
extensionState.virtualHosts = state.virtualHosts;
|
||||
const newTlds = getActiveTlds(extensionState.virtualHosts);
|
||||
applyPAC(newTlds);
|
||||
// Request host permission for any new TLD not already covered
|
||||
if (message.payload?.type === 'setVirtualHost' && message.payload?.payload?.hostname) {
|
||||
const hostname = message.payload.payload.hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 3) {
|
||||
const baseDomain = parts.slice(-2).join('.');
|
||||
if (baseDomain !== 'hole.sail') {
|
||||
const origin = '*://*.' + baseDomain + '/*';
|
||||
browser.permissions.request({ origins: [origin] }, (granted) => {
|
||||
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
reply(r);
|
||||
})
|
||||
.catch((err) => reply({ error: err.message }));
|
||||
return true; // async response
|
||||
}
|
||||
if (message.action === 'subscribe' && sender.tab && sender.tab.id != null) {
|
||||
subscribedTabs.add(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message.action === 'unsubscribe' && sender.tab && sender.tab.id != null) {
|
||||
subscribedTabs.delete(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle dashboard registration
|
||||
if (message.action === 'registerDashboard' && sender.tab && sender.tab.id != null) {
|
||||
dashboardTabs.add(sender.tab.id);
|
||||
log('Dashboard registered for tab', sender.tab.id);
|
||||
reply({ ok: true, logs: logs.slice(0) });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'unregisterDashboard' && sender.tab && sender.tab.id != null) {
|
||||
dashboardTabs.delete(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle getState request from UI (host may augment via getState response when implemented)
|
||||
if (message.action === 'getState') {
|
||||
debugLog('getState action: port=', !!port);
|
||||
updateExtensionState();
|
||||
if (port) {
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
debugLog('getState response: ok=', payload && payload.ok, 'servers=', payload && payload.servers && payload.servers.length, 'virtualHosts=', payload && payload.virtualHosts && payload.virtualHosts.length, 'proxyPort=', payload && payload.proxyPort, 'connectProxyPort=', payload && payload.connectProxyPort);
|
||||
if (payload && payload.ok === true && (payload.servers || payload.virtualHosts || payload.proxyPort != null || payload.connectProxyPort != null)) {
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || [];
|
||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||
}
|
||||
if (!pacConfirmedActive) {
|
||||
log('getState: PAC not confirmed active, re-applying');
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
}
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: true,
|
||||
servers: payload.servers || [],
|
||||
virtualHosts: payload.virtualHosts || [],
|
||||
serviceTunnels: payload.serviceTunnels || [],
|
||||
proxyPort: payload.proxyPort ?? null,
|
||||
connectProxyPort: payload.connectProxyPort ?? null,
|
||||
caInstalled: payload.caInstalled ?? false,
|
||||
settings: payload.settings || {},
|
||||
sshConnections: payload.sshConnections || [],
|
||||
rdpConnections: payload.rdpConnections || [],
|
||||
stats: { ...extensionState.stats, ...payload.stats }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: true,
|
||||
servers: extensionState.servers || [],
|
||||
virtualHosts: extensionState.virtualHosts || [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLog('getState send failed:', err && err.message);
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: extensionState.hostConnected,
|
||||
servers: extensionState.servers || [],
|
||||
virtualHosts: extensionState.virtualHosts || [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
debugLog('getState: no port, replying hostConnected=false');
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: false,
|
||||
servers: [],
|
||||
virtualHosts: [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown action: respond so the port is not left hanging
|
||||
reply({ error: 'Unknown action' });
|
||||
return false;
|
||||
});
|
||||
|
||||
// Open dashboard when extension icon is clicked
|
||||
browser.action?.onClicked?.addListener((tab) => {
|
||||
browser.tabs.create({ url: 'dashboard.html' });
|
||||
browser.tabs.create({ url: 'dashboard/dashboard.html' });
|
||||
});
|
||||
|
||||
// Log proxy errors for *.hole.sail to help diagnose issues
|
||||
if (browser.proxy && browser.proxy.onError) {
|
||||
browser.proxy.onError.addListener((details) => {
|
||||
log('Proxy error:', details.error, 'url=', details.url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Log buffer and broadcasting to open dashboard tabs.
|
||||
// Depends on: (none - loaded first)
|
||||
|
||||
const logs = [];
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
// Track dashboard tabs
|
||||
const dashboardTabs = new Set();
|
||||
|
||||
function log(...args) {
|
||||
console.log('[Holesail-bg]', ...args);
|
||||
const timestamp = Date.now();
|
||||
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||
logs.push({ timestamp, message, level: 'info' });
|
||||
if (logs.length > MAX_LOGS) logs.shift();
|
||||
broadcastLogs();
|
||||
}
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_VERBOSE) return;
|
||||
log('[debug]', ...args);
|
||||
}
|
||||
|
||||
function broadcastLogs() {
|
||||
for (const tabId of dashboardTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-logs', logs: logs.slice(0) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Routes chrome.runtime.onMessage requests from content scripts and the dashboard.
|
||||
// Depends on: state.js, logs.js, proxy.js (getActiveTlds, applyPAC, pacConfirmedActive),
|
||||
// native-messaging.js (send, port, subscribedTabs, dashboardTabs)
|
||||
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
let responded = false;
|
||||
function reply(value) {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
try { sendResponse(value); } catch (_) {}
|
||||
}
|
||||
|
||||
if (message.target !== 'holesail-native') {
|
||||
reply({ error: 'Unknown target' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle registerSwarm locally (legacy) - don't forward to native host
|
||||
if (message.action === 'send' && message.payload?.type === 'registerSwarm') {
|
||||
const swarmId = message.payload?.payload?.swarmId;
|
||||
if (swarmId && sender.tab?.id != null) {
|
||||
if (!tabSwarms.has(sender.tab.id)) {
|
||||
tabSwarms.set(sender.tab.id, new Set());
|
||||
}
|
||||
tabSwarms.get(sender.tab.id).add(swarmId);
|
||||
const count = swarmRefCount.get(swarmId) || 0;
|
||||
swarmRefCount.set(swarmId, count + 1);
|
||||
log('Registered swarm', swarmId, 'for tab', sender.tab.id, '- refcount:', count + 1);
|
||||
}
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'send') {
|
||||
send(message.payload)
|
||||
.then((r) => {
|
||||
// After setVirtualHost or removeVirtualHost, refresh virtualHosts and re-apply PAC
|
||||
if (r && r.ok && (
|
||||
message.payload?.type === 'setVirtualHost' ||
|
||||
message.payload?.type === 'removeVirtualHost'
|
||||
)) {
|
||||
send({ type: 'getState', payload: {} }).then((state) => {
|
||||
if (state && state.ok && Array.isArray(state.virtualHosts)) {
|
||||
extensionState.virtualHosts = state.virtualHosts;
|
||||
const newTlds = getActiveTlds(extensionState.virtualHosts);
|
||||
applyPAC(newTlds);
|
||||
if (message.payload?.type === 'setVirtualHost' && message.payload?.payload?.hostname) {
|
||||
const hostname = message.payload.payload.hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 3) {
|
||||
const baseDomain = parts.slice(-2).join('.');
|
||||
if (baseDomain !== 'hole.sail') {
|
||||
const origin = '*://*.' + baseDomain + '/*';
|
||||
browser.permissions.request({ origins: [origin] }, (granted) => {
|
||||
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
reply(r);
|
||||
})
|
||||
.catch((err) => reply({ error: err.message }));
|
||||
return true; // async response
|
||||
}
|
||||
|
||||
if (message.action === 'subscribe' && sender.tab && sender.tab.id != null) {
|
||||
subscribedTabs.add(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message.action === 'unsubscribe' && sender.tab && sender.tab.id != null) {
|
||||
subscribedTabs.delete(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'registerDashboard' && sender.tab && sender.tab.id != null) {
|
||||
dashboardTabs.add(sender.tab.id);
|
||||
log('Dashboard registered for tab', sender.tab.id);
|
||||
reply({ ok: true, logs: logs.slice(0) });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'unregisterDashboard' && sender.tab && sender.tab.id != null) {
|
||||
dashboardTabs.delete(sender.tab.id);
|
||||
reply({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.action === 'getState') {
|
||||
debugLog('getState action: port=', !!port);
|
||||
updateExtensionState();
|
||||
if (port) {
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
debugLog('getState response: ok=', payload && payload.ok, 'servers=', payload && payload.servers && payload.servers.length, 'virtualHosts=', payload && payload.virtualHosts && payload.virtualHosts.length, 'proxyPort=', payload && payload.proxyPort, 'connectProxyPort=', payload && payload.connectProxyPort);
|
||||
if (payload && payload.ok === true && (payload.servers || payload.virtualHosts || payload.proxyPort != null || payload.connectProxyPort != null)) {
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || [];
|
||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||
}
|
||||
if (!pacConfirmedActive) {
|
||||
log('getState: PAC not confirmed active, re-applying');
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
}
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: true,
|
||||
servers: payload.servers || [],
|
||||
virtualHosts: payload.virtualHosts || [],
|
||||
serviceTunnels: payload.serviceTunnels || [],
|
||||
proxyPort: payload.proxyPort ?? null,
|
||||
connectProxyPort: payload.connectProxyPort ?? null,
|
||||
caInstalled: payload.caInstalled ?? false,
|
||||
settings: payload.settings || {},
|
||||
sshConnections: payload.sshConnections || [],
|
||||
rdpConnections: payload.rdpConnections || [],
|
||||
stats: { ...extensionState.stats, ...payload.stats }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: true,
|
||||
servers: extensionState.servers || [],
|
||||
virtualHosts: extensionState.virtualHosts || [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLog('getState send failed:', err && err.message);
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: extensionState.hostConnected,
|
||||
servers: extensionState.servers || [],
|
||||
virtualHosts: extensionState.virtualHosts || [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
debugLog('getState: no port, replying hostConnected=false');
|
||||
reply({
|
||||
ok: true,
|
||||
state: {
|
||||
hostConnected: false,
|
||||
servers: [],
|
||||
virtualHosts: [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
proxyPort: null,
|
||||
connectProxyPort: null,
|
||||
caInstalled: false,
|
||||
stats: extensionState.stats
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
reply({ error: 'Unknown action' });
|
||||
return false;
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
// Native messaging port management: connect, reconnect, send, and message dispatch.
|
||||
// Depends on: state.js, logs.js, proxy.js
|
||||
|
||||
const HOST_NAME = 'com.holesail.browser';
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const INITIAL_RECONNECT_DELAY = 100;
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
let port = null;
|
||||
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
let reconnectTimer = null;
|
||||
|
||||
// Pending requests: id -> { resolve, reject }
|
||||
const pending = new Map();
|
||||
|
||||
// Tabs that have subscribed to native host events (content script registered)
|
||||
const subscribedTabs = new Set();
|
||||
|
||||
function send(msg) {
|
||||
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
msg.id = id;
|
||||
debugLog('send: id=', id, 'type=', msg.type, 'payload=', msg.payload && JSON.stringify(msg.payload));
|
||||
log('Sending to native:', msg.type, msg.payload?.swarmId || '', msg.payload?.connId || '');
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!port) {
|
||||
debugLog('send: no port, rejecting');
|
||||
reject(new Error('Native host not connected'));
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
debugLog('send: timeout for id=', id, 'type=', msg.type);
|
||||
reject(new Error('Request timed out: ' + msg.type));
|
||||
}
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
pending.set(id, {
|
||||
resolve: (v) => { clearTimeout(timeoutId); resolve(v); },
|
||||
reject: (e) => { clearTimeout(timeoutId); reject(e); }
|
||||
});
|
||||
try {
|
||||
port.postMessage(msg);
|
||||
} catch (e) {
|
||||
pending.delete(id);
|
||||
clearTimeout(timeoutId);
|
||||
debugLog('send: postMessage threw', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
|
||||
const maxAttempts = 3;
|
||||
if (attempt >= maxAttempts || !port) return;
|
||||
const delayMs = delaySeconds * (attempt + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
if (!port) return;
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
if (payload && payload.ok && payload.connectProxyPort != null) {
|
||||
extensionState.connectProxyPort = payload.connectProxyPort;
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || extensionState.virtualHosts;
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
} else if (payload && payload.ok && attempt + 1 < maxAttempts) {
|
||||
retryGetStateForConnectProxy(delaySeconds, attempt + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
debugLog('connect: attempting connectNative', HOST_NAME);
|
||||
try {
|
||||
port = browser.runtime.connectNative(HOST_NAME);
|
||||
log('Connected to native host');
|
||||
debugLog('connect: connected, fetching getState then apply/clear PAC');
|
||||
send({ type: 'getState', payload: {} })
|
||||
.then((payload) => {
|
||||
if (payload && payload.ok) {
|
||||
extensionState.proxyPort = payload.proxyPort ?? null;
|
||||
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
|
||||
extensionState.virtualHosts = payload.virtualHosts || [];
|
||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||
}
|
||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
|
||||
} else {
|
||||
applyPAC();
|
||||
}
|
||||
})
|
||||
.catch(() => applyPAC());
|
||||
} catch (e) {
|
||||
log('connectNative failed:', e);
|
||||
debugLog('connect: failed', e.message, e.stack);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
port.onMessage.addListener((msg) => {
|
||||
debugLog('onMessage: full msg=', JSON.stringify(msg));
|
||||
log('Received from native:', msg.type, msg.event || '');
|
||||
|
||||
if (msg.type === 'event') {
|
||||
const payload = msg.payload || {};
|
||||
if (msg.event === 'connection') {
|
||||
const connId = payload.connId;
|
||||
const swarmId = payload.swarmId;
|
||||
const peerInfo = payload.peerInfo || {};
|
||||
log('Connection established:', connId, 'peer:', peerInfo.publicKey?.slice(0, 8));
|
||||
activeConnections.set(connId, {
|
||||
connId,
|
||||
swarmId,
|
||||
peerKey: peerInfo.publicKey || '',
|
||||
createdAt: Date.now()
|
||||
});
|
||||
updateExtensionState();
|
||||
} else if (msg.event === 'error') {
|
||||
const connId = payload.connId;
|
||||
const errorMsg = payload.message || 'Unknown error';
|
||||
log('Connection ERROR:', connId, errorMsg);
|
||||
activeConnections.delete(connId);
|
||||
updateExtensionState();
|
||||
} else if (msg.event === 'end') {
|
||||
const connId = payload.connId;
|
||||
log('Connection ended:', connId);
|
||||
activeConnections.delete(connId);
|
||||
updateExtensionState();
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'response' && msg.id != null) {
|
||||
debugLog('onMessage: response id=', msg.id, 'payloadKeys=', msg.payload && Object.keys(msg.payload), 'payload=', msg.payload && JSON.stringify(msg.payload).slice(0, 300));
|
||||
const p = pending.get(msg.id);
|
||||
if (p) {
|
||||
pending.delete(msg.id);
|
||||
if (msg.payload && msg.payload.error) {
|
||||
p.reject(new Error(msg.payload.error));
|
||||
} else {
|
||||
p.resolve(msg.payload);
|
||||
}
|
||||
} else {
|
||||
debugLog('onMessage: response id=', msg.id, 'no pending handler');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'event') {
|
||||
const p = msg.payload || {};
|
||||
const eventSwarmId = p.swarmId;
|
||||
const isTunnelEvent = msg.event === 'tunnelReady' || msg.event === 'tunnelClosed' || msg.event === 'tunnelError';
|
||||
if (isTunnelEvent) {
|
||||
log('Broadcasting event to tabs:', msg.event, 'hostname:', p.hostname);
|
||||
} else {
|
||||
log('Broadcasting event to tabs:', msg.event, 'connId:', p.connId, 'swarmId:', eventSwarmId);
|
||||
}
|
||||
if (eventSwarmId != null) {
|
||||
for (const [tabId, swarmIds] of tabSwarms) {
|
||||
if (swarmIds.has(eventSwarmId)) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTunnelEvent) {
|
||||
for (const tabId of subscribedTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
debugLog('onDisconnect');
|
||||
log('Native host disconnected');
|
||||
extensionState.hostConnected = false;
|
||||
activeConnections.clear();
|
||||
updateExtensionState();
|
||||
clearProxy();
|
||||
port = null;
|
||||
if (notifyOnDisconnect && browser.notifications) {
|
||||
browser.notifications.create('holesail-host-disconnect', {
|
||||
type: 'basic',
|
||||
title: 'Holesail Browser',
|
||||
message: 'Native host disconnected.',
|
||||
iconUrl: browser.runtime.getURL('icons/48.png'),
|
||||
}).catch(() => {});
|
||||
}
|
||||
const err = new Error('Native host disconnected');
|
||||
for (const [id, p] of pending) {
|
||||
p.reject(err);
|
||||
}
|
||||
pending.clear();
|
||||
for (const tabId of subscribedTabs) {
|
||||
browser.tabs.sendMessage(tabId, { type: 'holesail-host-disconnect' }).catch(() => {});
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// PAC proxy management.
|
||||
// Depends on: state.js (extensionState, pacConfirmedActive, DEFAULT_CONNECT_PROXY_PORT, DEFAULT_PROXY_PORT)
|
||||
// Depends on: logs.js (log, debugLog)
|
||||
|
||||
/** Extract unique two-label base domains from virtualHosts array, always including hole.sail */
|
||||
function getActiveTlds(virtualHosts) {
|
||||
const seen = new Set(['hole.sail']);
|
||||
for (const v of (virtualHosts || [])) {
|
||||
if (v && v.hostname) {
|
||||
const parts = v.hostname.split('.');
|
||||
if (parts.length >= 3) seen.add(parts.slice(-2).join('.'));
|
||||
}
|
||||
}
|
||||
return Array.from(seen).map(b => '.' + b);
|
||||
}
|
||||
|
||||
function applyPAC(tlds) {
|
||||
if (!browser.proxy || !browser.proxy.settings) {
|
||||
log('applyPAC: SKIPPED - no browser.proxy.settings API');
|
||||
return;
|
||||
}
|
||||
const proxyPort = extensionState.connectProxyPort ?? DEFAULT_CONNECT_PROXY_PORT ?? extensionState.proxyPort ?? DEFAULT_PROXY_PORT;
|
||||
const activeTlds = tlds || getActiveTlds(extensionState.virtualHosts);
|
||||
log('applyPAC: setting PAC port=', proxyPort, 'tlds=', activeTlds);
|
||||
|
||||
const clauses = activeTlds.map(t =>
|
||||
` if (dnsDomainIs(host, "${t}")) return "PROXY 127.0.0.1:${proxyPort}";`
|
||||
).join('\n');
|
||||
const pacData = `function FindProxyForURL(url, host) {\n${clauses}\n return "DIRECT";\n}`;
|
||||
|
||||
browser.proxy.settings.set(
|
||||
{ value: { mode: 'pac_script', pacScript: { data: pacData, mandatory: false } }, scope: 'regular' },
|
||||
() => {
|
||||
if (browser.runtime.lastError) {
|
||||
log('applyPAC: ERROR:', browser.runtime.lastError.message);
|
||||
pacConfirmedActive = false;
|
||||
return;
|
||||
}
|
||||
browser.proxy.settings.get({}, (details) => {
|
||||
if (browser.runtime.lastError) {
|
||||
log('applyPAC: get ERROR:', browser.runtime.lastError.message);
|
||||
return;
|
||||
}
|
||||
const mode = details && details.value && details.value.mode;
|
||||
const loc = details && details.levelOfControl;
|
||||
log('applyPAC: mode=', mode, 'levelOfControl=', loc);
|
||||
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
|
||||
pacConfirmedActive = true;
|
||||
log('applyPAC: ACTIVE');
|
||||
} else {
|
||||
pacConfirmedActive = false;
|
||||
log('applyPAC: WARNING not active - mode=', mode, 'loc=', loc);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Re-apply PAC whenever proxy settings change (e.g. another extension or system override)
|
||||
if (browser.proxy && browser.proxy.settings && browser.proxy.settings.onChange) {
|
||||
browser.proxy.settings.onChange.addListener((details) => {
|
||||
const loc = details && details.levelOfControl;
|
||||
const mode = details && details.value && details.value.mode;
|
||||
log('proxy.settings.onChange: levelOfControl=', loc, 'mode=', mode);
|
||||
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
|
||||
pacConfirmedActive = true;
|
||||
return;
|
||||
}
|
||||
pacConfirmedActive = false;
|
||||
log('proxy settings changed externally - reapplying PAC');
|
||||
applyPAC();
|
||||
});
|
||||
}
|
||||
|
||||
function clearProxy() {
|
||||
debugLog('clearProxy');
|
||||
if (!browser.proxy || !browser.proxy.settings) return;
|
||||
browser.proxy.settings.set({ value: { mode: 'direct' }, scope: 'regular' }).catch(() => {});
|
||||
}
|
||||
|
||||
// Log proxy errors for *.hole.sail to help diagnose issues
|
||||
if (browser.proxy && browser.proxy.onError) {
|
||||
browser.proxy.onError.addListener((details) => {
|
||||
log('Proxy error:', details.error, 'url=', details.url);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Extension-level state shared across background modules.
|
||||
|
||||
const DEFAULT_PROXY_PORT = 8443;
|
||||
/** CONNECT proxy port (browser must send CONNECT here, not to HTTPS port). Use when host omits connectProxyPort. */
|
||||
const DEFAULT_CONNECT_PROXY_PORT = 8442;
|
||||
|
||||
// Track which swarmIds belong to which tab: tabId -> Set<swarmId>
|
||||
const tabSwarms = new Map();
|
||||
// Track how many tabs are using each swarm: swarmId -> count
|
||||
const swarmRefCount = new Map();
|
||||
|
||||
// Track active connections from native host: connId -> { swarmId, peerKey, createdAt }
|
||||
const activeConnections = new Map();
|
||||
|
||||
let notifyOnDisconnect = false;
|
||||
|
||||
// True once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
|
||||
let pacConfirmedActive = false;
|
||||
|
||||
const extensionState = {
|
||||
hostConnected: false,
|
||||
proxyPort: 8443,
|
||||
connectProxyPort: null, // PAC uses this when set (CONNECT proxy); else proxyPort
|
||||
servers: [], // Holesail server tunnels
|
||||
virtualHosts: [], // virtual hostname -> hsUrl mappings
|
||||
stats: {
|
||||
totalConnections: 0,
|
||||
uptime: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
function updateExtensionState() {
|
||||
extensionState.hostConnected = !!port;
|
||||
extensionState.stats.totalConnections = activeConnections.size;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Tab lifecycle: clean up swarms and subscriptions when tabs close.
|
||||
// Depends on: state.js (tabSwarms, swarmRefCount), logs.js (log), native-messaging.js (send, subscribedTabs, dashboardTabs)
|
||||
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
log('Tab closed:', tabId);
|
||||
const swarmIds = tabSwarms.get(tabId);
|
||||
if (swarmIds) {
|
||||
for (const swarmId of swarmIds) {
|
||||
const count = (swarmRefCount.get(swarmId) || 1) - 1;
|
||||
swarmRefCount.set(swarmId, count);
|
||||
log('Swarm', swarmId, 'refcount now:', count);
|
||||
if (count <= 0) {
|
||||
log('Last tab for swarm', swarmId, '- destroying');
|
||||
send({ type: 'destroy', payload: { swarmId } }).catch(() => {});
|
||||
swarmRefCount.delete(swarmId);
|
||||
}
|
||||
}
|
||||
tabSwarms.delete(tabId);
|
||||
}
|
||||
subscribedTabs.delete(tabId);
|
||||
dashboardTabs.delete(tabId);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
// Depends on: all other modules (loaded before this file)
|
||||
|
||||
/**
|
||||
* Guard: only run when loaded as the actual dashboard page.
|
||||
*/
|
||||
if (!document.getElementById('page-dashboard') && !document.querySelector('.sidebar-logo')) {
|
||||
throw new Error('dashboard scripts loaded outside dashboard context — aborting');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
log('Dashboard initializing…');
|
||||
|
||||
try {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const versionEl = $('sidebarVersion');
|
||||
if (versionEl && manifest.version) {
|
||||
versionEl.textContent = 'v' + manifest.version + ' · hole.sail';
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
setupNavigation();
|
||||
setupEvents();
|
||||
setupCertValidator();
|
||||
setupSshEvents();
|
||||
setupRdpEvents();
|
||||
setupBackupEvents();
|
||||
await refresh();
|
||||
setInterval(refresh, 2000);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,29 @@
|
||||
// Wrapper around chrome.runtime.sendMessage for native host communication.
|
||||
|
||||
/** Send a typed message to the native host and return a Promise resolving to the response. */
|
||||
function sendToNative(type, payload) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||||
(response) => resolve(response)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the full extension state from the background service worker. */
|
||||
async function fetchState() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'getState' },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
log('fetchState error:', chrome.runtime.lastError.message);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (response && response.ok) resolve(response.state);
|
||||
else resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Depends on: core/utils.js ($)
|
||||
|
||||
const PAGE_TITLES = {
|
||||
dashboard: 'Overview',
|
||||
connections: 'Virtual Hosts',
|
||||
swarms: 'Server Tunnels',
|
||||
'service-tunnels': 'Service Tunnels',
|
||||
tabs: 'Proxy & CA',
|
||||
ssh: 'SSH Connections',
|
||||
rdp: 'Remote Desktop',
|
||||
backups: 'Backups',
|
||||
logs: 'Logs',
|
||||
settings: 'Settings'
|
||||
};
|
||||
|
||||
function navigateTo(page) {
|
||||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||||
if (navItem) navItem.classList.add('active');
|
||||
const pageEl = $(`page-${page}`);
|
||||
if (pageEl) pageEl.classList.add('active');
|
||||
const titleEl = $('topbarTitle');
|
||||
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
|
||||
}
|
||||
|
||||
function setupNavigation() {
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => navigateTo(item.dataset.page));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Central mutable state for the dashboard.
|
||||
// All modules read/write these variables directly (plain-script shared globals).
|
||||
|
||||
const SETTINGS_DEFAULTS = {
|
||||
proxyPort: 8443,
|
||||
connectProxyPort: 8442,
|
||||
readyTimeoutMs: 0,
|
||||
notifyOnDisconnect: true,
|
||||
debug: false,
|
||||
disableOnFileUrls: false,
|
||||
backupRetention: 5
|
||||
};
|
||||
|
||||
let currentState = null;
|
||||
let settings = { ...SETTINGS_DEFAULTS };
|
||||
@@ -0,0 +1,32 @@
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function log(...args) { console.log('[Holesail-dashboard]', ...args); }
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return seconds + 's ago';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + 'm ago';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
return Math.floor(hours / 24) + 'd ago';
|
||||
}
|
||||
|
||||
function formatUptime(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return hours + 'h ' + (minutes % 60) + 'm';
|
||||
}
|
||||
|
||||
function truncate(str, len = 20) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #111113;
|
||||
--card: #18181b;
|
||||
--elevated: #1f1f23;
|
||||
--border: #27272a;
|
||||
--border2: #3f3f46;
|
||||
--text: #fafafa;
|
||||
--text2: #a1a1aa;
|
||||
--text3: #71717a;
|
||||
--text4: #52525b;
|
||||
--cyan: #22d3ee;
|
||||
--cyan-dim: rgba(34,211,238,.12);
|
||||
--cyan-mid: rgba(34,211,238,.25);
|
||||
--green: #4ade80;
|
||||
--green-dim: rgba(74,222,128,.12);
|
||||
--amber: #fbbf24;
|
||||
--amber-dim: rgba(251,191,36,.12);
|
||||
--red: #f43f5e;
|
||||
--red-dim: rgba(244,63,94,.12);
|
||||
--red-mid: rgba(244,63,94,.25);
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--sidebar-w: 224px;
|
||||
--transition: 0.15s ease;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text4); }
|
||||
|
||||
/* ── Layout ────────────────────────────────────── */
|
||||
.layout { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ───────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 20px 18px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sidebar-logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sidebar-logo-icon img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
}
|
||||
.sidebar-logo-text { line-height: 1.2; }
|
||||
.sidebar-logo-name { font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
|
||||
.sidebar-logo-version { font-size: 11px; color: var(--text4); margin-top: 1px; }
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
margin: 1px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text3);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item:hover { background: var(--border); color: var(--text2); }
|
||||
.nav-item.active { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
background: var(--elevated);
|
||||
color: var(--text3);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-item.active .nav-badge { background: var(--cyan-mid); color: var(--cyan); }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2.5s infinite; }
|
||||
.status-dot.disconnected { background: var(--red); }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Main ──────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 28px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.content { padding: 24px 28px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Page visibility ───────────────────────────── */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
/* Overview page fills the content area as a flex column so Quick Actions
|
||||
sticks to the bottom and the list cards share all remaining space. */
|
||||
#page-dashboard.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Cards ─────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.card-subtitle { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card-body.flush { padding: 0; }
|
||||
|
||||
/* ── Stats grid ────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 1100px) { .stats-grid { grid-template-columns: repeat(3, 1fr) !important; } }
|
||||
@media (max-width: 700px) { .stats-grid { grid-template-columns: repeat(2, 1fr) !important; } }
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.stat-icon svg { width: 16px; height: 16px; }
|
||||
.stat-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.stat-icon.green { background: var(--green-dim); color: var(--green); }
|
||||
.stat-icon.amber { background: var(--amber-dim); color: var(--amber); }
|
||||
.stat-icon.red { background: var(--red-dim); color: var(--red); }
|
||||
.stat-value { font-size: 26px; font-weight: 700; color: var(--text); letter-spacing: -0.03em; line-height: 1; }
|
||||
.stat-label { font-size: 11.5px; font-weight: 500; color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.btn-primary { background: var(--cyan); color: #09090b; }
|
||||
.btn-primary:hover { background: #06b6d4; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--elevated);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border); color: var(--text); }
|
||||
|
||||
.btn-ghost { background: transparent; color: var(--text3); }
|
||||
.btn-ghost:hover { background: var(--border); color: var(--text2); }
|
||||
|
||||
.btn-danger { background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
|
||||
.btn-danger:hover { background: var(--red-mid); }
|
||||
|
||||
.btn-icon {
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.btn-icon:hover { background: var(--border); color: var(--text2); }
|
||||
.btn-icon svg { width: 14px; height: 14px; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-sm svg { width: 12px; height: 12px; }
|
||||
.btn-xs { padding: 3px 8px; font-size: 11px; }
|
||||
|
||||
/* ── Badges / Tags ─────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-cyan { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-amber { background: var(--amber-dim); color: var(--amber); }
|
||||
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||
.badge-neutral { background: var(--elevated); color: var(--text3); }
|
||||
|
||||
/* ── Overview list cards ────────────────────────── */
|
||||
.ov-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* Cards fill the grid cell height set by the flex grid rows */
|
||||
height: 100%;
|
||||
}
|
||||
/* The scroll area grows to fill whatever height the card has after the
|
||||
header and search input. No max-height — the grid rows control height. */
|
||||
.ov-scroll-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.ov-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
.ov-sentinel { height: 1px; }
|
||||
|
||||
/* ── Tables ────────────────────────────────────── */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
background: var(--elevated);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td {
|
||||
padding: 11px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: rgba(255,255,255,.025); }
|
||||
.table .mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.table .empty {
|
||||
text-align: center;
|
||||
color: var(--text4);
|
||||
padding: 36px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Mono chip ─────────────────────────────────── */
|
||||
.mono-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid rgba(34,211,238,.15);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Form inputs ───────────────────────────────── */
|
||||
.input {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 11px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus { border-color: var(--cyan); }
|
||||
.input::placeholder { color: var(--text4); }
|
||||
.input.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.form-label { font-size: 12px; font-weight: 500; color: var(--text3); }
|
||||
.form-error { font-size: 12px; color: var(--red); margin-top: 4px; display: none; }
|
||||
|
||||
/* ── Toggle ────────────────────────────────────── */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: var(--border2);
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle.active { background: var(--cyan); }
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
.toggle.active::after { transform: translateX(18px); }
|
||||
|
||||
/* ── Settings rows ─────────────────────────────── */
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { flex: 1; min-width: 0; }
|
||||
.setting-label { font-size: 13.5px; color: var(--text); font-weight: 500; }
|
||||
.setting-desc { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||||
.setting-control { flex-shrink: 0; }
|
||||
|
||||
/* ── Section heading ───────────────────────────── */
|
||||
.section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.section-heading:first-child { margin-top: 0; }
|
||||
|
||||
/* ── Logs ──────────────────────────────────────── */
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logs-filter {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.logs-container {
|
||||
background: #0a0a0c;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
height: calc(100vh - 220px);
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 5px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
.log-entry:hover { background: rgba(255,255,255,.025); }
|
||||
.log-time { color: var(--text4); flex-shrink: 0; font-size: 11px; padding-top: 1px; }
|
||||
.log-msg { color: var(--text2); word-break: break-all; line-height: 1.5; }
|
||||
.log-msg.is-error { color: var(--red); }
|
||||
.log-msg.is-warn { color: var(--amber); }
|
||||
.log-msg.is-info { color: var(--cyan); }
|
||||
|
||||
/* ── Proxy/CA page ─────────────────────────────── */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-item {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.info-item-label { font-size: 11px; font-weight: 600; color: var(--text4); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 6px; }
|
||||
.info-item-value { font-size: 20px; font-weight: 700; color: var(--text); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* ── Empty state ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 24px;
|
||||
color: var(--text4);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state svg { width: 36px; height: 36px; opacity: 0.4; }
|
||||
.empty-state-title { font-size: 14px; font-weight: 600; color: var(--text3); }
|
||||
.empty-state-desc { font-size: 13px; }
|
||||
|
||||
/* ── Modals ────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
margin: 16px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.6);
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text2); line-height: 1.6; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.modal-error { font-size: 12px; color: var(--red); display: none; }
|
||||
|
||||
/* ── Confirm inline ────────────────────────────── */
|
||||
.confirm-inline {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
.confirm-inline.show { display: inline-flex; }
|
||||
|
||||
/* ── Toast ─────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
z-index: 200;
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.success { border-color: rgba(74,222,128,.3); color: var(--green); }
|
||||
.toast.error { border-color: var(--red-mid); color: var(--red); }
|
||||
|
||||
/* ── Divider ───────────────────────────────────── */
|
||||
.divider { height: 1px; background: var(--border); margin: 16px 0; }
|
||||
|
||||
/* ── Copy feedback ─────────────────────────────── */
|
||||
.copy-btn { position: relative; }
|
||||
.copy-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.copy-btn.copied .copy-tooltip { opacity: 1; }
|
||||
|
||||
/* ── Page headers ──────────────────────────────── */
|
||||
/* ── Inline row layout ─────────────────────────── */
|
||||
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.row .form-group { flex: 1; min-width: 120px; }
|
||||
|
||||
|
||||
/* ── Checkbox ──────────────────────────────────── */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--cyan);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-row label { font-size: 13px; color: var(--text2); cursor: pointer; }
|
||||
|
||||
/* ── Radio ──────────────────────────────────────── */
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.radio-row:has(input:checked) {
|
||||
border-color: var(--cyan);
|
||||
background: color-mix(in srgb, var(--cyan) 10%, transparent);
|
||||
color: var(--cyan);
|
||||
}
|
||||
.radio-row input[type="radio"] {
|
||||
accent-color: var(--cyan);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Remote Desktop page ────────────────────────── */
|
||||
.rdp-conn-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: default;
|
||||
}
|
||||
.rdp-conn-card:hover { border-color: var(--border2); background: var(--elevated); }
|
||||
.rdp-conn-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rdp-conn-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--cyan-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.rdp-conn-icon svg { width: 18px; height: 18px; }
|
||||
.rdp-conn-info { flex: 1; min-width: 0; }
|
||||
.rdp-conn-label { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; }
|
||||
.rdp-conn-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: auto; }
|
||||
.rdp-conn-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 6px 8px;
|
||||
background: var(--base);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.rdp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
|
||||
/* ── RDP Viewer modal ───────────────────────────── */
|
||||
.modal-rdp-viewer {
|
||||
max-width: 95vw;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rdp-viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--elevated);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rdp-viewer-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 14px;
|
||||
background: var(--elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
}
|
||||
.rdp-viewer-statusbar span { color: var(--text3); }
|
||||
#rdpViewerContainer {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#rdpViewerContainer canvas { max-width: 100%; max-height: 100%; display: block; }
|
||||
/* noVNC injects its own canvas — make it fill the container */
|
||||
#rdpViewerContainer > :first-child { width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* ── SSH page ───────────────────────────────────── */
|
||||
.ssh-conn-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: default;
|
||||
}
|
||||
.ssh-conn-card:hover { border-color: var(--border2); background: var(--elevated); }
|
||||
.ssh-conn-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ssh-conn-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid var(--cyan-mid);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.ssh-conn-icon svg { width: 18px; height: 18px; }
|
||||
.ssh-conn-info { flex: 1; min-width: 0; }
|
||||
.ssh-conn-label { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; }
|
||||
.ssh-conn-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: auto; }
|
||||
.ssh-conn-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 6px 8px;
|
||||
background: var(--base);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.ssh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
|
||||
/* ── SSH Terminal modal ──────────────────────────── */
|
||||
.modal-terminal {
|
||||
max-width: 95vw;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
.terminal-conn-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid var(--cyan-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
flex-shrink: 0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.terminal-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green);
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2.5s infinite;
|
||||
}
|
||||
.terminal-status-dot.disconnected { background: var(--red); box-shadow: 0 0 6px var(--red); animation: none; }
|
||||
.terminal-spacer { flex: 1; }
|
||||
.terminal-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
flex-shrink: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
}
|
||||
.terminal-statusbar span { color: var(--text3); }
|
||||
#terminalContainer {
|
||||
flex: 1;
|
||||
background: #0d0d0f;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
/* Override xterm defaults to match our palette */
|
||||
#terminalContainer .xterm { height: 100%; }
|
||||
#terminalContainer .xterm-viewport { background: transparent !important; }
|
||||
#terminalContainer .xterm-screen { background: transparent !important; }
|
||||
@@ -7,896 +7,11 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vendor/xterm.css">
|
||||
<script src="vendor/xterm.js"></script>
|
||||
<script src="vendor/xterm-addon-fit.js"></script>
|
||||
<script type="module" src="vendor/novnc.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #111113;
|
||||
--card: #18181b;
|
||||
--elevated: #1f1f23;
|
||||
--border: #27272a;
|
||||
--border2: #3f3f46;
|
||||
--text: #fafafa;
|
||||
--text2: #a1a1aa;
|
||||
--text3: #71717a;
|
||||
--text4: #52525b;
|
||||
--cyan: #22d3ee;
|
||||
--cyan-dim: rgba(34,211,238,.12);
|
||||
--cyan-mid: rgba(34,211,238,.25);
|
||||
--green: #4ade80;
|
||||
--green-dim: rgba(74,222,128,.12);
|
||||
--amber: #fbbf24;
|
||||
--amber-dim: rgba(251,191,36,.12);
|
||||
--red: #f43f5e;
|
||||
--red-dim: rgba(244,63,94,.12);
|
||||
--red-mid: rgba(244,63,94,.25);
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--sidebar-w: 224px;
|
||||
--transition: 0.15s ease;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text4); }
|
||||
|
||||
/* ── Layout ────────────────────────────────────── */
|
||||
.layout { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ───────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 20px 18px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sidebar-logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sidebar-logo-icon img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
}
|
||||
.sidebar-logo-text { line-height: 1.2; }
|
||||
.sidebar-logo-name { font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
|
||||
.sidebar-logo-version { font-size: 11px; color: var(--text4); margin-top: 1px; }
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
margin: 1px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text3);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item:hover { background: var(--border); color: var(--text2); }
|
||||
.nav-item.active { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
background: var(--elevated);
|
||||
color: var(--text3);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-item.active .nav-badge { background: var(--cyan-mid); color: var(--cyan); }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2.5s infinite; }
|
||||
.status-dot.disconnected { background: var(--red); }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Main ──────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 28px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.content { padding: 24px 28px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Page visibility ───────────────────────────── */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
/* Overview page fills the content area as a flex column so Quick Actions
|
||||
sticks to the bottom and the list cards share all remaining space. */
|
||||
#page-dashboard.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Cards ─────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.card-subtitle { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card-body.flush { padding: 0; }
|
||||
|
||||
/* ── Stats grid ────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 1100px) { .stats-grid { grid-template-columns: repeat(3, 1fr) !important; } }
|
||||
@media (max-width: 700px) { .stats-grid { grid-template-columns: repeat(2, 1fr) !important; } }
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.stat-icon svg { width: 16px; height: 16px; }
|
||||
.stat-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.stat-icon.green { background: var(--green-dim); color: var(--green); }
|
||||
.stat-icon.amber { background: var(--amber-dim); color: var(--amber); }
|
||||
.stat-icon.red { background: var(--red-dim); color: var(--red); }
|
||||
.stat-value { font-size: 26px; font-weight: 700; color: var(--text); letter-spacing: -0.03em; line-height: 1; }
|
||||
.stat-label { font-size: 11.5px; font-weight: 500; color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.btn-primary { background: var(--cyan); color: #09090b; }
|
||||
.btn-primary:hover { background: #06b6d4; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--elevated);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border); color: var(--text); }
|
||||
|
||||
.btn-ghost { background: transparent; color: var(--text3); }
|
||||
.btn-ghost:hover { background: var(--border); color: var(--text2); }
|
||||
|
||||
.btn-danger { background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
|
||||
.btn-danger:hover { background: var(--red-mid); }
|
||||
|
||||
.btn-icon {
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.btn-icon:hover { background: var(--border); color: var(--text2); }
|
||||
.btn-icon svg { width: 14px; height: 14px; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-sm svg { width: 12px; height: 12px; }
|
||||
.btn-xs { padding: 3px 8px; font-size: 11px; }
|
||||
|
||||
/* ── Badges / Tags ─────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-cyan { background: var(--cyan-dim); color: var(--cyan); }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-amber { background: var(--amber-dim); color: var(--amber); }
|
||||
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||
.badge-neutral { background: var(--elevated); color: var(--text3); }
|
||||
|
||||
/* ── Overview list cards ────────────────────────── */
|
||||
.ov-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* Cards fill the grid cell height set by the flex grid rows */
|
||||
height: 100%;
|
||||
}
|
||||
/* The scroll area grows to fill whatever height the card has after the
|
||||
header and search input. No max-height — the grid rows control height. */
|
||||
.ov-scroll-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.ov-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
.ov-sentinel { height: 1px; }
|
||||
|
||||
/* ── Tables ────────────────────────────────────── */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
background: var(--elevated);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td {
|
||||
padding: 11px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: rgba(255,255,255,.025); }
|
||||
.table .mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.table .empty {
|
||||
text-align: center;
|
||||
color: var(--text4);
|
||||
padding: 36px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Mono chip ─────────────────────────────────── */
|
||||
.mono-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid rgba(34,211,238,.15);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Form inputs ───────────────────────────────── */
|
||||
.input {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 11px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus { border-color: var(--cyan); }
|
||||
.input::placeholder { color: var(--text4); }
|
||||
.input.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.form-label { font-size: 12px; font-weight: 500; color: var(--text3); }
|
||||
.form-error { font-size: 12px; color: var(--red); margin-top: 4px; display: none; }
|
||||
|
||||
/* ── Toggle ────────────────────────────────────── */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: var(--border2);
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle.active { background: var(--cyan); }
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
.toggle.active::after { transform: translateX(18px); }
|
||||
|
||||
/* ── Settings rows ─────────────────────────────── */
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { flex: 1; min-width: 0; }
|
||||
.setting-label { font-size: 13.5px; color: var(--text); font-weight: 500; }
|
||||
.setting-desc { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||||
.setting-control { flex-shrink: 0; }
|
||||
|
||||
/* ── Section heading ───────────────────────────── */
|
||||
.section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.section-heading:first-child { margin-top: 0; }
|
||||
|
||||
/* ── Logs ──────────────────────────────────────── */
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logs-filter {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.logs-container {
|
||||
background: #0a0a0c;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
height: calc(100vh - 220px);
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 5px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
.log-entry:hover { background: rgba(255,255,255,.025); }
|
||||
.log-time { color: var(--text4); flex-shrink: 0; font-size: 11px; padding-top: 1px; }
|
||||
.log-msg { color: var(--text2); word-break: break-all; line-height: 1.5; }
|
||||
.log-msg.is-error { color: var(--red); }
|
||||
.log-msg.is-warn { color: var(--amber); }
|
||||
.log-msg.is-info { color: var(--cyan); }
|
||||
|
||||
/* ── Proxy/CA page ─────────────────────────────── */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-item {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.info-item-label { font-size: 11px; font-weight: 600; color: var(--text4); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 6px; }
|
||||
.info-item-value { font-size: 20px; font-weight: 700; color: var(--text); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* ── Empty state ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 24px;
|
||||
color: var(--text4);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state svg { width: 36px; height: 36px; opacity: 0.4; }
|
||||
.empty-state-title { font-size: 14px; font-weight: 600; color: var(--text3); }
|
||||
.empty-state-desc { font-size: 13px; }
|
||||
|
||||
/* ── Modals ────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
margin: 16px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.6);
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text2); line-height: 1.6; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.modal-error { font-size: 12px; color: var(--red); display: none; }
|
||||
|
||||
/* ── Confirm inline ────────────────────────────── */
|
||||
.confirm-inline {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
.confirm-inline.show { display: inline-flex; }
|
||||
|
||||
/* ── Toast ─────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
z-index: 200;
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.success { border-color: rgba(74,222,128,.3); color: var(--green); }
|
||||
.toast.error { border-color: var(--red-mid); color: var(--red); }
|
||||
|
||||
/* ── Divider ───────────────────────────────────── */
|
||||
.divider { height: 1px; background: var(--border); margin: 16px 0; }
|
||||
|
||||
/* ── Copy feedback ─────────────────────────────── */
|
||||
.copy-btn { position: relative; }
|
||||
.copy-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.copy-btn.copied .copy-tooltip { opacity: 1; }
|
||||
|
||||
/* ── Page headers ──────────────────────────────── */
|
||||
/* ── Inline row layout ─────────────────────────── */
|
||||
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.row .form-group { flex: 1; min-width: 120px; }
|
||||
|
||||
|
||||
/* ── Checkbox ──────────────────────────────────── */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--cyan);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-row label { font-size: 13px; color: var(--text2); cursor: pointer; }
|
||||
|
||||
/* ── Radio ──────────────────────────────────────── */
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.radio-row:has(input:checked) {
|
||||
border-color: var(--cyan);
|
||||
background: color-mix(in srgb, var(--cyan) 10%, transparent);
|
||||
color: var(--cyan);
|
||||
}
|
||||
.radio-row input[type="radio"] {
|
||||
accent-color: var(--cyan);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Remote Desktop page ────────────────────────── */
|
||||
.rdp-conn-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: default;
|
||||
}
|
||||
.rdp-conn-card:hover { border-color: var(--border2); background: var(--elevated); }
|
||||
.rdp-conn-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rdp-conn-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--cyan-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.rdp-conn-icon svg { width: 18px; height: 18px; }
|
||||
.rdp-conn-info { flex: 1; min-width: 0; }
|
||||
.rdp-conn-label { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; }
|
||||
.rdp-conn-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: auto; }
|
||||
.rdp-conn-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 6px 8px;
|
||||
background: var(--base);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.rdp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
|
||||
/* ── RDP Viewer modal ───────────────────────────── */
|
||||
.modal-rdp-viewer {
|
||||
max-width: 95vw;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rdp-viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--elevated);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rdp-viewer-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 14px;
|
||||
background: var(--elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
}
|
||||
.rdp-viewer-statusbar span { color: var(--text3); }
|
||||
#rdpViewerContainer {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#rdpViewerContainer canvas { max-width: 100%; max-height: 100%; display: block; }
|
||||
/* noVNC injects its own canvas — make it fill the container */
|
||||
#rdpViewerContainer > :first-child { width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* ── SSH page ───────────────────────────────────── */
|
||||
.ssh-conn-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: default;
|
||||
}
|
||||
.ssh-conn-card:hover { border-color: var(--border2); background: var(--elevated); }
|
||||
.ssh-conn-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ssh-conn-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid var(--cyan-mid);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.ssh-conn-icon svg { width: 18px; height: 18px; }
|
||||
.ssh-conn-info { flex: 1; min-width: 0; }
|
||||
.ssh-conn-label { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; }
|
||||
.ssh-conn-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: auto; }
|
||||
.ssh-conn-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 6px 8px;
|
||||
background: var(--base);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.ssh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
|
||||
/* ── SSH Terminal modal ──────────────────────────── */
|
||||
.modal-terminal {
|
||||
max-width: 95vw;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
.terminal-conn-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-dim);
|
||||
border: 1px solid var(--cyan-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
flex-shrink: 0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.terminal-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green);
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2.5s infinite;
|
||||
}
|
||||
.terminal-status-dot.disconnected { background: var(--red); box-shadow: 0 0 6px var(--red); animation: none; }
|
||||
.terminal-spacer { flex: 1; }
|
||||
.terminal-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
flex-shrink: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
}
|
||||
.terminal-statusbar span { color: var(--text3); }
|
||||
#terminalContainer {
|
||||
flex: 1;
|
||||
background: #0d0d0f;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
/* Override xterm defaults to match our palette */
|
||||
#terminalContainer .xterm { height: 100%; }
|
||||
#terminalContainer .xterm-viewport { background: transparent !important; }
|
||||
#terminalContainer .xterm-screen { background: transparent !important; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="../vendor/xterm.css">
|
||||
<link rel="stylesheet" href="dashboard.css">
|
||||
<script src="../vendor/xterm.js"></script>
|
||||
<script src="../vendor/xterm-addon-fit.js"></script>
|
||||
<script type="module" src="../vendor/novnc.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
@@ -904,7 +19,7 @@
|
||||
<!-- ── Sidebar ──────────────────────────────────── -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon"><img src="icons/32.png" alt="Holesail"></div>
|
||||
<div class="sidebar-logo-icon"><img src="../icons/32.png" alt="Holesail"></div>
|
||||
<div class="sidebar-logo-text">
|
||||
<div class="sidebar-logo-name">Holesail</div>
|
||||
<div class="sidebar-logo-version" id="sidebarVersion">v1.0.0 · hole.sail</div>
|
||||
@@ -1986,6 +1101,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="dashboard.js"></script>
|
||||
|
||||
<!-- Load order: data → core → ui → pages → refresh → events → init -->
|
||||
|
||||
<!-- Data layer -->
|
||||
<script src="data/tlds.js"></script>
|
||||
<script src="data/hostname-validator.js"></script>
|
||||
|
||||
<!-- Core utilities and state -->
|
||||
<script src="core/utils.js"></script>
|
||||
<script src="core/state.js"></script>
|
||||
<script src="core/messaging.js"></script>
|
||||
<script src="core/navigation.js"></script>
|
||||
|
||||
<!-- UI primitives -->
|
||||
<script src="ui/toast.js"></script>
|
||||
<script src="ui/modal.js"></script>
|
||||
<script src="ui/state-tag.js"></script>
|
||||
|
||||
<!-- Page modules -->
|
||||
<script src="pages/settings.js"></script>
|
||||
<script src="pages/overview.js"></script>
|
||||
<script src="pages/virtual-hosts.js"></script>
|
||||
<script src="pages/servers.js"></script>
|
||||
<script src="pages/service-tunnels.js"></script>
|
||||
<script src="pages/proxy-ca.js"></script>
|
||||
<script src="pages/backups.js"></script>
|
||||
<script src="pages/logs.js"></script>
|
||||
<script src="pages/ssh.js"></script>
|
||||
<script src="pages/rdp.js"></script>
|
||||
|
||||
<!-- Orchestration -->
|
||||
<script src="refresh.js"></script>
|
||||
<script src="events.js"></script>
|
||||
|
||||
<!-- Entry point (runs init()) -->
|
||||
<script src="core/init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Depends on: data/tlds.js (REAL_TLDS, REAL_SLD_TLDS)
|
||||
|
||||
/**
|
||||
* Validate a virtual host hostname.
|
||||
* Rules:
|
||||
* 1. Must have at least 2 dots (3 labels minimum: host.second.tld)
|
||||
* 2. Each label: only [a-z0-9-], no leading/trailing hyphen, non-empty
|
||||
* 3. The last two labels (base domain, e.g. "hole.sail") must not be a
|
||||
* real public TLD or second-level public suffix
|
||||
* Returns { ok: true } or { ok: false, error: string }
|
||||
*/
|
||||
function isValidVhostHostname(hostname) {
|
||||
if (!hostname) return { ok: false, error: 'Hostname is required' };
|
||||
const labels = hostname.split('.');
|
||||
if (labels.length < 3) {
|
||||
return { ok: false, error: 'Hostname must have the form host.second.tld (e.g. myapp.hole.sail) — single-dot names are not allowed' };
|
||||
}
|
||||
for (const label of labels) {
|
||||
if (!label) return { ok: false, error: 'Hostname contains empty labels' };
|
||||
if (!/^[a-z0-9-]+$/.test(label)) return { ok: false, error: 'Hostname contains invalid characters — only letters, digits and hyphens allowed' };
|
||||
if (label.startsWith('-') || label.endsWith('-')) return { ok: false, error: 'Hostname labels must not start or end with a hyphen' };
|
||||
}
|
||||
const tld = labels[labels.length - 1];
|
||||
const baseDomain = labels.slice(-2).join('.');
|
||||
if (REAL_TLDS.has(tld) || REAL_SLD_TLDS.has(baseDomain)) {
|
||||
return { ok: false, error: 'The TLD ".' + baseDomain + '" is a real registered domain — use a private TLD like .hole.sail or .my.internal' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Extract the two-label base domain from a hostname (e.g. "hole.sail" from "myapp.hole.sail") */
|
||||
function extractBaseDomain(hostname) {
|
||||
const parts = hostname.split('.');
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
/** Extract unique two-label base domains from a list of virtual host objects */
|
||||
function extractActiveTlds(virtualHosts) {
|
||||
const seen = new Set();
|
||||
seen.add('hole.sail'); // always include baseline
|
||||
for (const v of (virtualHosts || [])) {
|
||||
if (v.hostname) seen.add(extractBaseDomain(v.hostname));
|
||||
}
|
||||
return Array.from(seen).map(b => '.' + b);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Real single-label TLDs that must be blocked (compact subset of IANA list)
|
||||
const REAL_TLDS = new Set([
|
||||
'ac','ad','ae','af','ag','ai','al','am','ao','aq','ar','as','at','au','aw','ax','az',
|
||||
'ba','bb','bd','be','bf','bg','bh','bi','bj','bm','bn','bo','br','bs','bt','bv','bw',
|
||||
'by','bz','ca','cc','cd','cf','cg','ch','ci','ck','cl','cm','cn','co','cr','cu','cv',
|
||||
'cw','cx','cy','cz','de','dj','dk','dm','do','dz','ec','ee','eg','er','es','et','eu',
|
||||
'fi','fj','fk','fm','fo','fr','ga','gb','gd','ge','gf','gg','gh','gi','gl','gm','gn',
|
||||
'gp','gq','gr','gs','gt','gu','gw','gy','hk','hm','hn','hr','ht','hu','id','ie','il',
|
||||
'im','in','io','iq','ir','is','it','je','jm','jo','jp','ke','kg','kh','ki','km','kn',
|
||||
'kp','kr','kw','ky','kz','la','lb','lc','li','lk','lr','ls','lt','lu','lv','ly','ma',
|
||||
'mc','md','me','mg','mh','mk','ml','mm','mn','mo','mp','mq','mr','ms','mt','mu','mv',
|
||||
'mw','mx','my','mz','na','nc','ne','nf','ng','ni','nl','no','np','nr','nu','nz','om',
|
||||
'pa','pe','pf','pg','ph','pk','pl','pm','pn','pr','ps','pt','pw','py','qa','re','ro',
|
||||
'rs','ru','rw','sa','sb','sc','sd','se','sg','sh','si','sj','sk','sl','sm','sn','so',
|
||||
'sr','ss','st','su','sv','sx','sy','sz','tc','td','tf','tg','th','tj','tk','tl','tm',
|
||||
'tn','to','tr','tt','tv','tw','tz','ua','ug','uk','us','uy','uz','va','vc','ve','vg',
|
||||
'vi','vn','vu','wf','ws','ye','yt','za','zm','zw',
|
||||
// Generic TLDs
|
||||
'aaa','aarp','abb','abc','able','abogado','abudhabi','academy','accenture','accountant',
|
||||
'accountants','aco','actor','ads','adult','aeg','aero','aetna','africa','agakhan','agency',
|
||||
'aig','airbus','airforce','airtel','akdn','alfaromeo','alibaba','alipay','allfinanz',
|
||||
'allstate','ally','alsace','alstom','amazon','americanexpress','americanfamily','amex',
|
||||
'amfam','amica','amsterdam','analytics','android','anquan','anz','aol','apartments',
|
||||
'app','apple','aquarelle','arab','aramco','archi','army','art','arte','asda','associates',
|
||||
'athleta','auction','audi','audible','audio','auspost','author','auto','autos','avianca',
|
||||
'aws','axa','azure','baby','baidu','banamex','band','bank','bar','barcelona','barclaycard',
|
||||
'barclays','barefoot','bargains','baseball','basketball','bauhaus','bayern','bbc','bbt',
|
||||
'bbva','bcg','bcn','beats','beauty','beer','bentley','berlin','best','bestbuy','bet',
|
||||
'bible','bid','bike','bing','bingo','bio','black','blackfriday','blockbuster','blog',
|
||||
'bloomberg','blue','bms','bmw','bnl','bnpparibas','boats','boehringer','bofa','bom',
|
||||
'bond','boo','book','booking','bosch','bostik','boston','bot','boutique','box','bradesco',
|
||||
'bridgestone','broadway','broker','brother','brussels','budapest','bugatti','build',
|
||||
'builders','business','buy','buzz','bzh','cab','cafe','cal','call','calvinklein','cam',
|
||||
'camera','camp','cancerresearch','canon','capetown','capital','capitalone','cards','care',
|
||||
'career','careers','cars','casa','case','cash','casino','cat','catering','catholic','cba',
|
||||
'cbn','cbre','cbs','center','ceo','cern','cfa','cfd','channel','charity','chase','chat',
|
||||
'cheap','chintai','christmas','chrome','church','cipriani','circle','cisco','citi',
|
||||
'citic','city','cityeats','claims','cleaning','click','clinic','clinique','clothing',
|
||||
'cloud','club','clubmed','codes','coffee','college','cologne','com','community','company',
|
||||
'compare','computer','comsec','condos','construction','consulting','contact','contractors',
|
||||
'cooking','cool','coop','corsica','country','coupon','coupons','courses','credit',
|
||||
'creditcard','creditunion','cricket','crown','crs','cruise','cruises','csc','cuisinella',
|
||||
'cymru','cyou','dabur','dad','dance','data','date','dating','datsun','day','dclk','dds',
|
||||
'deal','dealer','deals','degree','delivery','dell','deloitte','democrat','dental','design',
|
||||
'dev','dhl','diamonds','diet','digital','direct','directory','discount','discover',
|
||||
'dish','diy','dnp','docs','doctor','dog','domains','dot','download','drive','dtv','dubai',
|
||||
'dunlop','dupont','durban','dvag','dvr','earth','eat','eco','edeka','edu','education',
|
||||
'email','emerck','energy','engineering','enterprises','epson','equipment','ericsson',
|
||||
'erni','estate','esurance','etisalat','eurovision','eus','events','exchange','expert',
|
||||
'exposed','express','extraspace','fage','fail','fairwinds','faith','family','fan','fans',
|
||||
'farm','farmers','fashion','fast','fedex','feedback','ferrari','ferrero','fiat','fidelity',
|
||||
'fido','film','final','finance','financial','fire','firestone','firmdale','fish','fishing',
|
||||
'fit','fitness','flights','florist','flowers','fly','foo','food','foodnetwork','football',
|
||||
'ford','forex','forsale','forum','foundation','fox','free','fresenius','frl','frogans',
|
||||
'frontdoor','frontier','ftr','fujitsu','fun','fund','furniture','futbol','fyi','gal',
|
||||
'gallery','gallo','gallup','game','games','gap','garden','gay','gbiz','gdn','gea',
|
||||
'gent','genting','george','ggee','gift','gifts','gives','giving','glass','gle','global',
|
||||
'globo','gmail','gmbh','gold','goldpoint','golf','goo','goodyear','goog','google','gop',
|
||||
'got','gov','grainger','graphics','gratis','green','gripe','grocery','group','guardian',
|
||||
'gucci','guge','guide','guitars','guru','hair','hamburg','hangout','haus','hbo','hdfc',
|
||||
'hdfcbank','health','healthcare','help','helsinki','here','hermes','hgtv','hiphop',
|
||||
'hisamitsu','hitachi','hiv','hkt','hockey','holdings','holiday','homedepot','homegoods',
|
||||
'homes','homesense','honda','horse','hospital','host','hosting','hot','hoteles','hotels',
|
||||
'hotmail','house','how','hsbc','hughes','hyatt','hyundai','ibm','icbc','ice','icu','ieee',
|
||||
'ifm','ikano','imamat','imdb','immo','immobilien','industries','infiniti','info','ing',
|
||||
'ink','institute','insurance','insure','int','international','intuit','investments','ipiranga',
|
||||
'irish','ismaili','ist','istanbul','itau','itv','jaguar','java','jcb','jeep','jetzt',
|
||||
'jewelry','jio','jll','jobs','joburg','jot','joy','jpmorgan','jprs','juegos','juniper',
|
||||
'kaufen','kddi','kerryhotels','kerrylogistics','kerryproperties','kfh','kia','kids','kim',
|
||||
'kinder','kindle','kitchen','kiwi','koeln','komatsu','kosher','kpmg','kpn','krd','kred',
|
||||
'kuokgroup','kyoto','lacaixa','lamborghini','lamer','lancaster','land','landrover',
|
||||
'lanxess','lasalle','lat','latino','latrobe','law','lawyer','lds','lease','leclerc',
|
||||
'lefrak','legal','lego','lexus','lgbt','lidl','life','lifeinsurance','lifestyle','lighting',
|
||||
'like','lilly','limited','limo','lincoln','link','lipsy','live','living','llc','llp',
|
||||
'loan','loans','locker','locus','lol','london','lotte','lotto','love','lpl','lplfinancial',
|
||||
'ltd','ltda','lundbeck','luxe','luxury','madrid','maif','maison','makeup','man','management',
|
||||
'mango','map','market','marketing','markets','marriott','marshalls','mba','mckinsey','med',
|
||||
'media','meet','melbourne','meme','memorial','men','menu','merckmsd','miami','microsoft',
|
||||
'mil','mini','mint','mit','mitsubishi','mobi','mobile','moda','moe','moi','mom','monash',
|
||||
'money','monster','mormon','mortgage','moscow','moto','motorcycles','mov','movie','msd',
|
||||
'mtn','mtr','music','mutual','nab','nagoya','name','natura','navy','nba','net','netbank',
|
||||
'netflix','network','neustar','new','news','next','nextdirect','nexus','nfl','ngo','nhk',
|
||||
'nico','nike','nikon','ninja','nissan','nissay','nokia','norton','now','nowruz','nra',
|
||||
'nrw','ntt','nyc','obi','observer','office','okinawa','olayan','olayangroup','oldnavy',
|
||||
'ollo','omega','one','ong','onl','online','ooo','open','oracle','orange','org','organic',
|
||||
'origins','osaka','otsuka','ott','ovh','page','panasonic','paris','pars','partners','parts',
|
||||
'party','passagens','pay','pccw','pet','pfizer','pharmacy','phd','philips','phone','photo',
|
||||
'photography','photos','physio','pics','pictet','pictures','pid','pin','ping','pink',
|
||||
'pioneer','pizza','place','play','playstation','plumbing','plus','pnc','pohl','poker',
|
||||
'politie','porn','post','pramerica','praxi','press','prime','pro','prod','productions',
|
||||
'prof','progressive','promo','properties','property','protection','pru','prudential',
|
||||
'pub','pwc','qpon','quebec','quest','racing','radio','read','realestate','realtor',
|
||||
'realty','recipes','red','redstone','redumbrella','rehab','reise','reisen','reit','reliance',
|
||||
'ren','rent','rentals','repair','report','republican','rest','restaurant','review','reviews',
|
||||
'rexroth','rich','richardli','ricoh','rightathome','rio','rip','rocher','rocks','rodeo',
|
||||
'rogers','room','rsvp','rugby','ruhr','run','rwe','ryukyu','safe','safety','sakura','sale',
|
||||
'salon','samsclub','samsung','sandvik','sandvikcoromant','sanofi','sap','sarl','sas',
|
||||
'save','saxo','sbi','sbs','sca','scb','schaeffler','schmidt','scholarships','school',
|
||||
'schule','schwarz','science','scjohnson','scot','search','seat','secure','security',
|
||||
'seek','select','sener','services','ses','seven','sew','sex','sexy','sfr','shangrila',
|
||||
'sharp','shaw','shell','shia','shiksha','shoes','shop','shopping','shouji','show','silk',
|
||||
'sina','singles','ski','skin','sky','skype','smile','sncf','soccer','social','softbank',
|
||||
'software','sohu','solar','solutions','song','sony','soy','spa','space','sport','spot',
|
||||
'srl','stada','staples','star','statebank','statefarm','stc','stcgroup','stockholm',
|
||||
'storage','store','stream','studio','study','style','sucks','supplies','supply','support',
|
||||
'surf','surgery','suzuki','swatch','swiss','sydney','systems','tab','taipei','talk',
|
||||
'taobao','target','tatamotors','tatar','tattoo','tax','taxi','tci','tdk','team','tech',
|
||||
'technology','tel','temasek','tennis','teva','tiaa','tickets','tienda','tips','tires',
|
||||
'tirol','tjmaxx','tjx','tkmaxx','today','tokyo','tools','top','toray','toshiba','total',
|
||||
'tours','town','toyota','toys','trade','trading','training','travel','travelers',
|
||||
'travelersinsurance','trust','trv','tube','tui','tunes','tushu','tvs','ubank','unicom',
|
||||
'university','uno','uol','ups','vacations','vana','vanguard','vegas','ventures','verisign',
|
||||
'versicherung','vet','viajes','video','vig','viking','villas','vin','vip','virgin','visa',
|
||||
'vision','viva','vivo','vlaanderen','vodka','volkswagen','volvo','vote','voting','voto',
|
||||
'voyage','vuelos','wales','walmart','walter','wang','wanggou','watch','watches','weather',
|
||||
'weatherchannel','webcam','weber','website','wed','wedding','weibo','weir','whoswho',
|
||||
'wien','wiki','williamhill','win','windows','wine','winners','wme','wolterskluwer',
|
||||
'woodside','work','works','world','wow','wtc','wtf','xbox','xerox','xfinity','xihuan',
|
||||
'xin','xxx','xyz','yachts','yahoo','yamaxun','yandex','yodobashi','yoga','yokohama',
|
||||
'you','youtube','yun','zappos','zara','zero','zip','zone','zuerich'
|
||||
]);
|
||||
|
||||
// Real two-label public suffixes (e.g. co.uk) — base domain check
|
||||
const REAL_SLD_TLDS = new Set([
|
||||
'co.uk','org.uk','me.uk','net.uk','ltd.uk','plc.uk','sch.uk','gov.uk','nhs.uk','police.uk',
|
||||
'com.au','net.au','org.au','edu.au','gov.au','asn.au','id.au',
|
||||
'co.nz','net.nz','org.nz','edu.nz','govt.nz','geek.nz','gen.nz','maori.nz',
|
||||
'co.za','org.za','net.za','edu.za','gov.za','web.za',
|
||||
'com.br','net.br','org.br','edu.br','gov.br','mil.br',
|
||||
'com.ar','net.ar','org.ar','edu.ar','gov.ar',
|
||||
'com.mx','net.mx','org.mx','edu.mx','gob.mx',
|
||||
'com.cn','net.cn','org.cn','edu.cn','gov.cn','ac.cn',
|
||||
'co.jp','ne.jp','or.jp','ac.jp','go.jp','ad.jp',
|
||||
'co.in','net.in','org.in','edu.in','gov.in','ac.in','res.in',
|
||||
'co.ke','or.ke','ac.ke','go.ke','ne.ke',
|
||||
'com.sg','net.sg','org.sg','edu.sg','gov.sg',
|
||||
'com.hk','net.hk','org.hk','edu.hk','gov.hk',
|
||||
'com.tw','net.tw','org.tw','edu.tw','gov.tw',
|
||||
'com.my','net.my','org.my','edu.my','gov.my',
|
||||
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
|
||||
'com.ng','net.ng','org.ng','edu.ng','gov.ng',
|
||||
'com.gh','net.gh','org.gh','edu.gh','gov.gh',
|
||||
'com.eg','net.eg','org.eg','edu.eg','gov.eg',
|
||||
'com.tr','net.tr','org.tr','edu.tr','gov.tr',
|
||||
'com.sa','net.sa','org.sa','edu.sa','gov.sa',
|
||||
'com.ae','net.ae','org.ae','edu.ae','gov.ae',
|
||||
'com.il','net.il','org.il','edu.il','gov.il',
|
||||
'co.il','ac.il',
|
||||
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
|
||||
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
|
||||
'com.de','net.de','org.de',
|
||||
'com.fr','net.fr','org.fr',
|
||||
'com.es','net.es','org.es','edu.es','gob.es',
|
||||
'com.it','net.it','org.it','edu.it','gov.it',
|
||||
'com.ru','net.ru','org.ru','edu.ru','gov.ru',
|
||||
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
|
||||
'com.ph','net.ph','org.ph','edu.ph','gov.ph',
|
||||
'com.id','net.id','org.id','edu.id','go.id',
|
||||
'com.pe','net.pe','org.pe','edu.pe','gob.pe',
|
||||
'com.co','net.co','org.co','edu.co','gov.co',
|
||||
'com.ve','net.ve','org.ve','edu.ve','gov.ve',
|
||||
'com.ec','net.ec','org.ec','edu.ec','gov.ec',
|
||||
'com.bo','net.bo','org.bo','edu.bo','gov.bo',
|
||||
'com.py','net.py','org.py','edu.py','gov.py',
|
||||
'com.uy','net.uy','org.uy','edu.uy','gub.uy',
|
||||
'com.ni','net.ni','org.ni','edu.ni','gob.ni',
|
||||
'com.cr','net.cr','org.cr','edu.cr','go.cr',
|
||||
'com.gt','net.gt','org.gt','edu.gt','gob.gt',
|
||||
'com.hn','net.hn','org.hn','edu.hn','gob.hn',
|
||||
'com.sv','net.sv','org.sv','edu.sv','gob.sv',
|
||||
'com.pa','net.pa','org.pa','edu.pa','gob.pa',
|
||||
'com.do','net.do','org.do','edu.do','gob.do',
|
||||
'com.cu','net.cu','org.cu','edu.cu','inf.cu',
|
||||
'com.pr','net.pr','org.pr','edu.pr','gov.pr',
|
||||
'com.tt','net.tt','org.tt','edu.tt','gov.tt',
|
||||
'com.jm','net.jm','org.jm','edu.jm','gov.jm',
|
||||
'com.bb','net.bb','org.bb','edu.bb','gov.bb',
|
||||
'com.lc','net.lc','org.lc','edu.lc','gov.lc',
|
||||
'com.vc','net.vc','org.vc','edu.vc','gov.vc',
|
||||
'com.dm','net.dm','org.dm','edu.dm','gov.dm',
|
||||
'com.ag','net.ag','org.ag','edu.ag','gov.ag',
|
||||
'com.kn','net.kn','org.kn','edu.kn','gov.kn',
|
||||
'com.gd','net.gd','org.gd','edu.gd','gov.gd',
|
||||
'com.ms','net.ms','org.ms','edu.ms','gov.ms',
|
||||
'com.ai','net.ai','org.ai','edu.ai','gov.ai',
|
||||
'com.vg','net.vg','org.vg','edu.vg','gov.vg',
|
||||
'com.ky','net.ky','org.ky','edu.ky','gov.ky',
|
||||
'com.tc','net.tc','org.tc','edu.tc','gov.tc',
|
||||
'com.bm','net.bm','org.bm','edu.bm','gov.bm',
|
||||
'com.bs','net.bs','org.bs','edu.bs','gov.bs',
|
||||
'com.aw','net.aw','org.aw',
|
||||
'com.na','net.na','org.na','edu.na','gov.na',
|
||||
'com.zm','net.zm','org.zm','edu.zm','gov.zm',
|
||||
'com.zw','net.zw','org.zw','edu.zw','gov.zw',
|
||||
'com.mz','net.mz','org.mz','edu.mz','gov.mz',
|
||||
'com.tz','net.tz','org.tz','edu.tz','go.tz',
|
||||
'com.ug','net.ug','org.ug','edu.ug','go.ug',
|
||||
'com.rw','net.rw','org.rw','edu.rw','gov.rw',
|
||||
'com.et','net.et','org.et','edu.et','gov.et',
|
||||
'com.sd','net.sd','org.sd','edu.sd','gov.sd',
|
||||
'com.ly','net.ly','org.ly','edu.ly','gov.ly',
|
||||
'com.tn','net.tn','org.tn','edu.tn','gov.tn',
|
||||
'com.dz','net.dz','org.dz','edu.dz','gov.dz',
|
||||
'com.ma','net.ma','org.ma','edu.ma','gov.ma',
|
||||
'com.sn','net.sn','org.sn','edu.sn','gov.sn',
|
||||
'com.ci','net.ci','org.ci','edu.ci','gov.ci',
|
||||
'com.cm','net.cm','org.cm','edu.cm','gov.cm',
|
||||
'com.bf','net.bf','org.bf','edu.bf','gov.bf',
|
||||
'com.ml','net.ml','org.ml','edu.ml','gov.ml',
|
||||
'com.ne','net.ne','org.ne','edu.ne','gov.ne',
|
||||
'com.td','net.td','org.td','edu.td','gov.td',
|
||||
'com.mr','net.mr','org.mr','edu.mr','gov.mr',
|
||||
'com.gn','net.gn','org.gn','edu.gn','gov.gn',
|
||||
'com.sl','net.sl','org.sl','edu.sl','gov.sl',
|
||||
'com.lr','net.lr','org.lr','edu.lr','gov.lr',
|
||||
'com.gw','net.gw','org.gw','edu.gw','gov.gw',
|
||||
'com.gm','net.gm','org.gm','edu.gm','gov.gm',
|
||||
'com.cv','net.cv','org.cv','edu.cv','gov.cv',
|
||||
'com.st','net.st','org.st','edu.st','gov.st',
|
||||
'com.ga','net.ga','org.ga','edu.ga','gov.ga',
|
||||
'com.cg','net.cg','org.cg','edu.cg','gov.cg',
|
||||
'com.cd','net.cd','org.cd','edu.cd','gov.cd',
|
||||
'com.ao','net.ao','org.ao','edu.ao','gov.ao',
|
||||
'com.bw','net.bw','org.bw','edu.bw','gov.bw',
|
||||
'com.ls','net.ls','org.ls','edu.ls','gov.ls',
|
||||
'com.sz','net.sz','org.sz','edu.sz','gov.sz',
|
||||
'com.mg','net.mg','org.mg','edu.mg','gov.mg',
|
||||
'com.mu','net.mu','org.mu','edu.mu','gov.mu',
|
||||
'com.re','net.re','org.re','edu.re','gov.re',
|
||||
'com.yt','net.yt','org.yt',
|
||||
'com.sc','net.sc','org.sc','edu.sc','gov.sc',
|
||||
'com.km','net.km','org.km','edu.km','gov.km',
|
||||
'com.dj','net.dj','org.dj','edu.dj','gov.dj',
|
||||
'com.so','net.so','org.so','edu.so','gov.so',
|
||||
'com.er','net.er','org.er','edu.er','gov.er',
|
||||
'com.bi','net.bi','org.bi','edu.bi','gov.bi',
|
||||
'com.mw','net.mw','org.mw','edu.mw','gov.mw',
|
||||
'com.zm','net.zm','org.zm',
|
||||
'com.bd','net.bd','org.bd','edu.bd','gov.bd',
|
||||
'com.lk','net.lk','org.lk','edu.lk','gov.lk',
|
||||
'com.np','net.np','org.np','edu.np','gov.np',
|
||||
'com.mm','net.mm','org.mm','edu.mm','gov.mm',
|
||||
'com.kh','net.kh','org.kh','edu.kh','gov.kh',
|
||||
'com.la','net.la','org.la','edu.la','gov.la',
|
||||
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
|
||||
'com.mn','net.mn','org.mn','edu.mn','gov.mn',
|
||||
'com.kz','net.kz','org.kz','edu.kz','gov.kz',
|
||||
'com.uz','net.uz','org.uz','edu.uz','gov.uz',
|
||||
'com.tm','net.tm','org.tm','edu.tm','gov.tm',
|
||||
'com.tj','net.tj','org.tj','edu.tj','gov.tj',
|
||||
'com.kg','net.kg','org.kg','edu.kg','gov.kg',
|
||||
'com.af','net.af','org.af','edu.af','gov.af',
|
||||
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
|
||||
'com.ir','net.ir','org.ir','edu.ir','gov.ir',
|
||||
'com.iq','net.iq','org.iq','edu.iq','gov.iq',
|
||||
'com.sy','net.sy','org.sy','edu.sy','gov.sy',
|
||||
'com.lb','net.lb','org.lb','edu.lb','gov.lb',
|
||||
'com.jo','net.jo','org.jo','edu.jo','gov.jo',
|
||||
'com.ps','net.ps','org.ps','edu.ps','gov.ps',
|
||||
'com.ye','net.ye','org.ye','edu.ye','gov.ye',
|
||||
'com.om','net.om','org.om','edu.om','gov.om',
|
||||
'com.kw','net.kw','org.kw','edu.kw','gov.kw',
|
||||
'com.bh','net.bh','org.bh','edu.bh','gov.bh',
|
||||
'com.qa','net.qa','org.qa','edu.qa','gov.qa',
|
||||
'com.ge','net.ge','org.ge','edu.ge','gov.ge',
|
||||
'com.am','net.am','org.am','edu.am','gov.am',
|
||||
'com.az','net.az','org.az','edu.az','gov.az',
|
||||
'com.by','net.by','org.by','edu.by','gov.by',
|
||||
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
|
||||
'com.md','net.md','org.md','edu.md','gov.md',
|
||||
'com.ro','net.ro','org.ro','edu.ro','gov.ro',
|
||||
'com.bg','net.bg','org.bg','edu.bg','gov.bg',
|
||||
'com.mk','net.mk','org.mk','edu.mk','gov.mk',
|
||||
'com.al','net.al','org.al','edu.al','gov.al',
|
||||
'com.rs','net.rs','org.rs','edu.rs','gov.rs',
|
||||
'com.hr','net.hr','org.hr','edu.hr','gov.hr',
|
||||
'com.ba','net.ba','org.ba','edu.ba','gov.ba',
|
||||
'com.me','net.me','org.me','edu.me','gov.me',
|
||||
'com.si','net.si','org.si','edu.si','gov.si',
|
||||
'com.sk','net.sk','org.sk','edu.sk','gov.sk',
|
||||
'com.cz','net.cz','org.cz','edu.cz','gov.cz',
|
||||
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
|
||||
'com.hu','net.hu','org.hu','edu.hu','gov.hu',
|
||||
'com.at','net.at','org.at','edu.at','gov.at',
|
||||
'com.ch','net.ch','org.ch','edu.ch','gov.ch',
|
||||
'com.li','net.li','org.li','edu.li','gov.li',
|
||||
'com.be','net.be','org.be','edu.be','gov.be',
|
||||
'com.nl','net.nl','org.nl','edu.nl','gov.nl',
|
||||
'com.lu','net.lu','org.lu','edu.lu','gov.lu',
|
||||
'com.dk','net.dk','org.dk','edu.dk','gov.dk',
|
||||
'com.se','net.se','org.se','edu.se','gov.se',
|
||||
'com.no','net.no','org.no','edu.no','gov.no',
|
||||
'com.fi','net.fi','org.fi','edu.fi','gov.fi',
|
||||
'com.is','net.is','org.is','edu.is','gov.is',
|
||||
'com.ie','net.ie','org.ie','edu.ie','gov.ie',
|
||||
'com.pt','net.pt','org.pt','edu.pt','gov.pt',
|
||||
'com.gr','net.gr','org.gr','edu.gr','gov.gr',
|
||||
'com.cy','net.cy','org.cy','edu.cy','gov.cy',
|
||||
'com.mt','net.mt','org.mt','edu.mt','gov.mt',
|
||||
'com.ee','net.ee','org.ee','edu.ee','gov.ee',
|
||||
'com.lv','net.lv','org.lv','edu.lv','gov.lv',
|
||||
'com.lt','net.lt','org.lt','edu.lt','gov.lt'
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Global event wiring — toggle switches, settings buttons, and per-page setup calls.
|
||||
// Depends on: all page modules
|
||||
|
||||
function setupEvents() {
|
||||
// Toggle switches
|
||||
document.querySelectorAll('.toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => toggle.classList.toggle('active'));
|
||||
});
|
||||
|
||||
// Save settings
|
||||
$('btnSaveSettings')?.addEventListener('click', saveSettings);
|
||||
|
||||
// Reset settings to defaults
|
||||
$('btnResetSettings')?.addEventListener('click', () => {
|
||||
settings = { ...SETTINGS_DEFAULTS };
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||||
(response) => {
|
||||
if (response && response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
||||
updateSettingsUI();
|
||||
showToast('Settings reset to defaults', 'success');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
setupVirtualHostEvents();
|
||||
setupServerEvents();
|
||||
setupServiceTunnelEvents();
|
||||
setupLogsEvents();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml), core/utils.js (timeAgo),
|
||||
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
|
||||
|
||||
let pendingRestoreFilename = null;
|
||||
let pendingDeleteFilename = null;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function updateBackupsTable(backups) {
|
||||
const tbody = $('backupsTable');
|
||||
if (!tbody) return;
|
||||
const countEl = $('backupCount');
|
||||
if (countEl) countEl.textContent = backups ? backups.length : 0;
|
||||
if (!backups || backups.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="4"><div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;color:var(--text4)"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<div class="empty-state-title">No backups yet</div>
|
||||
<div class="empty-state-desc">Click "Take Backup" to create your first backup</div>
|
||||
</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = backups.map((b) => {
|
||||
const name = escapeHtml(b.filename);
|
||||
const created = b.createdAt ? timeAgo(b.createdAt) : '—';
|
||||
const size = b.size ? formatBytes(b.size) : '—';
|
||||
return `<tr>
|
||||
<td><span class="mono" style="font-size:12px;">${name}</span></td>
|
||||
<td>${created}</td>
|
||||
<td>${size}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn btn-secondary btn-sm" data-backup-restore="${escapeHtml(b.filename)}">Restore</button>
|
||||
<button class="btn btn-danger btn-sm" data-backup-delete="${escapeHtml(b.filename)}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshBackups() {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response && response.ok) {
|
||||
updateBackupsTable(response.backups || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function setupBackupEvents() {
|
||||
$('btnTakeBackup')?.addEventListener('click', () => {
|
||||
const btn = $('btnTakeBackup');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'createBackup' } },
|
||||
(response) => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Take Backup`;
|
||||
}
|
||||
if (response && response.ok) {
|
||||
showToast('Backup created: ' + response.filename, 'success');
|
||||
refreshBackups();
|
||||
} else {
|
||||
showToast(response?.error || 'Backup failed', 'error');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('backupsTable')?.addEventListener('click', (e) => {
|
||||
const restoreBtn = e.target.closest('[data-backup-restore]');
|
||||
if (restoreBtn) {
|
||||
pendingRestoreFilename = restoreBtn.dataset.backupRestore;
|
||||
const nameEl = $('restoreBackupName');
|
||||
if (nameEl) nameEl.textContent = pendingRestoreFilename;
|
||||
openModal('modal-restoreBackup');
|
||||
return;
|
||||
}
|
||||
const deleteBtn = e.target.closest('[data-backup-delete]');
|
||||
if (deleteBtn) {
|
||||
pendingDeleteFilename = deleteBtn.dataset.backupDelete;
|
||||
const nameEl = $('deleteBackupName');
|
||||
if (nameEl) nameEl.textContent = pendingDeleteFilename;
|
||||
openModal('modal-deleteBackup');
|
||||
}
|
||||
});
|
||||
|
||||
$('restoreBackupConfirm')?.addEventListener('click', () => {
|
||||
if (!pendingRestoreFilename) return;
|
||||
const btn = $('restoreBackupConfirm');
|
||||
if (btn) btn.disabled = true;
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'restoreBackup', payload: { filename: pendingRestoreFilename } } },
|
||||
(response) => {
|
||||
if (btn) btn.disabled = false;
|
||||
if (response && response.ok) {
|
||||
closeModal('modal-restoreBackup');
|
||||
const certsNote = response.restoredCerts ? ' Certificates restored.' : '';
|
||||
showToast('Backup restored.' + certsNote + ' Restart tunnels to apply changes.', 'success');
|
||||
pendingRestoreFilename = null;
|
||||
refresh();
|
||||
} else {
|
||||
showModalError('modal-restoreBackup', 'restoreBackupError', response?.error || 'Restore failed');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('deleteBackupConfirm')?.addEventListener('click', () => {
|
||||
if (!pendingDeleteFilename) return;
|
||||
const btn = $('deleteBackupConfirm');
|
||||
if (btn) btn.disabled = true;
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'deleteBackup', payload: { filename: pendingDeleteFilename } } },
|
||||
(response) => {
|
||||
if (btn) btn.disabled = false;
|
||||
if (response && response.ok) {
|
||||
closeModal('modal-deleteBackup');
|
||||
showToast('Backup deleted', 'success');
|
||||
pendingDeleteFilename = null;
|
||||
refreshBackups();
|
||||
} else {
|
||||
showModalError('modal-deleteBackup', 'deleteBackupError', response?.error || 'Delete failed');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml), ui/toast.js (showToast)
|
||||
|
||||
function setupLogsEvents() {
|
||||
let logs = [];
|
||||
let autoScroll = true;
|
||||
let logFilter = '';
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'registerDashboard' },
|
||||
(response) => {
|
||||
if (response && response.logs) { logs = response.logs; updateLogsDisplay(); }
|
||||
}
|
||||
);
|
||||
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === 'holesail-logs' && message.logs) {
|
||||
logs = message.logs;
|
||||
updateLogsDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
function getLogClass(msg) {
|
||||
const m = (msg || '').toLowerCase();
|
||||
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error';
|
||||
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateLogsDisplay() {
|
||||
const container = $('logsContainer');
|
||||
if (!container) return;
|
||||
const filtered = logFilter
|
||||
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()))
|
||||
: logs;
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
||||
<div class="empty-state-title">${logFilter ? 'No matching logs' : 'No logs yet'}</div>
|
||||
<div class="empty-state-desc">${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = filtered.map(entry => {
|
||||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
||||
const cls = getLogClass(entry.message);
|
||||
return `<div class="log-entry">
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (autoScroll) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
$('btnClearLogs')?.addEventListener('click', () => { logs = []; updateLogsDisplay(); });
|
||||
|
||||
$('btnAutoScroll')?.addEventListener('click', () => {
|
||||
autoScroll = !autoScroll;
|
||||
const btn = $('btnAutoScroll');
|
||||
if (btn) {
|
||||
const svgPart = btn.querySelector('svg')?.outerHTML || '';
|
||||
btn.innerHTML = svgPart + ' Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
||||
}
|
||||
});
|
||||
|
||||
$('logsFilter')?.addEventListener('input', (e) => {
|
||||
logFilter = e.target.value;
|
||||
updateLogsDisplay();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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)
|
||||
|
||||
const OV_PAGE = 20; // rows per page
|
||||
|
||||
class OvList {
|
||||
constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) {
|
||||
this.body = $(bodyId);
|
||||
this.sentinel = $(sentinelId);
|
||||
this.searchEl = $(searchId);
|
||||
this.subtitleEl = $(subtitleId);
|
||||
this.rowFn = rowFn;
|
||||
this.emptyMsg = emptyMsg;
|
||||
this.cols = cols;
|
||||
this.data = [];
|
||||
this.filtered = [];
|
||||
this.rendered = 0;
|
||||
this._debounce = null;
|
||||
this._observer = null;
|
||||
this._initObserver();
|
||||
this._initSearch();
|
||||
}
|
||||
|
||||
_initObserver() {
|
||||
if (!this.sentinel) return;
|
||||
this._observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) this._appendPage();
|
||||
}, { threshold: 0 });
|
||||
this._observer.observe(this.sentinel);
|
||||
}
|
||||
|
||||
_initSearch() {
|
||||
if (!this.searchEl) return;
|
||||
this.searchEl.addEventListener('input', () => {
|
||||
clearTimeout(this._debounce);
|
||||
this._debounce = setTimeout(() => this._applyFilter(), 180);
|
||||
});
|
||||
}
|
||||
|
||||
setData(data, subtitleText) {
|
||||
this.data = data;
|
||||
if (this.subtitleEl) this.subtitleEl.textContent = subtitleText;
|
||||
this._applyFilter();
|
||||
}
|
||||
|
||||
_applyFilter() {
|
||||
const q = (this.searchEl?.value || '').trim().toLowerCase();
|
||||
this.filtered = q
|
||||
? this.data.filter(item => JSON.stringify(item).toLowerCase().includes(q))
|
||||
: this.data.slice();
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (!this.body) return;
|
||||
this.rendered = 0;
|
||||
this.body.innerHTML = '';
|
||||
if (this.filtered.length === 0) {
|
||||
this.body.innerHTML = `<tr><td colspan="${this.cols}" class="empty">${this.emptyMsg}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this._appendPage();
|
||||
}
|
||||
|
||||
_appendPage() {
|
||||
if (!this.body || this.rendered >= this.filtered.length) return;
|
||||
const next = this.filtered.slice(this.rendered, this.rendered + OV_PAGE);
|
||||
this.body.insertAdjacentHTML('beforeend', next.map(this.rowFn).join(''));
|
||||
this.rendered += next.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Instances — created once, reused on every updateDashboard call
|
||||
let _ovVhost = null;
|
||||
let _ovServers = null;
|
||||
let _ovSvc = null;
|
||||
let _ovSsh = null;
|
||||
let _ovRdp = null;
|
||||
|
||||
function initOvLists() {
|
||||
_ovVhost = new OvList({
|
||||
bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel',
|
||||
searchId: 'ovVhostSearch', subtitleId: 'ovVhostSubtitle',
|
||||
cols: 3, emptyMsg: 'No virtual hosts',
|
||||
rowFn: v => {
|
||||
const h = v.hostname || v.id || '';
|
||||
return `<tr>
|
||||
<td style="font-size:12px;"><span class="mono-chip">${escapeHtml(h)}</span></td>
|
||||
<td>${stateTag(v.state)}</td>
|
||||
<td><a href="https://${escapeHtml(h)}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-sm" style="padding:3px 8px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
style="width:11px;height:11px;">
|
||||
<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></td>
|
||||
</tr>`;
|
||||
}
|
||||
});
|
||||
|
||||
_ovServers = new OvList({
|
||||
bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel',
|
||||
searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle',
|
||||
cols: 3, emptyMsg: 'No server tunnels',
|
||||
rowFn: s => {
|
||||
const key = s.url || s.hsUrl || '';
|
||||
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
|
||||
const display = s.label ? escapeHtml(s.label) : `<span class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(String(s.port || '—'))}</span>`;
|
||||
return `<tr>
|
||||
<td style="font-size:12px;">${display}</td>
|
||||
<td>${stateTag(s.state)}</td>
|
||||
<td class="mono" style="font-size:11px;color:var(--text3);"
|
||||
title="${escapeHtml(key)}">${escapeHtml(short)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
});
|
||||
|
||||
_ovSvc = new OvList({
|
||||
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
|
||||
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
|
||||
cols: 3, emptyMsg: 'No service tunnels',
|
||||
rowFn: t => `<tr>
|
||||
<td style="font-size:12px;">${escapeHtml(t.label || '—')}</td>
|
||||
<td class="mono" style="font-size:12px;">${escapeHtml(String(t.localPort || '—'))}</td>
|
||||
<td>${stateTag(t.state)}</td>
|
||||
</tr>`
|
||||
});
|
||||
|
||||
_ovSsh = new OvList({
|
||||
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
|
||||
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
|
||||
cols: 2, emptyMsg: 'No SSH connections',
|
||||
rowFn: c => `<tr>
|
||||
<td style="font-size:12px;">${escapeHtml(c.label || c.hsUrl || '—')}</td>
|
||||
<td class="mono" style="font-size:12px;color:var(--text3);">${escapeHtml(c.username || '—')}</td>
|
||||
</tr>`
|
||||
});
|
||||
|
||||
_ovRdp = new OvList({
|
||||
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
|
||||
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
|
||||
cols: 2, emptyMsg: 'No remote desktops',
|
||||
rowFn: c => `<tr>
|
||||
<td style="font-size:12px;">${escapeHtml(c.label || '—')}</td>
|
||||
<td><span class="badge badge-neutral" style="font-size:10px;padding:2px 6px;">
|
||||
${escapeHtml((c.type || 'vnc').toUpperCase())}</span></td>
|
||||
</tr>`
|
||||
});
|
||||
|
||||
// Quick action buttons
|
||||
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
|
||||
$('qaAddServer')?.addEventListener('click', () => {
|
||||
const editIdEl = $('serverEditId');
|
||||
if (editIdEl) editIdEl.value = '';
|
||||
const labelEl = $('startServerLabel');
|
||||
if (labelEl) labelEl.value = '';
|
||||
const portEl = $('startServerPort');
|
||||
if (portEl) portEl.value = '3000';
|
||||
const hostEl = $('startServerHost');
|
||||
if (hostEl) hostEl.value = '127.0.0.1';
|
||||
const secureEl = $('startServerSecure');
|
||||
if (secureEl) secureEl.checked = true;
|
||||
const tcpEl = $('startServerProtocolTcp');
|
||||
if (tcpEl) tcpEl.checked = true;
|
||||
const titleEl = $('modal-startServer-title');
|
||||
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
|
||||
const submitEl = $('startServerSubmit');
|
||||
if (submitEl) submitEl.textContent = 'Start Server';
|
||||
openModal('modal-startServer');
|
||||
});
|
||||
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
|
||||
$('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null));
|
||||
$('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null));
|
||||
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
|
||||
}
|
||||
|
||||
function updateDashboard(state) {
|
||||
currentState = state;
|
||||
const servers = state.servers || [];
|
||||
const virtualHosts = state.virtualHosts || [];
|
||||
const serviceTunnels = state.serviceTunnels || [];
|
||||
|
||||
const dot = $('sidebarDot');
|
||||
const statusText = $('sidebarStatus');
|
||||
if (state.hostConnected) {
|
||||
dot?.classList.add('connected');
|
||||
dot?.classList.remove('disconnected');
|
||||
if (statusText) statusText.textContent = 'Connected';
|
||||
} else {
|
||||
dot?.classList.remove('connected');
|
||||
dot?.classList.add('disconnected');
|
||||
if (statusText) statusText.textContent = 'Disconnected';
|
||||
}
|
||||
|
||||
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
|
||||
|
||||
setText('dashConnections', virtualHosts.length);
|
||||
setText('dashSwarms', servers.length);
|
||||
setText('dashServiceTunnels', serviceTunnels.length);
|
||||
setText('dashSsh', sshConnections.length);
|
||||
setText('dashRdp', rdpConnections.length);
|
||||
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
|
||||
|
||||
const caIcon = $('caStatIcon');
|
||||
if (caIcon) {
|
||||
caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)';
|
||||
caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
|
||||
}
|
||||
|
||||
const ovHostDot = $('ovHostDot');
|
||||
if (ovHostDot) {
|
||||
ovHostDot.classList.toggle('connected', !!state.hostConnected);
|
||||
ovHostDot.classList.toggle('disconnected', !state.hostConnected);
|
||||
}
|
||||
setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected');
|
||||
const ovCaDot = $('ovCaDot');
|
||||
if (ovCaDot) {
|
||||
ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)';
|
||||
ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none';
|
||||
}
|
||||
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
|
||||
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
|
||||
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
|
||||
|
||||
const lu = $('overviewLastUpdated');
|
||||
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
|
||||
setText('swarmCount', servers.length);
|
||||
setText('connCount', virtualHosts.length);
|
||||
setText('serviceTunnelCount', serviceTunnels.length);
|
||||
setText('sshCount', sshConnections.length);
|
||||
setText('rdpCount', rdpConnections.length);
|
||||
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
|
||||
|
||||
if (!_ovVhost) initOvLists();
|
||||
|
||||
const sub = (n, unit, readyArr) => {
|
||||
if (n === 0) return `No ${unit}s`;
|
||||
const r = readyArr.filter(x => x.state === 'ready').length;
|
||||
return `${n} ${unit}${n !== 1 ? 's' : ''} — ${r} ready`;
|
||||
};
|
||||
|
||||
_ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts));
|
||||
_ovServers.setData(servers, sub(servers.length, 'tunnel', servers));
|
||||
_ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels));
|
||||
_ovSsh .setData(sshConnections, sshConnections.length === 0
|
||||
? 'No SSH connections' : `${sshConnections.length} saved`);
|
||||
_ovRdp .setData(rdpConnections, rdpConnections.length === 0
|
||||
? 'No remote desktops' : `${rdpConnections.length} saved`);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml), core/state.js (currentState),
|
||||
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal)
|
||||
|
||||
// Per-host test results: hostname -> { status, tlsOk, httpStatus, ms, error }
|
||||
const validationResults = new Map();
|
||||
|
||||
function updateTabsTable(state) {
|
||||
const portEl = $('proxyInfoPort');
|
||||
const caEl = $('proxyInfoCA');
|
||||
if (portEl) portEl.textContent = state.proxyPort != null ? state.proxyPort : '—';
|
||||
if (caEl) {
|
||||
caEl.textContent = state.caInstalled ? 'Installed' : 'Not installed';
|
||||
caEl.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
|
||||
}
|
||||
|
||||
const validatorCard = $('certValidatorCard');
|
||||
if (validatorCard) validatorCard.style.display = state.caInstalled ? '' : 'none';
|
||||
|
||||
if (state.caInstalled) {
|
||||
renderValidatorTable(state.virtualHosts || []);
|
||||
}
|
||||
}
|
||||
|
||||
function renderValidatorTable(virtualHosts) {
|
||||
const tbody = $('certValidatorBody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (virtualHosts.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty">No virtual hosts to test — add one in Virtual Hosts</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = virtualHosts.map(v => {
|
||||
const hostname = v.hostname || '';
|
||||
const r = validationResults.get(hostname);
|
||||
return `<tr id="vrow-${CSS.escape(hostname)}">
|
||||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||||
<td>${renderTlsCell(hostname, r)}</td>
|
||||
<td>${renderStatusCell(hostname, r)}</td>
|
||||
<td>${renderTimeCell(hostname, r)}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" data-validate-host="${escapeHtml(hostname)}" id="vbtn-${CSS.escape(hostname)}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
Test
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('[data-validate-host]').forEach(btn => {
|
||||
btn.addEventListener('click', () => runValidation(btn.dataset.validateHost));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTlsCell(hostname, r) {
|
||||
if (!r) return `<span class="badge badge-neutral">—</span>`;
|
||||
if (r.status === 'running') return `<span class="badge badge-amber">Testing…</span>`;
|
||||
if (r.tlsOk) return `<span class="badge badge-green">✓ Trusted</span>`;
|
||||
return `<span class="badge badge-red" title="${escapeHtml(r.error || '')}">✗ Untrusted</span>`;
|
||||
}
|
||||
|
||||
function renderStatusCell(hostname, r) {
|
||||
if (!r || r.status === 'running') return `<span style="color:var(--text4);">—</span>`;
|
||||
if (!r.tlsOk) return `<span style="color:var(--text4);">—</span>`;
|
||||
if (r.httpStatus == null) return `<span class="badge badge-red">No response</span>`;
|
||||
const cls = r.httpStatus < 400 ? 'badge-green' : r.httpStatus < 500 ? 'badge-amber' : 'badge-red';
|
||||
return `<span class="badge ${cls}">${r.httpStatus}</span>`;
|
||||
}
|
||||
|
||||
function renderTimeCell(hostname, r) {
|
||||
if (!r || r.status === 'running' || !r.tlsOk || r.ms == null) return `<span style="color:var(--text4);">—</span>`;
|
||||
const color = r.ms < 500 ? 'var(--green)' : r.ms < 2000 ? 'var(--amber)' : 'var(--red)';
|
||||
return `<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:${color};">${r.ms}ms</span>`;
|
||||
}
|
||||
|
||||
function updateValidatorRow(hostname) {
|
||||
const r = validationResults.get(hostname);
|
||||
const row = document.getElementById('vrow-' + CSS.escape(hostname));
|
||||
if (!row) return;
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells[1]) cells[1].innerHTML = renderTlsCell(hostname, r);
|
||||
if (cells[2]) cells[2].innerHTML = renderStatusCell(hostname, r);
|
||||
if (cells[3]) cells[3].innerHTML = renderTimeCell(hostname, r);
|
||||
const btn = document.getElementById('vbtn-' + CSS.escape(hostname));
|
||||
if (btn) {
|
||||
btn.disabled = r && r.status === 'running';
|
||||
btn.innerHTML = (r && r.status === 'running')
|
||||
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;animation:spin 1s linear infinite;"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg> Testing…`
|
||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg> Test`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runValidation(hostname) {
|
||||
validationResults.set(hostname, { status: 'running' });
|
||||
updateValidatorRow(hostname);
|
||||
|
||||
const url = `https://${hostname}`;
|
||||
const start = Date.now();
|
||||
try {
|
||||
// fetch() goes through the PAC proxy → HTTPS proxy → Holesail tunnel.
|
||||
// If the CA is not trusted by the browser, this throws a TypeError (net::ERR_CERT_AUTHORITY_INVALID).
|
||||
// mode: 'no-cors' avoids CORS errors from opaque responses — we only care about TLS + reachability.
|
||||
const resp = await fetch(url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(15000) });
|
||||
const ms = Date.now() - start;
|
||||
const httpStatus = resp.type === 'opaque' ? null : resp.status;
|
||||
validationResults.set(hostname, { status: 'done', tlsOk: true, httpStatus, ms });
|
||||
} catch (err) {
|
||||
const ms = Date.now() - start;
|
||||
const msg = err.message || String(err);
|
||||
const isTlsError = msg.includes('ERR_CERT') || msg.includes('certificate') || msg.includes('SSL') || msg.includes('CERT');
|
||||
validationResults.set(hostname, { status: 'done', tlsOk: false, ms, error: msg, isTlsError });
|
||||
}
|
||||
updateValidatorRow(hostname);
|
||||
}
|
||||
|
||||
async function runAllValidations(virtualHosts) {
|
||||
if (!virtualHosts || virtualHosts.length === 0) return;
|
||||
for (const v of virtualHosts) {
|
||||
await runValidation(v.hostname || '');
|
||||
}
|
||||
}
|
||||
|
||||
function setupCertValidator() {
|
||||
$('runAllValidationsBtn')?.addEventListener('click', () => {
|
||||
if (currentState) runAllValidations(currentState.virtualHosts || []);
|
||||
});
|
||||
|
||||
$('installCaBtn')?.addEventListener('click', () => openModal('modal-installCA'));
|
||||
|
||||
$('installCaSubmit')?.addEventListener('click', () => {
|
||||
const btn = $('installCaSubmit');
|
||||
const errEl = $('installCaError');
|
||||
const successEl = $('installCaSuccess');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (successEl) { successEl.style.display = 'none'; }
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'installRootCA', payload: {} } },
|
||||
(response) => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Install CA'; }
|
||||
if (response?.ok) {
|
||||
if (successEl) { successEl.textContent = '✓ Root CA installed. Fully quit and reopen Chrome (Cmd+Q) to apply trust.'; successEl.style.display = 'block'; }
|
||||
showToast('Root CA installed', 'success');
|
||||
setTimeout(() => closeModal('modal-installCA'), 2000);
|
||||
refresh();
|
||||
} else {
|
||||
if (errEl) { errEl.textContent = response?.error || 'Installation failed'; errEl.style.display = 'block'; }
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
|
||||
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
|
||||
|
||||
let rdpConnections = [];
|
||||
let activeRdpSession = null; // { sessionId, wsPort, type, ws, rfb, conn }
|
||||
|
||||
function generateRdpId() {
|
||||
return 'rdp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function saveRdpConnections(cb) {
|
||||
// Encode password as base64 before persisting so it survives page reloads
|
||||
const toSave = rdpConnections.map(c => {
|
||||
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
|
||||
if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password)));
|
||||
else delete rest.passwordB64;
|
||||
return rest;
|
||||
});
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: toSave } } },
|
||||
(response) => {
|
||||
if (response && !response.ok) log('saveRdpConnections failed:', response.error);
|
||||
renderRdpGrid();
|
||||
const countEl = $('rdpCount');
|
||||
if (countEl) countEl.textContent = rdpConnections.length;
|
||||
if (cb) cb();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderRdpGrid() {
|
||||
const grid = $('rdpGrid');
|
||||
if (!grid) return;
|
||||
const countEl = $('rdpCount');
|
||||
if (countEl) countEl.textContent = rdpConnections.length;
|
||||
if (rdpConnections.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state" style="grid-column:1/-1">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
<div class="empty-state-title">No remote desktop connections</div>
|
||||
<div class="empty-state-desc">Add a VNC or RDP connection. You'll need an hs:// key for the remote peer.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = rdpConnections.map(conn => {
|
||||
const typeBadge = conn.type === 'rdp'
|
||||
? `<span class="badge badge-amber" style="font-size:10px;padding:2px 7px;">RDP</span>`
|
||||
: `<span class="badge badge-cyan" style="font-size:10px;padding:2px 7px;">VNC</span>`;
|
||||
return `
|
||||
<div class="rdp-conn-card" data-rdp-id="${escapeHtml(conn.id)}">
|
||||
<div class="rdp-conn-card-top">
|
||||
<div class="rdp-conn-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rdp-conn-info">
|
||||
<div class="rdp-conn-label">${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}</div>
|
||||
</div>
|
||||
<div class="rdp-conn-actions">
|
||||
<button class="btn btn-primary" data-rdp-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="5,12 19,12"/><polyline points="12,5 19,12 12,19"/></svg>
|
||||
Connect
|
||||
</button>
|
||||
<button class="btn btn-ghost" data-rdp-edit="${escapeHtml(conn.id)}" style="padding:6px 10px;" title="Edit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost" data-rdp-remove="${escapeHtml(conn.id)}" style="padding:6px 10px;color:var(--red);" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('[data-rdp-connect]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpConnect);
|
||||
if (conn) connectRdp(conn);
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll('[data-rdp-edit]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpEdit);
|
||||
if (conn) openAddRdpModal(conn);
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll('[data-rdp-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpRemove);
|
||||
if (conn) {
|
||||
$('removeRdpName').textContent = conn.label || conn.type.toUpperCase() + ' Desktop';
|
||||
$('removeRdpConfirm').dataset.rdpId = conn.id;
|
||||
openModal('modal-removeRdp');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openAddRdpModal(conn) {
|
||||
const isEdit = !!conn;
|
||||
$('modal-addRdp-title').textContent = isEdit ? 'Edit Remote Desktop Connection' : 'Add Remote Desktop Connection';
|
||||
$('rdpConnLabel').value = conn ? (conn.label || '') : '';
|
||||
$('rdpConnHsUrl').value = conn ? conn.hsUrl : '';
|
||||
$('rdpConnPort').value = conn ? conn.port : 5900;
|
||||
$('rdpConnWidth').value = conn ? (conn.width || 1280) : 1280;
|
||||
$('rdpConnHeight').value = conn ? (conn.height || 720) : 720;
|
||||
$('rdpConnUsername').value = conn ? (conn.username || '') : '';
|
||||
$('rdpConnPassword').value = conn ? (conn.password || '') : '';
|
||||
$('rdpConnEditId').value = conn ? conn.id : '';
|
||||
$('rdpConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
||||
|
||||
const type = conn ? conn.type : 'vnc';
|
||||
document.querySelector('input[name="rdpProtocol"][value="' + type + '"]').checked = true;
|
||||
updateRdpProtocolUI(type);
|
||||
openModal('modal-addRdp');
|
||||
}
|
||||
|
||||
function updateRdpProtocolUI(type) {
|
||||
const portEl = $('rdpConnPort');
|
||||
const usernameGroup = $('rdpUsernameGroup');
|
||||
if (type === 'rdp') {
|
||||
if (portEl && portEl.value === '5900') portEl.value = '3389';
|
||||
if (usernameGroup) usernameGroup.style.display = '';
|
||||
} else {
|
||||
if (portEl && portEl.value === '3389') portEl.value = '5900';
|
||||
if (usernameGroup) usernameGroup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── VNC viewer (noVNC RFB) ────────────────────────────────────────────────────
|
||||
|
||||
function initVncViewer(wsPort, conn) {
|
||||
const container = $('rdpViewerContainer');
|
||||
if (!container) return null;
|
||||
container.innerHTML = '';
|
||||
|
||||
const RFB = window.RFB;
|
||||
if (!RFB) {
|
||||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">noVNC (RFB) not loaded. Check vendor/novnc.js.</div>';
|
||||
return null;
|
||||
}
|
||||
|
||||
let rfb;
|
||||
try {
|
||||
rfb = new RFB(container, 'ws://127.0.0.1:' + wsPort, {
|
||||
credentials: conn.password ? { password: conn.password } : undefined
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.viewOnly = false;
|
||||
rfb.clipViewport = false;
|
||||
rfb.dragViewport = false;
|
||||
rfb.focusOnClick = true;
|
||||
rfb.background = '#000';
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">Failed to init noVNC: ' + escapeHtml(e.message) + '</div>';
|
||||
return null;
|
||||
}
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||||
$('rdpStateDisplay').textContent = 'Connected';
|
||||
$('rdpStateDisplay').style.color = 'var(--green)';
|
||||
const w = rfb._fbWidth || conn.width || '?';
|
||||
const h = rfb._fbHeight || conn.height || '?';
|
||||
$('rdpResDisplay').textContent = w + '×' + h;
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e) => {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('rdpStateDisplay').textContent = e.detail && e.detail.clean ? 'Disconnected' : 'Connection lost';
|
||||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||||
});
|
||||
|
||||
rfb.addEventListener('desktopname', (e) => {
|
||||
if (e.detail && e.detail.name) $('rdpViewerInfo').textContent = e.detail.name;
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
const pw = prompt('VNC password required:');
|
||||
if (pw !== null) rfb.sendCredentials({ password: pw });
|
||||
});
|
||||
|
||||
return rfb;
|
||||
}
|
||||
|
||||
// ── RDP viewer (node-rdpjs bitmap renderer) ───────────────────────────────────
|
||||
|
||||
function initRdpViewer(wsPort, conn) {
|
||||
const container = $('rdpViewerContainer');
|
||||
if (!container) return null;
|
||||
container.innerHTML = '';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = conn.width || 1280;
|
||||
canvas.height = conn.height || 720;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.cursor = 'default';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
$('rdpResDisplay').textContent = canvas.width + '×' + canvas.height;
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">WebSocket failed: ' + escapeHtml(e.message) + '</div>';
|
||||
return null;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
$('rdpStateDisplay').textContent = 'Waiting for RDP…';
|
||||
$('rdpStateDisplay').style.color = 'var(--amber)';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(event.data); } catch (_) { return; }
|
||||
|
||||
if (msg.type === 'connected') {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||||
$('rdpStateDisplay').textContent = 'Connected';
|
||||
$('rdpStateDisplay').style.color = 'var(--green)';
|
||||
if (msg.width && msg.height) {
|
||||
canvas.width = msg.width;
|
||||
canvas.height = msg.height;
|
||||
$('rdpResDisplay').textContent = msg.width + '×' + msg.height;
|
||||
}
|
||||
} else if (msg.type === 'bitmap') {
|
||||
renderRdpBitmap(ctx, msg);
|
||||
} else if (msg.type === 'close') {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('rdpStateDisplay').textContent = 'Disconnected';
|
||||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||||
} else if (msg.type === 'error') {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('rdpStateDisplay').textContent = 'Error: ' + (msg.message || 'unknown');
|
||||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('rdpStateDisplay').textContent = 'Disconnected';
|
||||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
$('rdpStateDisplay').textContent = 'WebSocket error';
|
||||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||||
};
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / r.width;
|
||||
const scaleY = canvas.height / r.height;
|
||||
ws.send(JSON.stringify({ type: 'mouseMove', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY) }));
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / r.width;
|
||||
const scaleY = canvas.height / r.height;
|
||||
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
|
||||
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: true }));
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / r.width;
|
||||
const scaleY = canvas.height / r.height;
|
||||
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
|
||||
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: false }));
|
||||
});
|
||||
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
e.preventDefault();
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: true }));
|
||||
});
|
||||
canvas.addEventListener('keyup', (e) => {
|
||||
e.preventDefault();
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: false }));
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function renderRdpBitmap(ctx, bitmap) {
|
||||
const { destLeft, destTop, destRight, destBottom, width, height, bitsPerPixel, data } = bitmap;
|
||||
if (!data) return;
|
||||
|
||||
const raw = atob(data);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
|
||||
const drawW = destRight - destLeft;
|
||||
const drawH = destBottom - destTop;
|
||||
if (drawW <= 0 || drawH <= 0) return;
|
||||
|
||||
const imgData = ctx.createImageData(width, height);
|
||||
const pixels = imgData.data;
|
||||
|
||||
if (bitsPerPixel === 32) {
|
||||
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 4, p += 4) {
|
||||
pixels[p] = bytes[i + 2]; // R (BGRA → RGBA)
|
||||
pixels[p + 1] = bytes[i + 1]; // G
|
||||
pixels[p + 2] = bytes[i]; // B
|
||||
pixels[p + 3] = 255;
|
||||
}
|
||||
} else if (bitsPerPixel === 24) {
|
||||
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 3, p += 4) {
|
||||
pixels[p] = bytes[i + 2]; // R
|
||||
pixels[p + 1] = bytes[i + 1]; // G
|
||||
pixels[p + 2] = bytes[i]; // B
|
||||
pixels[p + 3] = 255;
|
||||
}
|
||||
} else if (bitsPerPixel === 16) {
|
||||
for (let i = 0, p = 0; i < bytes.length - 1 && p < pixels.length; i += 2, p += 4) {
|
||||
const v = bytes[i] | (bytes[i + 1] << 8);
|
||||
pixels[p] = ((v >> 11) & 0x1f) << 3;
|
||||
pixels[p + 1] = ((v >> 5) & 0x3f) << 2;
|
||||
pixels[p + 2] = (v & 0x1f) << 3;
|
||||
pixels[p + 3] = 255;
|
||||
}
|
||||
} else {
|
||||
return; // unsupported depth
|
||||
}
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
offscreen.getContext('2d').putImageData(imgData, 0, 0);
|
||||
ctx.drawImage(offscreen, destLeft, destTop, drawW, drawH);
|
||||
}
|
||||
|
||||
async function connectRdp(conn) {
|
||||
openModal('modal-rdpViewer');
|
||||
|
||||
const labelEl = $('rdpViewerLabel');
|
||||
if (labelEl) labelEl.textContent = conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop');
|
||||
|
||||
const protoBadge = $('rdpProtocolBadge');
|
||||
if (protoBadge) {
|
||||
protoBadge.textContent = conn.type.toUpperCase();
|
||||
protoBadge.className = 'badge ' + (conn.type === 'rdp' ? 'badge-amber' : 'badge-cyan');
|
||||
}
|
||||
|
||||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||||
$('rdpStateDisplay').textContent = 'Connecting…';
|
||||
$('rdpStateDisplay').style.color = 'var(--amber)';
|
||||
$('rdpResDisplay').textContent = (conn.width || 1280) + '×' + (conn.height || 720);
|
||||
$('rdpViewerInfo').textContent = '';
|
||||
|
||||
await disconnectRdp();
|
||||
|
||||
const result = await sendToNative('startRdpSession', {
|
||||
type: conn.type,
|
||||
hsUrl: conn.hsUrl,
|
||||
port: conn.port,
|
||||
username: conn.username || '',
|
||||
password: conn.password || '',
|
||||
domain: '',
|
||||
width: conn.width || 1280,
|
||||
height: conn.height || 720,
|
||||
label: conn.label || ''
|
||||
});
|
||||
|
||||
if (!result || !result.ok) {
|
||||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('rdpStateDisplay').textContent = 'Failed: ' + ((result && result.error) || 'Unknown error');
|
||||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, wsPort } = result;
|
||||
let viewer = null;
|
||||
|
||||
if (conn.type === 'vnc') {
|
||||
viewer = initVncViewer(wsPort, conn);
|
||||
activeRdpSession = { sessionId, wsPort, type: 'vnc', rfb: viewer, ws: null, conn };
|
||||
} else {
|
||||
viewer = initRdpViewer(wsPort, conn);
|
||||
activeRdpSession = { sessionId, wsPort, type: 'rdp', rfb: null, ws: viewer, conn };
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectRdp() {
|
||||
if (!activeRdpSession) return;
|
||||
const { sessionId, rfb, ws } = activeRdpSession;
|
||||
activeRdpSession = null;
|
||||
|
||||
if (rfb) { try { rfb.disconnect(); } catch (_) {} }
|
||||
if (ws) { try { ws.close(); } catch (_) {} }
|
||||
|
||||
const container = $('rdpViewerContainer');
|
||||
if (container) container.innerHTML = '';
|
||||
|
||||
if (sessionId) {
|
||||
await sendToNative('stopRdpSession', { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
function setupRdpEvents() {
|
||||
$('addRdpBtn')?.addEventListener('click', () => openAddRdpModal(null));
|
||||
|
||||
document.querySelectorAll('input[name="rdpProtocol"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => updateRdpProtocolUI(radio.value));
|
||||
});
|
||||
|
||||
$('rdpConnSubmit')?.addEventListener('click', () => {
|
||||
const label = $('rdpConnLabel').value.trim();
|
||||
const hsUrl = $('rdpConnHsUrl').value.trim();
|
||||
const type = document.querySelector('input[name="rdpProtocol"]:checked')?.value || 'vnc';
|
||||
const port = parseInt($('rdpConnPort').value, 10) || (type === 'rdp' ? 3389 : 5900);
|
||||
const width = parseInt($('rdpConnWidth').value, 10) || 1280;
|
||||
const height = parseInt($('rdpConnHeight').value, 10) || 720;
|
||||
const username = $('rdpConnUsername').value.trim();
|
||||
const password = $('rdpConnPassword').value;
|
||||
const editId = $('rdpConnEditId').value;
|
||||
|
||||
if (!hsUrl) { showModalError('modal-addRdp', 'rdpConnError', 'Holesail key is required'); return; }
|
||||
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addRdp', 'rdpConnError', 'Key must start with hs://'); return; }
|
||||
|
||||
const entry = { id: editId || generateRdpId(), label, hsUrl, type, port, width, height, username, password };
|
||||
|
||||
if (editId) {
|
||||
const idx = rdpConnections.findIndex(c => c.id === editId);
|
||||
if (idx !== -1) rdpConnections[idx] = entry;
|
||||
} else {
|
||||
rdpConnections.push(entry);
|
||||
}
|
||||
saveRdpConnections();
|
||||
closeModal('modal-addRdp');
|
||||
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
|
||||
});
|
||||
|
||||
$('removeRdpConfirm')?.addEventListener('click', () => {
|
||||
const id = $('removeRdpConfirm').dataset.rdpId;
|
||||
rdpConnections = rdpConnections.filter(c => c.id !== id);
|
||||
saveRdpConnections();
|
||||
closeModal('modal-removeRdp');
|
||||
showToast('Connection removed', 'success');
|
||||
});
|
||||
|
||||
$('rdpDisconnectBtn')?.addEventListener('click', async () => {
|
||||
await disconnectRdp();
|
||||
closeModal('modal-rdpViewer');
|
||||
});
|
||||
|
||||
$('rdpFullscreenBtn')?.addEventListener('click', () => {
|
||||
const modal = document.querySelector('#modal-rdpViewer .modal');
|
||||
if (!modal) return;
|
||||
if (modal.style.width === '100vw') {
|
||||
modal.style.width = '';
|
||||
modal.style.height = '';
|
||||
modal.style.borderRadius = '';
|
||||
} else {
|
||||
modal.style.width = '100vw';
|
||||
modal.style.height = '100vh';
|
||||
modal.style.borderRadius = '0';
|
||||
}
|
||||
});
|
||||
|
||||
const viewerModal = $('modal-rdpViewer');
|
||||
if (viewerModal) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!viewerModal.classList.contains('open') && activeRdpSession) {
|
||||
disconnectRdp();
|
||||
}
|
||||
});
|
||||
observer.observe(viewerModal, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
|
||||
// ui/modal.js (openModal, closeModal, showModalError)
|
||||
|
||||
function updateSwarmsTable(state) {
|
||||
const tbody = $('swarmsTable');
|
||||
if (!tbody) return;
|
||||
const servers = state.servers || [];
|
||||
|
||||
if (servers.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="5">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg>
|
||||
<div class="empty-state-title">No server tunnels</div>
|
||||
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = servers.map(s => {
|
||||
const id = s.id || s.serverId || '';
|
||||
const url = s.url || s.hsUrl || '';
|
||||
const safeId = id.replace(/"/g, '"');
|
||||
const label = s.label || '';
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
${label ? `<div style="font-weight:600;color:var(--text);margin-bottom:2px;">${escapeHtml(label)}</div>` : ''}
|
||||
<div class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</div>
|
||||
</td>
|
||||
<td style="font-weight:600;color:var(--text);">${s.port ?? '—'}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span class="mono" title="${escapeHtml(url)}" style="font-size:11px;color:var(--cyan);">${truncate(url, 30)}</span>
|
||||
${url ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(url)}" title="Copy hs:// URL">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-neutral" style="margin-right:4px;">${s.udp ? 'UDP' : 'TCP'}</span>${s.secure ? `<span class="badge badge-green">secure</span>` : `<span class="badge badge-neutral">plain</span>`}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<button class="btn btn-ghost btn-sm" data-edit-server="${safeId}" title="Edit server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" data-stop-server="${safeId}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-edit-server]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const serverId = btn.dataset.editServer;
|
||||
const server = (state.servers || []).find(s => (s.id || s.serverId) === serverId);
|
||||
if (!server) return;
|
||||
$('serverEditId').value = serverId;
|
||||
const labelEl = $('startServerLabel');
|
||||
const portEl = $('startServerPort');
|
||||
const hostEl = $('startServerHost');
|
||||
const secureEl = $('startServerSecure');
|
||||
if (labelEl) labelEl.value = server.label || '';
|
||||
if (portEl) portEl.value = server.port ?? 3000;
|
||||
if (hostEl) hostEl.value = server.host ?? '127.0.0.1';
|
||||
if (secureEl) secureEl.checked = server.secure !== false;
|
||||
const udpEl = document.querySelector('input[name="startServerProtocol"][value="' + (server.udp ? 'udp' : 'tcp') + '"]');
|
||||
if (udpEl) udpEl.checked = true;
|
||||
const titleEl = $('modal-startServer-title');
|
||||
if (titleEl) titleEl.textContent = 'Edit Server Tunnel';
|
||||
const submitEl = $('startServerSubmit');
|
||||
if (submitEl) submitEl.textContent = 'Save Changes';
|
||||
openModal('modal-startServer');
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-stop-server]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const serverId = btn.dataset.stopServer;
|
||||
const nameEl = $('stopServerName');
|
||||
if (nameEl) nameEl.textContent = serverId;
|
||||
$('stopServerConfirm').dataset.serverId = serverId;
|
||||
openModal('modal-stopServer');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupServerEvents() {
|
||||
$('startServerBtn')?.addEventListener('click', () => {
|
||||
const editIdEl = $('serverEditId');
|
||||
if (editIdEl) editIdEl.value = '';
|
||||
const labelEl = $('startServerLabel');
|
||||
if (labelEl) labelEl.value = '';
|
||||
const portEl = $('startServerPort');
|
||||
if (portEl) portEl.value = '3000';
|
||||
const hostEl = $('startServerHost');
|
||||
if (hostEl) hostEl.value = '127.0.0.1';
|
||||
const secureEl = $('startServerSecure');
|
||||
if (secureEl) secureEl.checked = true;
|
||||
const tcpEl = $('startServerProtocolTcp');
|
||||
if (tcpEl) tcpEl.checked = true;
|
||||
const titleEl = $('modal-startServer-title');
|
||||
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
|
||||
const submitEl = $('startServerSubmit');
|
||||
if (submitEl) submitEl.textContent = 'Start Server';
|
||||
openModal('modal-startServer');
|
||||
});
|
||||
|
||||
$('startServerSubmit')?.addEventListener('click', () => {
|
||||
const portEl = $('startServerPort');
|
||||
const hostEl = $('startServerHost');
|
||||
const secureEl = $('startServerSecure');
|
||||
const labelEl = $('startServerLabel');
|
||||
const editIdEl = $('serverEditId');
|
||||
const port = parseInt(portEl?.value, 10) || 3000;
|
||||
const host = (hostEl?.value || '127.0.0.1').trim();
|
||||
const secure = secureEl?.checked !== false;
|
||||
const udp = document.querySelector('input[name="startServerProtocol"]:checked')?.value === 'udp';
|
||||
const label = (labelEl?.value || '').trim();
|
||||
const editId = (editIdEl?.value || '').trim();
|
||||
if (!port || port < 1 || port > 65535) { showModalError('modal-startServer', 'startServerError', 'Port must be 1–65535'); return; }
|
||||
const btn = $('startServerSubmit');
|
||||
if (btn) { btn.disabled = true; btn.textContent = editId ? 'Saving…' : 'Starting…'; }
|
||||
|
||||
const doStart = () => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'startServer', payload: { port, host, secure, udp, label } } },
|
||||
(response) => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save Changes' : 'Start Server'; }
|
||||
if (response?.ok) {
|
||||
if (editIdEl) editIdEl.value = '';
|
||||
closeModal('modal-startServer');
|
||||
showToast(editId ? 'Server updated' : 'Server started', 'success');
|
||||
refresh();
|
||||
} else {
|
||||
showModalError('modal-startServer', 'startServerError', response?.error || 'Failed to start');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (editId) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId: editId } } },
|
||||
() => doStart()
|
||||
);
|
||||
} else {
|
||||
doStart();
|
||||
}
|
||||
});
|
||||
|
||||
$('stopServerConfirm')?.addEventListener('click', () => {
|
||||
const serverId = $('stopServerConfirm').dataset.serverId;
|
||||
if (!serverId) return;
|
||||
const btn = $('stopServerConfirm');
|
||||
btn.disabled = true; btn.textContent = 'Stopping…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
|
||||
(response) => {
|
||||
btn.disabled = false; btn.textContent = 'Stop Server';
|
||||
closeModal('modal-stopServer');
|
||||
if (response?.ok) showToast('Server stopped', 'success');
|
||||
else showToast(response?.error || 'Failed to stop', 'error');
|
||||
refresh();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
|
||||
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
||||
|
||||
function updateServiceTunnelsTable(state) {
|
||||
const tbody = $('serviceTunnelsTable');
|
||||
if (!tbody) return;
|
||||
const tunnels = state.serviceTunnels || [];
|
||||
|
||||
const countEl = $('serviceTunnelCount');
|
||||
if (countEl) countEl.textContent = tunnels.length;
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="5">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<div class="empty-state-title">No service tunnels</div>
|
||||
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = tunnels.map(t => {
|
||||
const id = t.id || '';
|
||||
const label = t.label || id;
|
||||
const hsUrl = t.hsUrl || '';
|
||||
const localAddr = t.localPort != null ? `127.0.0.1:${t.localPort}` : '—';
|
||||
const safeId = id.replace(/"/g, '"');
|
||||
return `
|
||||
<tr>
|
||||
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span class="mono" title="${escapeHtml(hsUrl)}" style="font-size:11px;color:var(--text3);">${truncate(hsUrl, 28)}</span>
|
||||
${hsUrl ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// key">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-weight:600;color:var(--cyan);font-family:'JetBrains Mono',monospace;font-size:13px;">${escapeHtml(localAddr)}</span>
|
||||
${t.localPort != null ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(localAddr)}" title="Copy local address">
|
||||
<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>
|
||||
</td>
|
||||
<td>${stateTag(t.state)}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
${(t.state === 'error' || t.state === 'closed') ? `
|
||||
<button class="btn btn-ghost btn-sm" data-reconnect-svc="${safeId}" data-hsurl="${escapeHtml(hsUrl)}" data-label="${escapeHtml(label)}" data-localport="${t.localPort != null ? t.localPort : ''}">
|
||||
<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-2.12-9.36L23 10"/></svg>
|
||||
Reconnect
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" data-remove-service-tunnel="${safeId}">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tunnelId = btn.dataset.reconnectSvc;
|
||||
const hsUrl = btn.dataset.hsurl;
|
||||
const label = btn.dataset.label;
|
||||
const localPort = parseInt(btn.dataset.localport, 10);
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Reconnecting…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||
if (response?.ok) {
|
||||
showToast('Service tunnel reconnecting…', 'success');
|
||||
} else {
|
||||
showToast(response?.error || 'Reconnect failed', 'error');
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tunnelId = btn.dataset.editServiceTunnel;
|
||||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||||
if (!tunnel) return;
|
||||
$('serviceTunnelEditId').value = tunnelId;
|
||||
$('serviceTunnelLabel').value = tunnel.label || '';
|
||||
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
|
||||
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
|
||||
const titleEl = $('modal-addServiceTunnel-title');
|
||||
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
|
||||
const submitEl = $('serviceTunnelSubmit');
|
||||
if (submitEl) submitEl.textContent = 'Save';
|
||||
openModal('modal-addServiceTunnel');
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tunnelId = btn.dataset.removeServiceTunnel;
|
||||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||||
const nameEl = $('removeServiceTunnelName');
|
||||
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
|
||||
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
|
||||
openModal('modal-removeServiceTunnel');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupServiceTunnelEvents() {
|
||||
$('addServiceTunnelBtn')?.addEventListener('click', () => {
|
||||
$('serviceTunnelEditId').value = '';
|
||||
$('serviceTunnelLabel').value = '';
|
||||
$('serviceTunnelHsUrl').value = '';
|
||||
$('serviceTunnelLocalPort').value = '';
|
||||
const titleEl = $('modal-addServiceTunnel-title');
|
||||
if (titleEl) titleEl.textContent = 'Add Service Tunnel';
|
||||
const submitEl = $('serviceTunnelSubmit');
|
||||
if (submitEl) submitEl.textContent = 'Connect';
|
||||
openModal('modal-addServiceTunnel');
|
||||
});
|
||||
|
||||
$('serviceTunnelSubmit')?.addEventListener('click', () => {
|
||||
const label = ($('serviceTunnelLabel')?.value || '').trim();
|
||||
const hsUrl = ($('serviceTunnelHsUrl')?.value || '').trim();
|
||||
const localPort = parseInt($('serviceTunnelLocalPort')?.value, 10);
|
||||
const editId = ($('serviceTunnelEditId')?.value || '').trim();
|
||||
|
||||
if (!label) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Label is required'); return; }
|
||||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Enter a valid hs:// key'); return; }
|
||||
if (!localPort || localPort < 1 || localPort > 65535) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Local port must be 1–65535'); return; }
|
||||
|
||||
const btn = $('serviceTunnelSubmit');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
|
||||
|
||||
const type = editId ? 'updateServiceTunnel' : 'startServiceTunnel';
|
||||
const payload = editId ? { tunnelId: editId, label, hsUrl, localPort } : { label, hsUrl, localPort };
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||||
(response) => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save' : 'Connect'; }
|
||||
if (response?.ok) {
|
||||
closeModal('modal-addServiceTunnel');
|
||||
showToast(editId ? 'Tunnel updated' : 'Service tunnel connected', 'success');
|
||||
refresh();
|
||||
} else {
|
||||
showModalError('modal-addServiceTunnel', 'serviceTunnelError', response?.error || 'Failed to connect');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('removeServiceTunnelConfirm')?.addEventListener('click', () => {
|
||||
const tunnelId = $('removeServiceTunnelConfirm').dataset.tunnelId;
|
||||
if (!tunnelId) return;
|
||||
const btn = $('removeServiceTunnelConfirm');
|
||||
btn.disabled = true; btn.textContent = 'Removing…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
|
||||
(response) => {
|
||||
btn.disabled = false; btn.textContent = 'Remove';
|
||||
closeModal('modal-removeServiceTunnel');
|
||||
if (response?.ok) showToast('Service tunnel removed', 'success');
|
||||
else showToast(response?.error || 'Failed to remove', 'error');
|
||||
refresh();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Depends on: core/utils.js ($), core/state.js (settings, SETTINGS_DEFAULTS), ui/toast.js (showToast)
|
||||
|
||||
function updateSettingsUI() {
|
||||
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
|
||||
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
||||
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
||||
const proxyPortEl = $('proxyPort');
|
||||
const readyTimeoutMsEl = $('readyTimeoutMs');
|
||||
const backupRetentionEl = $('backupRetention');
|
||||
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
||||
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
|
||||
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
||||
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
|
||||
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
|
||||
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||
if (response && response.ok) {
|
||||
if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
||||
if (response.requiresRestart) {
|
||||
showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning');
|
||||
} else {
|
||||
showToast('Settings saved', 'success');
|
||||
}
|
||||
} else {
|
||||
showToast(response?.error || 'Failed to save settings', 'error');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
|
||||
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
||||
|
||||
let sshConnections = [];
|
||||
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
|
||||
|
||||
function generateSshId() {
|
||||
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function loadSshConnections(cb) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'getSshConnections' } },
|
||||
(response) => {
|
||||
if (response && response.ok && Array.isArray(response.sshConnections)) {
|
||||
sshConnections = response.sshConnections.map(c => {
|
||||
let password = '';
|
||||
if (c.passwordB64) {
|
||||
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
|
||||
}
|
||||
return { ...c, password };
|
||||
});
|
||||
}
|
||||
if (cb) cb(sshConnections);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function saveSshConnections(cb) {
|
||||
const toSave = sshConnections.map(c => {
|
||||
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
|
||||
if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password)));
|
||||
else delete rest.passwordB64;
|
||||
return rest;
|
||||
});
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: toSave } } },
|
||||
(response) => {
|
||||
if (response && !response.ok) {
|
||||
log('saveSshConnections failed:', response.error);
|
||||
}
|
||||
renderSshGrid();
|
||||
const countEl = $('sshCount');
|
||||
if (countEl) countEl.textContent = sshConnections.length;
|
||||
if (cb) cb();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderSshGrid() {
|
||||
const grid = $('sshGrid');
|
||||
if (!grid) return;
|
||||
if (sshConnections.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state" style="grid-column:1/-1">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="8,10 12,14 16,10"/></svg>
|
||||
<div class="empty-state-title">No SSH connections</div>
|
||||
<div class="empty-state-desc">Add a connection to get started. You'll need an hs:// key for the remote peer.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = sshConnections.map(conn => `
|
||||
<div class="ssh-conn-card" data-ssh-id="${escapeHtml(conn.id)}">
|
||||
<div class="ssh-conn-card-top">
|
||||
<div class="ssh-conn-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<polyline points="8,10 12,14 16,10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ssh-conn-info">
|
||||
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
|
||||
</div>
|
||||
<div class="ssh-conn-actions">
|
||||
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="5,12 19,12"/><polyline points="12,5 19,12 12,19"/></svg>
|
||||
Connect
|
||||
</button>
|
||||
<button class="btn btn-ghost" data-ssh-edit="${escapeHtml(conn.id)}" style="padding:6px 10px;" title="Edit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost" data-ssh-remove="${escapeHtml(conn.id)}" style="padding:6px 10px;color:var(--red);" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
grid.querySelectorAll('[data-ssh-connect]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = sshConnections.find(c => c.id === btn.dataset.sshConnect);
|
||||
if (conn) connectSsh(conn);
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll('[data-ssh-edit]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = sshConnections.find(c => c.id === btn.dataset.sshEdit);
|
||||
if (conn) openAddSshModal(conn);
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll('[data-ssh-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const conn = sshConnections.find(c => c.id === btn.dataset.sshRemove);
|
||||
if (conn) {
|
||||
$('removeSshName').textContent = conn.label || conn.username;
|
||||
$('removeSshConfirm').dataset.sshId = conn.id;
|
||||
openModal('modal-removeSsh');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openAddSshModal(conn) {
|
||||
const isEdit = !!conn;
|
||||
$('modal-addSsh-title').textContent = isEdit ? 'Edit SSH Connection' : 'Add SSH Connection';
|
||||
$('sshConnLabel').value = conn ? conn.label : '';
|
||||
$('sshConnHsUrl').value = conn ? conn.hsUrl : '';
|
||||
$('sshConnUsername').value = conn ? conn.username : '';
|
||||
$('sshConnPassword').value = conn ? (conn.password || '') : '';
|
||||
$('sshConnEditId').value = conn ? conn.id : '';
|
||||
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
||||
openModal('modal-addSsh');
|
||||
}
|
||||
|
||||
function updateTermSizeDisplay(term) {
|
||||
const el = $('termSizeDisplay');
|
||||
if (el && term) el.textContent = term.cols + '×' + term.rows;
|
||||
}
|
||||
|
||||
async function connectSsh(conn) {
|
||||
openModal('modal-sshTerminal');
|
||||
$('termConnLabel').textContent = conn.label || conn.username;
|
||||
$('termUserHost').textContent = conn.username + '@ssh';
|
||||
$('termStatusDot').className = 'terminal-status-dot';
|
||||
$('termStateDisplay').textContent = 'Connecting…';
|
||||
$('termStateDisplay').style.color = 'var(--amber)';
|
||||
|
||||
await disconnectSsh();
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.3,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#0d0d0f',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#22d3ee',
|
||||
cursorAccent: '#0d0d0f',
|
||||
selectionBackground: 'rgba(34,211,238,0.25)',
|
||||
black: '#18181b',
|
||||
red: '#f43f5e',
|
||||
green: '#4ade80',
|
||||
yellow: '#fbbf24',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#3f3f46',
|
||||
brightRed: '#fb7185',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde68a',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#fafafa'
|
||||
}
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const container = $('terminalContainer');
|
||||
container.innerHTML = '';
|
||||
term.open(container);
|
||||
|
||||
// Fit synchronously now that the container is in the DOM, then wait a frame
|
||||
// for the browser to finish layout so dimensions are accurate before we
|
||||
// send cols/rows to the native host.
|
||||
await new Promise(resolve => requestAnimationFrame(() => {
|
||||
try { fitAddon.fit(); } catch (_) {}
|
||||
updateTermSizeDisplay(term);
|
||||
resolve();
|
||||
}));
|
||||
|
||||
term.writeln('\x1b[36mConnecting to ' + escapeHtml(conn.label || conn.username) + '…\x1b[0m');
|
||||
term.writeln('\x1b[90mEstablishing Holesail tunnel…\x1b[0m');
|
||||
|
||||
const cols = term.cols || 80;
|
||||
const rows = term.rows || 24;
|
||||
const result = await sendToNative('startSshSession', {
|
||||
hsUrl: conn.hsUrl,
|
||||
username: conn.username,
|
||||
password: conn.password || '',
|
||||
cols,
|
||||
rows,
|
||||
label: conn.label || conn.username
|
||||
});
|
||||
|
||||
if (!result || !result.ok) {
|
||||
const errMsg = (result && result.error) || 'Unknown error';
|
||||
term.writeln('\x1b[31mFailed to start session: ' + errMsg + '\x1b[0m');
|
||||
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('termStateDisplay').textContent = 'Error';
|
||||
$('termStateDisplay').style.color = 'var(--red)';
|
||||
activeSshSession = { term, fitAddon, ws: null, resizeObserver: null, conn, sessionId: null };
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, wsPort } = result;
|
||||
term.writeln('\x1b[90mTunnel ready — connecting SSH…\x1b[0m');
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
} catch (e) {
|
||||
term.writeln('\x1b[31mWebSocket connection failed: ' + e.message + '\x1b[0m');
|
||||
sendToNative('stopSshSession', { sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
$('termStatusDot').className = 'terminal-status-dot';
|
||||
$('termStateDisplay').textContent = 'Connected';
|
||||
$('termStateDisplay').style.color = 'var(--green)';
|
||||
// Send a ready-signal so the native host knows the browser WebSocket is
|
||||
// fully open and can safely flush buffered PTY output (MOTD, prompt).
|
||||
ws.send('\x00');
|
||||
term.focus();
|
||||
};
|
||||
|
||||
let firstMessage = true;
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data instanceof ArrayBuffer
|
||||
? new Uint8Array(event.data)
|
||||
: event.data;
|
||||
if (firstMessage) {
|
||||
firstMessage = false;
|
||||
// Prepend ESC[2J (clear screen) + ESC[H (cursor home) to the first SSH
|
||||
// data chunk so the clear and the MOTD are written atomically in the
|
||||
// same xterm.js render pass — avoids the race where term.clear() wipes
|
||||
// data that was already queued by term.write().
|
||||
const CLEAR_HOME = '\x1b[2J\x1b[H';
|
||||
if (typeof data === 'string') {
|
||||
term.write(CLEAR_HOME + data);
|
||||
} else {
|
||||
const prefix = new TextEncoder().encode(CLEAR_HOME);
|
||||
const combined = new Uint8Array(prefix.length + data.length);
|
||||
combined.set(prefix);
|
||||
combined.set(data, prefix.length);
|
||||
term.write(combined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
term.write(data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
||||
$('termStateDisplay').textContent = 'Disconnected';
|
||||
$('termStateDisplay').style.color = 'var(--text3)';
|
||||
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.writeln('\r\n\x1b[31m[WebSocket error]\x1b[0m');
|
||||
};
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Resize observer — refit on container resize, debounced so we don't
|
||||
// flood the native host with stty commands during a window drag.
|
||||
let resizeTimer = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try { fitAddon.fit(); updateTermSizeDisplay(term); } catch (_) {}
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
try {
|
||||
sendToNative('resizeSshSession', { sessionId, cols: term.cols, rows: term.rows });
|
||||
} catch (_) {}
|
||||
}, 150);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
activeSshSession = { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable };
|
||||
}
|
||||
|
||||
async function disconnectSsh() {
|
||||
if (!activeSshSession) return;
|
||||
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
|
||||
activeSshSession = null;
|
||||
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (dataDisposable) dataDisposable.dispose();
|
||||
if (ws) { try { ws.close(); } catch (_) {} }
|
||||
if (term) { try { term.dispose(); } catch (_) {} }
|
||||
if (sessionId) {
|
||||
await sendToNative('stopSshSession', { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
function setupSshEvents() {
|
||||
$('addSshBtn')?.addEventListener('click', () => openAddSshModal(null));
|
||||
|
||||
$('sshConnSubmit')?.addEventListener('click', () => {
|
||||
const label = $('sshConnLabel').value.trim();
|
||||
const hsUrl = $('sshConnHsUrl').value.trim();
|
||||
const username = $('sshConnUsername').value.trim();
|
||||
const password = $('sshConnPassword').value;
|
||||
const editId = $('sshConnEditId').value;
|
||||
|
||||
if (!hsUrl) { showModalError('modal-addSsh', 'sshConnError', 'Holesail key is required'); return; }
|
||||
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
|
||||
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
|
||||
|
||||
if (editId) {
|
||||
const idx = sshConnections.findIndex(c => c.id === editId);
|
||||
if (idx !== -1) {
|
||||
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password };
|
||||
}
|
||||
} else {
|
||||
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password });
|
||||
}
|
||||
saveSshConnections();
|
||||
closeModal('modal-addSsh');
|
||||
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
|
||||
});
|
||||
|
||||
$('removeSshConfirm')?.addEventListener('click', () => {
|
||||
const id = $('removeSshConfirm').dataset.sshId;
|
||||
sshConnections = sshConnections.filter(c => c.id !== id);
|
||||
saveSshConnections();
|
||||
closeModal('modal-removeSsh');
|
||||
showToast('Connection removed', 'success');
|
||||
});
|
||||
|
||||
$('termDisconnectBtn')?.addEventListener('click', async () => {
|
||||
await disconnectSsh();
|
||||
closeModal('modal-sshTerminal');
|
||||
});
|
||||
|
||||
$('termCopyBtn')?.addEventListener('click', () => {
|
||||
if (activeSshSession && activeSshSession.term) {
|
||||
const sel = activeSshSession.term.getSelection();
|
||||
if (sel) copyToClipboard(sel, null);
|
||||
else showToast('No text selected', 'default');
|
||||
}
|
||||
});
|
||||
|
||||
$('termFullscreenBtn')?.addEventListener('click', () => {
|
||||
const modal = document.querySelector('#modal-sshTerminal .modal');
|
||||
if (!modal) return;
|
||||
if (modal.style.width === '100vw') {
|
||||
modal.style.width = '';
|
||||
modal.style.height = '';
|
||||
modal.style.borderRadius = '';
|
||||
} else {
|
||||
modal.style.width = '100vw';
|
||||
modal.style.height = '100vh';
|
||||
modal.style.borderRadius = '0';
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (activeSshSession && activeSshSession.fitAddon) {
|
||||
activeSshSession.fitAddon.fit();
|
||||
updateTermSizeDisplay(activeSshSession.term);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const termModal = $('modal-sshTerminal');
|
||||
if (termModal) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!termModal.classList.contains('open') && activeSshSession) {
|
||||
disconnectSsh();
|
||||
}
|
||||
});
|
||||
observer.observe(termModal, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
|
||||
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
|
||||
// data/hostname-validator.js (isValidVhostHostname)
|
||||
|
||||
function updateConnectionsTable(state) {
|
||||
const tbody = $('connectionsTable');
|
||||
if (!tbody) return;
|
||||
const virtualHosts = state.virtualHosts || [];
|
||||
|
||||
if (virtualHosts.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="5">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>
|
||||
<div class="empty-state-title">No virtual hosts</div>
|
||||
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = virtualHosts.map(v => {
|
||||
const hostname = v.hostname || v.id || '';
|
||||
const hsUrl = v.hsUrl || '';
|
||||
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
|
||||
const openUrl = `https://${hostname}`;
|
||||
const safeHostname = hostname.replace(/"/g, '"');
|
||||
const needsReconnect = v.state === 'error' || v.state === 'closed';
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
|
||||
<td>${stateTag(v.state)}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
|
||||
<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">
|
||||
<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}">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-reconnect-vhost]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const hostname = btn.dataset.reconnectVhost;
|
||||
const hsUrl = btn.dataset.hsUrl;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Reconnecting…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||||
(response) => {
|
||||
if (response?.ok) {
|
||||
showToast('Tunnel reconnecting…', 'success');
|
||||
} else {
|
||||
showToast(response?.error || 'Reconnect failed', 'error');
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('[data-remove-vhost]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const hostname = btn.dataset.removeVhost;
|
||||
const nameEl = $('removeVhostName');
|
||||
if (nameEl) nameEl.textContent = hostname;
|
||||
$('removeVhostConfirm').dataset.hostname = hostname;
|
||||
openModal('modal-removeVhost');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupVirtualHostEvents() {
|
||||
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
|
||||
|
||||
$('addVhostSubmit')?.addEventListener('click', () => {
|
||||
const hostnameEl = $('addVhostHostname');
|
||||
const hsUrlEl = $('addVhostHsUrl');
|
||||
let hostname = (hostnameEl?.value || '').trim();
|
||||
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
||||
const hsUrl = (hsUrlEl?.value || '').trim();
|
||||
const hostnameValidation = isValidVhostHostname(hostname);
|
||||
if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
|
||||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
|
||||
const btn = $('addVhostSubmit');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||||
(response) => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
|
||||
if (response?.ok) {
|
||||
if (hostnameEl) hostnameEl.value = '';
|
||||
if (hsUrlEl) hsUrlEl.value = '';
|
||||
closeModal('modal-addVhost');
|
||||
showToast('Virtual host added', 'success');
|
||||
refresh();
|
||||
} else {
|
||||
showModalError('modal-addVhost', 'addVhostError', response?.error || 'Failed to add');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('removeVhostConfirm')?.addEventListener('click', () => {
|
||||
const hostname = $('removeVhostConfirm').dataset.hostname;
|
||||
if (!hostname) return;
|
||||
const btn = $('removeVhostConfirm');
|
||||
btn.disabled = true; btn.textContent = 'Removing…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
|
||||
(response) => {
|
||||
btn.disabled = false; btn.textContent = 'Remove';
|
||||
closeModal('modal-removeVhost');
|
||||
if (response?.ok) {
|
||||
showToast('Virtual host removed', 'success');
|
||||
} else {
|
||||
showToast(response?.error || 'Failed to remove', 'error');
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Orchestrates a full state refresh cycle.
|
||||
// Depends on: core/messaging.js (fetchState), core/state.js (settings, SETTINGS_DEFAULTS, currentState),
|
||||
// pages/ssh.js (sshConnections, renderSshGrid),
|
||||
// pages/rdp.js (rdpConnections, renderRdpGrid),
|
||||
// pages/overview.js (updateDashboard),
|
||||
// pages/virtual-hosts.js (updateConnectionsTable),
|
||||
// pages/servers.js (updateSwarmsTable),
|
||||
// pages/proxy-ca.js (updateTabsTable),
|
||||
// pages/service-tunnels.js (updateServiceTunnelsTable),
|
||||
// pages/settings.js (updateSettingsUI),
|
||||
// pages/backups.js (refreshBackups)
|
||||
|
||||
async function refresh() {
|
||||
const state = await fetchState();
|
||||
if (state) {
|
||||
// Sync SSH connections from native host state — decode base64 password if present
|
||||
if (Array.isArray(state.sshConnections)) {
|
||||
sshConnections = state.sshConnections.map(c => {
|
||||
const existing = sshConnections.find(e => e.id === c.id);
|
||||
let password = (existing && existing.password) || '';
|
||||
if (!password && c.passwordB64) {
|
||||
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
|
||||
}
|
||||
return { ...c, password };
|
||||
});
|
||||
renderSshGrid();
|
||||
}
|
||||
// Sync RDP connections from native host state — decode base64 password if present
|
||||
if (Array.isArray(state.rdpConnections)) {
|
||||
rdpConnections = state.rdpConnections.map(c => {
|
||||
const existing = rdpConnections.find(e => e.id === c.id);
|
||||
let password = (existing && existing.password) || '';
|
||||
if (!password && c.passwordB64) {
|
||||
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
|
||||
}
|
||||
return { ...c, password };
|
||||
});
|
||||
renderRdpGrid();
|
||||
}
|
||||
// Sync settings from native host state
|
||||
if (state.settings && typeof state.settings === 'object') {
|
||||
settings = { ...SETTINGS_DEFAULTS, ...state.settings };
|
||||
}
|
||||
updateDashboard(state);
|
||||
updateConnectionsTable(state);
|
||||
updateSwarmsTable(state);
|
||||
updateTabsTable(state);
|
||||
updateServiceTunnelsTable(state);
|
||||
updateSettingsUI();
|
||||
}
|
||||
const sshCountEl = $('sshCount');
|
||||
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
||||
refreshBackups();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Depends on: core/utils.js ($), core/navigation.js (navigateTo)
|
||||
|
||||
function openModal(id) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.classList.add('open');
|
||||
setTimeout(() => {
|
||||
const input = el.querySelector('input:not([type="checkbox"])');
|
||||
if (input) input.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.classList.remove('open');
|
||||
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
|
||||
}
|
||||
|
||||
function showModalError(modalId, errorId, msg) {
|
||||
const el = $(errorId);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
// Close modals on backdrop click or close button
|
||||
document.addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
closeModal(closeBtn.dataset.closeModal);
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('modal-backdrop')) {
|
||||
closeModal(e.target.id);
|
||||
}
|
||||
const pageLink = e.target.closest('[data-page-link]');
|
||||
if (pageLink) {
|
||||
navigateTo(pageLink.dataset.pageLink);
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key closes topmost open modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const open = document.querySelector('.modal-backdrop.open');
|
||||
if (open) closeModal(open.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Depends on: core/utils.js (escapeHtml)
|
||||
|
||||
function stateTag(state) {
|
||||
const dot = (color) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`;
|
||||
if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`;
|
||||
if (state === 'ready') return `<span class="badge badge-green">${dot('green')}ready</span>`;
|
||||
if (state === 'error') return `<span class="badge badge-red">${dot('red')}error</span>`;
|
||||
if (state === 'closed') return `<span class="badge badge-neutral">${dot('text4')}closed</span>`;
|
||||
if (state === 'connecting') return `<span class="badge badge-amber">${dot('amber')}connecting</span>`;
|
||||
return `<span class="badge badge-amber">${dot('amber')}${escapeHtml(state)}</span>`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Depends on: core/utils.js ($)
|
||||
|
||||
let toastTimer = null;
|
||||
|
||||
function showToast(msg, type = 'default') {
|
||||
const el = $('toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show' + (type !== 'default' ? ' ' + type : '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2800);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, btnEl) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (btnEl) {
|
||||
btnEl.classList.add('copied');
|
||||
setTimeout(() => btnEl.classList.remove('copied'), 1500);
|
||||
}
|
||||
showToast('Copied to clipboard', 'success');
|
||||
}).catch(() => showToast('Copy failed', 'error'));
|
||||
}
|
||||
+10
-1
@@ -42,7 +42,16 @@
|
||||
"vendor/xterm-addon-fit.js",
|
||||
"vendor/xterm.css",
|
||||
"vendor/novnc.js",
|
||||
"wrong-domain.html"
|
||||
"wrong-domain.html",
|
||||
"dashboard/dashboard.html",
|
||||
"dashboard/dashboard.css",
|
||||
"dashboard/data/*.js",
|
||||
"dashboard/core/*.js",
|
||||
"dashboard/ui/*.js",
|
||||
"dashboard/pages/*.js",
|
||||
"dashboard/refresh.js",
|
||||
"dashboard/events.js",
|
||||
"background/*.js"
|
||||
],
|
||||
"matches": [
|
||||
"chrome-extension://*/*"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const host = (params.get('host') || '').trim();
|
||||
const subEl = document.getElementById('sub');
|
||||
const suggestionEl = document.getElementById('suggestion');
|
||||
|
||||
if (host) {
|
||||
if (subEl) subEl.textContent = 'You tried to open: ' + host + '.host.test';
|
||||
const suggestedHost = host + '.hs';
|
||||
const proxyPort = 8443;
|
||||
const suggestedUrl = 'https://' + suggestedHost + ':' + proxyPort + '/';
|
||||
if (suggestionEl) {
|
||||
suggestionEl.innerHTML = 'Use this address instead: <a href="' + suggestedUrl + '" id="go">' + suggestedUrl + '</a>';
|
||||
const link = document.getElementById('go');
|
||||
if (link) link.addEventListener('click', function (e) { e.preventDefault(); window.location.href = suggestedUrl; });
|
||||
}
|
||||
} else {
|
||||
if (suggestionEl) suggestionEl.textContent = 'Use a hostname ending in .hs (e.g. myapp.hs) and add it in the Holesail Dashboard.';
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user