Add prototype UI clients

This commit is contained in:
pascal
2025-11-01 12:17:02 +01:00
parent 96f71ff1e1
commit 76b1003810
180 changed files with 44281 additions and 18 deletions
+308
View File
@@ -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 || '';
}
}
+584
View File
@@ -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;
}
});
+89
View File
@@ -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;
};
}
}