mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
684 lines
19 KiB
JavaScript
684 lines
19 KiB
JavaScript
const { app, BrowserWindow, ipcMain, Tray, Menu, screen, shell, dialog } = require('electron');
|
|
const path = require('path');
|
|
const { exec } = require('child_process');
|
|
const util = require('util');
|
|
const { DaemonClient } = require('./grpc-client.cjs');
|
|
|
|
const execPromise = util.promisify(exec);
|
|
|
|
// Daemon address - Unix socket on Linux/BSD/macOS, TCP on Windows
|
|
const DAEMON_ADDR = process.platform === 'win32'
|
|
? 'localhost:41731'
|
|
: 'unix:///var/run/netbird.sock';
|
|
|
|
let mainWindow = null;
|
|
let tray = null;
|
|
let daemonClient = null;
|
|
let daemonVersion = '0.0.0';
|
|
|
|
// Parse command line arguments for expert mode
|
|
const expertMode = process.argv.includes('--expert-mode') || process.argv.includes('--expert');
|
|
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 520,
|
|
height: 800,
|
|
resizable: false,
|
|
title: 'NetBird',
|
|
backgroundColor: '#1a1a1a',
|
|
autoHideMenuBar: true,
|
|
frame: true,
|
|
show: false, // Don't show initially
|
|
skipTaskbar: true, // Hide from taskbar
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.cjs'),
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
});
|
|
|
|
// Load the app
|
|
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
|
mainWindow.loadURL('http://localhost:5173');
|
|
mainWindow.webContents.openDevTools(); // Temporarily enabled for debugging
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
|
}
|
|
|
|
// Hide window when it loses focus
|
|
mainWindow.on('blur', () => {
|
|
if (!mainWindow.webContents.isDevToolsOpened()) {
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
}
|
|
|
|
let connectionState = 'disconnected'; // 'disconnected', 'connecting', 'connected', 'disconnecting'
|
|
let pulseState = false; // For pulsating animation
|
|
let pulseInterval = null;
|
|
|
|
function createTray() {
|
|
const iconPath = path.join(__dirname, 'assets', 'netbird-systemtray-disconnected-white-monochrome.png');
|
|
tray = new Tray(iconPath);
|
|
|
|
updateTrayMenu();
|
|
|
|
tray.setToolTip('NetBird - Disconnected');
|
|
|
|
tray.on('click', () => {
|
|
toggleWindow();
|
|
});
|
|
}
|
|
|
|
function getStatusLabel() {
|
|
let indicator = '⚪'; // Gray circle
|
|
let statusText = 'Disconnected';
|
|
|
|
switch (connectionState) {
|
|
case 'disconnected':
|
|
indicator = '⚪';
|
|
statusText = 'Disconnected';
|
|
break;
|
|
case 'connecting':
|
|
indicator = pulseState ? '🟢' : '⚪';
|
|
statusText = 'Connecting...';
|
|
break;
|
|
case 'connected':
|
|
indicator = '🟢';
|
|
statusText = 'Connected';
|
|
break;
|
|
case 'disconnecting':
|
|
indicator = pulseState ? '🟢' : '⚪';
|
|
statusText = 'Disconnecting...';
|
|
break;
|
|
}
|
|
|
|
return `${indicator} ${statusText}`;
|
|
}
|
|
|
|
function startPulseAnimation() {
|
|
if (pulseInterval) {
|
|
clearInterval(pulseInterval);
|
|
}
|
|
|
|
pulseInterval = setInterval(() => {
|
|
pulseState = !pulseState;
|
|
updateTrayMenu();
|
|
}, 500); // Pulse every 500ms
|
|
}
|
|
|
|
function stopPulseAnimation() {
|
|
if (pulseInterval) {
|
|
clearInterval(pulseInterval);
|
|
pulseInterval = null;
|
|
}
|
|
pulseState = false;
|
|
}
|
|
|
|
function setConnectionState(state) {
|
|
connectionState = state;
|
|
|
|
// Start/stop pulse animation based on state
|
|
if (state === 'connecting' || state === 'disconnecting') {
|
|
startPulseAnimation();
|
|
} else {
|
|
stopPulseAnimation();
|
|
}
|
|
|
|
updateTrayMenu();
|
|
updateTrayIcon();
|
|
}
|
|
|
|
async function updateTrayMenu() {
|
|
// Fetch version from daemon
|
|
try {
|
|
const statusInfo = await daemonClient.getStatus();
|
|
if (statusInfo.version) {
|
|
daemonVersion = statusInfo.version;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to get version:', error);
|
|
}
|
|
|
|
const connectDisconnectIcon = connectionState === 'connected' || connectionState === 'disconnecting'
|
|
? path.join(__dirname, 'assets', 'power-off-icon.png')
|
|
: path.join(__dirname, 'assets', 'power-icon.png');
|
|
|
|
const connectDisconnectLabel = connectionState === 'connected' || connectionState === 'disconnecting'
|
|
? 'Disconnect'
|
|
: 'Connect';
|
|
|
|
const menuTemplate = [
|
|
{
|
|
label: getStatusLabel(),
|
|
enabled: false
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: connectDisconnectLabel,
|
|
icon: connectDisconnectIcon,
|
|
enabled: connectionState === 'disconnected' || connectionState === 'connected',
|
|
click: async () => {
|
|
if (connectionState === 'connected') {
|
|
setConnectionState('disconnecting');
|
|
try {
|
|
await daemonClient.down();
|
|
setConnectionState('disconnected');
|
|
} catch (error) {
|
|
console.error('Disconnect error:', error);
|
|
setConnectionState('connected');
|
|
}
|
|
} else if (connectionState === 'disconnected') {
|
|
setConnectionState('connecting');
|
|
try {
|
|
// Step 1: Call login to check if SSO is needed
|
|
console.log('[Tray] Calling login...');
|
|
const loginResp = await daemonClient.login();
|
|
console.log('[Tray] Login response:', loginResp);
|
|
|
|
// Step 2: If SSO login is needed, open browser and wait
|
|
if (loginResp.needsSSOLogin) {
|
|
console.log('[Tray] SSO login required, opening browser...');
|
|
|
|
// Open the verification URL in the default browser
|
|
if (loginResp.verificationURIComplete) {
|
|
await shell.openExternal(loginResp.verificationURIComplete);
|
|
console.log('[Tray] Opened URL:', loginResp.verificationURIComplete);
|
|
}
|
|
|
|
// Wait for user to complete login in browser
|
|
console.log('[Tray] Waiting for SSO login completion...');
|
|
const waitResp = await daemonClient.waitSSOLogin(loginResp.userCode);
|
|
console.log('[Tray] SSO login completed, email:', waitResp.email);
|
|
}
|
|
|
|
// Step 3: Call Up to connect
|
|
console.log('[Tray] Calling Up to connect...');
|
|
await daemonClient.up();
|
|
console.log('[Tray] Connected successfully');
|
|
|
|
setConnectionState('connected');
|
|
} catch (error) {
|
|
console.error('Connect error:', error);
|
|
setConnectionState('disconnected');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Show',
|
|
icon: path.join(__dirname, 'assets', 'netbird-systemtray-disconnected-white-monochrome.png'),
|
|
click: () => {
|
|
showWindow();
|
|
}
|
|
}
|
|
];
|
|
|
|
// Add expert mode menu items
|
|
if (expertMode) {
|
|
menuTemplate.push({ type: 'separator' });
|
|
|
|
// Profiles submenu - load from daemon
|
|
let profiles = [];
|
|
try {
|
|
profiles = await daemonClient.listProfiles();
|
|
} catch (error) {
|
|
console.error('Failed to load profiles:', error);
|
|
}
|
|
|
|
const profilesSubmenu = profiles.map(profile => ({
|
|
label: profile.email ? `${profile.name} (${profile.email})` : profile.name,
|
|
type: 'radio',
|
|
checked: profile.active,
|
|
click: async () => {
|
|
try {
|
|
await daemonClient.switchProfile(profile.name);
|
|
updateTrayMenu(); // Refresh menu after profile switch
|
|
} catch (error) {
|
|
console.error('Failed to switch profile:', error);
|
|
}
|
|
}
|
|
}));
|
|
|
|
profilesSubmenu.push({ type: 'separator' });
|
|
profilesSubmenu.push({
|
|
label: 'Add New Profile...',
|
|
click: () => {
|
|
console.log('Add new profile - TODO: implement dialog');
|
|
// TODO: Show dialog to add new profile
|
|
}
|
|
});
|
|
|
|
menuTemplate.push({
|
|
label: 'Profiles',
|
|
icon: path.join(__dirname, 'assets', 'profiles-icon.png'),
|
|
submenu: profilesSubmenu
|
|
});
|
|
|
|
// Settings submenu - load from daemon
|
|
let config = {};
|
|
try {
|
|
config = await daemonClient.getConfig();
|
|
} catch (error) {
|
|
console.error('Failed to load config:', error);
|
|
// Use defaults if loading fails
|
|
config = {
|
|
autoConnect: false,
|
|
networkMonitor: true,
|
|
disableDns: false,
|
|
blockLanAccess: false,
|
|
};
|
|
}
|
|
|
|
menuTemplate.push({
|
|
label: 'Settings',
|
|
icon: path.join(__dirname, 'assets', 'settings-icon.png'),
|
|
submenu: [
|
|
{
|
|
label: 'Auto Connect',
|
|
type: 'checkbox',
|
|
checked: config.autoConnect || false,
|
|
click: async (menuItem) => {
|
|
console.log('Auto Connect:', menuItem.checked);
|
|
try {
|
|
await daemonClient.updateConfig({ autoConnect: menuItem.checked });
|
|
} catch (error) {
|
|
console.error('Failed to update autoConnect:', error);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
label: 'Network Monitor',
|
|
type: 'checkbox',
|
|
checked: config.networkMonitor !== undefined ? config.networkMonitor : true,
|
|
click: async (menuItem) => {
|
|
console.log('Network Monitor:', menuItem.checked);
|
|
try {
|
|
await daemonClient.updateConfig({ networkMonitor: menuItem.checked });
|
|
} catch (error) {
|
|
console.error('Failed to update networkMonitor:', error);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
label: 'Disable DNS',
|
|
type: 'checkbox',
|
|
checked: config.disableDns || false,
|
|
click: async (menuItem) => {
|
|
console.log('Disable DNS:', menuItem.checked);
|
|
try {
|
|
await daemonClient.updateConfig({ disableDns: menuItem.checked });
|
|
} catch (error) {
|
|
console.error('Failed to update disableDns:', error);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
label: 'Block LAN Access',
|
|
type: 'checkbox',
|
|
checked: config.blockLanAccess || false,
|
|
click: async (menuItem) => {
|
|
console.log('Block LAN Access:', menuItem.checked);
|
|
try {
|
|
await daemonClient.updateConfig({ blockLanAccess: menuItem.checked });
|
|
} catch (error) {
|
|
console.error('Failed to update blockLanAccess:', error);
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Networks button
|
|
menuTemplate.push({
|
|
label: 'Networks',
|
|
icon: path.join(__dirname, 'assets', 'networks-icon.png'),
|
|
click: () => {
|
|
showWindow('networks');
|
|
}
|
|
});
|
|
|
|
// Exit Nodes button
|
|
menuTemplate.push({
|
|
label: 'Exit Nodes',
|
|
icon: path.join(__dirname, 'assets', 'exit-node-icon.png'),
|
|
click: () => {
|
|
showWindow('networks'); // Assuming exit nodes is part of networks tab
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add Debug (available in both modes)
|
|
menuTemplate.push({ type: 'separator' });
|
|
menuTemplate.push({
|
|
label: 'Debug',
|
|
icon: path.join(__dirname, 'assets', 'debug-icon.png'),
|
|
click: () => {
|
|
showWindow('debug');
|
|
}
|
|
});
|
|
|
|
// Add About and Quit
|
|
menuTemplate.push({ type: 'separator' });
|
|
menuTemplate.push({
|
|
label: 'About',
|
|
icon: path.join(__dirname, 'assets', 'info-icon.png'),
|
|
submenu: [
|
|
{
|
|
label: `Version: ${daemonVersion}`,
|
|
icon: path.join(__dirname, 'assets', 'version-icon.png'),
|
|
enabled: false
|
|
},
|
|
{
|
|
label: 'Check for Updates',
|
|
icon: path.join(__dirname, 'assets', 'refresh-icon.png'),
|
|
click: () => {
|
|
// TODO: Implement update check
|
|
console.log('Checking for updates...');
|
|
}
|
|
}
|
|
]
|
|
});
|
|
menuTemplate.push({ type: 'separator' });
|
|
menuTemplate.push({
|
|
label: 'Quit',
|
|
icon: path.join(__dirname, 'assets', 'quit-icon.png'),
|
|
click: () => {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
|
tray.setContextMenu(contextMenu);
|
|
}
|
|
|
|
function updateTrayIcon() {
|
|
let iconName = 'netbird-systemtray-disconnected-white-monochrome.png';
|
|
let tooltip = 'NetBird - Disconnected';
|
|
|
|
switch (connectionState) {
|
|
case 'disconnected':
|
|
iconName = 'netbird-systemtray-disconnected-white-monochrome.png';
|
|
tooltip = 'NetBird - Disconnected';
|
|
break;
|
|
case 'connecting':
|
|
iconName = 'netbird-systemtray-connecting-white-monochrome.png';
|
|
tooltip = 'NetBird - Connecting...';
|
|
break;
|
|
case 'connected':
|
|
iconName = 'netbird-systemtray-connected-white-monochrome.png';
|
|
tooltip = 'NetBird - Connected';
|
|
break;
|
|
case 'disconnecting':
|
|
iconName = 'netbird-systemtray-connecting-white-monochrome.png';
|
|
tooltip = 'NetBird - Disconnecting...';
|
|
break;
|
|
}
|
|
|
|
const iconPath = path.join(__dirname, 'assets', iconName);
|
|
tray.setImage(iconPath);
|
|
tray.setToolTip(tooltip);
|
|
}
|
|
|
|
async function syncConnectionState() {
|
|
try {
|
|
const statusInfo = await daemonClient.getStatus();
|
|
const daemonStatus = statusInfo.status || 'Disconnected';
|
|
|
|
// Map daemon status to our connection state
|
|
let newState = 'disconnected';
|
|
if (daemonStatus === 'Connected') {
|
|
newState = 'connected';
|
|
} else if (daemonStatus === 'Connecting') {
|
|
newState = 'connecting';
|
|
} else {
|
|
newState = 'disconnected';
|
|
}
|
|
|
|
// Only update if state changed to avoid unnecessary menu rebuilds
|
|
if (newState !== connectionState) {
|
|
console.log(`[Tray] Connection state changed: ${connectionState} -> ${newState}`);
|
|
setConnectionState(newState);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Tray] Failed to sync connection state:', error);
|
|
// On error, assume disconnected
|
|
if (connectionState !== 'disconnected') {
|
|
setConnectionState('disconnected');
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleWindow() {
|
|
if (mainWindow.isVisible()) {
|
|
mainWindow.hide();
|
|
} else {
|
|
showWindow();
|
|
}
|
|
}
|
|
|
|
function showWindow(page) {
|
|
const windowBounds = mainWindow.getBounds();
|
|
const trayBounds = tray.getBounds();
|
|
|
|
// Calculate position (center horizontally under tray icon)
|
|
const x = Math.round(trayBounds.x + (trayBounds.width / 2) - (windowBounds.width / 2));
|
|
const y = Math.round(trayBounds.y + trayBounds.height + 4);
|
|
|
|
mainWindow.setPosition(x, y, false);
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
|
|
// Send page navigation message to renderer if page is specified
|
|
if (page) {
|
|
mainWindow.webContents.send('navigate-to-page', page);
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
// Initialize gRPC client
|
|
daemonClient = new DaemonClient(DAEMON_ADDR);
|
|
|
|
createWindow();
|
|
createTray();
|
|
|
|
// Initialize connection state from daemon
|
|
await syncConnectionState();
|
|
|
|
// Poll daemon status every 3 seconds to keep tray updated
|
|
setInterval(async () => {
|
|
await syncConnectionState();
|
|
}, 3000);
|
|
});
|
|
|
|
app.on('window-all-closed', (e) => {
|
|
// Prevent app from quitting - tray app should stay running
|
|
e.preventDefault();
|
|
});
|
|
|
|
// IPC Handlers for NetBird daemon communication via gRPC
|
|
ipcMain.handle('netbird:connect', async () => {
|
|
try {
|
|
// Check if already connected
|
|
const status = await daemonClient.getStatus();
|
|
if (status.status === 'Connected') {
|
|
console.log('Already connected');
|
|
return { success: true };
|
|
}
|
|
|
|
// Step 1: Call login to check if SSO is needed
|
|
console.log('Calling login...');
|
|
const loginResp = await daemonClient.login();
|
|
console.log('Login response:', loginResp);
|
|
|
|
// Step 2: If SSO login is needed, open browser and wait
|
|
if (loginResp.needsSSOLogin) {
|
|
console.log('SSO login required, opening browser...');
|
|
|
|
// Open the verification URL in the default browser
|
|
if (loginResp.verificationURIComplete) {
|
|
const { shell } = require('electron');
|
|
await shell.openExternal(loginResp.verificationURIComplete);
|
|
console.log('Opened URL:', loginResp.verificationURIComplete);
|
|
}
|
|
|
|
// Wait for user to complete login in browser
|
|
console.log('Waiting for SSO login completion...');
|
|
const waitResp = await daemonClient.waitSSOLogin(loginResp.userCode);
|
|
console.log('SSO login completed, email:', waitResp.email);
|
|
}
|
|
|
|
// Step 3: Call Up to connect
|
|
console.log('Calling Up to connect...');
|
|
await daemonClient.up();
|
|
console.log('Connected successfully');
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Connection error:', error);
|
|
throw new Error(error.message || 'Failed to connect');
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:disconnect', async () => {
|
|
try {
|
|
await daemonClient.down();
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:logout', async () => {
|
|
try {
|
|
await daemonClient.logout();
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:status', async () => {
|
|
try {
|
|
const statusInfo = await daemonClient.getStatus();
|
|
return {
|
|
status: statusInfo.status,
|
|
version: statusInfo.version,
|
|
daemon: 'Connected'
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: 'Disconnected',
|
|
version: '0.0.0',
|
|
daemon: 'Disconnected'
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-config', async () => {
|
|
try {
|
|
return await daemonClient.getConfig();
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:update-config', async (event, config) => {
|
|
try {
|
|
await daemonClient.updateConfig(config);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-networks', async () => {
|
|
try {
|
|
// TODO: Implement networks retrieval via gRPC
|
|
return [];
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:toggle-network', async (event, networkId) => {
|
|
try {
|
|
// TODO: Implement network toggle via gRPC
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-profiles', async () => {
|
|
try {
|
|
return await daemonClient.listProfiles();
|
|
} catch (error) {
|
|
console.error('get-profiles error:', error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:switch-profile', async (event, profileId) => {
|
|
try {
|
|
await daemonClient.switchProfile(profileId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:delete-profile', async (event, profileId) => {
|
|
try {
|
|
await daemonClient.removeProfile(profileId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:add-profile', async (event, name) => {
|
|
try {
|
|
await daemonClient.addProfile(name);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:remove-profile', async (event, profileId) => {
|
|
try {
|
|
await daemonClient.removeProfile(profileId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-peers', async () => {
|
|
try {
|
|
return await daemonClient.getPeers();
|
|
} catch (error) {
|
|
console.error('get-peers error:', error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-local-peer', async () => {
|
|
try {
|
|
return await daemonClient.getLocalPeer();
|
|
} catch (error) {
|
|
console.error('get-local-peer error:', error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('netbird:get-expert-mode', async () => {
|
|
return expertMode;
|
|
});
|