oxen-electron-gui-wallet/src-electron/main-process/modules/backend.js

740 lines
20 KiB
JavaScript

import { Daemon } from "./daemon";
import { WalletRPC } from "./wallet-rpc";
import { SCEE } from "./SCEE-Node";
import { dialog } from "electron";
import semver from "semver";
import axios from "axios";
import { version } from "../../../package.json";
const bunyan = require("bunyan");
const WebSocket = require("ws");
const electron = require("electron");
const os = require("os");
const fs = require("fs-extra");
const path = require("upath");
const objectAssignDeep = require("object-assign-deep");
const { ipcMain: ipc } = electron;
const LOG_LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
export class Backend {
constructor(mainWindow) {
this.mainWindow = mainWindow;
this.daemon = null;
this.walletd = null;
this.wss = null;
this.token = null;
this.config_dir = null;
this.wallet_dir = null;
this.config_file = null;
this.config_data = {};
this.scee = new SCEE();
this.log = null;
}
init(config) {
let configDir;
let legacyLokiConfigDir;
if (os.platform() === "win32") {
configDir = "C:\\ProgramData\\oxen";
legacyLokiConfigDir = "C:\\ProgramData\\loki\\";
this.wallet_dir = `${os.homedir()}\\Documents\\Oxen`;
} else {
configDir = path.join(os.homedir(), ".oxen");
legacyLokiConfigDir = path.join(os.homedir(), ".loki/");
this.wallet_dir = path.join(os.homedir(), "Oxen");
}
// if the user has used loki before, just keep the same stuff
if (fs.existsSync(legacyLokiConfigDir)) {
this.config_dir = legacyLokiConfigDir;
} else {
// create the new, Oxen location
this.config_dir = configDir;
if (!fs.existsSync(configDir)) {
fs.mkdirpSync(configDir);
}
}
if (!fs.existsSync(path.join(this.config_dir, "gui"))) {
fs.mkdirpSync(path.join(this.config_dir, "gui"));
}
this.config_file = path.join(this.config_dir, "gui", "config.json");
const daemon = {
type: "remote",
p2p_bind_ip: "0.0.0.0",
p2p_bind_port: 22022,
rpc_bind_ip: "127.0.0.1",
rpc_bind_port: 22023,
zmq_rpc_bind_ip: "127.0.0.1",
out_peers: -1,
in_peers: -1,
limit_rate_up: -1,
limit_rate_down: -1,
log_level: 0
};
const daemons = {
mainnet: {
...daemon,
remote_host: "imaginary.stream",
remote_port: 22023
},
stagenet: {
...daemon,
type: "local",
p2p_bind_port: 38153,
rpc_bind_port: 38154
},
testnet: {
...daemon,
type: "local",
p2p_bind_port: 38156,
rpc_bind_port: 38157
}
};
// Default values
this.defaults = {
daemons: objectAssignDeep({}, daemons),
app: {
data_dir: this.config_dir,
wallet_data_dir: this.wallet_dir,
ws_bind_port: 12313,
net_type: "mainnet"
},
wallet: {
rpc_bind_port: 18082,
log_level: 0
}
};
this.config_data = {
// Copy all the properties of defaults
...objectAssignDeep({}, this.defaults),
appearance: {
theme: "dark"
}
};
this.remotes = [
{
host: "imaginary.stream",
port: "22023"
},
{
host: "nodes.hashvault.pro",
port: "22023"
},
{
host: "explorer.loki.aussie-pools.com",
port: "18081"
},
{
host: "public.loki.foundation",
port: "22023"
}
];
this.token = config.token;
this.wss = new WebSocket.Server({
port: config.port,
maxPayload: Number.POSITIVE_INFINITY
});
this.wss.on("connection", ws => {
ws.on("message", data => this.receive(data));
});
}
send(event, data = {}) {
let message = {
event,
data
};
let encrypted_data = this.scee.encryptString(
JSON.stringify(message),
this.token
);
this.wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(encrypted_data);
}
});
}
receive(data) {
let decrypted_data = JSON.parse(this.scee.decryptString(data, this.token));
// route incoming request to either the daemon, wallet, or here
switch (decrypted_data.module) {
case "core":
this.handle(decrypted_data);
break;
case "daemon":
if (this.daemon) {
this.daemon.handle(decrypted_data);
}
break;
case "wallet":
if (this.walletd) {
this.walletd.handle(decrypted_data);
}
break;
}
}
handle(data) {
let params = data.data;
// check if config has changed
let config_changed = false;
switch (data.method) {
case "set_language":
this.send("set_language", { lang: params.lang });
break;
case "quick_save_config":
// save only partial config settings
Object.keys(params).map(key => {
this.config_data[key] = Object.assign(
this.config_data[key],
params[key]
);
});
fs.writeFile(
this.config_file,
JSON.stringify(this.config_data, null, 4),
"utf8",
() => {
this.send("set_app_data", {
config: params,
pending_config: params
});
}
);
break;
case "save_config_init":
case "save_config": {
if (data.method === "save_config") {
Object.keys(this.config_data).map(i => {
if (i == "appearance") return;
Object.keys(this.config_data[i]).map(j => {
if (this.config_data[i][j] !== params[i][j]) {
config_changed = true;
}
});
});
}
Object.keys(params).map(key => {
this.config_data[key] = Object.assign(
this.config_data[key],
params[key]
);
});
const validated = Object.keys(this.defaults)
.filter(k => k in this.config_data)
.map(k => [
k,
this.validate_values(this.config_data[k], this.defaults[k])
])
.reduce((map, obj) => {
map[obj[0]] = obj[1];
return map;
}, {});
// Validate daemon data
this.config_data = {
...this.config_data,
...validated
};
fs.writeFile(
this.config_file,
JSON.stringify(this.config_data, null, 4),
"utf8",
() => {
if (data.method == "save_config_init") {
this.startup();
} else {
this.send("set_app_data", {
config: this.config_data,
pending_config: this.config_data
});
if (config_changed) {
this.send("settings_changed_reboot");
}
}
}
);
break;
}
case "init":
this.startup();
break;
case "open_explorer": {
const { net_type } = this.config_data.app;
let path = null;
if (params.type === "tx") {
path = "tx";
} else if (params.type === "service_node") {
path = "service_node";
}
if (path) {
const baseUrl =
net_type === "testnet"
? "https://lokitestnet.com"
: "https://lokiblocks.com";
const url = `${baseUrl}/${path}/`;
require("electron").shell.openExternal(url + params.id);
}
break;
}
case "open_url":
require("electron").shell.openExternal(params.url);
break;
case "save_png": {
let filename = dialog.showSaveDialog(this.mainWindow, {
title: "Save " + params.type,
filters: [{ name: "PNG", extensions: ["png"] }],
defaultPath: os.homedir()
});
if (filename) {
let base64Data = params.img.replace(/^data:image\/png;base64,/, "");
let binaryData = Buffer.from(base64Data, "base64").toString("binary");
fs.writeFile(filename, binaryData, "binary", err => {
if (err) {
this.send("show_notification", {
type: "negative",
i18n: [
"notification.errors.errorSavingItem",
{ item: params.type }
],
timeout: 2000
});
} else {
this.send("show_notification", {
i18n: [
"notification.positive.itemSaved",
{ item: params.type, filename }
],
timeout: 2000
});
}
});
}
break;
}
default:
break;
}
}
// if the version is a whole minor version out of date (hardfork out of date)
// set update required to true
async checkVersion() {
try {
const { data } = await axios.get(
"https://api.github.com/repos/loki-project/loki-electron-gui-wallet/releases/latest"
);
// remove the 'v' from front of the version
const latestVersion = data.tag_name.substring(1);
// can return "major", "minor", "patch"
const vSizeDiff = semver.diff(version, latestVersion);
const updateAvailable = semver.ltr(version, latestVersion);
const majorOrMinor = vSizeDiff === "major" || vSizeDiff == "minor";
const updateRequired = updateAvailable && majorOrMinor;
this.send("set_update_required", updateRequired);
} catch (e) {
this.send("set_updated_required", false);
}
}
initLogger(logPath) {
let log = bunyan.createLogger({
name: "log",
streams: [
{
type: "rotating-file",
path: path.join(logPath, "electron.log"),
period: "1d", // daily rotation
count: 4 // keep 4 days of logs
}
]
});
LOG_LEVELS.forEach(level => {
ipc.on(`log-${level}`, (first, ...rest) => {
log[level](...rest);
});
});
this.log = log;
process.on("uncaughtException", error => {
log.error("Unhandled Error", error);
});
process.on("unhandledRejection", error => {
log.error("Unhandled Promise Rejection", error);
});
}
startup() {
this.send("set_app_data", {
remotes: this.remotes,
defaults: this.defaults
});
this.checkVersion();
fs.readFile(this.config_file, "utf8", (err, data) => {
if (err) {
this.send("set_app_data", {
status: {
code: -1 // Config not found
},
config: this.config_data,
pending_config: this.config_data
});
return;
}
let disk_config_data = JSON.parse(data);
// semi-shallow object merge
Object.keys(disk_config_data).map(key => {
if (!this.config_data.hasOwnProperty(key)) {
this.config_data[key] = {};
}
this.config_data[key] = Object.assign(
this.config_data[key],
disk_config_data[key]
);
});
// here we may want to check if config data is valid, if not also send code -1
// i.e. check ports are integers and > 1024, check that data dir path exists, etc
const validated = Object.keys(this.defaults)
.filter(k => k in this.config_data)
.map(k => [
k,
this.validate_values(this.config_data[k], this.defaults[k])
])
.reduce((map, obj) => {
map[obj[0]] = obj[1];
return map;
}, {});
// Make sure the daemon data is valid
this.config_data = {
...this.config_data,
...validated
};
// save config file back to file, so updated options are stored on disk
fs.writeFile(
this.config_file,
JSON.stringify(this.config_data, null, 4),
"utf8",
() => {}
);
this.send("set_app_data", {
config: this.config_data,
pending_config: this.config_data
});
// Make the wallet dir
const { wallet_data_dir, data_dir } = this.config_data.app;
if (!fs.existsSync(wallet_data_dir)) {
fs.mkdirpSync(wallet_data_dir);
}
// Check to see if data and wallet directories exist
const dirs_to_check = [
{
path: data_dir,
error: "notification.errors.dataPathNotFound"
},
{
path: wallet_data_dir,
error: "notification.errors.walletPathNotFound"
}
];
for (const dir of dirs_to_check) {
// Check to see if dir exists
if (!fs.existsSync(dir.path)) {
this.send("show_notification", {
type: "negative",
i18n: dir.error,
timeout: 2000
});
// Go back to config
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
return;
}
}
const { net_type } = this.config_data.app;
const dirs = {
mainnet: this.config_data.app.data_dir,
stagenet: path.join(this.config_data.app.data_dir, "stagenet"),
testnet: path.join(this.config_data.app.data_dir, "testnet")
};
// Make sure we have the directories we need
const net_dir = dirs[net_type];
if (!fs.existsSync(net_dir)) {
fs.mkdirpSync(net_dir);
}
const log_dir = path.join(net_dir, "logs");
if (!fs.existsSync(log_dir)) {
fs.mkdirpSync(log_dir);
}
this.initLogger(log_dir);
this.daemon = new Daemon(this);
this.walletd = new WalletRPC(this);
this.send("set_app_data", {
status: {
code: 3 // Starting daemon
}
});
// Make sure the remote node provided is accessible
const config_daemon = this.config_data.daemons[net_type];
this.daemon.checkRemote(config_daemon).then(data => {
if (data.error) {
// If we can default to local then we do so, otherwise we tell the user to re-set the node
if (config_daemon.type === "local_remote") {
this.config_data.daemons[net_type].type = "local";
this.send("set_app_data", {
config: this.config_data,
pending_config: this.config_data
});
this.send("show_notification", {
type: "warning",
textColor: "black",
i18n: "notification.warnings.usingLocalNode",
timeout: 2000
});
} else {
this.send("show_notification", {
type: "negative",
i18n: "notification.errors.cannotAccessRemoteNode",
timeout: 2000
});
// Go back to config
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
return;
}
}
// If we got a net type back then check if ours match
if (data.net_type && data.net_type !== net_type) {
this.send("show_notification", {
type: "negative",
i18n: "notification.errors.differentNetType",
timeout: 2000
});
// Go back to config
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
return;
}
this.daemon
.checkVersion()
.then(version => {
if (version) {
this.send("set_app_data", {
status: {
code: 4,
message: version
}
});
} else {
// daemon not found, probably removed by AV, set to remote node
this.config_data.daemons[net_type].type = "remote";
this.send("set_app_data", {
status: {
code: 5
},
config: this.config_data,
pending_config: this.config_data
});
}
this.daemon
.start(this.config_data)
.then(() => {
this.send("set_app_data", {
status: {
code: 6 // Starting wallet
}
});
this.walletd
.start(this.config_data)
.then(() => {
this.send("set_app_data", {
status: {
code: 7 // Reading wallet list
}
});
this.walletd.listWallets(true);
this.send("set_app_data", {
status: {
code: 0 // Ready
}
});
// eslint-disable-next-line
})
.catch(error => {
this.daemon.killProcess();
this.send("show_notification", {
type: "negative",
message: error.message,
timeout: 3000
});
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
});
// eslint-disable-next-line
})
.catch(error => {
if (this.config_data.daemons[net_type].type == "remote") {
this.send("show_notification", {
type: "negative",
i18n: "notification.errors.remoteCannotBeReached",
timeout: 3000
});
} else {
this.send("show_notification", {
type: "negative",
message: error.message,
timeout: 3000
});
}
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
});
// eslint-disable-next-line
})
.catch(() => {
this.send("set_app_data", {
status: {
code: -1 // Return to config screen
}
});
});
});
});
}
quit() {
return new Promise(resolve => {
let process = [];
if (this.daemon) {
process.push(this.daemon.quit());
}
if (this.walletd) {
process.push(this.walletd.quit());
}
if (this.wss) {
this.wss.close();
}
Promise.all(process).then(() => {
resolve();
});
});
}
// Replace any invalid value with default values
validate_values(values, defaults) {
const isDictionary = v =>
typeof v === "object" &&
v !== null &&
!(v instanceof Array) &&
!(v instanceof Date);
const modified = { ...values };
// Make sure we have valid defaults
if (!isDictionary(defaults)) return modified;
for (const key in modified) {
// Only modify if we have a default
if (!(key in defaults)) continue;
const defaultValue = defaults[key];
const invalidDefault =
defaultValue === null ||
defaultValue === undefined ||
Number.isNaN(defaultValue);
if (invalidDefault) continue;
const value = modified[key];
// If we have a object then recurse through it
if (isDictionary(value)) {
modified[key] = this.validate_values(value, defaultValue);
} else {
// Check if we need to replace the value
const isValidValue = !(
value === undefined ||
value === null ||
value === "" ||
Number.isNaN(value)
);
if (isValidValue) continue;
// Otherwise set the default value
modified[key] = defaultValue;
}
}
return modified;
}
}