Compare commits
17 Commits
v1.0.0-rc.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d85f9253c1 | |||
| cb63f10888 | |||
| f84a572cec | |||
| b72f6eb70b | |||
| c71963fda2 | |||
| 79a161d303 | |||
| 24553a38e8 | |||
| 66cc11d43d | |||
| 00ab8ef053 | |||
| 71a98fbff7 | |||
| 90c6aee83b | |||
| d7b08e09af | |||
| 16c2dcde41 | |||
| 00dca3ba9b | |||
| a7d40d293c | |||
| 2ed1128607 | |||
| c4e57776ca |
@@ -1,4 +1,5 @@
|
||||
resources/netbird*
|
||||
resources/*.dll
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vynte-connect",
|
||||
"productName": "Vynte Connect",
|
||||
"version": "1.0.0-rc.1",
|
||||
"version": "1.0.0",
|
||||
"description": "VPN used to access internal Vynte services",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -13,13 +13,13 @@ const targets = [
|
||||
{
|
||||
label: "macOS arm64",
|
||||
assetName: `netbird_${version}_darwin_arm64.tar.gz`,
|
||||
outputDir: join(import.meta.dirname, "resources"),
|
||||
outputDir: join(import.meta.dirname, "..", "resources"),
|
||||
files: [{ source: "netbird", destination: "netbird" }]
|
||||
},
|
||||
{
|
||||
label: "Windows amd64",
|
||||
assetName: `netbird_${version}_windows_amd64_signed.tar.gz`,
|
||||
outputDir: join(import.meta.dirname, "resources"),
|
||||
outputDir: join(import.meta.dirname, "..", "resources"),
|
||||
files: [
|
||||
{ source: "netbird.exe", destination: "netbird.exe" },
|
||||
{ source: "wintun.dll", destination: "wintun.dll" }
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
import { NetworkStatus } from "./types";
|
||||
|
||||
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 WINDOW_WIDTH = 384;
|
||||
export const WINDOW_HEIGHT = 420;
|
||||
export const DEFAULT_STATUS = {
|
||||
export const DEFAULT_STATUS: NetworkStatus = {
|
||||
state: "checking",
|
||||
title: `Checking ${GATEWAY_HOST}`,
|
||||
message: "Preparing the Vynte access client.",
|
||||
connectedSince: null,
|
||||
gatewayHost: GATEWAY_HOST
|
||||
};
|
||||
+77
-28
@@ -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 started from 'electron-squirrel-startup';
|
||||
import { APP_NAME, DEFAULT_STATUS, GATEWAY_HOST, MANAGEMENT_URL, POLL_INTERVAL_MS, WINDOW_HEIGHT, WINDOW_WIDTH } from './config';
|
||||
import { NetBirdClient } from './netbird';
|
||||
import { baseStatus, normalizeStatus } from './status';
|
||||
import { NetworkStatus, UIStatus } from './types';
|
||||
|
||||
// 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);
|
||||
const singleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!singleInstanceLock) app.quit();
|
||||
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
|
||||
// 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', () => {
|
||||
// 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) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
createWindow();
|
||||
}
|
||||
|
||||
if (!tray) {
|
||||
createTray();
|
||||
}
|
||||
|
||||
showWindow();
|
||||
});
|
||||
|
||||
// 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 trayMenu: Menu;
|
||||
let window: BrowserWindow;
|
||||
let pollTimer: NodeJS.Timeout;
|
||||
let autoConnect = false;
|
||||
let cachedStatus = { ...DEFAULT_STATUS };
|
||||
let cachedStatus = DEFAULT_STATUS;
|
||||
|
||||
function resourcePath(name: string) {
|
||||
if (app.isPackaged) {
|
||||
@@ -51,7 +76,7 @@ function netbirdClient() {
|
||||
return new NetBirdClient(resourcePath(process.platform === "win32" ? 'netbird.exe' : 'netbird'));
|
||||
}
|
||||
|
||||
function withAutoConnect(status: Record<string, any>) {
|
||||
function withAutoConnect(status: NetworkStatus): UIStatus {
|
||||
return { ...status, autoConnect };
|
||||
}
|
||||
|
||||
@@ -65,9 +90,29 @@ function updateTray() {
|
||||
if (!tray) return;
|
||||
const label = cachedStatus.state === 'connected' ? `${APP_NAME}: Connected` : APP_NAME;
|
||||
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,
|
||||
...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() {
|
||||
@@ -100,11 +147,11 @@ async function connect() {
|
||||
|
||||
const client = netbirdClient();
|
||||
const serviceReady = await client.ensureService();
|
||||
if (!serviceReady) {
|
||||
if (!serviceReady.ok) {
|
||||
return setStatus({
|
||||
state: 'error',
|
||||
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();
|
||||
}
|
||||
|
||||
function toggleWindow() {
|
||||
if (window.isVisible()) {
|
||||
window.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
showWindow();
|
||||
}
|
||||
|
||||
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);
|
||||
updateTray();
|
||||
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:refresh', refreshStatus);
|
||||
ipcMain.handle('vpn:connect', connect);
|
||||
@@ -213,4 +262,4 @@ ipcMain.handle('auto:toggle', () => {
|
||||
return autoConnect;
|
||||
});
|
||||
ipcMain.handle('app:quit', () => app.quit());
|
||||
ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL));
|
||||
ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL));
|
||||
|
||||
+98
-11
@@ -9,6 +9,20 @@ export interface CommandResult {
|
||||
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 {
|
||||
private executablePath: string;
|
||||
|
||||
@@ -30,8 +44,30 @@ export class NetBirdClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Deprecated???
|
||||
runElevated(args: string[]): Promise<Omit<CommandResult, 'code'>> {
|
||||
runElevated(args: string[]): Promise<CommandResult> {
|
||||
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 quotedArgs = args.map(arg => `'${String(arg).replace(/'/g, "''")}'`).join(',');
|
||||
|
||||
@@ -48,6 +84,7 @@ export class NetBirdClient {
|
||||
(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
|
||||
@@ -63,32 +100,82 @@ export class NetBirdClient {
|
||||
});
|
||||
}
|
||||
|
||||
async ensureService(): Promise<boolean> {
|
||||
async ensureService(): Promise<ServiceReadyResult> {
|
||||
const status = await this.status({ timeout: 10000 });
|
||||
|
||||
if (status.ok) return true;
|
||||
if (status.ok) return { ok: true };
|
||||
|
||||
await this.run([
|
||||
'service',
|
||||
'install',
|
||||
const serviceArgs = [
|
||||
'--management-url',
|
||||
MANAGEMENT_URL,
|
||||
'--admin-url',
|
||||
MANAGEMENT_URL,
|
||||
'--log-level',
|
||||
'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++) {
|
||||
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));
|
||||
}
|
||||
|
||||
return false;
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
serviceAlreadyInstalled ? installDetails : '',
|
||||
reconfigureDetails,
|
||||
start.ok ? '' : startDetails,
|
||||
lastStatus
|
||||
].filter(Boolean).join('\n\n')
|
||||
};
|
||||
}
|
||||
|
||||
connect(): Promise<CommandResult> {
|
||||
@@ -122,4 +209,4 @@ export class NetBirdClient {
|
||||
MANAGEMENT_URL
|
||||
], { timeout: 60000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { UIStatus } from "./types";
|
||||
|
||||
contextBridge.exposeInMainWorld('vynte', {
|
||||
getStatus: () => ipcRenderer.invoke('status:get'),
|
||||
@@ -12,5 +13,5 @@ contextBridge.exposeInMainWorld('vynte', {
|
||||
toggleAuto: () => ipcRenderer.invoke('auto:toggle'),
|
||||
quit: () => ipcRenderer.invoke('app:quit'),
|
||||
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
@@ -27,7 +27,7 @@
|
||||
*/
|
||||
|
||||
import './index.css'
|
||||
import type { NetworkStatus } from './status';
|
||||
import { UIStatus } from './types';
|
||||
|
||||
const body = document.body;
|
||||
|
||||
@@ -40,10 +40,6 @@ 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',
|
||||
};
|
||||
@@ -72,11 +68,10 @@ function setStatus(status: UIStatus): void {
|
||||
}
|
||||
|
||||
orb.addEventListener('click', async () => {
|
||||
if (currentStatus.state === 'connected') {
|
||||
if (['connected', 'connecting'].includes(currentStatus.state ?? '')) {
|
||||
await window.vynte.disconnect();
|
||||
} else if (
|
||||
![
|
||||
'connecting',
|
||||
'disconnecting',
|
||||
'loggingOut',
|
||||
'checking',
|
||||
|
||||
+2
-12
@@ -1,12 +1,5 @@
|
||||
import { GATEWAY_HOST } from './config';
|
||||
|
||||
export interface NetworkStatus {
|
||||
state?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
connectedSince: number | null;
|
||||
gatewayHost: string;
|
||||
}
|
||||
import { NetworkStatus } from './types';
|
||||
|
||||
interface NetBirdStatusResponse {
|
||||
daemonStatus?: string;
|
||||
@@ -22,11 +15,8 @@ interface NetBirdStatusResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export function baseStatus(
|
||||
overrides: Partial<NetworkStatus> = {}
|
||||
): NetworkStatus {
|
||||
export function baseStatus(overrides: NetworkStatus): NetworkStatus {
|
||||
return {
|
||||
connectedSince: null,
|
||||
gatewayHost: GATEWAY_HOST,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface NetworkStatus {
|
||||
state: string;
|
||||
title: string;
|
||||
message: string;
|
||||
connectedSince?: number;
|
||||
gatewayHost?: string;
|
||||
}
|
||||
|
||||
export interface UIStatus extends NetworkStatus {
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user