const { app, dialog, ipcMain, shell, globalShortcut, screen, net, Menu, Tray, BrowserWindow, } = require('electron'); const { autoUpdater } = require('electron-updater'); const AutoLaunch = require('auto-launch'); const Positioner = require('electron-traywindow-positioner'); const Store = require('electron-store'); const bonjour = require('bonjour')(); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); app.allowRendererProcessReuse = true; // prevent multiple instances if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on('second-instance', () => { if (window) { showWindow(); } }); } // hide dock icon on macOS if (process.platform === 'darwin') { app.dock.hide(); } const store = new Store(); const autoLauncher = new AutoLaunch({ name: 'Home Assistant Desktop' }); const indexFile = `file://${__dirname}/web/index.html`; const errorFile = `file://${__dirname}/web/error.html`; let autostartEnabled = false; let forceQuit = false; let resizeEvent = false; function registerKeyboardShortcut() { globalShortcut.register('CommandOrControl+Alt+X', () => { if (window.isVisible()) { window.hide(); } else { showWindow(); } }); } function unregisterKeyboardShortcut() { globalShortcut.unregisterAll(); } function useAutoUpdater() { autoUpdater.on('error', (message) => { console.error('There was a problem updating the application'); console.error(message); }); autoUpdater.on('update-downloaded', () => { forceQuit = true; autoUpdater.quitAndInstall(); }); setInterval(() => { if (store.get('autoUpdate')) { autoUpdater.checkForUpdates(); } }, 1000 * 60 * 60); if (store.get('autoUpdate')) { autoUpdater.checkForUpdates(); } } function checkAutoStart() { autoLauncher .isEnabled() .then((isEnabled) => { autostartEnabled = isEnabled; }) .catch((err) => { console.error(err); }); } function startAvailabilityCheck() { let interval; function availabilityCheck() { const request = net.request(`${currentInstance()}/auth/providers`); request.on('response', (response) => { showError(response.statusCode !== 200); }); request.on('error', (error) => { clearInterval(interval); showError(true); if (store.get('automaticSwitching')) { checkForAvailableInstance(); } }); request.end(); } interval = setInterval(availabilityCheck, 3000); }; function changePosition() { const trayBounds = tray.getBounds(); const windowBounds = window.getBounds(); const displayWorkArea = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y, }).workArea; const taskBarPosition = Positioner.getTaskbarPosition(trayBounds); if (taskBarPosition === 'top' || taskBarPosition === 'bottom') { const alignment = { x: 'center', y: taskBarPosition === 'top' ? 'up' : 'down', }; if (trayBounds.x + (trayBounds.width + windowBounds.width) / 2 < displayWorkArea.width) { Positioner.position(window, trayBounds, alignment); } else { const { y } = Positioner.calculate(window.getBounds(), trayBounds, alignment); window.setPosition( displayWorkArea.width - windowBounds.width + displayWorkArea.x, y + (taskBarPosition === 'bottom' && displayWorkArea.y), false, ); } } else { const alignment = { x: taskBarPosition, y: 'center', }; if (trayBounds.y + (trayBounds.height + windowBounds.height) / 2 < displayWorkArea.height) { const { x, y } = Positioner.calculate(window.getBounds(), trayBounds, alignment); window.setPosition(x + (taskBarPosition === 'right' && displayWorkArea.x), y); } else { const { x } = Positioner.calculate(window.getBounds(), trayBounds, alignment); window.setPosition(x, displayWorkArea.y + displayWorkArea.height - windowBounds.height, false); } } } function checkForAvailableInstance() { const instances = store.get('allInstances'); if (instances?.length > 1) { bonjour.find({ type: 'home-assistant' }, (instance) => { if (instance.txt.internal_url && instances.indexOf(instance.txt.internal_url) !== -1) { return currentInstance(instance.txt.internal_url); } if (instance.txt.external_url && instances.indexOf(instance.txt.external_url) !== -1) { return currentInstance(instance.txt.external_url); } }); let found; for (let instance of instances.filter((e) => e.url !== currentInstance())) { const request = net.request(`${instance}/auth/providers`); request.on('response', (response) => { if (response.statusCode === 200) { found = instance; } }); request.on('error', (_) => { }); request.end(); if (found) { currentInstance(found); break; } } } } function getMenu() { let instancesMenu = [ { label: 'Open in Browser', enabled: currentInstance(), click: () => { shell.openExternal(currentInstance()); }, }, { type: 'separator', }, ]; const allInstances = store.get('allInstances'); if (allInstances) { allInstances.forEach((e) => { instancesMenu.push({ label: e, type: 'checkbox', checked: currentInstance() === e, click: () => { currentInstance(e); window.loadURL(e); window.show(); }, }); }); instancesMenu.push( { type: 'separator', }, { label: 'Add another Instance...', click: () => { store.delete('currentInstance'); window.loadURL(indexFile); window.show(); }, }, { label: 'Automatic Switching', type: 'checkbox', enabled: store.has('allInstances') && store.get('allInstances').length > 1, checked: store.get('automaticSwitching'), click: () => { store.set('automaticSwitching', !store.get('automaticSwitching')); }, }, ); } else { instancesMenu.push({ label: 'Not Connected...', enabled: false }); } return Menu.buildFromTemplate([ { label: 'Show/Hide Window', visible: process.platform === 'linux', click: () => { if (window.isVisible()) { window.hide(); } else { showWindow(); } }, }, { visible: process.platform === 'linux', type: 'separator', }, ...instancesMenu, { type: 'separator', }, { label: 'Hover to Show', visible: process.platform !== 'linux' && !store.get('detachedMode'), enabled: !store.get('detachedMode'), type: 'checkbox', checked: !store.get('disableHover'), click: () => { store.set('disableHover', !store.get('disableHover')); }, }, { label: 'Stay on Top', type: 'checkbox', checked: store.get('stayOnTop'), click: () => { store.set('stayOnTop', !store.get('stayOnTop')); window.setAlwaysOnTop(store.get('stayOnTop')); if (window.isAlwaysOnTop()) { showWindow(); } }, }, { label: 'Start at Login', type: 'checkbox', checked: autostartEnabled, click: () => { if (autostartEnabled) { autoLauncher.disable(); } else { autoLauncher.enable(); } checkAutoStart(); }, }, { label: 'Enable Shortcut', type: 'checkbox', accelerator: 'CommandOrControl+Alt+X', checked: store.get('shortcutEnabled'), click: () => { store.set('shortcutEnabled', !store.get('shortcutEnabled')); if (store.get('shortcutEnabled')) { registerKeyboardShortcut(); } else { unregisterKeyboardShortcut(); } }, }, { type: 'separator', }, { label: 'Use detached Window', type: 'checkbox', checked: store.get('detachedMode'), click: () => { store.set('detachedMode', !store.get('detachedMode')); window.hide(); createMainWindow(store.get('detachedMode')); }, }, { label: 'Use Fullscreen', type: 'checkbox', checked: store.get('fullScreen'), accelerator: 'CommandOrControl+Alt+Return', click: () => { toggleFullScreen(); }, }, { type: 'separator', }, { label: `v${app.getVersion()}`, enabled: false, }, { label: 'Automatic Updates', type: 'checkbox', checked: store.get('autoUpdate'), click: () => { store.set('autoUpdate', !store.get('autoUpdate')); }, }, { label: 'Open on github.com', click: () => { shell.openExternal('https://github.com/iprodanovbg/homeassistant-desktop'); }, }, { type: 'separator', }, { label: 'Reload Window', click: () => { window.reload(); window.show(); window.focus(); }, }, { label: 'Reset Application...', click: () => { dialog .showMessageBox({ message: 'Are you sure you want to reset Home Assistant Desktop?', buttons: ['Reset Everything!', 'Reset Windows', 'Cancel'], }) .then((res) => { if (res.response === 0) { store.clear(); window.webContents.session.clearCache(); window.webContents.session.clearStorageData(); app.relaunch(); app.exit(); } if (res.response === 1) { store.delete('windowSizeDetached'); store.delete('windowSize'); store.delete('windowPosition'); store.delete('fullScreen'); store.delete('detachedMode'); app.relaunch(); app.exit(); } }); }, }, { type: 'separator', }, { label: 'Quit', click: () => { forceQuit = true; app.quit(); }, }, ]); } function createMainWindow(show = false) { window = new BrowserWindow({ width: 420, height: 420, show: false, skipTaskbar: true, autoHideMenuBar: true, frame: false, webPreferences: { nodeIntegration: true, contextIsolation: false, }, }); // window.webContents.openDevTools(); window.loadURL(indexFile); // open external links in default browser window.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); // hide scrollbar window.webContents.on('did-finish-load', function () { window.webContents.insertCSS('::-webkit-scrollbar { display: none; } body { -webkit-user-select: none; }'); if (store.get('detachedMode') && process.platform === 'darwin') { window.webContents.insertCSS('body { -webkit-app-region: drag; }'); } // let code = `document.addEventListener('mousemove', () => { ipcRenderer.send('mousemove'); });`; // window.webContents.executeJavaScript(code); }); if (store.get('detachedMode')) { if (store.has('windowPosition')) { window.setSize(...store.get('windowSizeDetached')); } else { store.set('windowPosition', window.getPosition()); } if (store.has('windowSizeDetached')) { window.setPosition(...store.get('windowPosition')); } else { store.set('windowSizeDetached', window.getSize()); } } else if (store.has('windowSize')) { window.setSize(...store.get('windowSize')); } else { store.set('windowSize', window.getSize()); } window.on('resize', (e) => { // ignore resize event when using fullscreen mode if (window.isFullScreen()) { return e; } if (!store.get('disableHover') || resizeEvent) { store.set('disableHover', true); resizeEvent = e; setTimeout(() => { if (resizeEvent === e) { store.set('disableHover', false); resizeEvent = false; } }, 600); } if (store.get('detachedMode')) { store.set('windowSizeDetached', window.getSize()); } else { if (process.platform !== 'linux') { changePosition(); } store.set('windowSize', window.getSize()); } }); window.on('move', () => { if (store.get('detachedMode')) { store.set('windowPosition', window.getPosition()); } }); window.on('close', (e) => { if (!forceQuit) { window.hide(); e.preventDefault(); } }); window.on('blur', () => { if (!store.get('detachedMode') && !window.isAlwaysOnTop()) { window.hide(); } }); window.setAlwaysOnTop(!!store.get('stayOnTop')); if (window.isAlwaysOnTop() || show) { showWindow(); } toggleFullScreen(!!store.get('fullScreen')); } function showWindow() { if (!store.get('detachedMode')) { changePosition(); } if (!window.isVisible()) { window.setVisibleOnAllWorkspaces(true); // put the window on all screens window.show(); window.focus(); window.setVisibleOnAllWorkspaces(false); // disable all screen behavior } }; function createTray() { tray = new Tray( ['win32', 'linux'].includes(process.platform) ? `${__dirname}/assets/IconWin.png` : `${__dirname}/assets/IconTemplate.png`, ); tray.on('click', () => { if (window.isVisible()) { window.hide(); } else { showWindow(); } }); tray.on('right-click', () => { if (!store.get('detachedMode')) { window.hide(); } tray.popUpContextMenu(getMenu()); }); let timer = undefined; tray.on('mouse-move', () => { if (store.get('detachedMode') || window.isAlwaysOnTop() || store.get('disableHover')) { return; } if (!window.isVisible()) { showWindow(); } if (timer) { clearTimeout(timer); } timer = setTimeout(() => { let mousePos = screen.getCursorScreenPoint(); let trayBounds = tray.getBounds(); if ( !(mousePos.x >= trayBounds.x && mousePos.x <= trayBounds.x + trayBounds.width) || !(mousePos.y >= trayBounds.y && mousePos.y <= trayBounds.y + trayBounds.height) ) { setWindowFocusTimer(); } }, 100); }); } function setWindowFocusTimer() { setTimeout(() => { let mousePos = screen.getCursorScreenPoint(); let windowPosition = window.getPosition(); let windowSize = window.getSize(); if ( !resizeEvent && ( !(mousePos.x >= windowPosition[ 0 ] && mousePos.x <= windowPosition[ 0 ] + windowSize[ 0 ]) || !(mousePos.y >= windowPosition[ 1 ] && mousePos.y <= windowPosition[ 1 ] + windowSize[ 1 ]) ) ) { window.hide(); } else { setWindowFocusTimer(); } }, 110); } function toggleFullScreen(mode = !window.isFullScreen()) { store.set('fullScreen', mode); window.setFullScreen(mode); if (mode) { window.setAlwaysOnTop(true); } else { window.setAlwaysOnTop(store.get('stayOnTop')); } } function currentInstance(url = null) { if (url) { store.set('currentInstance', store.get('allInstances').indexOf(url)); } if (store.has('currentInstance')) { return store.get('allInstances')[ store.get('currentInstance') ]; } return false; } function addInstance(url) { if (!store.has('allInstances')) { store.set('allInstances', []); } let instances = store.get('allInstances'); if (instances.find((e) => e === url)) { currentInstance(url); return; } // active hover by default after adding first instance if (!instances.length) { store.set('disableHover', false); } instances.push(url); store.set('allInstances', instances); currentInstance(url); } function showError(isError) { if (!isError && window.webContents.getURL().includes('error.html')) { window.loadURL(indexFile); } if (isError && currentInstance() && !window.webContents.getURL().includes('error.html')) { window.loadURL(errorFile); } } app.on('ready', async () => { useAutoUpdater(); checkAutoStart(); createTray(); // workaround for initial window misplacement due to traybounds being incorrect while (tray.getBounds().x === 0 && process.uptime() <= 1) { await delay(15); } createMainWindow(!store.has('currentInstance')); if (process.platform === 'linux') { tray.setContextMenu(getMenu()); } startAvailabilityCheck(); // register shortcut if (store.get('shortcutEnabled')) { registerKeyboardShortcut(); } globalShortcut.register('CommandOrControl+Alt+Return', () => { toggleFullScreen(); }); // disable hover for first start if (!store.has('currentInstance')) { store.set('disableHover', true); } // enable auto update by default if (!store.has('autoUpdate')) { store.set('autoUpdate', true); } }); app.on('will-quit', () => { unregisterKeyboardShortcut(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); ipcMain.on('get-instances', (event) => { event.reply('get-instances', store.get('allInstances') || []); }); ipcMain.on('ha-instance', (event, url) => { if (url) { addInstance(url); } if (currentInstance()) { event.reply('ha-instance', currentInstance()); } });