Initial Vynte Connect clients

This commit is contained in:
Zachariah K. Sharma
2026-05-26 16:41:14 -05:00
commit 980535d682
35 changed files with 4958 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Vynte Connect for Windows
Vynte Connect for Windows is a tray wrapper around the bundled `netbird.exe` CLI for `https://vpn.vyntehome.com`.
It exposes the same basic flow as the macOS app:
- Connect to `vpn.vyntehome.com`
- Disconnect the tunnel
- Log out so the next connect registers with NetBird again
- Show gateway, internal IP, peer count, and connection state
## Build
Place the Windows NetBird runtime files in `resources/` before packaging:
- `resources/netbird.exe`
- `resources/wintun.dll`
```powershell
npm install
npm run package:win
```
The packaged app is written to `dist/Vynte Connect-win32-x64/`, and a portable zip is written to `dist/Vynte-Connect-Windows-x64.zip`.
This build intentionally creates a portable Windows app without stamping Windows executable metadata, so it can be built from macOS without Wine.
## Runtime behavior
On first connect, the app uses Windows UAC to install/start the bundled Vynte network helper service:
```powershell
netbird.exe service install --management-url https://vpn.vyntehome.com --admin-url https://vpn.vyntehome.com
netbird.exe service start
```
Then it runs:
```powershell
netbird.exe up --management-url https://vpn.vyntehome.com --admin-url https://vpn.vyntehome.com
```
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "vynte-connect-windows",
"version": "0.1.0",
"private": true,
"description": "Vynte Connect Windows tray wrapper for vpn.vyntehome.com",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"package:win": "node scripts/package-win-portable.mjs"
},
"devDependencies": {
"electron": "^36.3.1",
"electron-packager": "^17.1.2"
}
}
@@ -0,0 +1,16 @@
This BSD3Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/.
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
BSD 3-Clause License
Copyright (c) 2022 NetBird GmbH & AUTHORS
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

