Electron forge migration (#2)

Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-31 19:28:32 -05:00
parent 980535d682
commit 30bedb9b25
48 changed files with 11388 additions and 4356 deletions
+14
View File
@@ -0,0 +1,14 @@
export const MANAGEMENT_URL = 'https://vpn.vyntehome.com';
export const GATEWAY_HOST = new URL(MANAGEMENT_URL).host;
export const APP_NAME = "Vynte Connect";
export const POLL_INTERVAL_MS = 5000;
export const DEFAULT_STATUS = {
state: "checking",
title: `Checking ${GATEWAY_HOST}`,
message: "Preparing the Vynte access client.",
ip: '',
fqdn: '',
peers: '0/0',
connectedSince: null,
gatewayHost: GATEWAY_HOST
};
+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); } }
+212
View File
@@ -0,0 +1,212 @@
import { app, BrowserWindow, ipcMain, nativeImage, shell, Tray } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { APP_NAME, DEFAULT_STATUS, GATEWAY_HOST, MANAGEMENT_URL, POLL_INTERVAL_MS } from './config';
import { NetBirdClient } from './netbird';
import { baseStatus, normalizeStatus } from './status';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
let tray: Tray;
let window: BrowserWindow;
let pollTimer: NodeJS.Timeout;
let autoConnect = false;
let cachedStatus = { ...DEFAULT_STATUS };
function resourcePath(name: string) {
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: Record<string, any>) {
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: Record<string, any>) {
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
}
});
// and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
window.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
window.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/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));
+124
View File
@@ -0,0 +1,124 @@
import { execFile } from 'child_process';
import { MANAGEMENT_URL } from './config';
export interface CommandResult {
ok: boolean;
code?: number;
stdout: string;
stderr: string;
error: Error | null;
}
export class NetBirdClient {
private executablePath: string;
constructor(executablePath: string) {
this.executablePath = executablePath;
}
run(args: string[], options: { timeout?: number } = {}): Promise<CommandResult> {
return new Promise(resolve => {
execFile(this.executablePath, args, { windowsHide: true, timeout: options.timeout ?? 30000, }, (error, stdout, stderr) => {
resolve({
ok: !error,
code: error && typeof (error as NodeJS.ErrnoException).code === 'number' ? (error as NodeJS.ErrnoException).code : 0,
stdout: stdout || '',
stderr: stderr || '',
error
})
});
});
}
runElevated(args: string[]): Promise<Omit<CommandResult, 'code'>> {
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: { timeout?: number } = {}): Promise<CommandResult> {
return this.run(['status', '--json'], {
timeout: options.timeout ?? 12000
});
}
async ensureService(): Promise<boolean> {
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++) {
const check = await this.status({ timeout: 8000 });
if (check.ok) return true;
await new Promise<void>(resolve => setTimeout(resolve, 500));
}
return false;
}
connect(): Promise<CommandResult> {
return this.run([
'up',
'--management-url',
MANAGEMENT_URL,
'--admin-url',
MANAGEMENT_URL
], { timeout: 180000 });
}
disconnect(): Promise<CommandResult> {
return this.run([
'down',
'--management-url', MANAGEMENT_URL
], { timeout: 45000 });
}
async logout(): Promise<CommandResult> {
await this.run(
['down', '--management-url', MANAGEMENT_URL],
{ timeout: 30000 }
);
return this.run([
'deregister',
'--management-url',
MANAGEMENT_URL,
'--admin-url',
MANAGEMENT_URL
], { timeout: 60000 });
}
}
+16
View File
@@ -0,0 +1,16 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer } from "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: Function) => ipcRenderer.on('status', (_, status) => callback(status))
})
+115
View File
@@ -0,0 +1,115 @@
/**
* This file will automatically be loaded by vite and run in the "renderer" context.
* To learn more about the differences between the "main" and the "renderer" context in
* Electron, visit:
*
* https://electronjs.org/docs/tutorial/process-model
*
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
* in a renderer process, please be aware of potential security implications. You can read
* more about security risks here:
*
* https://electronjs.org/docs/tutorial/security
*
* To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration`
* flag:
*
* ```
* // Create the browser window.
* mainWindow = new BrowserWindow({
* width: 800,
* height: 600,
* webPreferences: {
* nodeIntegration: true
* }
* });
* ```
*/
import './index.css'
import type { NetworkStatus } from './status';
const body = document.body;
const orb = document.getElementById('orb') as HTMLElement;
const statusTitle = document.getElementById('status-title') as HTMLElement;
const statusMessage = document.getElementById('status-message') as HTMLElement;
const identity = document.getElementById('identity') as HTMLElement;
const gatewayState = document.getElementById('gateway-state') as HTMLElement;
const stats = document.getElementById('stats') as HTMLElement;
const ip = document.getElementById('ip') as HTMLElement;
const peers = document.getElementById('peers') as HTMLElement;
const refresh = document.getElementById('refresh') as HTMLElement;
const auto = document.getElementById('auto') as HTMLElement;
const logout = document.getElementById('logout') as HTMLElement;
const quit = document.getElementById('quit') as HTMLElement;
interface UIStatus extends NetworkStatus {
autoConnect?: boolean;
}
let currentStatus: Partial<UIStatus> = {
state: 'checking',
};
function setStatus(status: UIStatus): void {
currentStatus = status;
body.className = status.state ?? 'disconnected';
statusTitle.textContent = status.title ?? 'Disconnected';
statusMessage.textContent = status.message ?? '';
identity.textContent = status.fqdn ?? 'Vynte access';
const gatewayHostElement = document.querySelector(
'[data-gateway-host]'
) as HTMLElement | null;
if (gatewayHostElement) {
gatewayHostElement.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', () => {
void window.vynte.refresh();
});
auto.addEventListener('click', () => {
void window.vynte.toggleAuto();
});
logout.addEventListener('click', () => {
void window.vynte.logout();
});
quit.addEventListener('click', () => {
void window.vynte.quit();
});
window.vynte.onStatus(setStatus);
window.vynte.getStatus().then(setStatus);
+93
View File
@@ -0,0 +1,93 @@
import { GATEWAY_HOST } from './config';
export interface NetworkStatus {
state?: string;
title?: string;
message?: string;
ip: string;
fqdn: string;
peers: string;
connectedSince: number | null;
gatewayHost: string;
}
interface NetBirdStatusResponse {
daemonStatus?: string;
netbirdIp?: string;
fqdn?: string;
management?: {
connected?: boolean;
};
signal?: {
connected?: boolean;
};
peers?: {
connected?: number;
total?: number;
};
}
export function baseStatus(
overrides: Partial<NetworkStatus> = {}
): NetworkStatus {
return {
ip: '',
fqdn: '',
peers: '0/0',
connectedSince: null,
gatewayHost: GATEWAY_HOST,
...overrides,
};
}
export function normalizeStatus(
raw: string,
previousStatus: Partial<NetworkStatus> = {}
): NetworkStatus {
let parsed: NetBirdStatusResponse;
try {
parsed = JSON.parse(raw) as NetBirdStatusResponse;
} 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?.connected === true;
const signalConnected = 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}`,
});
}