17 Commits
Author SHA1 Message Date
ishan-karmakar d85f9253c1 Merge pull request 'Open the menu bar popover on dock activate' (#22) from 21 into main
Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/22
Reviewed-by: Ishan Karmakar <[email protected]>
2026-06-03 23:18:15 -05:00
Zachariah K. Sharma cb63f10888 Open the menu bar popover on dock activate 2026-06-03 22:07:49 -06:00
ishan-karmakar f84a572cec infra: Bump to stable release 1.0.0 2026-06-01 23:49:19 -05:00
ishan-karmakar b72f6eb70b Merge pull request 'MacOS Fixes' (#20) from MacOS-Fixes into main
Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/20
2026-06-01 23:46:02 -05:00
Zachariah K. Sharma c71963fda2 packaging netbird with it 2026-06-01 23:31:40 -05:00
ishan-karmakar 79a161d303 feat: Bump version to RC 2 2026-06-01 23:09:35 -05:00
ishan-karmakar 24553a38e8 Merge pull request 'Fix disconnecting state issue' (#18) from fix-disconnecting into main
Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/18
2026-06-01 21:15:51 -05:00
ishan-karmakar 66cc11d43d fix: Allow disconnecting if state is connecting 2026-06-01 21:13:45 -05:00
ishan-karmakar 00ab8ef053 fix: Fix intermittent state switches 2026-06-01 21:00:16 -05:00
ishan-karmakar 71a98fbff7 Merge pull request 'Fix linting errors' (#17) from fix-linting into main
Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/17
2026-06-01 18:31:07 -05:00
ishan-karmakar 90c6aee83b fix: Fix linting issues 2026-06-01 18:30:05 -05:00
ishan-karmakar d7b08e09af Merge pull request 'Add context menu' (#16) from add-context-menu into main
Reviewed-on: https://git.internal.vyntehome.com/vynte/vynte-connect/pulls/16
2026-06-01 17:56:04 -05:00
ishan-karmakar 16c2dcde41 feat: Switch between connect/disconnect based on current state 2026-06-01 17:49:04 -05:00
ishan-karmakar 00dca3ba9b feat: Use single instance lock to avoid multiple instances of Vynte Connect 2026-06-01 14:50:12 -05:00
ishan-karmakar a7d40d293c feat: Disconnect on app quit 2026-06-01 14:15:47 -05:00
ishan-karmakar 2ed1128607 feat: Add connect, disconnect, and logout to context menu 2026-06-01 14:14:45 -05:00
ishan-karmakar c4e57776ca feat: Add Quit item to context menu 2026-06-01 14:07:08 -05:00
11 changed files with 199 additions and 64 deletions
+1
View File
@@ -1,4 +1,5 @@
resources/netbird* resources/netbird*
resources/*.dll
# Logs # Logs
logs logs
Executable
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "vynte-connect", "name": "vynte-connect",
"productName": "Vynte Connect", "productName": "Vynte Connect",
"version": "1.0.0-rc.1", "version": "1.0.0",
"description": "VPN used to access internal Vynte services", "description": "VPN used to access internal Vynte services",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
+2 -2
View File
@@ -13,13 +13,13 @@ const targets = [
{ {
label: "macOS arm64", label: "macOS arm64",
assetName: `netbird_${version}_darwin_arm64.tar.gz`, assetName: `netbird_${version}_darwin_arm64.tar.gz`,
outputDir: join(import.meta.dirname, "resources"), outputDir: join(import.meta.dirname, "..", "resources"),
files: [{ source: "netbird", destination: "netbird" }] files: [{ source: "netbird", destination: "netbird" }]
}, },
{ {
label: "Windows amd64", label: "Windows amd64",
assetName: `netbird_${version}_windows_amd64_signed.tar.gz`, assetName: `netbird_${version}_windows_amd64_signed.tar.gz`,
outputDir: join(import.meta.dirname, "resources"), outputDir: join(import.meta.dirname, "..", "resources"),
files: [ files: [
{ source: "netbird.exe", destination: "netbird.exe" }, { source: "netbird.exe", destination: "netbird.exe" },
{ source: "wintun.dll", destination: "wintun.dll" } { source: "wintun.dll", destination: "wintun.dll" }
+3 -2
View File
@@ -1,13 +1,14 @@
import { NetworkStatus } from "./types";
export const MANAGEMENT_URL = 'https://vpn.vyntehome.com'; export const MANAGEMENT_URL = 'https://vpn.vyntehome.com';
export const GATEWAY_HOST = new URL(MANAGEMENT_URL).host; export const GATEWAY_HOST = new URL(MANAGEMENT_URL).host;
export const APP_NAME = "Vynte Connect"; export const APP_NAME = "Vynte Connect";
export const POLL_INTERVAL_MS = 5000; export const POLL_INTERVAL_MS = 5000;
export const WINDOW_WIDTH = 384; export const WINDOW_WIDTH = 384;
export const WINDOW_HEIGHT = 420; export const WINDOW_HEIGHT = 420;
export const DEFAULT_STATUS = { export const DEFAULT_STATUS: NetworkStatus = {
state: "checking", state: "checking",
title: `Checking ${GATEWAY_HOST}`, title: `Checking ${GATEWAY_HOST}`,
message: "Preparing the Vynte access client.", message: "Preparing the Vynte access client.",
connectedSince: null,
gatewayHost: GATEWAY_HOST gatewayHost: GATEWAY_HOST
}; };
+77 -28
View File
@@ -1,19 +1,39 @@
import { app, BrowserWindow, ipcMain, nativeImage, shell, Tray } from 'electron'; import { app, BrowserWindow, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell, Tray } from 'electron';
import path from 'node:path'; import path from 'node:path';
import started from 'electron-squirrel-startup'; import started from 'electron-squirrel-startup';
import { APP_NAME, DEFAULT_STATUS, GATEWAY_HOST, MANAGEMENT_URL, POLL_INTERVAL_MS, WINDOW_HEIGHT, WINDOW_WIDTH } from './config'; import { APP_NAME, DEFAULT_STATUS, GATEWAY_HOST, MANAGEMENT_URL, POLL_INTERVAL_MS, WINDOW_HEIGHT, WINDOW_WIDTH } from './config';
import { NetBirdClient } from './netbird'; import { NetBirdClient } from './netbird';
import { baseStatus, normalizeStatus } from './status'; import { baseStatus, normalizeStatus } from './status';
import { NetworkStatus, UIStatus } from './types';
// Handle creating/removing shortcuts on Windows when installing/uninstalling. // Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) { if (started) {
app.quit(); app.quit();
} }
// This method will be called when Electron has finished const singleInstanceLock = app.requestSingleInstanceLock()
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. if (!singleInstanceLock) app.quit();
app.on('ready', createWindow); else {
app.on('second-instance', () => {
if (window) {
if (window.isMinimized()) window.restore();
window.focus();
}
});
app.on('before-quit', disconnect);
}
app.whenReady().then(() => {
createWindow();
createTray();
refreshStatus();
pollTimer = setInterval(refreshStatus, POLL_INTERVAL_MS);
});
app.on('before-quit', () => {
if (pollTimer) clearInterval(pollTimer);
});
// Quit when all windows are closed, except on macOS. There, it's common // 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 // for applications and their menu bar to stay active until the user quits
@@ -25,20 +45,25 @@ app.on('window-all-closed', () => {
}); });
app.on('activate', () => { app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the if (!window || window.isDestroyed()) {
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
if (!tray) {
createTray();
}
showWindow();
}); });
// In this file you can include the rest of your app's specific main process // 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. // code. You can also put them in separate files and import them here.
let tray: Tray; let tray: Tray;
let trayMenu: Menu;
let window: BrowserWindow; let window: BrowserWindow;
let pollTimer: NodeJS.Timeout; let pollTimer: NodeJS.Timeout;
let autoConnect = false; let autoConnect = false;
let cachedStatus = { ...DEFAULT_STATUS }; let cachedStatus = DEFAULT_STATUS;
function resourcePath(name: string) { function resourcePath(name: string) {
if (app.isPackaged) { if (app.isPackaged) {
@@ -51,7 +76,7 @@ function netbirdClient() {
return new NetBirdClient(resourcePath(process.platform === "win32" ? 'netbird.exe' : 'netbird')); return new NetBirdClient(resourcePath(process.platform === "win32" ? 'netbird.exe' : 'netbird'));
} }
function withAutoConnect(status: Record<string, any>) { function withAutoConnect(status: NetworkStatus): UIStatus {
return { ...status, autoConnect }; return { ...status, autoConnect };
} }
@@ -65,9 +90,29 @@ function updateTray() {
if (!tray) return; if (!tray) return;
const label = cachedStatus.state === 'connected' ? `${APP_NAME}: Connected` : APP_NAME; const label = cachedStatus.state === 'connected' ? `${APP_NAME}: Connected` : APP_NAME;
tray.setToolTip(label); tray.setToolTip(label);
const template: MenuItemConstructorOptions[] = [];
const connected = cachedStatus.state === "connected";
const busy = ['connecting', 'disconnecting'].includes(cachedStatus.state);
template.push({
label: connected ? 'Disconnect' : 'Connect',
click: connected ? disconnect : connect,
enabled: !busy
});
template.push(
{
label: 'Logout',
click: logout
},
{ role: 'quit' }
);
trayMenu = Menu.buildFromTemplate(template);
tray.setContextMenu(process.platform === 'darwin' ? null : trayMenu);
} }
function setStatus(status: Record<string, any>) { function setStatus(status: NetworkStatus) {
cachedStatus = { cachedStatus = {
...cachedStatus, ...cachedStatus,
...status, ...status,
@@ -88,7 +133,9 @@ async function refreshStatus() {
})); }));
} }
return setStatus(normalizeStatus(result.stdout, cachedStatus)); const status = normalizeStatus(result.stdout, cachedStatus);
if (status.state === "disconnected" && cachedStatus.state === "connecting") return;
setStatus(status);
} }
async function connect() { async function connect() {
@@ -100,11 +147,11 @@ async function connect() {
const client = netbirdClient(); const client = netbirdClient();
const serviceReady = await client.ensureService(); const serviceReady = await client.ensureService();
if (!serviceReady) { if (!serviceReady.ok) {
return setStatus({ return setStatus({
state: 'error', state: 'error',
title: 'Connection failed', title: 'Connection failed',
message: `${GATEWAY_HOST} helper did not become ready.` message: serviceReady.message || `${GATEWAY_HOST} helper did not become ready.`
}); });
} }
@@ -184,24 +231,26 @@ function showWindow() {
broadcastStatus(); broadcastStatus();
} }
function toggleWindow() {
if (window.isVisible()) {
window.hide();
return;
}
showWindow();
}
function createTray() { function createTray() {
const icon = nativeImage.createFromPath(resourcePath('VynteIcon.png')).resize({ width: 18, height: 18 }); const icon = nativeImage.createFromPath(resourcePath('VynteIcon.png')).resize({ width: 18, height: 18 });
tray = new Tray(icon); tray = new Tray(icon);
tray.setToolTip(APP_NAME); updateTray();
tray.on('click', showWindow); tray.on('click', toggleWindow);
tray.on('right-click', () => {
window.hide();
tray.popUpContextMenu(trayMenu);
});
} }
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:get', () => withAutoConnect(cachedStatus));
ipcMain.handle('status:refresh', refreshStatus); ipcMain.handle('status:refresh', refreshStatus);
ipcMain.handle('vpn:connect', connect); ipcMain.handle('vpn:connect', connect);
@@ -213,4 +262,4 @@ ipcMain.handle('auto:toggle', () => {
return autoConnect; return autoConnect;
}); });
ipcMain.handle('app:quit', () => app.quit()); ipcMain.handle('app:quit', () => app.quit());
ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL)); ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL));
+98 -11
View File
@@ -9,6 +9,20 @@ export interface CommandResult {
error: Error | null; error: Error | null;
} }
export interface ServiceReadyResult {
ok: boolean;
message?: string;
}
function commandDetails(label: string, result: CommandResult) {
const output = `${result.stdout}\n${result.stderr}\n${result.error?.message ?? ''}`.trim();
return output ? `${label}: ${output}` : `${label}: exited with code ${result.code ?? 1}`;
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, "'\\''")}'`;
}
export class NetBirdClient { export class NetBirdClient {
private executablePath: string; private executablePath: string;
@@ -30,8 +44,30 @@ export class NetBirdClient {
}); });
} }
/// Deprecated??? runElevated(args: string[]): Promise<CommandResult> {
runElevated(args: string[]): Promise<Omit<CommandResult, 'code'>> { if (process.platform === 'darwin') {
const command = [this.executablePath, ...args].map(shellQuote).join(' ');
return new Promise(resolve => {
execFile(
'osascript',
['-e', `do shell script ${JSON.stringify(command)} with administrator privileges`],
{
timeout: 180000,
},
(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
});
}
)
});
}
const quotedExe = this.executablePath.replace(/'/g, "''"); const quotedExe = this.executablePath.replace(/'/g, "''");
const quotedArgs = args.map(arg => `'${String(arg).replace(/'/g, "''")}'`).join(','); const quotedArgs = args.map(arg => `'${String(arg).replace(/'/g, "''")}'`).join(',');
@@ -48,6 +84,7 @@ export class NetBirdClient {
(error, stdout, stderr) => { (error, stdout, stderr) => {
resolve({ resolve({
ok: !error, ok: !error,
code: error && typeof (error as NodeJS.ErrnoException).code === 'number' ? (error as NodeJS.ErrnoException).code : 0,
stdout: stdout || '', stdout: stdout || '',
stderr: stderr || '', stderr: stderr || '',
error error
@@ -63,32 +100,82 @@ export class NetBirdClient {
}); });
} }
async ensureService(): Promise<boolean> { async ensureService(): Promise<ServiceReadyResult> {
const status = await this.status({ timeout: 10000 }); const status = await this.status({ timeout: 10000 });
if (status.ok) return true; if (status.ok) return { ok: true };
await this.run([ const serviceArgs = [
'service',
'install',
'--management-url', '--management-url',
MANAGEMENT_URL, MANAGEMENT_URL,
'--admin-url', '--admin-url',
MANAGEMENT_URL, MANAGEMENT_URL,
'--log-level', '--log-level',
'info' 'info'
];
let install = await this.run([
'service',
'install',
...serviceArgs
]); ]);
await this.run(['service', 'start']); let installDetails = commandDetails('service install', install);
let serviceAlreadyInstalled = installDetails.includes('Init already exists');
if (!install.ok && !serviceAlreadyInstalled && process.platform === 'darwin') {
install = await this.runElevated([
'service',
'install',
...serviceArgs
]);
installDetails = commandDetails('service install', install);
serviceAlreadyInstalled = installDetails.includes('Init already exists');
}
if (!install.ok && !serviceAlreadyInstalled) {
return {
ok: false,
message: installDetails
};
}
let reconfigureDetails = '';
if (serviceAlreadyInstalled) {
const reconfigure = await this.runElevated([
'service',
'reconfigure',
...serviceArgs
]);
if (!reconfigure.ok) {
reconfigureDetails = commandDetails('service reconfigure', reconfigure);
}
}
let start = await this.run(['service', 'start']);
if (!start.ok && process.platform === 'darwin') {
start = await this.runElevated(['service', 'start']);
}
const startDetails = commandDetails('service start', start);
let lastStatus = commandDetails('status', status);
for (let index = 0; index < 20; index++) { for (let index = 0; index < 20; index++) {
const check = await this.status({ timeout: 8000 }); const check = await this.status({ timeout: 8000 });
if (check.ok) return true; if (check.ok) return { ok: true };
lastStatus = commandDetails('status', check);
await new Promise<void>(resolve => setTimeout(resolve, 500)); await new Promise<void>(resolve => setTimeout(resolve, 500));
} }
return false; return {
ok: false,
message: [
serviceAlreadyInstalled ? installDetails : '',
reconfigureDetails,
start.ok ? '' : startDetails,
lastStatus
].filter(Boolean).join('\n\n')
};
} }
connect(): Promise<CommandResult> { connect(): Promise<CommandResult> {
@@ -122,4 +209,4 @@ export class NetBirdClient {
MANAGEMENT_URL MANAGEMENT_URL
], { timeout: 60000 }); ], { timeout: 60000 });
} }
} }
+2 -1
View File
@@ -2,6 +2,7 @@
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { UIStatus } from "./types";
contextBridge.exposeInMainWorld('vynte', { contextBridge.exposeInMainWorld('vynte', {
getStatus: () => ipcRenderer.invoke('status:get'), getStatus: () => ipcRenderer.invoke('status:get'),
@@ -12,5 +13,5 @@ contextBridge.exposeInMainWorld('vynte', {
toggleAuto: () => ipcRenderer.invoke('auto:toggle'), toggleAuto: () => ipcRenderer.invoke('auto:toggle'),
quit: () => ipcRenderer.invoke('app:quit'), quit: () => ipcRenderer.invoke('app:quit'),
openGateway: () => ipcRenderer.invoke('external:openGateway'), openGateway: () => ipcRenderer.invoke('external:openGateway'),
onStatus: (callback: Function) => ipcRenderer.on('status', (_, status) => callback(status)) onStatus: (callback: (status: UIStatus) => void) => ipcRenderer.on('status', (_, status) => callback(status))
}) })
+2 -7
View File
@@ -27,7 +27,7 @@
*/ */
import './index.css' import './index.css'
import type { NetworkStatus } from './status'; import { UIStatus } from './types';
const body = document.body; const body = document.body;
@@ -40,10 +40,6 @@ const auto = document.getElementById('auto') as HTMLElement;
const logout = document.getElementById('logout') as HTMLElement; const logout = document.getElementById('logout') as HTMLElement;
const quit = document.getElementById('quit') as HTMLElement; const quit = document.getElementById('quit') as HTMLElement;
interface UIStatus extends NetworkStatus {
autoConnect?: boolean;
}
let currentStatus: Partial<UIStatus> = { let currentStatus: Partial<UIStatus> = {
state: 'checking', state: 'checking',
}; };
@@ -72,11 +68,10 @@ function setStatus(status: UIStatus): void {
} }
orb.addEventListener('click', async () => { orb.addEventListener('click', async () => {
if (currentStatus.state === 'connected') { if (['connected', 'connecting'].includes(currentStatus.state ?? '')) {
await window.vynte.disconnect(); await window.vynte.disconnect();
} else if ( } else if (
![ ![
'connecting',
'disconnecting', 'disconnecting',
'loggingOut', 'loggingOut',
'checking', 'checking',
+2 -12
View File
@@ -1,12 +1,5 @@
import { GATEWAY_HOST } from './config'; import { GATEWAY_HOST } from './config';
import { NetworkStatus } from './types';
export interface NetworkStatus {
state?: string;
title?: string;
message?: string;
connectedSince: number | null;
gatewayHost: string;
}
interface NetBirdStatusResponse { interface NetBirdStatusResponse {
daemonStatus?: string; daemonStatus?: string;
@@ -22,11 +15,8 @@ interface NetBirdStatusResponse {
}; };
} }
export function baseStatus( export function baseStatus(overrides: NetworkStatus): NetworkStatus {
overrides: Partial<NetworkStatus> = {}
): NetworkStatus {
return { return {
connectedSince: null,
gatewayHost: GATEWAY_HOST, gatewayHost: GATEWAY_HOST,
...overrides, ...overrides,
}; };
+11
View File
@@ -0,0 +1,11 @@
export interface NetworkStatus {
state: string;
title: string;
message: string;
connectedSince?: number;
gatewayHost?: string;
}
export interface UIStatus extends NetworkStatus {
autoConnect?: boolean;
}