Files
homeassistant-desktop/app.js

785 lines
19 KiB
JavaScript

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 Bonjour = require('bonjour-service');
const bonjour = new Bonjour.Bonjour();
const logger = require('electron-log');
const config = require('./config');
const updateUrl = `https://update.iprodanov.com/files`;
autoUpdater.logger = logger;
autoUpdater.setFeedURL({
provider: 'generic',
url: updateUrl,
});
logger.catchErrors();
logger.info(`${app.name} started`);
logger.info(`Platform: ${process.platform} ${process.arch}`);
// hide dock icon on macOS
if (process.platform === 'darwin') {
app.dock.hide();
}
const autoLauncher = new AutoLaunch({ name: 'Home Assistant Desktop' });
const indexFile = `file://${__dirname}/web/index.html`;
const errorFile = `file://${__dirname}/web/error.html`;
let initialized = false;
let autostartEnabled = false;
let forceQuit = false;
let resizeEvent = false;
let mainWindow;
let tray;
let updateCheckerInterval;
let availabilityCheckerInterval;
function registerKeyboardShortcut() {
globalShortcut.register('CommandOrControl+Alt+X', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
showWindow();
}
});
}
function unregisterKeyboardShortcut() {
globalShortcut.unregisterAll();
}
async function useAutoUpdater() {
autoUpdater.on('error', async (message) => {
logger.error('There was a problem updating the application');
logger.error(message);
clearInterval(updateCheckerInterval);
});
autoUpdater.on('update-downloaded', () => {
forceQuit = true;
autoUpdater.quitAndInstall();
});
if (!updateCheckerInterval && config.get('autoUpdate')) {
updateCheckerInterval = setInterval(checkForUpdates, 1000 * 60 * 60 * 4);
}
await checkForUpdates();
}
async function checkForUpdates() {
try {
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error(error);
clearInterval(updateCheckerInterval);
}
}
function checkAutoStart() {
autoLauncher
.isEnabled()
.then((isEnabled) => {
autostartEnabled = isEnabled;
})
.catch((err) => {
logger.error('There was a problem with application auto start');
logger.error(err);
});
}
function availabilityCheck() {
const instance = currentInstance();
if (!instance) {
return;
}
let url = new URL(instance);
const request = net.request(`${url.origin}/auth/providers`);
request.on('response', async (response) => {
if (response.statusCode !== 200) {
logger.error('Response error: ' + response);
await showError(true);
}
});
request.on('error', async (error) => {
logger.error(error);
clearInterval(availabilityCheckerInterval);
availabilityCheckerInterval = null;
await showError(true);
if (config.get('automaticSwitching')) {
checkForAvailableInstance();
}
});
request.end();
}
function changePosition() {
const trayBounds = tray.getBounds();
const windowBounds = mainWindow.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(mainWindow, trayBounds, alignment);
} else {
const { y } = Positioner.calculate(mainWindow.getBounds(), trayBounds, alignment);
mainWindow.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(mainWindow.getBounds(), trayBounds, alignment);
mainWindow.setPosition(x + (taskBarPosition === 'right' && displayWorkArea.x), y);
} else {
const { x } = Positioner.calculate(mainWindow.getBounds(), trayBounds, alignment);
mainWindow.setPosition(x, displayWorkArea.y + displayWorkArea.height - windowBounds.height, false);
}
}
}
function checkForAvailableInstance() {
const instances = config.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 url = new URL(instance);
const request = net.request(`${url.origin}/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: async () => {
await shell.openExternal(currentInstance());
},
},
{
type: 'separator',
},
];
const allInstances = config.get('allInstances');
if (allInstances) {
allInstances.forEach((e) => {
instancesMenu.push({
label: e,
type: 'checkbox',
checked: currentInstance() === e,
click: async () => {
currentInstance(e);
await mainWindow.loadURL(e);
mainWindow.show();
},
});
});
instancesMenu.push(
{
type: 'separator',
},
{
label: 'Add another Instance...',
click: async () => {
config.delete('currentInstance');
await mainWindow.loadURL(indexFile);
mainWindow.show();
},
},
{
label: 'Automatic Switching',
type: 'checkbox',
enabled: config.has('allInstances') && config.get('allInstances').length > 1,
checked: config.get('automaticSwitching'),
click: () => {
config.set('automaticSwitching', !config.get('automaticSwitching'));
},
},
);
} else {
instancesMenu.push({ label: 'Not Connected...', enabled: false });
}
return Menu.buildFromTemplate([
{
label: 'Show/Hide Window',
visible: process.platform === 'linux',
click: () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
showWindow();
}
},
},
{
visible: process.platform === 'linux',
type: 'separator',
},
...instancesMenu,
{
type: 'separator',
},
{
label: 'Hover to Show',
visible: process.platform !== 'linux' && !config.get('detachedMode'),
enabled: !config.get('detachedMode'),
type: 'checkbox',
checked: !config.get('disableHover'),
click: () => {
config.set('disableHover', !config.get('disableHover'));
},
},
{
label: 'Stay on Top',
type: 'checkbox',
checked: config.get('stayOnTop'),
click: () => {
config.set('stayOnTop', !config.get('stayOnTop'));
mainWindow.setAlwaysOnTop(config.get('stayOnTop'));
if (mainWindow.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: config.get('shortcutEnabled'),
click: () => {
config.set('shortcutEnabled', !config.get('shortcutEnabled'));
if (config.get('shortcutEnabled')) {
registerKeyboardShortcut();
} else {
unregisterKeyboardShortcut();
}
},
},
{
type: 'separator',
},
{
label: 'Use detached Window',
type: 'checkbox',
checked: config.get('detachedMode'),
click: async () => {
config.set('detachedMode', !config.get('detachedMode'));
mainWindow.hide();
await createMainWindow(config.get('detachedMode'));
},
},
{
label: 'Use Fullscreen',
type: 'checkbox',
checked: config.get('fullScreen'),
accelerator: 'CommandOrControl+Alt+Return',
click: () => {
toggleFullScreen();
},
},
{
type: 'separator',
},
{
label: `v${app.getVersion()}`,
enabled: false,
},
{
label: 'Automatic Updates',
type: 'checkbox',
checked: config.get('autoUpdate'),
click: async () => {
const currentStatus = config.get('autoUpdate');
config.set('autoUpdate', !currentStatus);
if (currentStatus) {
clearInterval(updateCheckerInterval);
updateCheckerInterval = null;
} else {
await useAutoUpdater();
}
},
},
{
label: 'Open on github.com',
click: async () => {
await shell.openExternal('https://github.com/iprodanovbg/homeassistant-desktop');
},
},
{
type: 'separator',
},
{
label: 'Restart Application',
click: () => {
app.relaunch();
app.exit();
},
},
{
label: '⚠️ Reset Application',
click: () => {
dialog
.showMessageBox({
message: 'Are you sure you want to reset Home Assistant Desktop?',
buttons: ['Reset Everything!', 'Reset Windows', 'Cancel'],
})
.then(async (res) => {
if (res.response !== 2) {
if (res.response === 0) {
config.clear();
await mainWindow.webContents.session.clearCache();
await mainWindow.webContents.session.clearStorageData();
} else {
config.delete('windowSizeDetached');
config.delete('windowSize');
config.delete('windowPosition');
config.delete('fullScreen');
config.delete('detachedMode');
}
app.relaunch();
app.exit();
}
});
},
},
{
type: 'separator',
},
{
label: 'Quit',
click: () => {
forceQuit = true;
app.quit();
},
},
]);
}
async function createMainWindow(show = false) {
logger.info('Initialized main window');
mainWindow = new BrowserWindow({
width: 420,
height: 460,
minWidth: 420,
minHeight: 460,
show: false,
skipTaskbar: !show,
autoHideMenuBar: true,
frame: config.get('detachedMode') && process.platform !== 'darwin',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
// mainWindow.webContents.openDevTools();
await mainWindow.loadURL(indexFile);
createTray();
// open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// hide scrollbar
mainWindow.webContents.on('did-finish-load', async function () {
await mainWindow.webContents.insertCSS('::-webkit-scrollbar { display: none; } body { -webkit-user-select: none; }');
if (config.get('detachedMode') && process.platform === 'darwin') {
await mainWindow.webContents.insertCSS('body { -webkit-app-region: drag; }');
}
// let code = `document.addEventListener('mousemove', () => { ipcRenderer.send('mousemove'); });`;
// mainWindow.webContents.executeJavaScript(code);
});
if (config.get('detachedMode')) {
if (config.has('windowPosition')) {
mainWindow.setSize(...config.get('windowSizeDetached'));
} else {
config.set('windowPosition', mainWindow.getPosition());
}
if (config.has('windowSizeDetached')) {
mainWindow.setPosition(...config.get('windowPosition'));
} else {
config.set('windowSizeDetached', mainWindow.getSize());
}
} else if (config.has('windowSize')) {
mainWindow.setSize(...config.get('windowSize'));
} else {
config.set('windowSize', mainWindow.getSize());
}
mainWindow.on('resize', (e) => {
// ignore resize event when using fullscreen mode
if (mainWindow.isFullScreen()) {
return e;
}
if (!config.get('disableHover') || resizeEvent) {
config.set('disableHover', true);
resizeEvent = e;
setTimeout(() => {
if (resizeEvent === e) {
config.set('disableHover', false);
resizeEvent = false;
}
}, 600);
}
if (config.get('detachedMode')) {
config.set('windowSizeDetached', mainWindow.getSize());
} else {
if (process.platform !== 'linux') {
changePosition();
}
config.set('windowSize', mainWindow.getSize());
}
});
mainWindow.on('move', () => {
if (config.get('detachedMode')) {
config.set('windowPosition', mainWindow.getPosition());
}
});
mainWindow.on('close', (e) => {
if (!forceQuit) {
mainWindow.hide();
e.preventDefault();
}
});
mainWindow.on('blur', () => {
if (!config.get('detachedMode') && !mainWindow.isAlwaysOnTop()) {
mainWindow.hide();
}
});
mainWindow.setAlwaysOnTop(!!config.get('stayOnTop'));
if (initialized && (mainWindow.isAlwaysOnTop() || show)) {
showWindow();
}
toggleFullScreen(!!config.get('fullScreen'));
initialized = true;
}
async function reinitMainWindow() {
logger.info('Re-initialized main window');
mainWindow.destroy();
mainWindow = null;
await createMainWindow(!config.has('currentInstance'));
if (!availabilityCheckerInterval) {
logger.info('Re-initialized availability check');
availabilityCheckerInterval = setInterval(availabilityCheck, 3000);
}
}
function showWindow() {
if (!config.get('detachedMode')) {
changePosition();
}
if (!mainWindow.isVisible()) {
mainWindow.setVisibleOnAllWorkspaces(true); // put the window on all screens
mainWindow.show();
mainWindow.focus();
mainWindow.setVisibleOnAllWorkspaces(false); // disable all screen behavior
mainWindow.setSkipTaskbar(!config.get("detachedMode"));
}
}
function createTray() {
if (tray instanceof Tray) {
return;
}
logger.info('Initialized Tray menu');
tray = new Tray(
['win32', 'linux'].includes(process.platform) ? `${__dirname}/assets/IconWin.png` : `${__dirname}/assets/IconTemplate.png`,
);
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
if (process.platform === 'darwin') {
app.dock.hide();
}
} else {
showWindow();
}
});
tray.on('right-click', () => {
if (!config.get('detachedMode')) {
mainWindow.hide();
}
tray.popUpContextMenu(getMenu());
});
let timer = undefined;
tray.on('mouse-move', () => {
if (config.get('detachedMode') || mainWindow.isAlwaysOnTop() || config.get('disableHover')) {
return;
}
if (!mainWindow.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 = mainWindow.getPosition();
let windowSize = mainWindow.getSize();
if (
!resizeEvent &&
(
!(mousePos.x >= windowPosition[ 0 ] && mousePos.x <= windowPosition[ 0 ] + windowSize[ 0 ]) ||
!(mousePos.y >= windowPosition[ 1 ] && mousePos.y <= windowPosition[ 1 ] + windowSize[ 1 ])
)
) {
mainWindow.hide();
} else {
setWindowFocusTimer();
}
}, 110);
}
function toggleFullScreen(mode = !mainWindow.isFullScreen()) {
config.set('fullScreen', mode);
mainWindow.setFullScreen(mode);
if (mode) {
mainWindow.setAlwaysOnTop(true);
} else {
mainWindow.setAlwaysOnTop(config.get('stayOnTop'));
}
}
function currentInstance(url = null) {
if (url) {
config.set('currentInstance', config.get('allInstances').indexOf(url));
}
if (config.has('currentInstance')) {
return config.get('allInstances')[ config.get('currentInstance') ];
}
return false;
}
function addInstance(url) {
if (!config.has('allInstances')) {
config.set('allInstances', []);
}
let instances = config.get('allInstances');
if (instances.find((e) => e === url)) {
currentInstance(url);
return;
}
// active hover by default after adding first instance
if (!instances.length) {
config.set('disableHover', false);
}
instances.push(url);
config.set('allInstances', instances);
currentInstance(url);
}
async function showError(isError) {
if (!isError && mainWindow.webContents.getURL().includes('error.html')) {
await mainWindow.loadURL(indexFile);
}
if (isError && currentInstance() && !mainWindow.webContents.getURL().includes('error.html')) {
await mainWindow.loadURL(errorFile);
}
}
app.whenReady().then(async () => {
await useAutoUpdater();
checkAutoStart();
await createMainWindow(!config.has('currentInstance'));
if (process.platform === 'linux') {
tray.setContextMenu(getMenu());
}
if (!availabilityCheckerInterval) {
logger.info('Initialized availability check');
availabilityCheckerInterval = setInterval(availabilityCheck, 3000);
}
// register shortcut
if (config.get('shortcutEnabled')) {
registerKeyboardShortcut();
}
globalShortcut.register('CommandOrControl+Alt+Return', () => {
toggleFullScreen();
});
// disable hover for first start
if (!config.has('currentInstance')) {
config.set('disableHover', true);
}
// enable auto update by default
if (!config.has('autoUpdate')) {
config.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', config.get('allInstances') || []);
});
ipcMain.on('ha-instance', (event, url) => {
if (url) {
addInstance(url);
}
if (currentInstance()) {
event.reply('ha-instance', currentInstance());
}
});
ipcMain.on('reconnect', async () => {
await reinitMainWindow();
});
ipcMain.on('restart', () => {
app.relaunch();
app.exit();
});