mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-08 22:49:10 +02:00
Add prototype UI clients
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface Config {
|
||||
managementUrl?: string;
|
||||
preSharedKey?: string;
|
||||
interfaceName?: string;
|
||||
wireguardPort?: number;
|
||||
mtu?: number;
|
||||
serverSSHAllowed?: boolean;
|
||||
autoConnect?: boolean;
|
||||
rosenpassEnabled?: boolean;
|
||||
rosenpassPermissive?: boolean;
|
||||
lazyConnectionEnabled?: boolean;
|
||||
blockInbound?: boolean;
|
||||
networkMonitor?: boolean;
|
||||
disableDns?: boolean;
|
||||
disableClientRoutes?: boolean;
|
||||
disableServerRoutes?: boolean;
|
||||
blockLanAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface Network {
|
||||
id: string;
|
||||
networkRange: string;
|
||||
domains: string[];
|
||||
resolvedIPs: string[];
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
private client: any;
|
||||
private protoPath: string;
|
||||
|
||||
constructor(private address: string) {
|
||||
// Path to proto file via symlink: dist/electron/grpc -> ../../../proto/daemon.proto
|
||||
this.protoPath = path.join(__dirname, '../../../proto/daemon.proto');
|
||||
this.initializeClient();
|
||||
}
|
||||
|
||||
private initializeClient() {
|
||||
const packageDefinition = protoLoader.loadSync(this.protoPath, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
|
||||
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition) as any;
|
||||
const DaemonService = protoDescriptor.daemon.DaemonService;
|
||||
|
||||
// Create client with Unix socket or TCP
|
||||
const credentials = grpc.credentials.createInsecure();
|
||||
this.client = new DaemonService(this.address, credentials);
|
||||
}
|
||||
|
||||
promisifyCall(method: string, request: any = {}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.client[method](request, (error: any, response: any) => {
|
||||
if (error) {
|
||||
// Add more context to the error
|
||||
const enhancedError = {
|
||||
...error,
|
||||
method,
|
||||
message: error.message || 'Unknown gRPC error',
|
||||
code: error.code,
|
||||
};
|
||||
reject(enhancedError);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Catch synchronous errors (like EPIPE on write)
|
||||
console.error(`gRPC call ${method} failed synchronously:`, error);
|
||||
reject({
|
||||
method,
|
||||
message: error.message,
|
||||
code: error.code || 'UNKNOWN',
|
||||
originalError: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getStatus(): Promise<string> {
|
||||
try {
|
||||
const response = await this.promisifyCall('Status', {});
|
||||
return response.status || 'Unknown';
|
||||
} catch (error) {
|
||||
console.error('getStatus error:', error);
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async getFullStatus(): Promise<any> {
|
||||
try {
|
||||
const response = await this.promisifyCall('Status', {
|
||||
getFullPeerStatus: true,
|
||||
shouldRunProbes: false
|
||||
});
|
||||
console.log('getFullStatus response:', JSON.stringify(response.fullStatus, null, 2));
|
||||
return response.fullStatus || null;
|
||||
} catch (error) {
|
||||
console.error('getFullStatus error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async up(): Promise<void> {
|
||||
await this.promisifyCall('Up', {});
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.promisifyCall('Down', {});
|
||||
}
|
||||
|
||||
async getConfig(): Promise<Config> {
|
||||
const username = require('os').userInfo().username;
|
||||
|
||||
// Get active profile name
|
||||
const profiles = await this.listProfiles();
|
||||
const activeProfile = profiles.find(p => p.active);
|
||||
const profileName = activeProfile?.name || 'default';
|
||||
|
||||
const response = await this.promisifyCall('GetConfig', { username, profileName });
|
||||
return {
|
||||
managementUrl: response.managementUrl || '',
|
||||
preSharedKey: response.preSharedKey || '',
|
||||
interfaceName: response.interfaceName || '',
|
||||
wireguardPort: response.wireguardPort || 51820,
|
||||
mtu: response.mtu || 1280,
|
||||
serverSSHAllowed: response.serverSSHAllowed || false,
|
||||
autoConnect: !response.disableAutoConnect, // Invert the daemon's disableAutoConnect
|
||||
rosenpassEnabled: response.rosenpassEnabled || false,
|
||||
rosenpassPermissive: response.rosenpassPermissive || false,
|
||||
lazyConnectionEnabled: response.lazyConnectionEnabled || false,
|
||||
blockInbound: response.blockInbound || false,
|
||||
networkMonitor: response.networkMonitor || false,
|
||||
disableDns: response.disable_dns || false,
|
||||
disableClientRoutes: response.disable_client_routes || false,
|
||||
disableServerRoutes: response.disable_server_routes || false,
|
||||
blockLanAccess: response.block_lan_access || false,
|
||||
};
|
||||
}
|
||||
|
||||
async updateConfig(config: Partial<Config>): Promise<void> {
|
||||
const username = require('os').userInfo().username;
|
||||
|
||||
// Get active profile name
|
||||
const profiles = await this.listProfiles();
|
||||
const activeProfile = profiles.find(p => p.active);
|
||||
const profileName = activeProfile?.name || 'default';
|
||||
|
||||
// Build the SetConfigRequest with proper field names matching proto
|
||||
const request: any = {
|
||||
username,
|
||||
profileName,
|
||||
};
|
||||
|
||||
// Map config fields to proto field names (snake_case for gRPC)
|
||||
if (config.managementUrl !== undefined) request.managementUrl = config.managementUrl;
|
||||
if (config.interfaceName !== undefined) request.interfaceName = config.interfaceName;
|
||||
if (config.wireguardPort !== undefined) request.wireguardPort = config.wireguardPort;
|
||||
if (config.preSharedKey !== undefined) request.optionalPreSharedKey = config.preSharedKey;
|
||||
if (config.mtu !== undefined) request.mtu = config.mtu;
|
||||
if (config.serverSSHAllowed !== undefined) request.serverSSHAllowed = config.serverSSHAllowed;
|
||||
if (config.autoConnect !== undefined) request.disableAutoConnect = !config.autoConnect; // Invert for daemon
|
||||
if (config.rosenpassEnabled !== undefined) request.rosenpassEnabled = config.rosenpassEnabled;
|
||||
if (config.rosenpassPermissive !== undefined) request.rosenpassPermissive = config.rosenpassPermissive;
|
||||
if (config.lazyConnectionEnabled !== undefined) request.lazyConnectionEnabled = config.lazyConnectionEnabled;
|
||||
if (config.blockInbound !== undefined) request.block_inbound = config.blockInbound;
|
||||
if (config.networkMonitor !== undefined) request.networkMonitor = config.networkMonitor;
|
||||
if (config.disableDns !== undefined) request.disable_dns = config.disableDns;
|
||||
if (config.disableClientRoutes !== undefined) request.disable_client_routes = config.disableClientRoutes;
|
||||
if (config.disableServerRoutes !== undefined) request.disable_server_routes = config.disableServerRoutes;
|
||||
if (config.blockLanAccess !== undefined) request.block_lan_access = config.blockLanAccess;
|
||||
|
||||
await this.promisifyCall('SetConfig', request);
|
||||
}
|
||||
|
||||
async listNetworks(): Promise<Network[]> {
|
||||
const response = await this.promisifyCall('ListNetworks', {});
|
||||
return (response.networks || []).map((network: any) => ({
|
||||
id: network.id,
|
||||
networkRange: network.networkRange,
|
||||
domains: network.domains || [],
|
||||
resolvedIPs: network.resolvedIPs || [],
|
||||
selected: network.selected || false,
|
||||
}));
|
||||
}
|
||||
|
||||
async selectNetworks(networkIds: string[]): Promise<void> {
|
||||
await this.promisifyCall('SelectNetworks', { networkIds });
|
||||
}
|
||||
|
||||
async deselectNetworks(networkIds: string[]): Promise<void> {
|
||||
await this.promisifyCall('DeselectNetworks', { networkIds });
|
||||
}
|
||||
|
||||
async listProfiles(): Promise<Profile[]> {
|
||||
try {
|
||||
// Get OS username for profiles API
|
||||
const username = require('os').userInfo().username;
|
||||
const response = await this.promisifyCall('ListProfiles', { username });
|
||||
|
||||
console.log('Raw gRPC response profiles:', JSON.stringify(response.profiles, null, 2));
|
||||
|
||||
const mapped = (response.profiles || []).map((profile: any) => ({
|
||||
id: profile.id || profile.name, // Use name as id if id is not provided
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
active: profile.is_active || false, // gRPC uses snake_case: is_active
|
||||
}));
|
||||
|
||||
console.log('Mapped profiles:', JSON.stringify(mapped, null, 2));
|
||||
|
||||
return mapped;
|
||||
} catch (error: any) {
|
||||
console.error('listProfiles error:', error);
|
||||
// Return empty array on error instead of throwing
|
||||
if (error.code === 'EPIPE' || error.code === 'ECONNREFUSED') {
|
||||
console.warn('gRPC connection lost, returning empty profiles list');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveProfile(): Promise<Profile | null> {
|
||||
try {
|
||||
const response = await this.promisifyCall('GetActiveProfile', {});
|
||||
if (response.profile) {
|
||||
return {
|
||||
id: response.profile.id,
|
||||
name: response.profile.name,
|
||||
email: response.profile.email,
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('getActiveProfile error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async switchProfile(profileId: string): Promise<void> {
|
||||
console.log('gRPC client: switchProfile called with profileId:', profileId);
|
||||
// The proto expects profileName, not profileId
|
||||
const username = require('os').userInfo().username;
|
||||
const result = await this.promisifyCall('SwitchProfile', { profileName: profileId, username });
|
||||
console.log('gRPC client: switchProfile result:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async addProfile(profileName: string): Promise<void> {
|
||||
const username = require('os').userInfo().username;
|
||||
await this.promisifyCall('AddProfile', { username, profileName });
|
||||
}
|
||||
|
||||
async removeProfile(profileName: string): Promise<void> {
|
||||
const username = require('os').userInfo().username;
|
||||
await this.promisifyCall('RemoveProfile', { username, profileName });
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.promisifyCall('Logout', {});
|
||||
}
|
||||
|
||||
async login(setupKey?: string): Promise<{
|
||||
needsSSOLogin: boolean;
|
||||
userCode?: string;
|
||||
verificationURI?: string;
|
||||
verificationURIComplete?: string;
|
||||
}> {
|
||||
const request = setupKey ? { setupKey } : {};
|
||||
const response = await this.promisifyCall('Login', request);
|
||||
return {
|
||||
needsSSOLogin: response.needsSSOLogin || false,
|
||||
userCode: response.userCode,
|
||||
verificationURI: response.verificationURI,
|
||||
verificationURIComplete: response.verificationURIComplete,
|
||||
};
|
||||
}
|
||||
|
||||
async waitSSOLogin(userCode: string): Promise<{ email: string }> {
|
||||
const hostname = require('os').hostname();
|
||||
const response = await this.promisifyCall('WaitSSOLogin', { userCode, hostname });
|
||||
return {
|
||||
email: response.email || '',
|
||||
};
|
||||
}
|
||||
|
||||
async createDebugBundle(anonymize: boolean): Promise<string> {
|
||||
const response = await this.promisifyCall('DebugBundle', { anonymize });
|
||||
return response.path || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { DaemonClient } from './grpc/client';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let daemonClient: DaemonClient;
|
||||
let isQuitting = false;
|
||||
let cachedProfiles: string = ''; // Cache profiles to avoid menu rebuilding
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Daemon address - Unix socket on Linux/BSD/macOS, TCP on Windows
|
||||
const DAEMON_ADDR = process.platform === 'win32'
|
||||
? 'localhost:41731'
|
||||
: 'unix:///var/run/netbird.sock';
|
||||
|
||||
// Helper function to get NetBird config directory
|
||||
function getNetBirdConfigDir(): string {
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, '.config', 'netbird');
|
||||
}
|
||||
|
||||
// Helper function to read active profile from filesystem
|
||||
function readActiveProfileFromFS(): string | null {
|
||||
try {
|
||||
const configDir = getNetBirdConfigDir();
|
||||
const activeProfilePath = path.join(configDir, 'active_profile.txt');
|
||||
|
||||
if (fs.existsSync(activeProfilePath)) {
|
||||
const profileName = fs.readFileSync(activeProfilePath, 'utf-8').trim();
|
||||
return profileName || 'default';
|
||||
}
|
||||
return 'default';
|
||||
} catch (error) {
|
||||
console.error('Error reading active profile from filesystem:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to read profile state (email) from filesystem
|
||||
function readProfileState(profileName: string): { email?: string } | null {
|
||||
try {
|
||||
const configDir = getNetBirdConfigDir();
|
||||
const stateFilePath = path.join(configDir, `${profileName}.state.json`);
|
||||
|
||||
if (fs.existsSync(stateFilePath)) {
|
||||
const stateContent = fs.readFileSync(stateFilePath, 'utf-8');
|
||||
return JSON.parse(stateContent);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error reading profile state for ${profileName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor: '#121218',
|
||||
show: false,
|
||||
frame: true,
|
||||
autoHideMenuBar: true, // Hide the menu bar (File, Edit, View, etc.)
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
|
||||
// Remove the application menu completely
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
|
||||
// Load the app
|
||||
if (isDev) {
|
||||
const port = process.env.VITE_PORT || '5173';
|
||||
mainWindow.loadURL(`http://localhost:${port}`);
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
// Show window when ready
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Hide instead of close
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createTray() {
|
||||
// Create tray icon
|
||||
const iconPath = path.join(__dirname, '../../assets/tray-icon-disconnected.png');
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
tray = new Tray(icon.resize({ width: 22, height: 22 }));
|
||||
tray.setToolTip('NetBird - Disconnected');
|
||||
|
||||
// Update tray menu
|
||||
updateTrayMenu(false);
|
||||
|
||||
// Show window on tray click
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateTrayMenu(connected: boolean) {
|
||||
if (!tray) return;
|
||||
|
||||
// Get profiles for dynamic submenu
|
||||
let profileMenuItems: any[] = [];
|
||||
let profilesHash = '';
|
||||
try {
|
||||
const username = require('os').userInfo().username;
|
||||
const profilesResponse = await daemonClient.promisifyCall('ListProfiles', { username });
|
||||
const profiles = (profilesResponse.profiles || []).map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
email: p.email,
|
||||
active: p.active || false,
|
||||
}));
|
||||
|
||||
// Create hash to detect changes
|
||||
profilesHash = JSON.stringify(profiles);
|
||||
|
||||
// If profiles haven't changed, don't rebuild the menu
|
||||
if (profilesHash === cachedProfiles) {
|
||||
return;
|
||||
}
|
||||
cachedProfiles = profilesHash;
|
||||
|
||||
profileMenuItems = profiles.map((profile: any) => ({
|
||||
label: `${profile.name}${profile.email ? ` (${profile.email})` : ''}`,
|
||||
type: 'radio' as const,
|
||||
checked: profile.active,
|
||||
click: async () => {
|
||||
if (!profile.active) {
|
||||
try {
|
||||
await daemonClient.switchProfile(profile.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch profile:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles for menu:', error);
|
||||
profileMenuItems = [{
|
||||
label: 'Manage Profiles...',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('navigate', '/profiles');
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
// Add manage profiles option
|
||||
if (profileMenuItems.length > 0) {
|
||||
profileMenuItems.push({ type: 'separator' });
|
||||
}
|
||||
profileMenuItems.push({
|
||||
label: 'Manage Profiles...',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('navigate', '/profiles');
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: connected ? 'Connected' : 'Disconnected',
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: connected ? 'Disconnect' : 'Connect',
|
||||
click: async () => {
|
||||
try {
|
||||
if (connected) {
|
||||
await daemonClient.down();
|
||||
} else {
|
||||
await daemonClient.up();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle connection:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Show Dashboard',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Allow SSH',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: async (menuItem) => {
|
||||
try {
|
||||
await daemonClient.updateConfig({ serverSSHAllowed: menuItem.checked });
|
||||
} catch (error) {
|
||||
console.error('Failed to update SSH setting:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Connect on Startup',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: async (menuItem) => {
|
||||
try {
|
||||
await daemonClient.updateConfig({ autoConnect: menuItem.checked });
|
||||
} catch (error) {
|
||||
console.error('Failed to update auto-connect:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Enable Quantum-Resistance (Rosenpass)',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: async (menuItem) => {
|
||||
try {
|
||||
await daemonClient.updateConfig({ rosenpassEnabled: menuItem.checked });
|
||||
} catch (error) {
|
||||
console.error('Failed to update Rosenpass:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Enable Lazy Connections',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: async (menuItem) => {
|
||||
try {
|
||||
await daemonClient.updateConfig({ lazyConnectionEnabled: menuItem.checked });
|
||||
} catch (error) {
|
||||
console.error('Failed to update lazy connection:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Block Inbound Connections',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: async (menuItem) => {
|
||||
try {
|
||||
await daemonClient.updateConfig({ blockInbound: menuItem.checked });
|
||||
} catch (error) {
|
||||
console.error('Failed to update block inbound:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Advanced Settings...',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Networks',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('navigate', '/networks');
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Profiles',
|
||||
submenu: profileMenuItems,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Create Debug Bundle',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('navigate', '/debug');
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'About',
|
||||
submenu: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
click: () => {
|
||||
require('electron').shell.openExternal('https://github.com/netbirdio/netbird');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Version: 0.1.0',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
// Initialize gRPC client
|
||||
daemonClient = new DaemonClient(DAEMON_ADDR);
|
||||
|
||||
// Create tray
|
||||
await createTray();
|
||||
|
||||
// Create window
|
||||
await createWindow();
|
||||
|
||||
// Start status polling
|
||||
startStatusPolling();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Don't quit on window close - run in background
|
||||
if (process.platform !== 'darwin') {
|
||||
// Keep running
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
// Status polling
|
||||
function startStatusPolling() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const status = await daemonClient.getStatus();
|
||||
const connected = status === 'Connected';
|
||||
|
||||
// Update tray icon
|
||||
const iconName = connected ? 'tray-icon-connected' : 'tray-icon-disconnected';
|
||||
const iconPath = path.join(__dirname, `../../assets/${iconName}.png`);
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
tray?.setImage(icon.resize({ width: 22, height: 22 }));
|
||||
tray?.setToolTip(`NetBird - ${status}`);
|
||||
|
||||
// Update tray menu
|
||||
updateTrayMenu(connected);
|
||||
|
||||
// Send status to renderer
|
||||
mainWindow?.webContents.send('status-update', { status, connected });
|
||||
} catch (error) {
|
||||
console.error('Status poll error:', error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.handle('daemon:status', async () => {
|
||||
try {
|
||||
return await daemonClient.getStatus();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:status\':', error);
|
||||
throw new Error(error.message || error.details || 'Failed to get status');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:getFullStatus', async () => {
|
||||
try {
|
||||
return await daemonClient.getFullStatus();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:getFullStatus\':', error);
|
||||
throw new Error(error.message || error.details || 'Failed to get full status');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:up', async () => {
|
||||
try {
|
||||
return await daemonClient.up();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:up\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to connect');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:down', async () => {
|
||||
try {
|
||||
return await daemonClient.down();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:down\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to disconnect');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:getConfig', async () => {
|
||||
try {
|
||||
return await daemonClient.getConfig();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:getConfig\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to get config');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:updateConfig', async (_, config) => {
|
||||
try {
|
||||
return await daemonClient.updateConfig(config);
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:updateConfig\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to update config');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:listNetworks', async () => {
|
||||
return await daemonClient.listNetworks();
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:selectNetworks', async (_, networkIds: string[]) => {
|
||||
return await daemonClient.selectNetworks(networkIds);
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:deselectNetworks', async (_, networkIds: string[]) => {
|
||||
return await daemonClient.deselectNetworks(networkIds);
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:listProfiles', async () => {
|
||||
return await daemonClient.listProfiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:getActiveProfile', async () => {
|
||||
return await daemonClient.getActiveProfile();
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:switchProfile', async (_, profileId: string) => {
|
||||
return await daemonClient.switchProfile(profileId);
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:addProfile', async (_, profileName: string) => {
|
||||
return await daemonClient.addProfile(profileName);
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:removeProfile', async (_, profileId: string) => {
|
||||
return await daemonClient.removeProfile(profileId);
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:logout', async () => {
|
||||
try {
|
||||
return await daemonClient.logout();
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:logout\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to logout');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:login', async (_, setupKey?: string) => {
|
||||
try {
|
||||
return await daemonClient.login(setupKey);
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:login\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to initiate login');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('daemon:waitSSOLogin', async (_, userCode: string) => {
|
||||
try {
|
||||
return await daemonClient.waitSSOLogin(userCode);
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'daemon:waitSSOLogin\':', error);
|
||||
throw new Error(error.details || error.message || 'Failed to wait for SSO login');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||
try {
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal(url);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Error occurred in handler for \'shell:openExternal\':', error);
|
||||
throw new Error(error.message || 'Failed to open URL');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:getActiveProfile', async () => {
|
||||
const profileName = readActiveProfileFromFS();
|
||||
if (!profileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileState = readProfileState(profileName);
|
||||
return {
|
||||
id: profileName,
|
||||
name: profileName,
|
||||
email: profileState?.email || '',
|
||||
active: true,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:setActiveProfile', async (_, profileName: string) => {
|
||||
try {
|
||||
const configDir = getNetBirdConfigDir();
|
||||
|
||||
// Create config directory if it doesn't exist
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const activeProfilePath = path.join(configDir, 'active_profile.txt');
|
||||
fs.writeFileSync(activeProfilePath, profileName, 'utf-8');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error writing active profile to filesystem:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Daemon communication
|
||||
daemon: {
|
||||
getStatus: () => ipcRenderer.invoke('daemon:status'),
|
||||
getFullStatus: () => ipcRenderer.invoke('daemon:getFullStatus'),
|
||||
up: () => ipcRenderer.invoke('daemon:up'),
|
||||
down: () => ipcRenderer.invoke('daemon:down'),
|
||||
getConfig: () => ipcRenderer.invoke('daemon:getConfig'),
|
||||
updateConfig: (config: any) => ipcRenderer.invoke('daemon:updateConfig', config),
|
||||
listNetworks: () => ipcRenderer.invoke('daemon:listNetworks'),
|
||||
selectNetworks: (networkIds: string[]) =>
|
||||
ipcRenderer.invoke('daemon:selectNetworks', networkIds),
|
||||
deselectNetworks: (networkIds: string[]) =>
|
||||
ipcRenderer.invoke('daemon:deselectNetworks', networkIds),
|
||||
listProfiles: () => ipcRenderer.invoke('daemon:listProfiles'),
|
||||
getActiveProfile: () => ipcRenderer.invoke('daemon:getActiveProfile'),
|
||||
switchProfile: (profileId: string) =>
|
||||
ipcRenderer.invoke('daemon:switchProfile', profileId),
|
||||
addProfile: (profileName: string) =>
|
||||
ipcRenderer.invoke('daemon:addProfile', profileName),
|
||||
removeProfile: (profileId: string) =>
|
||||
ipcRenderer.invoke('daemon:removeProfile', profileId),
|
||||
logout: () => ipcRenderer.invoke('daemon:logout'),
|
||||
login: (setupKey?: string) => ipcRenderer.invoke('daemon:login', setupKey),
|
||||
waitSSOLogin: (userCode: string) => ipcRenderer.invoke('daemon:waitSSOLogin', userCode),
|
||||
},
|
||||
|
||||
// Shell operations
|
||||
shell: {
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
},
|
||||
|
||||
// Filesystem operations
|
||||
fs: {
|
||||
getActiveProfile: () => ipcRenderer.invoke('fs:getActiveProfile'),
|
||||
},
|
||||
|
||||
// Event listeners
|
||||
onStatusUpdate: (callback: (data: any) => void) => {
|
||||
ipcRenderer.on('status-update', (_, data) => callback(data));
|
||||
},
|
||||
onNavigate: (callback: (path: string) => void) => {
|
||||
ipcRenderer.on('navigate', (_, path) => callback(path));
|
||||
},
|
||||
});
|
||||
|
||||
// Type definitions for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
daemon: {
|
||||
getStatus: () => Promise<string>;
|
||||
getFullStatus: () => Promise<any>;
|
||||
up: () => Promise<void>;
|
||||
down: () => Promise<void>;
|
||||
getConfig: () => Promise<any>;
|
||||
updateConfig: (config: any) => Promise<void>;
|
||||
listNetworks: () => Promise<any[]>;
|
||||
selectNetworks: (networkIds: string[]) => Promise<void>;
|
||||
deselectNetworks: (networkIds: string[]) => Promise<void>;
|
||||
listProfiles: () => Promise<any[]>;
|
||||
getActiveProfile: () => Promise<any>;
|
||||
switchProfile: (profileId: string) => Promise<void>;
|
||||
addProfile: (profileName: string) => Promise<void>;
|
||||
removeProfile: (profileId: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
login: (setupKey?: string) => Promise<{
|
||||
needsSSOLogin: boolean;
|
||||
userCode?: string;
|
||||
verificationURI?: string;
|
||||
verificationURIComplete?: string;
|
||||
}>;
|
||||
waitSSOLogin: (userCode: string) => Promise<{ email: string }>;
|
||||
};
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
};
|
||||
fs: {
|
||||
getActiveProfile: () => Promise<any>;
|
||||
};
|
||||
onStatusUpdate: (callback: (data: any) => void) => void;
|
||||
onNavigate: (callback: (path: string) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user