@@ -0,0 +1,64 @@
import { existsSync, mkdirSync, rmSync, copyFileSync, cpSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import { spawnSync } from "node:child_process";
const root = join(import.meta.dirname, "..");
const electronVersion = "36.9.5";
const cacheRoot = join(homedir(), "Library", "Caches", "electron");
const zipName = `electron-v${electronVersion}-win32-x64.zip`;
const appDir = join(root, "dist", "Vynte Connect-win32-x64");
const zipPath = join(root, "dist", "Vynte-Connect-Windows-x64.zip");
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: "inherit", ...options });
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`);
}
}
function findElectronZip(dir) {
if (!existsSync(dir)) return null;
const result = spawnSync("find", [dir, "-name", zipName, "-print", "-quit"], { encoding: "utf8" });
return result.stdout.trim() || null;
}
function sha256(file) {
const result = spawnSync("shasum", ["-a", "256", file], { encoding: "utf8" });
if (result.status !== 0) return "unknown";
return result.stdout.split(/\s+/)[0];
}
const electronZip = findElectronZip(cacheRoot);
if (!electronZip) {
throw new Error(`Missing ${zipName}. Run npm install once so Electron downloads its Windows runtime.`);
}
mkdirSync(join(root, "dist"), { recursive: true });
rmSync(appDir, { recursive: true, force: true });
rmSync(zipPath, { force: true });
mkdirSync(appDir, { recursive: true });
run("ditto", ["-x", "-k", electronZip, appDir]);
renameSync(join(appDir, "electron.exe"), join(appDir, "Vynte Connect.exe"));
const packagedAppDir = join(appDir, "resources", "app");
mkdirSync(packagedAppDir, { recursive: true });
copyFileSync(join(root, "package.json"), join(packagedAppDir, "package.json"));
cpSync(join(root, "src"), join(packagedAppDir, "src"), { recursive: true });
cpSync(join(root, "resources"), join(packagedAppDir, "resources"), { recursive: true });
writeFileSync(
join(appDir, "BUILD-INFO.txt"),
[
"Vynte Connect for Windows",
`Electron runtime: ${basename(electronZip)}`,
`Electron runtime sha256: ${sha256(electronZip)}`,
"Gateway: https://vpn.vyntehome.com",
"",
].join("\n"),
);
run("zip", ["-qry", zipPath, "."], { cwd: appDir });
console.log(`Packaged ${appDir}`);
console.log(`Created ${zipPath}`);
+19
View File
@@ -0,0 +1,19 @@
const MANAGEMENT_URL = 'https://vpn.vyntehome.com';
const GATEWAY_HOST = new URL(MANAGEMENT_URL).host;
module.exports = {
APP_NAME: 'Vynte Connect',
MANAGEMENT_URL,
GATEWAY_HOST,
POLL_INTERVAL_MS: 5000,
DEFAULT_STATUS: {
state: 'checking',
title: `Checking ${GATEWAY_HOST}`,
message: 'Preparing the Vynte access client.',
ip: '',
fqdn: '',
peers: '0/0',
connectedSince: null,
gatewayHost: GATEWAY_HOST
}
};
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Vynte Connect</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main class="panel">
<header class="header">
<img class="logo-small" src="../resources/VynteIcon.png" alt="">
<div class="title-block">
<div class="title">Vynte Connect</div>
<div id="identity" class="identity">Vynte access</div>
</div>
<button id="refresh" class="icon-button" title="Refresh"></button>
</header>
<section class="hero">
<button id="orb" class="orb" aria-label="Connect or disconnect">
<span class="orb-ring"></span>
<span class="orb-core"></span>
<span class="orb-dot"></span>
<img id="orb-logo" class="orb-logo" src="../resources/VynteIcon.png" alt="">
<span id="orb-power" class="orb-power"></span>
</button>
<div class="status-line">
<span id="status-dot" class="status-dot"></span>
<span id="status-title">Checking vpn.vyntehome.com</span>
</div>
<div id="status-message" class="message">Preparing the Vynte access client.</div>
</section>
<section class="gateway">
<div class="server-icon"></div>
<div>
<div class="label">Gateway</div>
<div class="mono" data-gateway-host>vpn.vyntehome.com</div>
</div>
<span id="gateway-state" class="badge">Offline</span>
</section>
<section id="stats" class="stats hidden">
<div class="stat">
<div class="label">Internal IP</div>
<div id="ip" class="mono">Assigned</div>
</div>
<div class="divider"></div>
<div class="stat">
<div class="label">Peers</div>
<div id="peers" class="mono">0/0</div>
</div>
</section>
<footer class="footer">
<button id="auto" class="chip">
<span></span>
<span>Auto-connect</span>
<span id="toggle" class="toggle"><span></span></span>
</button>
<span class="spacer"></span>
<button id="logout" class="icon-button hidden" title="Log out"></button>
<button id="quit" class="icon-button" title="Quit"></button>
</footer>
</main>
<script src="./renderer.js"></script>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
const { app, BrowserWindow, Tray, ipcMain, nativeImage, shell } = require('electron');
const path = require('path');
const { APP_NAME, DEFAULT_STATUS, GATEWAY_HOST, MANAGEMENT_URL, POLL_INTERVAL_MS } = require('./config');
const { NetBirdClient } = require('./netbird');
const { baseStatus, normalizeStatus } = require('./status');
let tray;
let window;
let pollTimer;
let autoConnect = false;
let cachedStatus = { ...DEFAULT_STATUS };
function resourcePath(name) {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'app', 'resources', name);
}
return path.join(app.getAppPath(), 'resources', name);
}
function netbirdClient() {
return new NetBirdClient(resourcePath('netbird.exe'));
}
function withAutoConnect(status) {
return { ...status, autoConnect };
}
function broadcastStatus() {
if (window && !window.isDestroyed()) {
window.webContents.send('status', withAutoConnect(cachedStatus));
}
}
function updateTray() {
if (!tray) return;
const label = cachedStatus.state === 'connected' ? `${APP_NAME}: Connected` : APP_NAME;
tray.setToolTip(label);
}
function setStatus(status) {
cachedStatus = {
...cachedStatus,
...status,
gatewayHost: GATEWAY_HOST
};
broadcastStatus();
updateTray();
return cachedStatus;
}
async function refreshStatus() {
const result = await netbirdClient().status();
if (!result.ok) {
return setStatus(baseStatus({
state: 'disconnected',
title: 'Disconnected',
message: 'Click the flame to install the Vynte network helper.'
}));
}
return setStatus(normalizeStatus(result.stdout, cachedStatus));
}
async function connect() {
setStatus({
state: 'connecting',
title: 'Connecting...',
message: 'Starting secure Vynte access...'
});
const client = netbirdClient();
const serviceReady = await client.ensureService();
if (!serviceReady) {
return setStatus({
state: 'error',
title: 'Connection failed',
message: `${GATEWAY_HOST} helper did not become ready.`
});
}
const result = await client.connect();
if (!result.ok) {
const output = `${result.stdout}\n${result.stderr}`.trim();
return setStatus({
state: 'error',
title: 'Connection failed',
message: output || `${GATEWAY_HOST} command failed.`
});
}
return refreshStatus();
}
async function disconnect() {
setStatus({
state: 'disconnecting',
title: 'Disconnecting...',
message: 'Closing secure Vynte access...'
});
await netbirdClient().disconnect();
return refreshStatus();
}
async function logout() {
setStatus({
state: 'loggingOut',
title: 'Logging out',
message: "Removing this PC's NetBird registration."
});
await netbirdClient().logout();
return refreshStatus();
}
function createWindow() {
window = new BrowserWindow({
width: 384,
height: 456,
show: false,
resizable: false,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
backgroundColor: '#191b20',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true
}
});
window.loadFile(path.join(__dirname, 'index.html'));
window.on('blur', () => window.hide());
}
function showWindow() {
const bounds = tray.getBounds();
const windowBounds = window.getBounds();
const x = Math.round(bounds.x + bounds.width - windowBounds.width);
const y = Math.round(bounds.y - windowBounds.height - 8);
window.setPosition(Math.max(x, 0), Math.max(y, 0), false);
window.show();
window.focus();
broadcastStatus();
}
function createTray() {
const icon = nativeImage.createFromPath(resourcePath('VynteIcon.png')).resize({ width: 18, height: 18 });
tray = new Tray(icon);
tray.setToolTip(APP_NAME);
tray.on('click', showWindow);
}
app.whenReady().then(() => {
createWindow();
createTray();
refreshStatus();
pollTimer = setInterval(refreshStatus, POLL_INTERVAL_MS);
});
app.on('before-quit', () => {
if (pollTimer) clearInterval(pollTimer);
});
ipcMain.handle('status:get', () => withAutoConnect(cachedStatus));
ipcMain.handle('status:refresh', refreshStatus);
ipcMain.handle('vpn:connect', connect);
ipcMain.handle('vpn:disconnect', disconnect);
ipcMain.handle('vpn:logout', logout);
ipcMain.handle('auto:toggle', () => {
autoConnect = !autoConnect;
broadcastStatus();
return autoConnect;
});
ipcMain.handle('app:quit', () => app.quit());
ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL));
+100
View File
@@ -0,0 +1,100 @@
const { execFile } = require('child_process');
const { MANAGEMENT_URL } = require('./config');
class NetBirdClient {
constructor(executablePath) {
this.executablePath = executablePath;
}
run(args, options = {}) {
return new Promise((resolve) => {
execFile(this.executablePath, args, {
windowsHide: true,
timeout: options.timeout || 30000
}, (error, stdout, stderr) => {
resolve({
ok: !error,
code: error && typeof error.code === 'number' ? error.code : 0,
stdout: stdout || '',
stderr: stderr || '',
error
});
});
});
}
runElevated(args) {
const quotedExe = this.executablePath.replace(/'/g, "''");
const quotedArgs = args.map((arg) => `'${String(arg).replace(/'/g, "''")}'`).join(',');
const script = `Start-Process -FilePath '${quotedExe}' -ArgumentList @(${quotedArgs}) -Verb RunAs -Wait`;
return new Promise((resolve) => {
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
windowsHide: true,
timeout: 180000
}, (error, stdout, stderr) => {
resolve({
ok: !error,
stdout: stdout || '',
stderr: stderr || '',
error
});
});
});
}
status(options = {}) {
return this.run(['status', '--json'], { timeout: options.timeout || 12000 });
}
async ensureService() {
const status = await this.status({ timeout: 10000 });
if (status.ok) {
return true;
}
await this.runElevated([
'service',
'install',
'--management-url', MANAGEMENT_URL,
'--admin-url', MANAGEMENT_URL,
'--log-level', 'info'
]);
await this.runElevated(['service', 'start']);
for (let index = 0; index < 20; index += 1) {
const check = await this.status({ timeout: 8000 });
if (check.ok) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
connect() {
return this.run([
'up',
'--management-url', MANAGEMENT_URL,
'--admin-url', MANAGEMENT_URL
], { timeout: 180000 });
}
disconnect() {
return this.run(['down', '--management-url', MANAGEMENT_URL], { timeout: 45000 });
}
async logout() {
await this.run(['down', '--management-url', MANAGEMENT_URL], { timeout: 30000 });
return this.run([
'deregister',
'--management-url', MANAGEMENT_URL,
'--admin-url', MANAGEMENT_URL
], { timeout: 60000 });
}
}
module.exports = {
NetBirdClient
};
+13
View File
@@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('vynte', {
getStatus: () => ipcRenderer.invoke('status:get'),
refresh: () => ipcRenderer.invoke('status:refresh'),
connect: () => ipcRenderer.invoke('vpn:connect'),
disconnect: () => ipcRenderer.invoke('vpn:disconnect'),
logout: () => ipcRenderer.invoke('vpn:logout'),
toggleAuto: () => ipcRenderer.invoke('auto:toggle'),
quit: () => ipcRenderer.invoke('app:quit'),
openGateway: () => ipcRenderer.invoke('external:openGateway'),
onStatus: (callback) => ipcRenderer.on('status', (_event, status) => callback(status))
});
@@ -0,0 +1,46 @@
const body = document.body;
const orb = document.getElementById('orb');
const statusTitle = document.getElementById('status-title');
const statusMessage = document.getElementById('status-message');
const identity = document.getElementById('identity');
const gatewayState = document.getElementById('gateway-state');
const stats = document.getElementById('stats');
const ip = document.getElementById('ip');
const peers = document.getElementById('peers');
const refresh = document.getElementById('refresh');
const auto = document.getElementById('auto');
const logout = document.getElementById('logout');
const quit = document.getElementById('quit');
let currentStatus = { state: 'checking' };
function setStatus(status) {
currentStatus = status;
body.className = status.state || 'disconnected';
statusTitle.textContent = status.title || 'Disconnected';
statusMessage.textContent = status.message || '';
identity.textContent = status.fqdn || 'Vynte access';
document.querySelector('[data-gateway-host]').textContent = status.gatewayHost || 'vpn.vyntehome.com';
gatewayState.textContent = status.state === 'connected' ? 'Live' : 'Offline';
ip.textContent = status.ip || 'Assigned';
peers.textContent = status.peers || '0/0';
stats.classList.toggle('hidden', status.state !== 'connected');
logout.classList.toggle('hidden', status.state !== 'connected');
auto.classList.toggle('active', Boolean(status.autoConnect));
}
orb.addEventListener('click', async () => {
if (currentStatus.state === 'connected') {
await window.vynte.disconnect();
} else if (!['connecting', 'disconnecting', 'loggingOut', 'checking'].includes(currentStatus.state)) {
await window.vynte.connect();
}
});
refresh.addEventListener('click', () => window.vynte.refresh());
auto.addEventListener('click', () => window.vynte.toggleAuto());
logout.addEventListener('click', () => window.vynte.logout());
quit.addEventListener('click', () => window.vynte.quit());
window.vynte.onStatus(setStatus);
window.vynte.getStatus().then(setStatus);
+57
View File
@@ -0,0 +1,57 @@
const { GATEWAY_HOST } = require('./config');
function baseStatus(overrides) {
return {
ip: '',
fqdn: '',
peers: '0/0',
connectedSince: null,
gatewayHost: GATEWAY_HOST,
...overrides
};
}
function normalizeStatus(raw, previousStatus = {}) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return baseStatus({
state: 'error',
title: 'Connection failed',
message: `${GATEWAY_HOST} status could not be parsed.`
});
}
const daemonConnected = String(parsed.daemonStatus || '').toLowerCase().includes('connected');
const managementConnected = parsed.management && parsed.management.connected === true;
const signalConnected = parsed.signal && parsed.signal.connected === true;
const hasIdentity = Boolean(parsed.netbirdIp || parsed.fqdn);
const connected = daemonConnected && (managementConnected || signalConnected || hasIdentity);
const peerCount = parsed.peers || {};
if (connected) {
return baseStatus({
state: 'connected',
title: 'Connected',
message: "You're on the Vynte network.",
ip: parsed.netbirdIp || 'Assigned',
fqdn: parsed.fqdn || '',
peers: `${peerCount.connected || 0}/${peerCount.total || 0}`,
connectedSince: previousStatus.connectedSince || Date.now()
});
}
return baseStatus({
state: 'disconnected',
title: 'Disconnected',
message: 'Click the flame to connect with NetBird.',
fqdn: parsed.fqdn || '',
peers: `${peerCount.connected || 0}/${peerCount.total || 0}`
});
}
module.exports = {
baseStatus,
normalizeStatus
};
+273
View File
@@ -0,0 +1,273 @@
:root {
--bg: #191b20;
--panel: rgba(20, 22, 26, .94);
--line: rgba(255, 255, 255, .07);
--text: #f5f6f8;
--muted: #9099a3;
--low: #5a5d66;
--orange: #ff7a1a;
--pink: #e63e5c;
--purple: #6f2bae;
--blue: #1e90ff;
--gradient: linear-gradient(135deg, var(--orange), var(--pink) 38%, var(--purple) 70%, var(--blue));
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
body {
color: var(--text);
font-family: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
background:
radial-gradient(120% 80% at 20% 0%, rgba(255, 122, 26, .18), transparent 55%),
radial-gradient(90% 80% at 100% 100%, rgba(30, 144, 255, .16), transparent 60%),
var(--bg);
}
.panel {
width: 100%;
min-height: 100%;
background: rgba(25, 27, 32, .88);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.header, .footer {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.footer {
border-top: 1px solid var(--line);
border-bottom: 0;
padding: 8px 6px;
}
.logo-small { width: 22px; height: 22px; border-radius: 4px; }
.title-block { flex: 1; min-width: 0; }
.title { font-size: 13px; font-weight: 650; }
.identity {
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
font-family: inherit;
color: inherit;
}
.icon-button {
width: 30px;
height: 26px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.icon-button:hover { background: rgba(255,255,255,.08); color: var(--text); }
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px 16px;
}
.orb {
width: 126px;
height: 126px;
border: 0;
padding: 0;
position: relative;
background: transparent;
cursor: pointer;
}
.orb-ring {
position: absolute;
inset: 5px;
border-radius: 50%;
background: conic-gradient(from 90deg, #2a2d33, #3a3d44, #2a2d33);
transition: background .25s, filter .25s;
}
.orb-core {
position: absolute;
inset: 7px;
border-radius: 50%;
background: radial-gradient(circle at 30% 25%, #1f2228 0%, #14161a 70%);
}
.orb-logo, .orb-power {
position: absolute;
inset: 0;
margin: auto;
}
.orb-logo {
width: 52px;
height: 52px;
display: none;
border-radius: 10px;
}
.orb-power {
width: 52px;
height: 52px;
display: grid;
place-items: center;
font-size: 42px;
color: #7a7e87;
}
.orb-dot {
display: none;
position: absolute;
left: 59px;
top: 1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 14px rgba(255, 122, 26, .78);
}
.connected .orb-ring,
.connecting .orb-ring,
.disconnecting .orb-ring,
.loggingOut .orb-ring {
background: conic-gradient(from 0deg, var(--orange), var(--pink), var(--purple), var(--blue), var(--orange));
filter: drop-shadow(0 0 20px rgba(255,122,26,.28));
}
.connected .orb-logo { display: block; }
.connected .orb-power { display: none; }
.connecting .orb-ring { animation: spin 1.05s linear infinite, pulse 1s ease-in-out infinite; }
.disconnecting .orb-ring, .loggingOut .orb-ring { animation: spin-reverse 1.35s linear infinite, pulse 1s ease-in-out infinite; }
.connecting .orb-dot, .disconnecting .orb-dot, .loggingOut .orb-dot { display: block; animation: orbit 1.05s linear infinite; transform-origin: 4px 62px; }
.disconnecting .orb-dot, .loggingOut .orb-dot { background: var(--purple); animation-direction: reverse; }
.status-line {
margin-top: 8px;
font-size: 16px;
font-weight: 650;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
display: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 0 4px rgba(255,122,26,.14), 0 0 14px rgba(255,122,26,.7);
}
.connected .status-dot, .error .status-dot { display: inline-block; }
.error .status-dot { background: var(--pink); box-shadow: none; }
.message {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
margin-top: 4px;
text-align: center;
min-height: 32px;
}
.gateway, .stats {
margin: 0 14px 12px;
padding: 10px;
border: 1px solid var(--line);
background: rgba(255,255,255,.04);
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.server-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255,255,255,.04);
color: var(--muted);
display: grid;
place-items: center;
}
.label {
color: var(--low);
text-transform: uppercase;
font-size: 10px;
letter-spacing: .6px;
}
.mono {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12.5px;
}
.badge {
margin-left: auto;
padding: 3px 7px;
border-radius: 999px;
text-transform: uppercase;
font-size: 10px;
font-weight: 700;
color: var(--low);
background: rgba(255,255,255,.06);
}
.connected .badge {
color: #ff9747;
background: rgba(255,122,26,.14);
}
.stats { padding: 0; gap: 0; overflow: hidden; }
.stat {
flex: 1;
padding: 9px 12px;
background: rgba(0,0,0,.16);
}
.divider { width: 1px; align-self: stretch; background: var(--line); }
.hidden { display: none !important; }
.chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 11px;
cursor: pointer;
}
.chip.active {
background: rgba(255,122,26,.10);
color: #ff9747;
}
.toggle {
width: 22px;
height: 12px;
border-radius: 99px;
background: rgba(255,255,255,.10);
position: relative;
}
.toggle span {
position: absolute;
top: 1.5px;
left: 1.5px;
width: 9px;
height: 9px;
border-radius: 50%;
background: white;
transition: left .18s;
}
.chip.active .toggle {
background: var(--gradient);
}
.chip.active .toggle span { left: 11px; }
.spacer { flex: 1; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin-reverse { to { transform: rotate(-360deg); } }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .82; transform: scale(1.025); }
}
@keyframes orbit { to { transform: rotate(360deg); } }