Refactory, behavior consolidation and minor fixes

This commit is contained in:
argenius 2024-11-16 01:23:24 +01:00
parent 248189ad34
commit ab82920f08
24 changed files with 1165 additions and 610 deletions

2
.gitignore vendored
View file

@ -9,6 +9,8 @@
main.js
src/**/*.js
app/auto-launch/**/*.js
app/process/**/*.js
app/utils/**/*.js
*.js.map
# dependencies

View file

@ -1,95 +1,18 @@
import { app, BrowserWindow, ipcMain, screen, dialog, Tray, Menu, MenuItemConstructorOptions,
IpcMainInvokeEvent, Notification, NotificationConstructorOptions, clipboard, powerMonitor
IpcMainInvokeEvent, Notification, NotificationConstructorOptions, clipboard, powerMonitor,
WebContents,
HandlerDetails,
Event,
WebContentsWillNavigateEventParams
} from 'electron';
import { ChildProcessWithoutNullStreams, exec, ExecException, spawn } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as https from 'https';
import { createHash } from 'crypto';
import * as tar from 'tar';
import * as os from 'os';
import AutoLaunch from './auto-launch';
const AdmZip = require('adm-zip');
const pidusage = require('pidusage');
const batteryLevel = require('battery-level');
const network = require('network');
function isOnBatteryPower(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
exec("upower -i $(upower -e | grep 'battery') | grep 'state'", (error, stdout) => {
if (error) {
console.error(`isOnBatteryPower(): ${error.message}`);
resolve(false); // Ritorna false se non riesce a rilevare lo stato della batteria
return;
}
const isOnBattery = stdout.includes("discharging");
resolve(isOnBattery);
});
});
}
interface Stats {
/**
* percentage (from 0 to 100*vcore)
*/
cpu: number;
/**
* bytes
*/
memory: number;
/**
* PPID
*/
ppid: number;
/**
* PID
*/
pid: number;
/**
* ms user + system time
*/
ctime: number;
/**
* ms since the start of the process
*/
elapsed: number;
/**
* ms since epoch
*/
timestamp: number;
}
//import bz2 from 'unbzip2-stream';
//import * as bz2 from 'unbzip2-stream';
const bz2 = require('unbzip2-stream');
import { AppMainProcess, MonerodProcess } from './process';
import { BatteryUtils, FileUtils, NetworkUtils } from './utils';
app.setName('Monero Daemon');
let autoLauncher = new AutoLaunch({
name: 'monerod-gui',
path: process.execPath,
options: {
extraArguments: [
'--auto-launch'
],
linux: {
comment: 'Monerod GUI startup script',
version: '1.0.1'
}
}
});
const isAutoLaunched: boolean = process.argv.includes('--auto-launch');
const minimized: boolean = process.argv.includes('--hidden');
let win: BrowserWindow | null = null;
let isHidden: boolean = false;
let isQuitting: boolean = false;
@ -102,7 +25,9 @@ const dirname = (__dirname.endsWith(appApp) ? __dirname.replace(appApp, appSrc)
console.log('dirname: ' + dirname);
let monerodProcess: ChildProcessWithoutNullStreams | null = null;
//let monerodProcess: ChildProcessWithoutNullStreams | null = null;
let monerodProcess: MonerodProcess | null = null;
const iconRelPath: string = 'assets/icons/monero-symbol-on-white-480.png';
//const wdwIcon = `${dirname}/${iconRelPath}`;
const wdwIcon = path.join(dirname, iconRelPath);
@ -110,13 +35,6 @@ const wdwIcon = path.join(dirname, iconRelPath);
let tray: Tray;
let trayMenu: Menu;
const args = process.argv.slice(1),
serve = args.some(val => val === '--serve');
const isAppImage: () => boolean = () => {
return (!!process.env.APPIMAGE) || (!!process.env.PORTABLE_EXECUTABLE_DIR);
}
// #region Window
function updateTrayMenu(): void {
@ -217,7 +135,7 @@ function createWindow(): BrowserWindow {
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
allowRunningInsecureContent: (serve),
allowRunningInsecureContent: (AppMainProcess.serve),
contextIsolation: true,
devTools: !app.isPackaged,
sandbox: true
@ -227,11 +145,11 @@ function createWindow(): BrowserWindow {
icon: wdwIcon
});
isHidden = minimized;
isHidden = AppMainProcess.startMinized;
if (!app.isPackaged) win.webContents.openDevTools();
if (serve) {
if (AppMainProcess.serve) {
const debug = require('electron-debug');
debug();
@ -277,7 +195,7 @@ function createWindow(): BrowserWindow {
const createSplashWindow = async (): Promise<BrowserWindow | undefined> => {
return undefined;
if (os.platform() == 'win32' || isAppImage()) {
if (os.platform() == 'win32' || AppMainProcess.isPortable) {
return undefined;
}
@ -320,129 +238,49 @@ const createSplashWindow = async (): Promise<BrowserWindow | undefined> => {
// #region WiFi
function isConnectedToWiFi(): Promise<boolean> {
async function isWifiConnected() {
let connected: boolean = false;
try {
return new Promise<boolean>((resolve, reject) => {
network.get_active_interface((err: any | null, obj: { name: string, ip_address: string, mac_address: string, type: string, netmask: string, gateway_ip: string }) => {
if (err) {
console.error(err);
reject(err);
}
else {
resolve(obj.type == 'Wireless');
}
})
});
connected = await NetworkUtils.isConnectedToWiFi();
}
catch(error: any) {
return isConnectedToWiFiV2();
}
}
function isConnectedToWiFiV2(): Promise<boolean> {
return new Promise((resolve, reject) => {
const platform = os.platform(); // Use os to get the platform
let command = '';
if (platform === 'win32') {
// Windows: Use 'netsh' command to check the Wi-Fi status
command = 'netsh wlan show interfaces';
} else if (platform === 'darwin') {
// macOS: Use 'airport' command to check the Wi-Fi status
command = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I | grep 'state: running'";
} else if (platform === 'linux') {
// Linux: Use 'nmcli' to check for Wi-Fi connectivity
command = 'nmcli dev status';
} else {
resolve(false); // Unsupported platform
}
// Execute the platform-specific command
if (command) {
exec(command, (error: ExecException | null, stdout: string, stderr: string) => {
if (error) {
console.error(error);
reject(stderr);
resolve(false); // In case of error, assume not connected to Wi-Fi
} else {
// Check if the output indicates a connected status
if (stdout) {
const components: string[] = stdout.split("\n");
components.forEach((component: string) => {
if (component.includes('wifi') && !component.includes('--')) {
resolve(true);
}
});
resolve(false);
} else {
resolve(false);
}
}
});
}
});
}
function isWifiConnected() {
isConnectedToWiFi().then((connected: boolean) => {
win?.webContents.send('is-wifi-connected-result', connected);
}).catch((error: any) => {
catch (error: any) {
console.error(error);
win?.webContents.send('is-wifi-connected-result', false);
});
connected = false;
}
win?.webContents.send('is-wifi-connected-result', connected);
}
// #endregion
// #region monerod
function getMonerodVersion(monerodFilePath: string): void {
const monerodProcess = spawn(monerodFilePath, [ '--version' ]);
monerodProcess.on('error', (err: Error) => {
win?.webContents.send('monero-version-error', `${err.message}`);
});
monerodProcess.stdout.on('data', (data) => {
win?.webContents.send('monero-version', `${data}`);
});
monerodProcess.stderr.on('data', (data) => {
win?.webContents.send('monero-version-error', `${data}`);
async function getMonerodVersion(monerodFilePath: string): Promise<void> {
const proc = new MonerodProcess({
monerodCmd: monerodFilePath,
isExe: true
});
try {
console.log("Before proc.getVersion()");
const version = await proc.getVersion();
console.log("After proc.getVersion()");
win?.webContents.send('monero-version', version);
}
catch(error: any) {
const err = (error instanceof Error) ? error.message : `${error}`;
win?.webContents.send('monero-version-error', err);
}
}
function checkValidMonerodPath(monerodPath: string): void {
let foundUsage: boolean = false;
const monerodProcess = spawn(monerodPath, ['--help']);
monerodProcess.on('error', (err: Error) => {
win?.webContents.send('on-check-valid-monerod-path', false);
});
monerodProcess.stderr.on('data', (data) => {
win?.webContents.send('on-check-valid-monerod-path', false);
});
monerodProcess.stdout.on('data', (data) => {
if (`${data}`.includes('monerod [options|settings] [daemon_command...]')) {
foundUsage = true;
}
});
monerodProcess.on('close', (code: number) => {
win?.webContents.send('on-check-valid-monerod-path', foundUsage);
});
async function checkValidMonerodPath(monerodPath: string): Promise<void> {
const valid = await MonerodProcess.isValidMonerodPath(monerodPath);
win?.webContents.send('on-check-valid-monerod-path', valid);
}
let moneroFirstStdout: boolean = true;
function startMoneroDaemon(commandOptions: string[]): ChildProcessWithoutNullStreams {
async function startMoneroDaemon(commandOptions: string[]): Promise<MonerodProcess> {
const monerodPath = commandOptions.shift();
if (!monerodPath) {
@ -456,162 +294,78 @@ function startMoneroDaemon(commandOptions: string[]): ChildProcessWithoutNullStr
win?.webContents.send('monero-stderr', error);
throw new Error("Monerod already started");
}
const message: string = "Starting monerod daemon with options: " + commandOptions.join(" ");
console.log(message);
moneroFirstStdout = true;
commandOptions.push('--non-interactive');
// Avvia il processo usando spawn
monerodProcess = spawn(monerodPath, commandOptions);
// Gestisci l'output di stdout in streaming
monerodProcess.stdout.on('data', (data) => {
//console.log(`monerod stdout: ${data}`);
const pattern = '**********************************************************************';
if (moneroFirstStdout && data.includes(pattern)) {
win?.webContents.send('monerod-started', true);
moneroFirstStdout = false;
}
win?.webContents.send('monero-stdout', `${data}`);
// Puoi anche inviare i log all'interfaccia utente tramite IPC
monerodProcess = new MonerodProcess({
monerodCmd: monerodPath,
flags: commandOptions,
isExe: true
});
// Gestisci gli errori in stderr
monerodProcess.stderr.on('data', (data) => {
console.error(`monerod error: ${data}`);
if (moneroFirstStdout) {
win?.webContents.send('monerod-started', false);
moneroFirstStdout = false;
}
monerodProcess.onStdOut((data) => {
win?.webContents.send('monero-stdout', `${data}`);
});
monerodProcess.onStdErr((data) => {
win?.webContents.send('monero-stderr', `${data}`);
});
// Gestisci la chiusura del processo
monerodProcess.on('error', (err: Error) => {
monerodProcess.onError((err: Error) => {
win?.webContents.send('monero-stderr', `${err.message}`);
});
monerodProcess.on('close', (code: number) => {
monerodProcess.onClose((_code: number | null) => {
const code = _code != null ? _code : -Number.MAX_SAFE_INTEGER;
console.log(`monerod exited with code: ${code}`);
win?.webContents.send('monero-stdout', `monerod exited with code: ${code}`);
win?.webContents.send('monero-close', code);
monerodProcess = null;
});
try {
await monerodProcess.start();
win?.webContents.send('monerod-started', true);
}
catch(error: any) {
win?.webContents.send('monerod-started', false);
}
return monerodProcess;
}
function monitorMonerod(): void {
async function monitorMonerod(): Promise<void> {
if (!monerodProcess) {
win?.webContents.send('on-monitor-monerod-error', 'Monerod not running');
return;
}
if (!monerodProcess.pid) {
win?.webContents.send('on-monitor-monerod-error', 'Unknown monero pid');
return;
try {
const stats = await monerodProcess.getStats();
win?.webContents.send('on-monitor-monerod', stats);
}
catch(error: any) {
let message: string;
pidusage(monerodProcess.pid, (error: Error | null, stats: Stats) => {
if (error) {
win?.webContents.send('on-monitor-monerod-error', `${error}`);
return;
if (error instanceof Error) {
message = error.message;
}
else {
message = `${error}`;
}
win?.webContents.send('on-monitor-monerod', stats);
});
win?.webContents.send('on-monitor-monerod-error', message);
}
}
// #endregion
// #region Download Utils
const downloadFile = (url: string, destinationDir: string, onProgress: (progress: number) => void): Promise<string> => {
return new Promise((resolve, reject) => {
const request = (url: string) => {
https.get(url, (response) => {
if (response.statusCode === 200) {
const contentDisposition = response.headers['content-disposition'];
let finalFilename = '';
// Estrai il nome del file dall'URL o dal content-disposition
if (contentDisposition && contentDisposition.includes('filename')) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
finalFilename = match[1];
}
} else {
// Se non c'è content-disposition, prendiamo il nome dall'URL
finalFilename = url.split('/').pop() || 'downloaded-file';
}
const destination = `${destinationDir}/${finalFilename}`;
let file: fs.WriteStream;
try {
file = fs.createWriteStream(destination);
file.on('error', (error: Error) => {
console.log("file error: " + error);
reject(error);
});
}
catch (error: any) {
reject(error);
return;
}
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
const progress = (downloadedBytes / totalBytes) * 100;
onProgress(progress); // Notifica il progresso
});
response.pipe(file);
file.on('finish', () => {
file.close(() => resolve(finalFilename)); // Restituisci il nome del file finale
});
} else if (response.statusCode === 301 || response.statusCode === 302) {
// Se è un redirect, effettua una nuova richiesta verso il location header
const newUrl = response.headers.location;
if (newUrl) {
request(newUrl); // Ripeti la richiesta con il nuovo URL
} else {
reject(new Error('Redirection failed without a location header'));
}
} else {
reject(new Error(`Failed to download: ${response.statusCode}`));
}
}).on('error', (err) => {
reject(err);
});
};
request(url); // Inizia la richiesta
});
};
// Funzione per scaricare e verificare l'hash
const downloadAndVerifyHash = async (hashUrl: string, fileName: string, filePath: string): Promise<boolean> => {
//const hashFilePath = path.join(app.getPath('temp'), 'monero_hashes.txt');
// Scarica il file di hash
const hashFileName = await downloadFile(hashUrl, app.getPath('temp'), () => {});
async function downloadAndVerifyHash(hashUrl: string, fileName: string, filePath: string): Promise<boolean> {
const hashFileName = await FileUtils.downloadFile(hashUrl, app.getPath('temp'), () => {});
const hashFilePath = `${app.getPath('temp')}/${hashFileName}`;
// Leggi il file di hash e cerca l'hash corrispondente
const hashContent = fs.readFileSync(hashFilePath, 'utf8');
const hashLines = hashContent.split('\n');
let expectedHash: string | null = null;
@ -629,106 +383,10 @@ const downloadAndVerifyHash = async (hashUrl: string, fileName: string, filePath
}
// Verifica l'hash del file scaricato
const calculatedHash = await verifyFileHash(`${filePath}/${fileName}`);
return calculatedHash === expectedHash;
return await FileUtils.checkFileHash(`${filePath}/${fileName}`, expectedHash);
};
// Funzione per verificare l'hash del file
const verifyFileHash = (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const fileStream = fs.createReadStream(filePath);
fileStream.on('data', (data) => {
hash.update(data);
});
fileStream.on('end', () => {
resolve(hash.digest('hex'));
});
fileStream.on('error', (err) => {
reject(err);
});
});
};
const extractTarBz2 = (filePath: string, destination: string): Promise<string> => {
return new Promise((resolve, reject) => {
// Crea il file decomprimendo il .bz2 in uno .tar temporaneo
const tarPath = path.join(destination, 'temp.tar');
const fileStream = fs.createReadStream(filePath);
const decompressedStream = fileStream.pipe(bz2());
const writeStream = fs.createWriteStream(tarPath);
decompressedStream.pipe(writeStream);
let extractedDir: string = '';
writeStream.on('finish', () => {
// Una volta che il file .tar è stato creato, estrailo
tar.extract({ cwd: destination, file: tarPath, onReadEntry: (entry: tar.ReadEntry) => {
if (extractedDir == '') {
const topLevelDir = entry.path.split('/')[0];
extractedDir = topLevelDir; // Salva la prima directory
}
} })
.then(() => {
// Elimina il file .tar temporaneo dopo l'estrazione
fs.unlink(tarPath, (err) => {
if (err) reject(err);
else if (extractedDir == '') reject('Extraction failed')
else resolve(extractedDir);
});
})
.catch(reject);
});
writeStream.on('error', reject);
});
};
const extractZip = (filePath: string, destination: string): Promise<string> => {
return new Promise<string>((resolve, reject) => {
try {
const zip = new AdmZip(filePath);
// Ensure destination exists
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
// Extract the ZIP file
zip.extractAllTo(destination, true);
// Get the name of the extracted folder
const extractedEntries = zip.getEntries();
const folderName = extractedEntries[0]?.entryName.split('/')[0];
// Ensure folder name exists
if (!folderName) {
reject(new Error("Could not determine the extracted folder name"));
return;
}
resolve(path.join(destination, folderName));
} catch (error) {
reject(error);
}
});
};
const extract = (filePath: string, destination: string): Promise<string> => {
if (filePath.endsWith('.zip')) {
return extractZip(filePath, destination);
}
else if (filePath.endsWith('.tar.bz2')) {
return extractTarBz2(filePath, destination);
}
throw new Error("Unknown file type " + filePath);
}
// #endregion
@ -744,6 +402,21 @@ function showNotification(options?: NotificationConstructorOptions): void {
new Notification(options).show();
}
function showSecurityWarning(msg: string): void {
if (win) {
dialog.showMessageBoxSync(win, {
type: 'warning',
title: 'Security Warning',
message: msg
});
}
else {
dialog.showErrorBox('Security Warning', msg);
}
console.warn(msg);
}
try {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
@ -767,7 +440,7 @@ try {
try {
setTimeout(() => {
if (splash) splash.close();
if (!minimized) {
if (!AppMainProcess.startMinized) {
win?.show();
win?.maximize();
}
@ -804,59 +477,43 @@ try {
// #region Security
app.on('web-contents-created', (event, webContents) => {
webContents.setWindowOpenHandler((details) => {
console.warn("Prevented unsafe window creation");
app.on('web-contents-created', (event, webContents: WebContents) => {
webContents.setWindowOpenHandler((details: HandlerDetails) => {
const msg = `Prevented unsafe content: ${details.url}`;
showSecurityWarning(msg);
console.warn(details);
return { action: 'deny' };
});
webContents.on('will-navigate', (event: Event<WebContentsWillNavigateEventParams>, navigationUrl: string) => {
event.preventDefault();
const msg = `Prevented unsage window navigation to ${navigationUrl}`;
showSecurityWarning(msg);
});
});
app.on('web-contents-created', (event, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
event.preventDefault();
console.warn(`Prevented unsage window navigation to ${navigationUrl}`);
/*
const parsedUrl = new URL(navigationUrl)
if (parsedUrl.origin !== 'https://example.com') {
event.preventDefault()
}
*/
})
})
// #endregion
ipcMain.handle('is-on-battery-power', (event: IpcMainInvokeEvent) => {
const onBattery = powerMonitor.isOnBatteryPower();
if (!onBattery && os.platform() == 'linux') {
isOnBatteryPower().then((value) => {
win?.webContents.send('on-is-on-battery-power', value);
}).catch((error: any) => {
console.error(`${error}`);
win?.webContents.send('on-is-on-battery-power', false);
});
return;
}
else {
win?.webContents.send('on-is-on-battery-power', onBattery);
}
ipcMain.handle('is-on-battery-power', async (event: IpcMainInvokeEvent) => {
const onBattery = await BatteryUtils.isOnBatteryPower();
win?.webContents.send('on-is-on-battery-power', onBattery);
});
powerMonitor.on('on-ac', () => win?.webContents.send('on-ac'));
powerMonitor.on('on-battery', () => win?.webContents.send('on-battery'));
ipcMain.handle('is-auto-launched', (event: IpcMainInvokeEvent) => {
console.debug(event);
win?.webContents.send('on-is-auto-launched', isAutoLaunched);
ipcMain.handle('is-auto-launched', (event: IpcMainInvokeEvent) => {
win?.webContents.send('on-is-auto-launched', AppMainProcess.autoLaunched);
});
ipcMain.handle('quit', (event: IpcMainInvokeEvent) => {
ipcMain.handle('quit', async (event: IpcMainInvokeEvent) => {
isQuitting = true;
if (monerodProcess) {
await monerodProcess.stop();
}
tray.destroy();
win?.close();
win?.destroy();
@ -886,7 +543,7 @@ try {
});
// Scarica il file Monero
const fileName = await downloadFile(downloadUrl, destination, (progress) => {
const fileName = await FileUtils.downloadFile(downloadUrl, destination, (progress) => {
win?.setProgressBar(progress, {
mode: 'normal'
});
@ -905,7 +562,7 @@ try {
// Estrai il file
const fPath = `${destination}/${fileName}`;
event.sender.send('download-progress', { progress: 100, status: 'Extracting' });
const extractedDir = await extract(fPath, destination);
const extractedDir = await FileUtils.extract(fPath, destination);
event.sender.send('download-progress', { progress: 100, status: 'Download and extraction completed successfully' });
event.sender.send('download-progress', { progress: 200, status: os.platform() == 'win32' ? extractedDir : `${destination}/${extractedDir}` });
@ -926,7 +583,7 @@ try {
try {
event.sender.send('download-file-progress', { progress: 0, status: 'Starting download' });
const fileName = await downloadFile(url, destination, (progress) => {
const fileName = await FileUtils.downloadFile(url, destination, (progress) => {
win?.setProgressBar(progress, {
mode: 'normal'
});
@ -1045,87 +702,36 @@ try {
// #region Auto Launch
ipcMain.handle('is-auto-launch-enabled', (event: IpcMainInvokeEvent) => {
autoLauncher.isEnabled().then((enabled: boolean) => {
win?.webContents.send('on-is-auto-launch-enabled', enabled);
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-is-auto-launch-enabled', false);
});
ipcMain.handle('is-auto-launch-enabled', async (event: IpcMainInvokeEvent) => {
const enabled = await AppMainProcess.isAutoLaunchEnabled();
win?.webContents.send('on-is-auto-launch-enabled', enabled);
});
ipcMain.handle('enable-auto-launch', (event: IpcMainInvokeEvent, minimized: boolean) => {
autoLauncher.isEnabled().then((enabled: boolean) => {
if (enabled) {
win?.webContents.send('on-enable-auto-launch-error', 'already enabled');
return;
}
ipcMain.handle('enable-auto-launch', async (event: IpcMainInvokeEvent, minimized: boolean) => {
try {
await AppMainProcess.enableAutoLaunch(minimized);
win?.webContents.send('on-enable-auto-launch-success');
}
catch(error: any) {
const err = (error instanceof Error) ? error.message : `${error}`;
autoLauncher = new AutoLaunch({
name: 'monerod-gui',
path: process.execPath,
options: {
launchInBackground: minimized,
extraArguments: [
'--auto-launch'
]
}
});
autoLauncher.enable().then(() => {
autoLauncher.isEnabled().then((enabled: boolean) => {
if (enabled) {
win?.webContents.send('on-enable-auto-launch-success');
}
win?.webContents.send('on-enable-auto-launch-error', `Could not enabled auto launch`);
}).catch((error: any) => {
win?.webContents.send('on-enable-auto-launch-error', `${error}`);
});
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-enable-auto-launch-error', `${error}`);
});
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-enable-auto-launch-error', `${error}`);
});
win?.webContents.send('on-enable-auto-launch-error', err);
}
});
ipcMain.handle('get-battery-level', (event: IpcMainInvokeEvent) => {
batteryLevel().then((level: number) => {
win?.webContents.send('on-get-battery-level', level);
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-get-battery-level', -1);
})
ipcMain.handle('get-battery-level', async (event: IpcMainInvokeEvent) => {
win?.webContents.send('on-get-battery-level', await BatteryUtils.getLevel());
});
ipcMain.handle('disable-auto-launch', (event: IpcMainInvokeEvent) => {
autoLauncher.isEnabled().then((enabled: boolean) => {
if (!enabled) {
win?.webContents.send('on-disable-auto-launch-error', 'already disabled');
return;
}
autoLauncher.disable().then(() => {
autoLauncher.isEnabled().then((enabled: boolean) => {
if (!enabled) {
win?.webContents.send('on-disable-auto-launch-success');
}
win?.webContents.send('on-disable-auto-launch-error', `Could not disable auto launch`);
}).catch((error: any) => {
win?.webContents.send('on-disable-auto-launch-error', `${error}`);
});
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-disable-auto-launch-error', `${error}`);
});
}).catch((error: any) => {
console.error(error);
win?.webContents.send('on-disable-auto-launch-error', `${error}`);
});
ipcMain.handle('disable-auto-launch', async (event: IpcMainInvokeEvent) => {
try {
await AppMainProcess.disableAutoLaunch();
win?.webContents.send('on-disable-auto-launch-success');
}
catch(error: any) {
const err = (error instanceof Error) ? error.message : `${error}`;
win?.webContents.send('on-disable-auto-launch-error', err);
}
});
// #endregion
@ -1142,8 +748,8 @@ try {
tray.setToolTip(toolTip);
});
ipcMain.handle('is-app-image', (event: IpcMainInvokeEvent) => {
win?.webContents.send('on-is-app-image', isAppImage());
ipcMain.handle('is-portable', (event: IpcMainInvokeEvent) => {
win?.webContents.send('on-is-portable', AppMainProcess.isPortable);
});
ipcMain.handle('copy-to-clipboard', (event: IpcMainInvokeEvent, content: string) => {
@ -1157,6 +763,5 @@ try {
dialog.showErrorBox('', `${e}`);
app.quit();
// throw e;
}

View file

@ -93,10 +93,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('monero-version-error', callback);
},
unregisterOnMoneroVersion: () => {
ipcRenderer.removeAllListeners('on-monero-version');
ipcRenderer.removeAllListeners('monero-version');
},
unregisterOnMoneroVersionError: () => {
ipcRenderer.removeAllListeners('unregister-on-monero-version-error');
ipcRenderer.removeAllListeners('monero-version-error');
},
downloadMonerod: (downloadUrl, destination) => {
ipcRenderer.invoke('download-monerod', downloadUrl, destination);
@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
onCheckValidMonerodPath: (callback) => {
ipcRenderer.on('on-check-valid-monerod-path', callback);
},
unregisterOnCheckValidMonerodPath: () => {
ipcRenderer.removeAllListeners('on-check-valid-monerod-path');
},
selectFolder: () => {
ipcRenderer.invoke('select-folder')
},
@ -227,14 +230,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
unregisterOnDisableAutoLaunchSuccess: () => {
ipcRenderer.removeAllListeners('on-disable-auto-launch-success')
},
isAppImage: () => {
ipcRenderer.invoke('is-app-image');
isPortable: () => {
ipcRenderer.invoke('is-portable');
},
onIsAppImage: (callback) => {
ipcRenderer.on('on-is-app-image', callback);
onIsPortable: (callback) => {
ipcRenderer.on('on-is-portable', callback);
},
unregisterOnIsAppImage: () => {
ipcRenderer.removeAllListeners('on-is-app-image');
unregisterIsPortable: () => {
ipcRenderer.removeAllListeners('on-is-portable');
},
isAutoLaunched: () => {
ipcRenderer.invoke('is-auto-launched');

View file

@ -0,0 +1,243 @@
import * as fs from 'fs';
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import { ProcessStats } from './ProcessStats';
const pidusage = require('pidusage');
export class AppChildProcess {
protected _starting: boolean = false;
protected _stopping: boolean = false;
protected _running: boolean = false;
protected _isExe: boolean = true;
protected _process?: ChildProcessWithoutNullStreams;
protected _command: string;
protected readonly _args?: string[];
protected readonly _handlers: {
'stdout': ((data: string) => void)[],
'stderr': ((err: string) => void)[],
'onclose': ((code: number | null) => void)[],
'onerror': ((error: Error) => void)[],
} = {
'stdout': [],
'stderr': [],
'onclose': [],
'onerror': []
};
private readonly mOnErrorDefaultHandler: (error: Error) => void = (error: Error) => {
if (!this._process) {
return;
}
const listeners = this._process.listeners('error');
if (listeners.length > 1) {
return;
}
console.error("Uncaught exeception: ");
console.error(error);
};
public get command(): string {
return this._command;
}
public get args(): string[] {
return this._args ? this._args : [];
}
public get running(): boolean {
return this._running;
}
constructor({ command, args, isExe } : { command: string, args?: string[], isExe?: boolean}) {
this._command = command;
this._args = args;
this._isExe = isExe === false ? false : true;
}
protected static replaceAll(value: string, oldValue: string, newValue: string): string {
let v = value;
while(v.includes(oldValue)) {
v = v.replace(oldValue, newValue);
}
return v;
}
protected static checkExecutable(executablePath: string): void {
const exeComponents: string[] = executablePath.split(" ").filter((c) => c != '');
console.log("AppProcess.checkExecutable(): " + executablePath);
if (exeComponents.length == 0) {
throw new Error("Invalid command provided");
}
const exePath = exeComponents[0];
if (!fs.existsSync(exePath)) {
throw new Error("Cannot find executable: " + exePath);
}
}
protected checkExecutable(): void {
AppChildProcess.checkExecutable(this.command);
}
public onStdOut(callback: (out: string) => void): void {
const cbk = (chunk: any) => callback(`${chunk}`);
if (!this._process) {
this._handlers.stdout.push(cbk);
return;
}
this._process.stdout.on('data', cbk);
}
public onStdErr(callback: (err: string) => void): void {
const cbk = (chunk: any) => callback(`${chunk}`);
if (!this._process) {
this._handlers.stderr.push(cbk);
return;
}
this._process.stderr.on('data', cbk);
}
public onError(callback: (err: Error) => void): void {
if (!this._process)
{
this._handlers.onerror.push(callback);
return;
}
this._process.on('error', callback);
}
public onClose(callback: (code: number | null) => void): void {
if (!this._process) {
this._handlers.onclose.push(callback);
return;
}
this._process.on('close', callback);
}
public async start(): Promise<void> {
if (this._starting) {
throw new Error("Process is already starting");
}
if (this._stopping) {
throw new Error("Process is stopping");
}
if (this._running) {
throw new Error("Process already running");
}
if (this._isExe) {
this.checkExecutable();
}
this._starting = true;
const process = spawn(this._command, this._args);
this._process = process;
const promise = new Promise<void>((resolve, reject) => {
const onSpawnError = (err: Error) => {
this._starting = false;
this._running = false;
reject(err);
};
const onSpawn = () => {
this._starting = false;
this._running = true;
process.off('error', onSpawnError);
process.on('error', this.mOnErrorDefaultHandler);
this._handlers.onclose.forEach((listener) => process.on('close', listener));
this._handlers.onerror.forEach((listener) => process.on('error', listener));
this._handlers.stdout.forEach((listener) => process.stdout.on('data', listener));
this._handlers.stderr.forEach((listener) => process.stderr.on('data', listener));
resolve();
};
process.once('error', onSpawnError);
process.once('spawn', onSpawn);
});
process.once('close', () => {
if (this._stopping) return;
this._running = false;
this._process = undefined;
});
await promise;
}
public async stop(): Promise<number | null> {
if (this._starting) {
throw new Error("Process is starting");
}
if (this._stopping) {
throw new Error("Process is already stopping");
}
if (!this._running || !this._process) {
throw new Error("Process is not running");
}
this._stopping = true;
const promise = new Promise<number | null>((resolve) => {
process.on('close', (code: number | null) => {
this._process = undefined;
this._running = false;
this._stopping = false;
resolve(code);
});
});
this._process?.kill();
return await promise;
}
public async getStats(): Promise<ProcessStats> {
if (!this._process) {
throw new Error("Process not running");
}
const pid = this._process.pid;
if (!pid) {
throw new Error("Process is unknown");
}
return await new Promise<ProcessStats>((resolve, reject) => {
pidusage(pid, (err: Error | null, stats: ProcessStats) => {
if (err) {
reject(err);
}
else {
resolve(stats);
}
});
});
}
}

View file

@ -0,0 +1,90 @@
import AutoLaunch from "../auto-launch";
export abstract class AppMainProcess {
private static autoLaunch: AutoLaunch = new AutoLaunch({
name: 'monerod-gui',
path: process.execPath,
options: {
launchInBackground: process.argv.includes('--hidden'),
extraArguments: [
'--auto-launch'
],
linux: {
comment: 'Monerod GUI startup script',
version: '1.0.1'
}
}
});
public static get serve(): boolean {
const args = process.argv.slice(1);
return args.some(val => val === '--serve');
}
public static get autoLaunched(): boolean {
return process.argv.includes('--auto-launch');
}
public static get startMinized(): boolean {
return process.argv.includes('--hidden');
}
public static get isPortable(): boolean {
return (!!process.env.APPIMAGE) || (!!process.env.PORTABLE_EXECUTABLE_DIR);
}
public static async isAutoLaunchEnabled(): Promise<boolean> {
try {
return this.autoLaunch.isEnabled();
}
catch {
return false;
}
}
public static async enableAutoLaunch(startMinized: boolean): Promise<void> {
let enabled = await this.isAutoLaunchEnabled();
if (enabled) {
throw new Error("Auto launch already enabled");
}
this.autoLaunch = new AutoLaunch({
name: 'monerod-gui',
path: process.execPath,
options: {
launchInBackground: startMinized,
extraArguments: [
'--auto-launch'
]
}
});
await this.autoLaunch.enable();
enabled = await this.isAutoLaunchEnabled();
if (!enabled) {
throw new Error("Could not enable auto launch due an unkown error");
}
}
public static async disableAutoLaunch(): Promise<void> {
let enabled = await this.isAutoLaunchEnabled();
if (!enabled) {
throw new Error("Auto launch already disabled");
}
await this.autoLaunch.disable();
enabled = await this.isAutoLaunchEnabled();
if (enabled) {
throw new Error("Could not disable auto launch due an unknown error");
}
}
}

View file

@ -0,0 +1,203 @@
import { AppChildProcess } from "./AppChildProcess";
export class MonerodProcess extends AppChildProcess {
protected static readonly stdoutPattern: string = '**********************************************************************';
public get interactive(): boolean {
return this.args ? !this.args.includes('--non-interactive') : true;
}
public get detached(): boolean {
return this.args ? this.args.includes('--detached') : false;
}
constructor({ monerodCmd, flags, isExe }: { monerodCmd: string, flags?: string[], isExe?: boolean }) {
super({
command: monerodCmd,
args: flags,
isExe: isExe
})
}
public static async isValidMonerodPath(monerodPath: string): Promise<boolean> {
console.log(`MonerodProcess.isValidMonerodPath('${monerodPath}')`);
if (typeof monerodPath !== 'string' || MonerodProcess.replaceAll(monerodPath, " ", "") == "") {
return false;
}
try {
MonerodProcess.checkExecutable(monerodPath);
}
catch {
return false;
}
const proc = new AppChildProcess({
command: monerodPath,
args: [ '--help' ],
isExe: true
});
const promise = new Promise<boolean>((resolve) => {
let foundUsage: boolean = false;
proc.onError((err: Error) => {
console.log(`MonerodProcess.isValidMonerodPath(): '${err.message}'`);
resolve(false);
});
proc.onStdErr((err: string) => {
console.log(`MonerodProcess.isValidMonerodPath(): '${err}'`);
resolve(false);
});
proc.onStdOut((data: string) => {
if (foundUsage) {
return;
}
console.log(`MonerodProcess.isValidMonerodPath(): '${data}'`);
if (
`${data}`.includes('monerod [options|settings] [daemon_command...]') ||
`${data}`.includes('monerod.exe [options|settings] [daemon_command...]')
) {
foundUsage = true;
}
});
proc.onClose((code: number | null) => {
console.log(`MonerodProcess.isValidMonerodPath(): exit code '${code}', found usage: ${foundUsage}`);
resolve(foundUsage);
});
});
try {
await proc.start();
}
catch(error: any) {
console.log(`MonerodProcess.isValidMonerodPath(): exit code '${error}'`);
}
return await promise;
}
public override async start(): Promise<void> {
if (this._isExe) {
const validPath = await MonerodProcess.isValidMonerodPath(this._command);
if (!validPath) {
throw new Error("Invalid monerod path provided: " + this._command);
}
}
let message: string = "Starting monerod process";
message += `\n\t${this._isExe ? 'Path' : 'Command'}: ${this._command}`;
if (this._args) {
message += `\n\tFlags: ${this._args.join(" ")}`
}
console.log(message);
let firstPatternFound = false;
const patternPromise = new Promise<void>((resolve, reject) => {
let firstStdout = true;
let timeout: NodeJS.Timeout | undefined = undefined;
const onStdOut = (out: string) => {
if (firstStdout) {
firstStdout = false;
timeout = setTimeout(() => {
if (this._process && this._process.exitCode == null) {
this._process.kill();
}
timeout = undefined;
reject(new Error("Timeout out"));
}, 90*1000);
}
const foundPattern = out.includes(MonerodProcess.stdoutPattern);
if (!foundPattern) {
return;
}
if (firstPatternFound) {
if(timeout !== undefined) clearTimeout(timeout);
resolve();
}
else {
firstPatternFound = true;
}
};
this.onStdOut(onStdOut);
});
await super.start();
if (!this._process || !this._process.pid) {
throw new Error("Monerod process did not start!");
}
try {
const waitForPattern = this._args ? !this._args.includes('--version') && !this.args.includes('--help') : true;
if (waitForPattern) await patternPromise;
console.log("Started monerod process pid: " + this._process.pid);
}
catch(error: any) {
this._running = false;
this._starting = false;
this._stopping = false;
if (error instanceof Error) {
throw error;
}
else {
throw new Error(`${error}`);
}
}
}
public async getVersion(): Promise<string> {
const proc = new MonerodProcess({
monerodCmd: this._command,
flags: [ '--version' ],
isExe: this._isExe
});
const promise = new Promise<string>((resolve, reject) => {
proc.onError((err: Error) => {
console.log("proc.onError():");
console.error(err);
reject(err)
});
proc.onStdErr((err: string) => {
console.log("proc.onStdErr()");
console.error(err);
reject(new Error(err));
});
proc.onStdOut((version: string) => {
console.log("proc.onStdOut():");
console.log(version);
resolve(version);
});
});
console.log("Before proc.start()");
await proc.start();
console.log("After proc.start()");
return await promise;
}
}

View file

@ -0,0 +1,36 @@
export interface ProcessStats {
/**
* percentage (from 0 to 100*vcore)
*/
cpu: number;
/**
* bytes
*/
memory: number;
/**
* PPID
*/
ppid: number;
/**
* PID
*/
pid: number;
/**
* ms user + system time
*/
ctime: number;
/**
* ms since the start of the process
*/
elapsed: number;
/**
* ms since epoch
*/
timestamp: number;
}

4
app/process/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { AppMainProcess } from "./AppMainProcess";
export { ProcessStats } from "./ProcessStats";
export { AppChildProcess } from "./AppChildProcess";
export { MonerodProcess } from "./MonerodProcess";

39
app/utils/BatteryUtils.ts Normal file
View file

@ -0,0 +1,39 @@
import * as os from 'os';
import { exec, ExecException } from "child_process";
import { powerMonitor } from "electron";
const batteryLevel = require('battery-level');
export abstract class BatteryUtils {
public static async isOnBatteryPower(): Promise<boolean> {
const onBattery = powerMonitor.isOnBatteryPower();
if (!onBattery && os.platform() == 'linux') {
return await new Promise<boolean>((resolve) => {
exec("upower -i $(upower -e | grep 'battery') | grep 'state'", (error: ExecException | null, stdout: string) => {
if (error) {
console.error(`isOnBatteryPower(): ${error.message}`);
resolve(false);
return;
}
const isOnBattery = stdout.includes("discharging");
resolve(isOnBattery);
});
});
}
return onBattery;
}
public static async getLevel(): Promise<number> {
try {
return batteryLevel();
}
catch(error: any) {
console.error(error);
return -1;
}
}
}

185
app/utils/FileUtils.ts Normal file
View file

@ -0,0 +1,185 @@
import * as fs from 'fs';
import * as tar from 'tar';
import * as path from 'path';
import * as https from 'https';
import { createHash } from 'crypto';
const AdmZip = require('adm-zip');
const bz2 = require('unbzip2-stream');
export abstract class FileUtils {
public static async downloadFile(url: string, destinationDir: string, onProgress: (progress: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const request = (url: string) => {
https.get(url, (response) => {
if (response.statusCode === 200) {
const contentDisposition = response.headers['content-disposition'];
let finalFilename = '';
// Estrai il nome del file dall'URL o dal content-disposition
if (contentDisposition && contentDisposition.includes('filename')) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
finalFilename = match[1];
}
} else {
// Se non c'è content-disposition, prendiamo il nome dall'URL
finalFilename = url.split('/').pop() || 'downloaded-file';
}
const destination = `${destinationDir}/${finalFilename}`;
let file: fs.WriteStream;
try {
file = fs.createWriteStream(destination);
file.on('error', (error: Error) => {
console.log("file error: " + error);
reject(error);
});
}
catch (error: any) {
reject(error);
return;
}
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
const progress = (downloadedBytes / totalBytes) * 100;
onProgress(progress); // Notifica il progresso
});
response.pipe(file);
file.on('finish', () => {
file.close(() => resolve(finalFilename)); // Restituisci il nome del file finale
});
} else if (response.statusCode === 301 || response.statusCode === 302) {
// Se è un redirect, effettua una nuova richiesta verso il location header
const newUrl = response.headers.location;
if (newUrl) {
request(newUrl); // Ripeti la richiesta con il nuovo URL
} else {
reject(new Error('Redirection failed without a location header'));
}
} else {
reject(new Error(`Failed to download: ${response.statusCode}`));
}
}).on('error', (err) => {
reject(err);
});
};
request(url); // Inizia la richiesta
});
};
public static async checkFileHash(filePath: string, hash: string): Promise<boolean> {
const fileHash = await this.calculateFileHash(filePath);
return fileHash === hash;
}
public static calculateFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const fileStream = fs.createReadStream(filePath);
fileStream.on('data', (data) => {
hash.update(data);
});
fileStream.on('end', () => {
resolve(hash.digest('hex'));
});
fileStream.on('error', (err) => {
reject(err);
});
});
};
//#region Extraction
public static async extractTarBz2(filePath: string, destination: string): Promise<string> {
return await new Promise((resolve, reject) => {
// Crea il file decomprimendo il .bz2 in uno .tar temporaneo
const tarPath = path.join(destination, 'temp.tar');
const fileStream = fs.createReadStream(filePath);
const decompressedStream = fileStream.pipe(bz2());
const writeStream = fs.createWriteStream(tarPath);
decompressedStream.pipe(writeStream);
let extractedDir: string = '';
writeStream.on('finish', () => {
// Una volta che il file .tar è stato creato, estrailo
tar.extract({ cwd: destination, file: tarPath, onReadEntry: (entry: tar.ReadEntry) => {
if (extractedDir == '') {
const topLevelDir = entry.path.split('/')[0];
extractedDir = topLevelDir; // Salva la prima directory
}
} })
.then(() => {
// Elimina il file .tar temporaneo dopo l'estrazione
fs.unlink(tarPath, (err) => {
if (err) reject(err);
else if (extractedDir == '') reject('Extraction failed')
else resolve(extractedDir);
});
})
.catch(reject);
});
writeStream.on('error', reject);
});
};
public static async extractZip(filePath: string, destination: string): Promise<string> {
return await new Promise<string>((resolve, reject) => {
try {
const zip = new AdmZip(filePath);
// Ensure destination exists
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
// Extract the ZIP file
zip.extractAllTo(destination, true);
// Get the name of the extracted folder
const extractedEntries = zip.getEntries();
const folderName = extractedEntries[0]?.entryName.split('/')[0];
// Ensure folder name exists
if (!folderName) {
reject(new Error("Could not determine the extracted folder name"));
return;
}
resolve(path.join(destination, folderName));
} catch (error) {
reject(error);
}
});
};
public static async extract(filePath: string, destination: string): Promise<string> {
if (filePath.endsWith('.zip')) {
return await this.extractZip(filePath, destination);
}
else if (filePath.endsWith('.tar.bz2')) {
return await this.extractTarBz2(filePath, destination);
}
throw new Error("Unknown file type " + filePath);
}
//#endregion
}

71
app/utils/NetworkUtils.ts Normal file
View file

@ -0,0 +1,71 @@
import { exec, ExecException } from 'child_process';
import * as os from 'os';
const network = require('network');
export abstract class NetworkUtils {
public static isConnectedToWiFi(): Promise<boolean> {
try {
return new Promise<boolean>((resolve, reject) => {
network.get_active_interface((err: any | null, obj: { name: string, ip_address: string, mac_address: string, type: string, netmask: string, gateway_ip: string }) => {
if (err) {
console.error(err);
reject(err);
}
else {
resolve(obj.type == 'Wireless');
}
})
});
}
catch(error: any) {
return this.isConnectedToWiFiNative();
}
}
private static isConnectedToWiFiNative(): Promise<boolean> {
return new Promise((resolve, reject) => {
const platform = os.platform(); // Use os to get the platform
let command = '';
if (platform === 'win32') {
// Windows: Use 'netsh' command to check the Wi-Fi status
command = 'netsh wlan show interfaces';
} else if (platform === 'darwin') {
// macOS: Use 'airport' command to check the Wi-Fi status
command = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I | grep 'state: running'";
} else if (platform === 'linux') {
// Linux: Use 'nmcli' to check for Wi-Fi connectivity
command = 'nmcli dev status';
} else {
resolve(false); // Unsupported platform
}
// Execute the platform-specific command
if (command) {
exec(command, (error: ExecException | null, stdout: string, stderr: string) => {
if (error) {
console.error(error);
reject(stderr);
resolve(false); // In case of error, assume not connected to Wi-Fi
} else {
// Check if the output indicates a connected status
if (stdout) {
const components: string[] = stdout.split("\n");
components.forEach((component: string) => {
if (component.includes('wifi') && !component.includes('--')) {
resolve(true);
}
});
resolve(false);
} else {
resolve(false);
}
}
});
}
});
}
}

3
app/utils/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { BatteryUtils } from "./BatteryUtils";
export { FileUtils } from "./FileUtils";
export { NetworkUtils } from "./NetworkUtils";

View file

@ -249,8 +249,19 @@ export class DaemonService {
}
public async saveSettings(settings: DaemonSettings, restartDaemon: boolean = true): Promise<void> {
settings.assertValid();
if (settings.monerodPath != '') {
const valid = await this.checkValidMonerodPath(settings.monerodPath);
if (!valid) {
throw new Error("Invalid monerod path provided");
}
}
const db = await this.openDbPromise;
await db.put(this.storeName, { id: 1, ...settings });
this.onSavedSettings.emit(settings);
if (restartDaemon) {
@ -276,6 +287,7 @@ export class DaemonService {
const checkPromise = new Promise<boolean>((resolve) => {
window.electronAPI.onCheckValidMonerodPath((event: any, valid: boolean) => {
window.electronAPI.unregisterOnCheckValidMonerodPath();
resolve(valid);
});
});

View file

@ -12,7 +12,7 @@ export class ElectronService {
childProcess!: typeof childProcess;
fs!: typeof fs;
private _isAppImage?: boolean;
private _isPortable?: boolean;
private _isAutoLaunched?: boolean;
private _online: boolean = false;
private _isProduction: boolean = false;
@ -124,7 +124,7 @@ export class ElectronService {
}
public async isAutoLaunchEnabled(): Promise<boolean> {
if (await this.isAppImage()) {
if (await this.isPortable()) {
return false;
}
@ -141,7 +141,7 @@ export class ElectronService {
}
public async enableAutoLaunch(minimized: boolean): Promise<void> {
if (await this.isAppImage()) {
if (await this.isPortable()) {
throw new Error("Cannot enable auto launch");
}
@ -174,7 +174,7 @@ export class ElectronService {
public async disableAutoLaunch(): Promise<void> {
if (await this.isAppImage()) {
if (await this.isPortable()) {
throw new Error("Cannot disable auto launch");
}
@ -205,21 +205,21 @@ export class ElectronService {
await promise;
}
public async isAppImage(): Promise<boolean> {
if (this._isAppImage === undefined) {
public async isPortable(): Promise<boolean> {
if (this._isPortable === undefined) {
const promise = new Promise<boolean>((resolve) => {
window.electronAPI.onIsAppImage((event: any, value: boolean) => {
window.electronAPI.unregisterOnIsAppImage();
window.electronAPI.onIsPortable((event: any, value: boolean) => {
window.electronAPI.unregisterIsPortable();
resolve(value);
});
});
window.electronAPI.isAppImage();
window.electronAPI.isPortable();
this._isAppImage = await promise;
this._isPortable = await promise;
}
return this._isAppImage;
return this._isPortable;
}
public async selectFile(extensions?: string[]): Promise<string> {

View file

@ -45,7 +45,7 @@
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<div *ngIf="setBansSuccess" class="alert alert-success d-flex align-items-center justify-content-center text-center" role="alert">
<h4><i class="bi bi-check-circle m-2"></i></h4>&nbsp;&nbsp;
<div>

View file

@ -49,7 +49,7 @@
</div>
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-10">
<div class="col-md-12 col-lg-10">
<h4 class="mb-3">Set the daemon log level</h4>
<div class="col-md-4">
@ -88,7 +88,7 @@
</div>
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-10">
<div class="col-md-12 col-lg-10">
<h4 class="mb-3">Set the daemon log categories</h4>
<div class="row gy-3">

View file

@ -482,7 +482,7 @@
<div class="row g-5 p-2">
<div class="cold-md-7 col-lg-12">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">Submit a mined block to the network</h4>
<form class="needs-validation" novalidate="">

View file

@ -30,7 +30,7 @@
<div class="tab-pane fade show active" id="pills-general" role="tabpanel" aria-labelledby="pills-general-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">Monerod</h4>
<div class="col-md-12">
@ -38,6 +38,7 @@
<div class="input-group mb-3">
<input id="general-monerod-path-control" type="text" class="form-control form-control-sm" placeholder="" aria-label="Monerod path" aria-describedby="basic-addon2" [value]="currentSettings.monerodPath" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="chooseMonerodFile()"><i class="bi bi-filetype-exe"></i> Choose executable</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeMonerodFile()" [disabled]="currentSettings.monerodPath === ''"><i class="bi bi-file-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Path to monerod executable</small>
</div>
@ -105,7 +106,7 @@
<div class="tab-pane fade" id="pills-node" role="tabpanel" aria-labelledby="pills-node-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">Node</h4>
<div class="row gy-3">
@ -121,14 +122,14 @@
<small class="text-body-secondary">Maximum number of connections allowed from the same IP address</small>
</div>
<div *ngIf="!isAppImage" class="form-check form-switch col-md-6">
<div *ngIf="!isPortable" class="form-check form-switch col-md-6">
<label for="start-at-login" class="form-check-label">Start at login</label>
<input class="form-control form-check-input" type="checkbox" role="switch" id="start-at-login" [checked]="currentSettings.startAtLogin" [(ngModel)]="currentSettings.startAtLogin" [ngModelOptions]="{standalone: true}">
<br>
<small class="text-body-secondary">Start monero daemon at login</small>
</div>
<div *ngIf="!isAppImage" class="form-check form-switch col-md-6">
<div *ngIf="!isPortable" class="form-check form-switch col-md-6">
<label for="start-minimized" class="form-check-label">Start minimized</label>
<input class="form-control form-check-input" type="checkbox" role="switch" id="start-minimized" [checked]="currentSettings.startAtLoginMinimized" [(ngModel)]="currentSettings.startAtLoginMinimized" [ngModelOptions]="{standalone: true}">
<br>
@ -147,6 +148,7 @@
<div class="input-group mb-3">
<input id="data-dir" type="text" class="form-control form-control-sm" placeholder="" aria-label="Data dir" aria-describedby="basic-addon2" [value]="currentSettings.dataDir" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="chooseDataDir()"><i class="bi bi-folder"></i> Choose folder</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeDataDir()" [disabled]="currentSettings.dataDir === ''"><i class="bi bi-folder-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Specify data directory</small>
</div>
@ -271,6 +273,7 @@
<div class="input-group mb-3">
<input id="upgrade-download-path=" type="text" class="form-control form-control-sm" placeholder="" aria-label="Monerod path" aria-describedby="basic-addon2" [value]="currentSettings.downloadUpgradePath" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="chooseMoneroDownloadPath()"><i class="bi bi-folder"></i> Choose folder</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeMoneroDownloadPath()" [disabled]="currentSettings.downloadUpgradePath === ''"><i class="bi bi-folder-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Folder where to save updates</small>
</div>
@ -283,7 +286,7 @@
<div class="tab-pane fade" id="pills-rpc" role="tabpanel" aria-labelledby="pills-rpc-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">General</h4>
<div class="row gy-3">
@ -484,7 +487,8 @@
<label for="rpc-ssl-private-key" class="form-label">Private key</label>
<div class="input-group mb-3">
<input id="rpc-ssl-private-key" type="text" class="form-control form-control-sm" placeholder="" aria-label="Monerod path" aria-describedby="basic-addon2" [value]="currentSettings.rpcSslPrivateKey" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslPrivateKey()"><i class="bi bi-filetype-key"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslPrivateKey()"><i class="bi bi-filetype-key"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeSslPrivateKey()" [disabled]="currentSettings.rpcSslPrivateKey === ''"><i class="bi bi-file-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Path to a PEM format private key</small>
</div>
@ -494,7 +498,8 @@
<label for="rpc-ssl-certificate" class="form-label">Certificate</label>
<div class="input-group mb-3">
<input id="rpc-ssl-certificate" type="text" class="form-control form-control-sm" placeholder="" aria-label="RPC SSL Certificate" aria-describedby="basic-addon2" [value]="currentSettings.rpcSslCertificate" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslCertificate()"><i class="bi bi-postcard"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslCertificate()"><i class="bi bi-postcard"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeSslCertificate()" [disabled]="currentSettings.rpcSslCertificate === ''"><i class="bi bi-file-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Path to a PEM format certificate</small>
</div>
@ -504,7 +509,8 @@
<label for="rpc-ssl-ca-certificates" class="form-label">CA Certificates</label>
<div class="input-group mb-3">
<input id="rpc-ssl-ca-certificates" type="text" class="form-control form-control-sm" placeholder="" aria-label="RPC SSL CA Certificates" aria-describedby="basic-addon2" [value]="currentSettings.rpcSslCACertificates" readonly>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslCACertificates()"><i class="bi bi-postcard"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon2"><button type="button" class="btn btn-secondary btn-sm" (click)="selectSslCACertificates()"><i class="bi bi-postcard"></i> Choose file</button></span>
<span class="input-group-text" id="basic-addon3"><button type="button" class="btn btn-secondary btn-sm" (click)="removeSslCACertificates()" [disabled]="currentSettings.rpcSslCACertificates === ''"><i class="bi bi-file-x"></i> Remove</button></span>
</div>
<small class="text-body-secondary">Path to file containing concatenated PEM format certificate(s) to replace system CA(s)</small>
</div>
@ -517,7 +523,7 @@
<div class="tab-pane fade" id="pills-p2p" role="tabpanel" aria-labelledby="pills-p2p-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">General</h4>
<div class="row gy-3">
@ -580,7 +586,7 @@
<div class="tab-pane fade" id="pills-blockchain" role="tabpanel" aria-labelledby="pills-blockchain-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<form class="needs-validation" novalidate="">
<h4 class="mb-3">Network type</h4>
@ -772,7 +778,7 @@
<div class="tab-pane fade" id="pills-mining" role="tabpanel" aria-labelledby="pills-mining-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<form class="needs-validation" novalidate="">
<div class="row g-3">
@ -819,7 +825,7 @@
<div class="tab-pane fade" id="pills-logs" role="tabpanel" aria-labelledby="pills-logs-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<form class="needs-validation" novalidate="">
<div class="row g-3">

View file

@ -85,7 +85,7 @@ export class SettingsComponent {
});
}
public isAppImage: boolean = true;
public isPortable: boolean = true;
public refreshSyncMode(): void {
setTimeout(() => {
@ -128,7 +128,7 @@ export class SettingsComponent {
this.loading = false;
this.isAppImage = await this.electronService.isAppImage();
this.isPortable = await this.electronService.isPortable();
this.networkType = this.currentSettings.mainnet ? 'mainnet' : this.currentSettings.testnet ? 'testnet' : this.currentSettings.stagenet ? 'stagenet' : 'mainnet';
}
@ -203,7 +203,7 @@ export class SettingsComponent {
}
private async refreshAutoLanch(minimizeChanged: boolean): Promise<void> {
if (await this.electronService.isAppImage()) {
if (await this.electronService.isPortable()) {
return;
}
@ -232,11 +232,7 @@ export class SettingsComponent {
this.savingChanges = true;
try {
if (this.currentSettings.upgradeAutomatically && this.currentSettings.downloadUpgradePath == '') {
throw new Error('You must set a download path for monerod updates when enabling automatic upgrade');
}
try {
const oldStartMinimized: boolean = this.originalSettings.startAtLoginMinimized;
await this.daemonService.saveSettings(this.currentSettings);
@ -343,6 +339,10 @@ export class SettingsComponent {
}
}
public removeMonerodFile(): void {
this.currentSettings.monerodPath = '';
}
public async chooseMonerodFile(): Promise<void> {
const spec = await this.getMonerodFileSpec();
const file = await this.electronService.selectFile(spec.extensions);
@ -417,6 +417,10 @@ export class SettingsComponent {
});
}
public removeSslPrivateKey(): void {
this.currentSettings.rpcSslPrivateKey = '';
}
public async selectSslCertificate(): Promise<void> {
const cert = await this.choosePemFile();
@ -427,6 +431,10 @@ export class SettingsComponent {
});
}
public removeSslCertificate(): void {
this.currentSettings.rpcSslCertificate = '';
}
public async selectSslCACertificates(): Promise<void> {
const cert = await this.choosePemFile();
@ -436,6 +444,10 @@ export class SettingsComponent {
this.currentSettings.rpcSslCACertificates = cert;
});
}
public removeSslCACertificates(): void {
this.currentSettings.rpcSslCACertificates = '';
}
public async chooseMoneroDownloadPath(): Promise<void> {
const folder = await this.electronService.selectFolder();
@ -450,6 +462,10 @@ export class SettingsComponent {
});
}
public removeMoneroDownloadPath(): void {
this.currentSettings.downloadUpgradePath = '';
}
public async chooseDataDir(): Promise<void> {
const folder = await this.electronService.selectFolder();
@ -462,6 +478,10 @@ export class SettingsComponent {
});
}
public removeDataDir(): void {
this.currentSettings.dataDir = '';
}
public chooseXmrigFile(): void {
const input = document.getElementById('general-xmrig-path');

View file

@ -128,7 +128,7 @@
<div class="tab-pane fade" id="pills-relay-tx" role="tabpanel" aria-labelledby="pills-relay-tx-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<div *ngIf="relaySuccess" class="alert alert-success d-flex align-items-center justify-content-center text-center" role="alert">
<h4><i class="bi bi-send-check m-2"></i></h4>&nbsp;&nbsp;
<div>
@ -174,7 +174,7 @@
<div class="tab-pane fade" id="pills-send-raw-tx" role="tabpanel" aria-labelledby="pills-send-raw-tx-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<div *ngIf="sendRawTxSuccess" class="alert alert-success d-flex align-items-center justify-content-center text-center" role="alert">
<h4><i class="bi bi-send-check m-2"></i></h4>&nbsp;&nbsp;
<div>
@ -242,7 +242,7 @@
<hr *ngIf="getFeeEstimateSuccess" class="my-4">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<form class="needs-validation" novalidate="">
<div class="row g-3">
@ -285,7 +285,7 @@
<div class="tab-pane fade" id="pills-coinbase-tx-sum" role="tabpanel" aria-labelledby="pills-coinbase-tx-sum-tab" tabindex="0">
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<div *ngIf="getCoinbaseTxSumError !== ''" class="alert alert-danger d-flex align-items-center justify-content-center text-center" role="alert">
<h4><i class="bi bi-exclamation-triangle m-2"></i></h4>&nbsp;&nbsp;
@ -346,7 +346,7 @@
</div>
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">Flush a list of transaction IDs</h4>
<form class="needs-validation" novalidate="">
<div class="row g-3">
@ -389,7 +389,7 @@
</div>
<div class="row g-5 p-2">
<div class="col-md-7 col-lg-12">
<div class="col-md-12 col-lg-12">
<h4 class="mb-3">Flush bad transactions / blocks from the cache</h4>
<form class="needs-validation" novalidate="">
<div class="row gy-3">

View file

@ -60,7 +60,7 @@
<button *ngIf="!loading && !upgrading && !installing" class="w-100 btn btn-primary btn-lg" type="submit" (click)="upgrade()" [disabled]="buttonDisabled">{{ buttonTitle }}</button>
<button *ngIf="!loading && upgrading || installing" class="w-100 btn btn-primary btn-lg" type="button" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ upgrading ? 'Upgrading' : 'Installing' }} monerod
{{ upgrading ? 'Upgrading' : 'Installing' }} monerod {{ downloadProgress }}
</button>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { AfterViewInit, Component } from '@angular/core';
import { AfterViewInit, Component, NgZone } from '@angular/core';
import { NavbarLink } from '../../shared/components/navbar/navbar.model';
import { DaemonService } from '../../core/services/daemon/daemon.service';
import { SimpleBootstrapCard } from '../../shared/utils';
@ -52,7 +52,7 @@ export class VersionComponent implements AfterViewInit {
return 'Upgrade';
}
constructor(private daemonData: DaemonDataService, private daemonService: DaemonService, private electronService: ElectronService, private moneroInstaller: MoneroInstallerService) {
constructor(private daemonData: DaemonDataService, private daemonService: DaemonService, private electronService: ElectronService, private moneroInstaller: MoneroInstallerService, private ngZone: NgZone) {
this.links = [
new NavbarLink('pills-monero-tab', '#pills-monero', 'pills-monero', true, 'Monero')
];
@ -62,7 +62,7 @@ export class VersionComponent implements AfterViewInit {
private createCards(): SimpleBootstrapCard[] {
return [
new SimpleBootstrapCard('GUI Version', this.daemonService.getGuiVersion()),
new SimpleBootstrapCard('Current Monerod version', this.currentVersion ? this.currentVersion.fullname : 'Not found', this.loading),
new SimpleBootstrapCard('Current Monerod version', this.currentVersion ? this.currentVersion.fullname : 'Not found', this.loading || this.installing),
new SimpleBootstrapCard('Latest Monerod version', this.latestVersion ? this.latestVersion.fullname : 'Error', this.loading)
];
}
@ -109,19 +109,21 @@ export class VersionComponent implements AfterViewInit {
}
public async load(): Promise<void> {
this.loading = true;
await this.ngZone.run(async () => {
this.loading = true;
try {
this.settings = await this.daemonService.getSettings();
await this.refreshLatestVersion();
await this.refreshCurrentVersion();
}
catch(error: any) {
console.error(error);
this.cards = this.createErrorCards();
}
this.loading = false;
try {
this.settings = await this.daemonService.getSettings();
await this.refreshLatestVersion();
await this.refreshCurrentVersion();
}
catch(error: any) {
console.error(error);
this.cards = this.createErrorCards();
}
this.loading = false;
});
}
public get upgrading(): boolean {
@ -134,7 +136,13 @@ export class VersionComponent implements AfterViewInit {
public upgradeSuccess: boolean = false;
public upgradeError: string = '';
public downloadProgress: number = 100;
public get downloadProgress(): string {
const ratio = this.moneroInstaller.installing ? this.moneroInstaller.progress.progress : 0;
return `${ratio <= 100 ? ratio.toFixed(2) : 100} %`;
}
public downloadStatus : string = '';
public async upgrade(): Promise<void> {
@ -161,17 +169,23 @@ export class VersionComponent implements AfterViewInit {
this.upgradeError = '';
this.upgradeSuccess = true;
await this.load();
}
catch(error: any) {
console.error(error);
this.upgradeSuccess = false;
let err = StringUtils.replaceAll(`${error}`, 'Error: ','');
const err = StringUtils.replaceAll(`${error}`, 'Error: ','');
if (err.includes('permission denied')) {
if (err.includes('permission denied') || err.includes('operation not permitted')) {
const settings = await this.daemonService.getSettings();
this.upgradeError = 'Cannot download monerod to ' + settings.downloadUpgradePath;
this.upgradeError = `Cannot download monerod to <strong>${settings.downloadUpgradePath}</strong> due to insufficient permissions`;
settings.downloadUpgradePath = '';
await this.daemonService.saveSettings(settings);
}
else {
this.upgradeError = err;

View file

@ -150,6 +150,24 @@ export class DaemonSettings {
return this.banList.split('\n');
}
public assertValid(): void {
if (this.upgradeAutomatically && this.downloadUpgradePath == '') {
throw new Error('You must set a download path for monerod updates when enabling automatic upgrade');
}
if (this.monerodPath != '' && this.downloadUpgradePath != '') {
const separator: '\\' | '/' = this.monerodPath.includes('\\') ? '\\' : '/';
const monerodDirComponents = this.monerodPath.split(separator);
monerodDirComponents.pop();
if (monerodDirComponents.join(separator) == this.downloadUpgradePath || this.monerodPath == this.downloadUpgradePath) {
throw new Error("You must choose a download path other than the monerod path")
}
}
}
public equals(settings: DaemonSettings): boolean {
//return this.toCommandOptions().join('') == settings.toCommandOptions().join('');
return this.deepEqual(this, settings);

View file

@ -87,6 +87,7 @@ declare global {
onDownloadProgress: (callback: (event: any, progress: { progress: number, status: string }) => void) => void;
checkValidMonerodPath: (path: string) => void;
onCheckValidMonerodPath: (callback: (event: any, valid: boolean) => void) => void;
unregisterOnCheckValidMonerodPath: () => void;
unsubscribeOnMonerodStarted: () => void;
onMoneroClose: (callback: (event: any, code: number) => void) => void;
onMoneroStdout: (callbak: (event: any, out: string) => void) => void;
@ -120,9 +121,9 @@ declare global {
showNotification: (options: NotificationConstructorOptions) => void;
quit: () => void;
isAppImage: () => void;
onIsAppImage: (callback: (event: any, value: boolean) => void) => void;
unregisterOnIsAppImage: () => void;
isPortable: () => void;
onIsPortable: (callback: (event: any, value: boolean) => void) => void;
unregisterIsPortable: () => void;
isAutoLaunchEnabled: () => void;
onIsAutoLaunchEnabled: (callback: (event: any, enabled: boolean) => void) => void;