diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..d389625 Binary files /dev/null and b/bun.lockb differ diff --git a/scripts/fetch-netbird-assets.mjs b/scripts/fetch-netbird-assets.mjs index a047ad0..8d27f97 100644 --- a/scripts/fetch-netbird-assets.mjs +++ b/scripts/fetch-netbird-assets.mjs @@ -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" } diff --git a/src/main.ts b/src/main.ts index 98b9bfa..242b7e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -55,6 +55,7 @@ app.on('activate', () => { // 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; @@ -103,7 +104,8 @@ function updateTray() { }, { role: 'quit' } ); - tray.setContextMenu(Menu.buildFromTemplate(template)); + trayMenu = Menu.buildFromTemplate(template); + tray.setContextMenu(process.platform === 'darwin' ? null : trayMenu); } function setStatus(status: NetworkStatus) { @@ -141,11 +143,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.` }); } @@ -225,11 +227,24 @@ 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); updateTray(); - tray.on('click', showWindow); + tray.on('click', toggleWindow); + tray.on('right-click', () => { + window.hide(); + tray.popUpContextMenu(trayMenu); + }); } ipcMain.handle('status:get', () => withAutoConnect(cachedStatus)); @@ -243,4 +258,4 @@ ipcMain.handle('auto:toggle', () => { return autoConnect; }); ipcMain.handle('app:quit', () => app.quit()); -ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL)); \ No newline at end of file +ipcMain.handle('external:openGateway', () => shell.openExternal(MANAGEMENT_URL)); diff --git a/src/netbird.ts b/src/netbird.ts index 98b0dbb..bef0c1a 100644 --- a/src/netbird.ts +++ b/src/netbird.ts @@ -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> { + runElevated(args: string[]): Promise { + 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 { + async ensureService(): Promise { 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(resolve => setTimeout(resolve, 500)); } - return false; + return { + ok: false, + message: [ + serviceAlreadyInstalled ? installDetails : '', + reconfigureDetails, + start.ok ? '' : startDetails, + lastStatus + ].filter(Boolean).join('\n\n') + }; } connect(): Promise { @@ -122,4 +209,4 @@ export class NetBirdClient { MANAGEMENT_URL ], { timeout: 60000 }); } -} \ No newline at end of file +}