Files
kopia/app/public/electron.js

726 lines
18 KiB
JavaScript

import {
app,
BrowserWindow,
Notification,
screen,
Menu,
Tray,
ipcMain,
dialog,
shell,
} from "electron";
import pkg from "electron-updater";
const autoUpdater = pkg.autoUpdater;
import { iconsPath, publicPath, selectByOS } from "./utils.js";
import {
toggleLaunchAtStartup,
willLaunchAtStartup,
refreshWillLaunchAtStartup,
} from "./auto-launch.js";
import {
setNotificationLevel,
getNotificationLevel,
LevelDisabled,
LevelWarningsAndErrors,
} from "./notifications.js";
import { serverForRepo } from "./server.js";
import {
loadConfigs,
allConfigs,
deleteConfigIfDisconnected,
addNewConfig,
configDir,
isFirstRun,
isPortableConfig,
} from "./config.js";
import Store from "electron-store";
import log from "electron-log";
import path from "path";
import crypto from "crypto";
// Store to save parameters
const store = new Store();
app.name = "KopiaUI";
let tray = null;
let repositoryWindows = {};
let repoIDForWebContents = {};
if (process.env.KOPIA_CUSTOM_APPDATA) {
app.setPath("appData", process.env.KOPIA_CUSTOM_APPDATA);
}
if (isPortableConfig()) {
// in portable mode, write cache under 'repositories'
app.setPath("userData", path.join(configDir(), "cache"));
}
/**
* Stores the ids of the currently connected displays.
* The ids are sorted to generate a hash that specifies the current display configuration
* @returns A hash of the configuration
*/
function getDisplayConfiguration() {
// Stores the IDs all all currently connected displays
let config = [];
let sha256 = crypto.createHash("sha256");
// Get all displays
let displays = screen.getAllDisplays();
let isFactorEqual = false;
// Stores the previous factor - initialized with the primary scaling factor
let prevFactor = screen.getPrimaryDisplay().scaleFactor;
//Workaround until https://github.com/electron/electron/issues/10862 is fixed
for (let dsp in displays) {
// Add the id to the config
config.push(displays[dsp].id);
isFactorEqual = prevFactor === displays[dsp].scaleFactor;
// Update the previous factors
prevFactor = displays[dsp].scaleFactor;
}
// Sort IDs to prevent different hashes through permutation
config.sort();
sha256.update(config.toString());
return { hash: sha256.digest("hex"), factorsEqual: isFactorEqual };
}
/**
* Creates a repository window with given options and parameters
* @param {*} repositoryID
* The id for that specific repository used as a reference for that window
*/
function showRepoWindow(repositoryID) {
let primaryScreenBounds = screen.getPrimaryDisplay().bounds;
if (repositoryWindows[repositoryID]) {
repositoryWindows[repositoryID].focus();
return;
}
let windowOptions = {
title: "KopiaUI is Loading...",
// default width
width: 1000,
// default height
height: 700,
// default x location
x: (primaryScreenBounds.width - 1000) / 2,
// default y location
y: (primaryScreenBounds.height - 700) / 2,
autoHideMenuBar: true,
resizable: true,
show: false,
webPreferences: {
preload: path.join(publicPath(), "preload.js"),
},
};
// The bounds of the windows
let configuration = getDisplayConfiguration();
let winBounds = store.get(configuration.hash);
let maximized = store.get("maximized");
if (configuration.factorsEqual) {
Object.assign(windowOptions, winBounds);
}
// Create the browser window
let repositoryWindow = new BrowserWindow(windowOptions);
// If the window was maximized, maximize it
if (maximized) {
repositoryWindow.maximize();
}
const webContentsID = repositoryWindow.webContents.id;
repositoryWindows[repositoryID] = repositoryWindow;
repoIDForWebContents[webContentsID] = repositoryID;
// Failed to load the content, retry
repositoryWindow.webContents.on("did-fail-load", () => {
log.error("failed to load content");
// schedule another attempt in 0.5s
if (repositoryWindows[repositoryID]) {
setTimeout(() => {
log.info("reloading");
repositoryWindows[repositoryID].loadURL(
serverForRepo(repositoryID).getServerAddress() +
"/?ts=" +
new Date().valueOf(),
);
}, 500);
}
});
repositoryWindow.loadURL(
serverForRepo(repositoryID).getServerAddress() +
"/?ts=" +
new Date().valueOf(),
);
updateDockIcon();
/**
* Store the window size, height and position on close
*/
repositoryWindow.on("close", function () {
store.set(getDisplayConfiguration().hash, repositoryWindow.getBounds());
store.set("maximized", repositoryWindow.isMaximized());
});
/**
* Show the window once the content is ready
*/
repositoryWindow.once("ready-to-show", function () {
repositoryWindow.show();
});
/**
* Delete references to the repository window
*/
repositoryWindow.on("closed", function () {
// Delete the reference to the window
repositoryWindow = null;
delete repositoryWindows[repositoryID];
delete repoIDForWebContents[webContentsID];
const s = serverForRepo(repositoryID);
if (deleteConfigIfDisconnected(repositoryID)) {
s.stopServer();
}
updateDockIcon();
});
}
// Check if another instance of kopia is running
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", (_event, _commandLine, _workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
for (let repositoryID in repositoryWindows) {
let rw = repositoryWindows[repositoryID];
if (rw.isMinimized()) {
rw.restore();
}
rw.focus();
}
});
}
app.on("will-quit", function () {
allConfigs().forEach((repositoryID) =>
serverForRepo(repositoryID).stopServer(),
);
});
app.on("login", (event, webContents, _request, _authInfo, callback) => {
const repositoryID = repoIDForWebContents[webContents.id];
// intercept password prompts and automatically enter password that the server has printed for us.
const password = serverForRepo(repositoryID).getServerPassword();
if (password) {
event.preventDefault();
log.info("automatically logging in...");
callback("kopia", password);
}
});
app.on(
"certificate-error",
(event, webContents, _url, _error, certificate, callback) => {
const repositoryID = repoIDForWebContents[webContents.id];
// intercept certificate errors and automatically trust the certificate the server has printed for us.
const expected =
"sha256/" +
Buffer.from(
serverForRepo(repositoryID).getServerCertSHA256(),
"hex",
).toString("base64");
if (certificate.fingerprint === expected) {
log.debug("accepting server certificate.");
// On certificate error we disable default behaviour (stop loading the page)
// and we then say "it is all fine - true" to the callback
event.preventDefault();
callback(true);
return;
}
log.warn("certificate error:", certificate.fingerprint, expected);
},
);
/**
* Ignore to let the application run, when all windows are closed
*/
app.on("window-all-closed", function () {});
ipcMain.handle("select-dir", async (_event, _arg) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
});
if (result.filePaths) {
return result.filePaths[0];
} else {
return null;
}
});
ipcMain.handle("browse-dir", async (_event, path) => {
shell.openPath(path);
});
ipcMain.on("server-status-updated", updateTrayContextMenu);
ipcMain.on("launch-at-startup-updated", updateTrayContextMenu);
ipcMain.on("notification-config-updated", updateTrayContextMenu);
let updateAvailableInfo = null;
let updateDownloadStatusInfo = "";
let updateFailed = false;
let checkForUpdatesTriggeredFromUI = false;
// set this environment variable when developing
// to allow offering downgrade to the latest released version.
autoUpdater.allowDowngrade = process.env["KOPIA_UI_ALLOW_DOWNGRADE"] == "1";
// we will be manually triggering download and quit&install.
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
let lastNotifiedVersion = "";
autoUpdater.on("update-available", (a) => {
log.info("update available " + a.version);
updateAvailableInfo = a;
updateDownloadStatusInfo = "";
updateTrayContextMenu();
// do not notify more than once for a particular version.
if (checkForUpdatesTriggeredFromUI) {
dialog
.showMessageBox({
buttons: ["Yes", "No"],
message:
"An updated KopiaUI v" +
a.version +
" is available.\n\nDo you want to install it now?",
})
.then((r) => {
if (r.response == 0) {
installUpdate();
}
});
checkForUpdatesTriggeredFromUI = false;
}
if (lastNotifiedVersion != a.version) {
lastNotifiedVersion = a.version;
const notification = new Notification({
title: "New version of KopiaUI",
body:
"Version v" +
a.version +
" is available.\n\nClick here to download and install it.",
});
notification.on("click", () => installUpdate());
notification.show();
}
});
autoUpdater.on("update-not-available", () => {
updateAvailableInfo = null;
updateDownloadStatusInfo = "";
updateFailed = false;
updateTrayContextMenu();
if (checkForUpdatesTriggeredFromUI) {
dialog.showMessageBox({
buttons: ["OK"],
message: "No updates available.",
});
checkForUpdatesTriggeredFromUI = false;
}
});
autoUpdater.on("download-progress", (progress) => {
if (updateAvailableInfo) {
updateDownloadStatusInfo =
"Downloading Update: v" +
updateAvailableInfo.version +
" (" +
Math.round(progress.percent * 10) / 10.0 +
"%) ...";
updateTrayContextMenu();
}
});
autoUpdater.on("update-downloaded", (_info) => {
updateDownloadStatusInfo =
"Installing Update: v" + updateAvailableInfo.version + " ...";
updateTrayContextMenu();
setTimeout(() => {
try {
autoUpdater.quitAndInstall();
} catch (e) {
log.info("update error", e);
}
updateDownloadStatusInfo = null;
updateFailed = true;
updateTrayContextMenu();
}, 500);
});
autoUpdater.on("error", (a) => {
updateAvailableInfo = null;
updateDownloadStatusInfo = "Error checking for updates.";
log.info("error checking for updates", a);
updateTrayContextMenu();
if (checkForUpdatesTriggeredFromUI) {
dialog.showErrorBox(
"Error checking for updates.",
"There was an error checking for updates, try again later.",
);
checkForUpdatesTriggeredFromUI = false;
}
});
function checkForUpdates() {
updateDownloadStatusInfo = "Checking for update...";
updateAvailableInfo = null;
updateTrayContextMenu();
autoUpdater.checkForUpdates();
}
function checkForUpdatesNow() {
checkForUpdatesTriggeredFromUI = true;
checkForUpdates();
}
function installUpdate() {
updateDownloadStatusInfo = "Downloading and installing update...";
autoUpdater.downloadUpdate();
}
function viewReleaseNotes() {
const ver = updateAvailableInfo.version + "";
if (ver.match(/^\d{8}\./)) {
// kopia-test builds are named yyyymmdd.0.hhmmss
shell.openExternal(
"https://github.com/kopia/kopia-test-builds/releases/v" + ver,
);
} else {
shell.openExternal("https://github.com/kopia/kopia/releases/v" + ver);
}
}
function isOutsideOfApplicationsFolderOnMac() {
if (!app.isPackaged || isPortableConfig()) {
return false;
}
// this method is only available on Mac.
if (!app.isInApplicationsFolder) {
return false;
}
return !app.isInApplicationsFolder();
}
function maybeMoveToApplicationsFolder() {
if (process.env["KOPIA_UI_TESTING"]) {
return;
}
dialog
.showMessageBox({
buttons: ["Yes", "No"],
message:
"For best experience, Kopia needs to be installed in Applications folder.\n\nDo you want to move it now?",
})
.then((r) => {
if (r.response == 0) {
app.moveToApplicationsFolder();
} else {
checkForUpdates();
}
})
.catch((e) => {
log.info(e);
});
}
function updateDockIcon() {
if (process.platform === "darwin") {
let any = false;
for (const _k in repositoryWindows) {
any = true;
}
if (any) {
app.dock.show();
} else {
app.dock.hide();
}
}
}
/**
* Show all repository windows at once
*/
function showAllRepoWindows() {
allConfigs().forEach(showRepoWindow);
}
function safeTrayHandler(ev, h) {
tray.on(ev, () => {
try {
h();
} catch (e) {}
});
}
app.on("ready", () => {
loadConfigs();
if (isPortableConfig()) {
const logDir = path.join(configDir(), "logs");
log.transports.file.resolvePath = (variables) =>
path.join(logDir, variables.fileName);
}
log.transports.console.level = "warn";
log.transports.file.level = "debug";
autoUpdater.logger = log;
// re-check for updates every 24 hours
setInterval(checkForUpdates, 86400000);
tray = new Tray(
path.join(
iconsPath(),
selectByOS({
mac: "kopiaTrayTemplate.png",
win: "kopia-tray.ico",
linux: "kopia-tray.png",
}),
),
);
tray.setToolTip("Kopia");
// hooks exposed to tests
if (process.env["KOPIA_UI_TESTING"]) {
app.testHooks = {
tray: tray,
showRepoWindow: showRepoWindow,
allConfigs: allConfigs,
};
}
safeTrayHandler("click", () => tray.popUpContextMenu());
safeTrayHandler("right-click", () => tray.popUpContextMenu());
safeTrayHandler("double-click", () => showAllRepoWindows());
updateTrayContextMenu();
refreshWillLaunchAtStartup();
updateDockIcon();
allConfigs().forEach((repoID) => serverForRepo(repoID).actuateServer());
if (isFirstRun()) {
// open all repo windows on first run.
showAllRepoWindows();
// on Windows, also show the notification.
if (process.platform === "win32") {
tray.displayBalloon({
title: "Kopia is running in the background",
content: "Click on the system tray icon to open the menu",
});
}
}
if (isOutsideOfApplicationsFolderOnMac()) {
setTimeout(maybeMoveToApplicationsFolder, 1000);
} else {
checkForUpdates();
}
});
function showRepoNotification(e) {
const nl = getNotificationLevel();
if (nl === LevelDisabled) {
// notifications disabled
return;
}
const severity = e.notification.severity;
if (severity < 10 && nl === LevelWarningsAndErrors) {
log.info(
"showRepoNotification",
"skipping notification",
e.notification.subject,
);
return;
}
let urgency = "normal";
if (severity < 0) {
urgency = "low";
} else if (severity >= 10) {
// warnings and errors
urgency = "critical";
} else {
urgency = "normal";
}
const notification = new Notification({
title: e.notification.subject,
body: e.notification.body,
urgency: urgency,
});
notification.on("click", () => showRepoWindow(e.repositoryID));
notification.show();
}
ipcMain.addListener("config-list-updated-event", () => updateTrayContextMenu());
ipcMain.addListener("status-updated-event", () => updateTrayContextMenu());
ipcMain.addListener("repo-notification-event", showRepoNotification);
function addAnotherRepository() {
const repoID = addNewConfig();
serverForRepo(repoID).actuateServer();
showRepoWindow(repoID);
}
function updateTrayContextMenu() {
if (!tray) {
return;
}
let defaultReposTemplates = [];
let additionalReposTemplates = [];
allConfigs().forEach((repoID) => {
const sd = serverForRepo(repoID).getServerStatusDetails();
let desc = "";
if (sd.startingUp) {
desc = "<starting up>";
} else if (!sd.connected) {
if (sd.initTaskID) {
desc = "<initializing>";
} else {
desc = "<not connected>";
}
} else {
desc = sd.description;
}
// put primary repository first.
const collection =
repoID === "repository"
? defaultReposTemplates
: additionalReposTemplates;
collection.push({
label: desc,
click: () => showRepoWindow(repoID),
toolTip: desc + " (" + repoID + ")",
});
});
if (additionalReposTemplates.length > 0) {
additionalReposTemplates.sort((a, b) => a.label.localeCompare(b.label));
}
let autoUpdateMenuItems = [];
if (updateDownloadStatusInfo) {
autoUpdateMenuItems.push({
label: updateDownloadStatusInfo,
enabled: false,
});
} else if (updateAvailableInfo) {
if (updateFailed) {
autoUpdateMenuItems.push({
label:
"Update Failed, click to manually download and install v" +
updateAvailableInfo.version,
click: viewReleaseNotes,
});
} else {
autoUpdateMenuItems.push({
label: "Update Available: v" + updateAvailableInfo.version,
click: viewReleaseNotes,
});
autoUpdateMenuItems.push({
label: "Download And Install...",
click: installUpdate,
});
}
} else {
autoUpdateMenuItems.push({
label: "KopiaUI is up-to-date: " + app.getVersion(),
enabled: false,
});
}
const nl = getNotificationLevel();
let template = defaultReposTemplates
.concat(additionalReposTemplates)
.concat([
{ type: "separator" },
{
label: "Connect To Another Repository...",
click: addAnotherRepository,
},
{ type: "separator" },
{ label: "Check For Updates Now", click: checkForUpdatesNow },
])
.concat(autoUpdateMenuItems)
.concat([
{ type: "separator" },
{
label: "Launch At Startup",
type: "checkbox",
click: toggleLaunchAtStartup,
checked: willLaunchAtStartup(),
},
{
label: "Notifications",
type: "submenu",
submenu: [
{
label: "Enabled",
type: "radio",
click: () => setNotificationLevel(2),
checked: nl === 2,
},
{
label: "Warnings And Errors",
type: "radio",
click: () => setNotificationLevel(1),
checked: nl === 1,
},
{
label: "Disabled",
type: "radio",
click: () => setNotificationLevel(0),
checked: nl === 0,
},
],
},
{ label: "Quit", role: "quit" },
]);
tray.setContextMenu(Menu.buildFromTemplate(template));
}