diff --git a/docs/misc/desktop_logger.md b/docs/misc/desktop_logger.md new file mode 100644 index 0000000000..69f6c6e107 --- /dev/null +++ b/docs/misc/desktop_logger.md @@ -0,0 +1,102 @@ +# Desktop Logger +The desktop application includes a logging library to display various types of log in the console or in a file. + +Four (or five if we count 'mute') log levels are currently implemented: +- error (1) +- warn (2) +- info (3) +- debug (4) + +All messages with an inferior level to the selected one will be displayed. For example, if the selected log level is _info_, then it will also display _warn_ and _error_ messages. + +## How to enable logging +Logging can be enabled by running Suite with the command line flag `--log-level=LEVEL` (replace _LEVEL_ with _error_, _warn_, _info_ or _debug_ based on the logging you wish to display). Additional command line flags can be found on the [Suite-Desktop page](../packages/suite-desktop.md). + +## API +### Exported Types +#### LogLevel +Any of the following values: +- mute (0) +- error (1) +- warn (2) +- info (3) +- debug (4) + +#### Options +| name | type | default value | description | +| --- | --- | --- | --- | +| colors | boolean | `true` | Console output has colors | +| writeToConsole | boolean | `true` | Output is displayed in the console | +| writeToDisk | boolean | `false` | Output is written to a file | +| outputFile | string | `'log-%ts.txt'` | file name for the output | +| outputPath | string | Electron app log path or CWD | path for the output | +| logFormat | string | `'%dt - %lvl(%top): %msg'` | Output format of the log | + +### String formatters +The options `outputFile` and `logFormat` can be used with some expressions, prefixed with the percent (%) symbol, to apply certain dynamic elements. It is for example possible to display a timestamp or the current date & time in the listed options above. While some expressions can be used in any strings, some strings have their own expressions. + +#### Global +| Expression | Example output | Description | +| --- | --- | --- | +| `%ts` | `1611054460306` | Timestamp | +| `%dt` | `2021-01-19T11:08:22.244Z` | Date and time in ISO format (ISO 8601) | + +#### logFormat +| Expression | Example output | Description | +| --- | --- | --- | +| `%lvl` | `INFO` | Level in letters and upper case | +| `%top` | `Example` | Topic | +| `%msg` | `Example message` | Message | + +### Constructor +The constructor has the following parameters: +- level (LogLevel): Selected log level (see LogLevels in **Exported Types** above) +- options (Options): Optional parameter containing settings for the logger (see Options in **Exported Types** above) + +### Log methods +All log methods have the same signature as they are just wrappers around a private logging method. + +The following methods are available: +- `error(topic: string, messages: string | string[]); // level: 1` +- `warn(topic: string, messages: string | string[]); // level: 2` +- `info(topic: string, messages: string | string[]); // level: 3` +- `debug(topic: string, messages: string | string[]); // level: 4` + +Parameters: +- **topic** (string): Message topic +- **messages** (string | string[]): Single message or array of messages which will be displayed one by line. + +### Exit method +The `exit()` method is used to close the write stream of the log file. If you are not planning to write logs to disk, you won't need to use this method. Otherwise it is highly advised to place this inside exit/crash callbacks. + +### Example +```typeScript +const logger = new Logger('warn', { + colors: false, // Turning off colors + logFormat: '%lvl: %msg', // Level and message only + writeToDisk: true, // Write to disk + outputFile: 'log-desktop.txt', // Static file name, will be overwritten if it exists +}); + +// These messages will be printed with the level provided above +logger.error('example', 'This is an example error message with the topic "example"'); +logger.warn('example', 'This is an example warn message with the topic "example"'); + +// And these won't +logger.info('example', 'This is an example info message with the topic "example"'); +logger.debug('example', 'This is an example debug message with the topic "example"'); + +// Closes the write stream (only needed of writing the log file) +logger.exit(); +``` + +## Q&A +### How to format a string? +The library does not have any formatting capabilities as JavaScript already has templating features built-in. Simply use string literals (for example: ```logger.info('Topic', `My string ${myvar}.`)```). + +### How to output an object? +The library does not include any helper for this as there is already a language feature that does this. Simply use `JSON.stringify(myObject)`. + +### How can I write the log in JSON format? +You can change the `logOutput` option to be formatted like JSON. For example: `logOutput: '{ "ts": "%ts", "lvl": "%lvl", "topic": "%top", "message": "%msg" },'`. +In order to display/use it properly, you will have to edit the output a little bit. Wrap all the messages in square brakets ([, ]) and remove the comma (,) from the last message. diff --git a/docs/packages/suite-desktop.md b/docs/packages/suite-desktop.md index 6615d7b6b8..d6c3fa67cb 100644 --- a/docs/packages/suite-desktop.md +++ b/docs/packages/suite-desktop.md @@ -5,6 +5,9 @@ Using VS Code configuration files (inside `.vscode`), Suite Desktop can be built Known issue: The devtools might blank out at launch. If this happens, simply close and re-open the devtools (CTRL + SHIFT + I). +## Logging +_TODO_ + ## Shortcuts _TODO_ @@ -18,3 +21,7 @@ Available flags: | `--disable-csp` | Disables the Content Security Policy. Necessary for using DevTools in a production build. | | `--pre-release` | Tells the auto-updater to fetch pre-release updates. | | `--bridge-dev` | Instruct Bridge to support emulator (starts Bridge with `-e 21324`). | +| `--log-level=NAME` | Set the logging level. Available levels are [name (value)]: error (1), warn (2), info(3), debug (4). All logs with a value equal or lower to the selected log level will be displayed. | +| `--log-write` | Write log to disk | +| `--log-no-print` | Disable the log priting in the console. | +| `--log-output=FILENAME` | Name of the output file (default: log-%ts.txt) | diff --git a/packages/suite-desktop/package.json b/packages/suite-desktop/package.json index ed4932eb8a..c3b5c94820 100644 --- a/packages/suite-desktop/package.json +++ b/packages/suite-desktop/package.json @@ -143,11 +143,13 @@ "afterSign": "scripts/notarize.js" }, "dependencies": { + "chalk": "^4.1.0", "electron-is-dev": "^1.2.0", "electron-localshortcut": "^3.2.1", "electron-store": "^5.1.1", "electron-updater": "^4.3.5", - "node-fetch": "^2.6.1" + "node-fetch": "^2.6.1", + "systeminformation": "^4.34.7" }, "devDependencies": { "@sentry/browser": "^5.29.2", diff --git a/packages/suite-desktop/scripts/build.js b/packages/suite-desktop/scripts/build.js index 8eb762d4fb..bd39ec02bb 100644 --- a/packages/suite-desktop/scripts/build.js +++ b/packages/suite-desktop/scripts/build.js @@ -1,14 +1,21 @@ +const fs = require('fs'); const path = require('path'); +const child_process = require('child_process'); +const glob = require('glob'); const { build } = require('esbuild'); const pkg = require('../package.json'); const electronSource = path.join(__dirname, '..', 'src-electron'); const isDev = process.env.NODE_ENV !== 'production'; +const gitRevision = child_process.execSync('git rev-parse HEAD').toString().trim(); +const modulePath = path.join(electronSource, 'modules'); +const modules = glob.sync(`${modulePath}/**/*.ts`).map(m => `modules/${m.replace(modulePath, '')}`); + console.log('[Electron Build] Starting...'); const hrstart = process.hrtime(); build({ - entryPoints: ['app', 'preload'].map(f => path.join(electronSource, `${f}.ts`)), + entryPoints: ['app.ts', 'preload.ts', ...modules].map(f => path.join(electronSource, f)), platform: 'node', bundle: true, target: 'node12.18.2', // Electron 11 @@ -20,6 +27,9 @@ build({ sourcemap: isDev, minify: !isDev, outdir: path.join(__dirname, '..', 'dist'), + define: { + 'process.env.COMMITHASH': JSON.stringify(gitRevision), + }, }) .then(() => { const hrend = process.hrtime(hrstart); diff --git a/packages/suite-desktop/scripts/dev.js b/packages/suite-desktop/scripts/dev.js index d3c0a37b42..1ab23be3ec 100644 --- a/packages/suite-desktop/scripts/dev.js +++ b/packages/suite-desktop/scripts/dev.js @@ -14,8 +14,7 @@ next.prepare().then(() => { const requestHandler = next.getRequestHandler(); const server = createServer(requestHandler).listen(8000, () => { if (!launchElectron) { - console.error('process.env.LAUNCH_ELECTRON not set to true') - return process.exit(1); + return; } const electron = exec('yarn run dev:run', { diff --git a/packages/suite-desktop/src-electron/__tests__/logger.test.ts b/packages/suite-desktop/src-electron/__tests__/logger.test.ts new file mode 100644 index 0000000000..2987b91c13 --- /dev/null +++ b/packages/suite-desktop/src-electron/__tests__/logger.test.ts @@ -0,0 +1,92 @@ +import fs from 'fs'; +import path from 'path'; +import Logger, { Options } from '../libs/logger'; + +const testOptions: Options = { + colors: false, + logFormat: '%lvl(%top): %msg', + outputFile: 'test-log.txt', + outputPath: __dirname, +}; +const output = path.join(testOptions.outputPath, testOptions.outputFile); + +describe('logger', () => { + let spy: any; + beforeEach(() => { + spy = jest.spyOn(global.console, 'log').mockImplementation(); + }); + afterEach(() => { + spy.mockRestore(); + }); + afterAll(() => { + // Clean up (delete log file) + fs.unlinkSync(output); + }); + + it('Level "mute" should not log any messages', () => { + const logger = new Logger('mute', testOptions); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + expect(spy).toHaveBeenCalledTimes(0); + }); + it('Level "error" should only show error messages', () => { + const logger = new Logger('error', testOptions); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + expect(console.log).toHaveBeenCalledTimes(1); + expect(spy.mock.calls).toEqual([['ERROR(test): A']]); + }); + it('Level "warn" should show error and warning messages', () => { + const logger = new Logger('warn', testOptions); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + expect(console.log).toHaveBeenCalledTimes(2); + expect(spy.mock.calls).toEqual([['ERROR(test): A'], ['WARN(test): B']]); + }); + it('Level "info" should show all messages except debug', () => { + const logger = new Logger('info', testOptions); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + expect(console.log).toHaveBeenCalledTimes(3); + expect(spy.mock.calls).toEqual([['ERROR(test): A'], ['WARN(test): B'], ['INFO(test): C']]); + }); + it('Level "debug" should not log all messages', () => { + const logger = new Logger('debug', testOptions); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + expect(console.log).toHaveBeenCalledTimes(4); + expect(spy.mock.calls).toEqual([ + ['ERROR(test): A'], + ['WARN(test): B'], + ['INFO(test): C'], + ['DEBUG(test): D'], + ]); + }); + it('Output file should be written', async () => { + const logger = new Logger('debug', { + ...testOptions, + writeToDisk: true, + }); + logger.error('test', 'A'); + logger.warn('test', 'B'); + logger.info('test', 'C'); + logger.debug('test', 'D'); + logger.exit(); + + // Delay for file to finish writing + await new Promise(res => setTimeout(res, 1000)); + + const logFile = fs.readFileSync(output, { encoding: 'utf8' }); + expect(logFile).toBe('ERROR(test): A\nWARN(test): B\nINFO(test): C\nDEBUG(test): D\n'); + }); +}); diff --git a/packages/suite-desktop/src-electron/app.ts b/packages/suite-desktop/src-electron/app.ts index ab4664ef75..dc71cf1a0f 100644 --- a/packages/suite-desktop/src-electron/app.ts +++ b/packages/suite-desktop/src-electron/app.ts @@ -3,25 +3,12 @@ import url from 'url'; import { app, BrowserWindow, ipcMain } from 'electron'; import isDev from 'electron-is-dev'; -// Libs -import { RESOURCES, PROTOCOL } from '@lib/constants'; +import { PROTOCOL } from '@lib/constants'; import * as store from '@lib/store'; import { MIN_HEIGHT, MIN_WIDTH } from '@lib/screen'; - -// Modules -import csp from '@module/csp'; -import autoUpdater from '@module/auto-updater'; -import windowControls from '@module/window-controls'; -import tor from '@module/tor'; -import bridge from '@module/bridge'; -import metadata from '@module/metadata'; -import menu from '@module/menu'; -import shortcuts from '@module/shortcuts'; -import devTools from '@module/dev-tools'; -import httpReceiver from '@module/http-receiver'; -import requestFilter from '@module/request-filter'; -import externalLinks from '@module/external-links'; -import fileProtocol from '@module/file-protocol'; +import Logger, { LogLevel } from '@lib/logger'; +import { buildInfo, computerInfo } from '@lib/info'; +import modules from '@lib/modules'; let mainWindow: BrowserWindow; const APP_NAME = 'Trezor Suite'; @@ -33,11 +20,32 @@ const src = isDev slashes: true, }); +// Logger +const log = { + level: app.commandLine.getSwitchValue('log-level') || (isDev ? 'debug' : 'error'), + writeToConsole: !app.commandLine.hasSwitch('log-no-print'), + writeToDisk: app.commandLine.hasSwitch('log-write'), + outputFile: app.commandLine.getSwitchValue('log-output'), +}; + +const logger = new Logger(log.level as LogLevel, { ...log }); + // Globals +global.logger = logger; +global.resourcesPath = isDev + ? path.join(__dirname, '..', 'public', 'static') + : process.resourcesPath; global.quitOnWindowClose = false; +logger.info('main', 'Application starting'); + const init = async () => { + buildInfo(); + await computerInfo(); + const winBounds = store.getWinBounds(); + logger.debug('init', `Create Browswer Window (${winBounds.width}x${winBounds.height})`); + mainWindow = new BrowserWindow({ title: APP_NAME, width: winBounds.width, @@ -54,32 +62,19 @@ const init = async () => { enableRemoteModule: false, preload: path.join(__dirname, 'preload.js'), }, - icon: path.join(RESOURCES, 'images', 'icons', '512x512.png'), + icon: path.join(global.resourcesPath, 'images', 'icons', '512x512.png'), }); // Load page + logger.debug('init', `Load URL (${src})`); mainWindow.loadURL(src); // Modules - menu(mainWindow); - shortcuts(mainWindow, src); - requestFilter(mainWindow); - externalLinks(mainWindow, store); - windowControls(mainWindow, store); - httpReceiver(mainWindow, src); - metadata(); - await bridge(); - await tor(mainWindow, store); - - if (isDev) { - // Dev only modules - devTools(mainWindow); - } else { - // Prod only modules - csp(mainWindow); - fileProtocol(mainWindow, src); - autoUpdater(mainWindow, store); - } + await modules({ + mainWindow, + src, + store, + }); }; app.name = APP_NAME; // overrides @trezor/suite-desktop app name in menu @@ -88,9 +83,11 @@ app.on('ready', init); app.on('before-quit', () => { if (!mainWindow) return; mainWindow.removeAllListeners(); + logger.exit(); }); ipcMain.on('app/restart', () => { + logger.info('main', 'App restart requested'); app.relaunch(); app.exit(); }); diff --git a/packages/suite-desktop/src-electron/index.d.ts b/packages/suite-desktop/src-electron/index.d.ts index aa9be48489..e248593f26 100644 --- a/packages/suite-desktop/src-electron/index.d.ts +++ b/packages/suite-desktop/src-electron/index.d.ts @@ -1,10 +1,54 @@ // Globals declare namespace NodeJS { export interface Global { + logger: ILogger; + resourcesPath: string; quitOnWindowClose: boolean; } } +declare interface ILogger { + /** + * Exit the Logger (will correctly end the log file) + */ + exit(); + /** + * Error message (level: 1) + * @param topic(string) - Log topic + * @param message(string | string[]) - Message content(s) + */ + error(topic: string, message: string | string[]); + /** + * Warning message (level: 2) + * @param topic(string) - Log topic + * @param message(string | string[]) - Message content(s) + */ + warn(topic: string, message: string | string[]); + /** + * Info message (level: 3) + * @param topic(string) - Log topic + * @param message(string | string[]) - Message content(s) + */ + info(topic: string, message: string | string[]); + /** + * Debug message (level: 4) + * @param topic(string) - Log topic + * @param message(string | string[]) - Message content(s) + */ + debug(topic: string, message: string | string[]); + /** + * Log Level getter + */ + level: LogLevel; +} + +// Dependencies +declare type Dependencies = { + mainWindow?: Electron.BrowserWindow; + store?: LocalStore; + src?: string; +}; + // Store declare interface LocalStore { getWinBounds(): WinBounds; diff --git a/packages/suite-desktop/src-electron/libs/constants.ts b/packages/suite-desktop/src-electron/libs/constants.ts index 15457d0cef..c98c4d4a5c 100644 --- a/packages/suite-desktop/src-electron/libs/constants.ts +++ b/packages/suite-desktop/src-electron/libs/constants.ts @@ -1,8 +1,24 @@ -import path from 'path'; -import isDev from 'electron-is-dev'; - export const PROTOCOL = 'file'; -export const RESOURCES = isDev - ? path.join(__dirname, '..', 'public', 'static') - : process.resourcesPath; +export const MODULES = [ + // Event Logging + { name: 'event-logging/process', dependencies: [] }, + { name: 'event-logging/app', dependencies: [] }, + { name: 'event-logging/contents', dependencies: ['mainWindow'] }, + // Standard modules + { name: 'menu', dependencies: ['mainWindow'] }, + { name: 'shortcuts', dependencies: ['mainWindow', 'src'] }, + { name: 'request-filter', dependencies: ['mainWindow'] }, + { name: 'external-links', dependencies: ['mainWindow', 'store'] }, + { name: 'window-controls', dependencies: ['mainWindow', 'store'] }, + { name: 'http-receiver', dependencies: ['mainWindow', 'src'] }, + { name: 'metadata', dependencies: [] }, + { name: 'bridge', dependencies: [] }, + { name: 'tor', dependencies: ['mainWindow', 'store'] }, + // Prod Only + { name: 'csp', dependencies: ['mainWindow'], isDev: false }, + { name: 'file-protocol', dependencies: ['mainWindow', 'src'], isDev: false }, + { name: 'auto-updater', dependencies: ['mainWindow', 'store'], isDev: false }, + // Dev Only + { name: 'dev-tools', dependencies: ['mainWindow'], isDev: true }, +]; diff --git a/packages/suite-desktop/src-electron/libs/http-receiver.ts b/packages/suite-desktop/src-electron/libs/http-receiver.ts index 96a0731e6e..6fddb6c2e4 100644 --- a/packages/suite-desktop/src-electron/libs/http-receiver.ts +++ b/packages/suite-desktop/src-electron/libs/http-receiver.ts @@ -37,6 +37,7 @@ export class HttpReceiver extends EventEmitter { pathname: string; handler: (request: Request, response: http.ServerResponse) => void; }[]; + logger: ILogger; // Possible ports // todo: add more to prevent case when port is blocked @@ -56,6 +57,7 @@ export class HttpReceiver extends EventEmitter { */ ]; this.server = http.createServer(this.onRequest); + this.logger = global.logger; } getServerAddress() { @@ -79,13 +81,13 @@ export class HttpReceiver extends EventEmitter { return new Promise((resolve, reject) => { this.server.on('error', e => { // @ts-ignore - type is missing - if (e.code === 'EADDRINUSE') { - // todo: - } + this.logger.error('http-receiver', `Start error code: ${e.code}`); + // if (e.code === 'EADDRINUSE') {} // TODO: Try different port if already in use this.server.close(); return reject(); }); this.server.listen(HttpReceiver.PORTS[0], '127.0.0.1', undefined, () => { + this.logger.info('http-receiver', 'Server started'); const address = this.getServerAddress(); if (address) { this.emit('server/listening', address); @@ -97,7 +99,9 @@ export class HttpReceiver extends EventEmitter { stop() { // note that this method only stops listening but keeps existing connections open and thus port blocked - this.server.close(); + this.server.close(() => { + this.logger.info('http-receiver', 'Server stopped'); + }); } /** @@ -106,6 +110,7 @@ export class HttpReceiver extends EventEmitter { private onRequest = (request: http.IncomingMessage, response: http.ServerResponse) => { // mostly ts stuff. request should always have url defined. if (!request.url) { + this.logger.warn('http-receiver', 'Unexpected incoming message (no url)'); this.emit('server/error', 'Unexpected incoming message'); return; } @@ -114,15 +119,21 @@ export class HttpReceiver extends EventEmitter { // only method GET is supported if (request.method !== 'GET') { + this.logger.warn('http-receiver', `Incorrect method used (${request.method})`); return; } const route = this.routes.find(r => r.pathname === pathname); - if (route) { - response.setHeader('Content-Type', 'text/html; charset=UTF-8'); - // original type has url as optional, Request alias makes it required. - return route.handler(request as Request, response); + if (!route) { + this.logger.warn('http-receiver', `Route not found for ${pathname}`); + return; } + + this.logger.info('http-receiver', `Handling request for ${pathname}`); + + response.setHeader('Content-Type', 'text/html; charset=UTF-8'); + // original type has url as optional, Request alias makes it required. + return route.handler(request as Request, response); }; // TODO: add option to auto-close after X seconds. Possible? diff --git a/packages/suite-desktop/src-electron/libs/info.ts b/packages/suite-desktop/src-electron/libs/info.ts new file mode 100644 index 0000000000..ba3a5ba0e4 --- /dev/null +++ b/packages/suite-desktop/src-electron/libs/info.ts @@ -0,0 +1,45 @@ +import { app } from 'electron'; +import isDev from 'electron-is-dev'; +import si from 'systeminformation'; + +import { b2t } from '@lib/utils'; +import { toHumanReadable } from '@suite/utils/suite/file'; + +export const buildInfo = () => { + global.logger.info('build', [ + 'Info:', + `- Version: ${app.getVersion()}`, + `- Commit: ${process.env.COMMITHASH}`, + `- Dev: ${b2t(isDev)}`, + `- Args: ${process.argv.slice(1).join(' ')}`, + `- CWD: ${process.cwd()}`, + ]); +}; + +export const computerInfo = async () => { + const { logger } = global; + if (logger.level !== 'debug') { + return; + } + + const { system, cpu, mem, osInfo } = await si.get({ + system: 'manufacturer, model, virtual', + cpu: 'manufacturer, brand, processors, physicalCores, cores, speed', + mem: 'total', + osInfo: 'platform, arch, distro, release', + }); + + logger.debug('computer', [ + 'Info:', + `- Platform: ${osInfo.platform} ${osInfo.arch}`, + `- OS: ${osInfo.distro} ${osInfo.release}`, + `- Manufacturer: ${system.manufacturer}`, + `- Model: ${system.model}`, + `- Virtual: ${b2t(system.virtual)}`, + `- CPU: ${cpu.manufacturer} ${cpu.brand}`, + `- Cores: ${cpu.processors}x${cpu.physicalCores}(+${cpu.cores - cpu.physicalCores}) @ ${ + cpu.speed + }GHz`, + `- RAM: ${toHumanReadable(mem.total)}`, + ]); +}; diff --git a/packages/suite-desktop/src-electron/libs/logger.ts b/packages/suite-desktop/src-electron/libs/logger.ts new file mode 100644 index 0000000000..8f147dab6c --- /dev/null +++ b/packages/suite-desktop/src-electron/libs/logger.ts @@ -0,0 +1,138 @@ +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { app } from 'electron'; + +const logLevels = ['mute', 'error', 'warn', 'info', 'debug'] as const; +export type LogLevel = typeof logLevels[number]; + +export type Options = { + colors?: boolean; // Console output has colors + writeToConsole?: boolean; // Output is displayed in the console + writeToDisk?: boolean; // Output is written to a file + outputFile?: string; // file name for the output + outputPath?: string; // path for the output + logFormat?: string; // Output format of the log +}; + +const defaultOptions: Options = { + colors: true, + writeToConsole: true, + writeToDisk: false, + outputFile: 'log-%ts.txt', + outputPath: app?.getPath('logs') || process.cwd(), + logFormat: '%dt - %lvl(%top): %msg', +}; + +class Logger implements ILogger { + static instance: Logger; + private stream: fs.WriteStream; + private options: Options; + private logLevel = 0; + + constructor(level: LogLevel, options?: Options) { + this.logLevel = logLevels.indexOf(level); + this.options = { + ...defaultOptions, + ...options, + }; + + if (this.logLevel > 0 && this.options.writeToDisk) { + this.stream = fs.createWriteStream( + path.join(this.options.outputPath, this.format(this.options.outputFile)), + ); + } + } + + private log(level: LogLevel, topic: string, message: string | string[]) { + const { writeToConsole, writeToDisk, logFormat } = this.options; + + if (!writeToConsole && !writeToDisk) { + return; + } + + const logLevel = logLevels.indexOf(level); + if (this.logLevel < logLevel) { + return; + } + + const messages: string[] = typeof message === 'string' ? [message] : message; + messages.forEach(m => + this.write( + level, + this.format(logFormat, { + lvl: level.toUpperCase(), + top: topic, + msg: m, + }), + ), + ); + } + + private write(level: LogLevel, message: string) { + if (this.options.writeToConsole) { + console.log(this.options.colors ? this.color(level, message) : message); + } + + if (this.options.writeToDisk) { + this.stream.write(`${message}\n`); + } + } + + private format(format: string, strings: { [key: string]: string } = {}) { + let message = format; + + Object.keys(strings).forEach(k => { + message = message.replace(`%${k}`, strings[k]); + }); + + message = message + .replace('%dt', new Date().toISOString()) + .replace('%ts', (+new Date()).toString()); + + return message; + } + + private color(level: LogLevel, message: string) { + switch (level) { + case 'error': + return chalk.red(message.replace('ERROR', chalk.bold('ERROR'))); + case 'warn': + return message.replace('WARN', chalk.bold.yellow('WARN')); + case 'info': + return message.replace('INFO', chalk.bold.blue('INFO')); + case 'debug': + return message.replace('DEBUG', chalk.bold.cyan('DEBUG')); + default: + return message; + } + } + + public exit() { + if (this.options.writeToDisk) { + this.stream.end(); + } + } + + public error(topic: string, message: string | string[]) { + this.log('error', topic, message); + } + + public warn(topic: string, message: string | string[]) { + this.log('warn', topic, message); + } + + public info(topic: string, message: string | string[]) { + this.log('info', topic, message); + } + + public debug(topic: string, message: string | string[]) { + this.log('debug', topic, message); + } + + public get level() { + return logLevels[this.logLevel]; + } +} + +export default Logger; diff --git a/packages/suite-desktop/src-electron/libs/modules.ts b/packages/suite-desktop/src-electron/libs/modules.ts new file mode 100644 index 0000000000..736e5f829c --- /dev/null +++ b/packages/suite-desktop/src-electron/libs/modules.ts @@ -0,0 +1,43 @@ +import isDev from 'electron-is-dev'; +import { MODULES } from '@lib/constants'; + +const modules = async (dependencies: Dependencies) => { + const { logger } = global; + + logger.info('modules', `Loading ${MODULES.length} modules`); + + await Promise.all( + MODULES.flatMap(async module => { + if (module.isDev !== undefined && module.isDev !== isDev) { + logger.debug( + 'modules', + `${module.name} was skipped because it is configured for a diferent environment`, + ); + return []; + } + + const deps: { [name in keyof Dependencies]: any } = {}; + module.dependencies.forEach((dep: keyof Dependencies) => { + if (dependencies[dep] === undefined) { + logger.error( + 'modules', + `Dependency ${dep} is missing for module ${module.name}`, + ); + throw new Error(); // TODO + } + deps[dep] = dependencies[dep]; + }); + + try { + const m = await import(`./modules/${module.name}`); + return [m.default(deps)]; + } catch (err) { + logger.error('modules', `Couldn't load ${module.name} (${err.toString()})`); + } + }), + ); + + logger.info('modules', 'All modules loaded'); +}; + +export default modules; diff --git a/packages/suite-desktop/src-electron/libs/processes/BaseProcess.ts b/packages/suite-desktop/src-electron/libs/processes/BaseProcess.ts index da786cc43f..b90eef6308 100644 --- a/packages/suite-desktop/src-electron/libs/processes/BaseProcess.ts +++ b/packages/suite-desktop/src-electron/libs/processes/BaseProcess.ts @@ -1,7 +1,7 @@ import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import isDev from 'electron-is-dev'; -import { RESOURCES } from '../constants'; +import { b2t } from '@lib/utils'; export type Status = { service: boolean; @@ -33,6 +33,8 @@ abstract class BaseProcess { startupThrottle: ReturnType | null; supportedSystems = ['linux-arm64', 'linux-x64', 'mac-x64', 'win-x64']; stopped = false; + logger: ILogger; + logTopic: string; /** * @param resourceName Resource folder name @@ -48,6 +50,15 @@ abstract class BaseProcess { ...defaultOptions, ...options, }; + + const { logger } = global; + this.logger = logger; + this.logTopic = `process-${this.processName}`; + + const { system } = this.getPlatformInfo(); + if (!this.isSystemSupported(system)) { + throw new Error(`[${this.resourceName}] unsupported system (${system})`); + } } /** @@ -63,6 +74,7 @@ abstract class BaseProcess { */ async start(params: string[] = []) { if (this.startupThrottle) { + this.logger.warn(this.logTopic, 'Canceling process start (throttle)'); return; } @@ -70,30 +82,38 @@ abstract class BaseProcess { // Service is running, nothing to do if (status.service) { + this.logger.warn(this.logTopic, 'Canceling process start (service running)'); return; } // If the process is running but the service isn't if (status.process) { + this.logger.warn(this.logTopic, 'Process is running but service is not'); // Stop the process await this.stop(); } // Throttle process start if (this.options.startupCooldown && this.options.startupCooldown > 0) { + this.logger.debug( + this.logTopic, + `Setting a restart throttle (${this.options.startupCooldown})`, + ); this.startupThrottle = setTimeout(() => { + this.logger.debug(this.logTopic, 'Clearing throttle'); this.startupThrottle = null; }, this.options.startupCooldown * 1000); } - const { system, ext } = this.getPlatformInfo(); - if (!this.isSystemSupported(system)) { - throw new Error(`[${this.resourceName}] unsupported system (${system})`); - } - this.stopped = false; - const processDir = path.join(RESOURCES, 'bin', this.resourceName, isDev ? system : ''); + const { system, ext } = this.getPlatformInfo(); + const processDir = path.join( + global.resourcesPath, + 'bin', + this.resourceName, + isDev ? system : '', + ); const processPath = path.join(processDir, `${this.processName}${ext}`); const processEnv = { ...process.env }; // library search path for macOS @@ -104,6 +124,13 @@ abstract class BaseProcess { processEnv.LD_LIBRARY_PATH = processEnv.LD_LIBRARY_PATH ? `${processEnv.LD_LIBRARY_PATH}:${processDir}` : `${processDir}`; + + this.logger.info(this.logTopic, [ + 'Starting process:', + `- Path: ${processPath}`, + `- Params: ${params}`, + `- CWD: ${processDir}`, + ]); this.process = spawn(processPath, params, { cwd: processDir, env: processEnv, @@ -117,27 +144,32 @@ abstract class BaseProcess { */ stop() { return new Promise(resolve => { + this.stopped = true; + if (!this.process) { - this.stopped = true; + this.logger.warn(this.logTopic, "Couldn't stop process (already stopped)"); resolve(); return; } + this.logger.info(this.logTopic, 'Stopping process'); this.process.kill(); let timeout = 0; const interval = setInterval(() => { if (!this.process || this.process.killed) { + this.logger.info(this.logTopic, 'Killed successfully'); clearInterval(interval); this.process = null; - this.stopped = true; resolve(); return; } if (this.options.stopKillWait && timeout < this.options.stopKillWait) { + this.logger.info(this.logTopic, 'Still alive, checking again...'); timeout++; } else { + this.logger.info(this.logTopic, 'Still alive, going for the SIGKILL'); this.process.kill('SIGKILL'); } }, 1000); @@ -149,17 +181,20 @@ abstract class BaseProcess { * @param force Force the restart */ async restart() { + this.logger.info(this.logTopic, 'Restarting'); await this.stop(); await this.start(); } onError(err: Error) { - console.error('ERROR', this.processName, err); + this.logger.error(this.logTopic, err.message); } onExit() { + this.logger.info(this.logTopic, `Exited (Stopped: ${b2t(this.stopped)})`); this.process = null; if (this.options.autoRestart && this.options.autoRestart > 0 && !this.stopped) { + this.logger.debug(this.logTopic, 'Auto restarting...'); setTimeout(() => this.start(), this.options.autoRestart * 1000); } } diff --git a/packages/suite-desktop/src-electron/libs/processes/BridgeProcess.ts b/packages/suite-desktop/src-electron/libs/processes/BridgeProcess.ts index 65cec85a2b..6b91d6b931 100644 --- a/packages/suite-desktop/src-electron/libs/processes/BridgeProcess.ts +++ b/packages/suite-desktop/src-electron/libs/processes/BridgeProcess.ts @@ -18,6 +18,7 @@ class BridgeProcess extends BaseProcess { Origin: 'https://electron.trezor.io', }, }); + this.logger.debug(this.logTopic, `Checking status (${resp.status})`); if (resp.status === 200) { const data = await resp.json(); if (data?.version) { @@ -27,8 +28,8 @@ class BridgeProcess extends BaseProcess { }; } } - } catch { - // + } catch (err) { + this.logger.error(this.logTopic, `Status error: ${err.message}`); } // process @@ -39,7 +40,7 @@ class BridgeProcess extends BaseProcess { } async startDev(): Promise { - await this.start(['-e', '21324']); + await super.start(['-e', '21324']); } } diff --git a/packages/suite-desktop/src-electron/libs/screen.ts b/packages/suite-desktop/src-electron/libs/screen.ts index c05713bced..1dd64fa393 100644 --- a/packages/suite-desktop/src-electron/libs/screen.ts +++ b/packages/suite-desktop/src-electron/libs/screen.ts @@ -10,6 +10,7 @@ export const BUFFER = 0.2; // Functions export const getInitialWindowSize = () => { const { bounds } = screen.getPrimaryDisplay(); + const buffer = { width: bounds.width * BUFFER, height: bounds.height * BUFFER, diff --git a/packages/suite-desktop/src-electron/libs/user-data.ts b/packages/suite-desktop/src-electron/libs/user-data.ts index bc03ef54ee..ac7a3d42bd 100644 --- a/packages/suite-desktop/src-electron/libs/user-data.ts +++ b/packages/suite-desktop/src-electron/libs/user-data.ts @@ -15,6 +15,7 @@ export const save = async (directory: string, name: string, content: string) => await fs.promises.writeFile(file, content, 'utf-8'); return { success: true }; } catch (error) { + global.logger.error('user-data', `Save failed: ${error.message}`); return { success: false, error }; } }; @@ -33,6 +34,7 @@ export const read = async (directory: string, name: string) => { const payload = await fs.promises.readFile(file, 'utf-8'); return { success: true, payload }; } catch (error) { + global.logger.error('user-data', `Read failed: ${error.message}`); return { success: false, error }; } }; diff --git a/packages/suite-desktop/src-electron/libs/utils.ts b/packages/suite-desktop/src-electron/libs/utils.ts index 27f60afab4..594aa5151b 100644 --- a/packages/suite-desktop/src-electron/libs/utils.ts +++ b/packages/suite-desktop/src-electron/libs/utils.ts @@ -15,3 +15,6 @@ export const isPortUsed = (port: number) => server.listen(port); }); + +// Bool 2 Text +export const b2t = (bool: boolean) => (bool ? 'Yes' : 'No'); diff --git a/packages/suite-desktop/src-electron/modules/auto-updater.ts b/packages/suite-desktop/src-electron/modules/auto-updater.ts index 3d68c51764..9e44983bc5 100644 --- a/packages/suite-desktop/src-electron/modules/auto-updater.ts +++ b/packages/suite-desktop/src-electron/modules/auto-updater.ts @@ -2,13 +2,17 @@ * Auto Updater feature (notify, download, install) */ -import { app, ipcMain, BrowserWindow } from 'electron'; +import { app, ipcMain } from 'electron'; import { autoUpdater, CancellationToken } from 'electron-updater'; +import { b2t } from '@lib/utils'; +import { toHumanReadable } from '@suite/utils/suite/file'; // Runtime flags const preReleaseFlag = app.commandLine.hasSwitch('pre-release'); -const init = (window: BrowserWindow, store: LocalStore) => { +const init = ({ mainWindow, store }: Dependencies) => { + const { logger } = global; + let isManualCheck = false; let latestVersion = {}; let updateCancellationToken: CancellationToken; @@ -17,45 +21,82 @@ const init = (window: BrowserWindow, store: LocalStore) => { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.allowPrerelease = preReleaseFlag; + autoUpdater.logger = null; + + logger.info('auto-updater', `Is looking for pre-releases? (${b2t(preReleaseFlag)})`); if (updateSettings.skipVersion) { - window.webContents.send('update/skip', updateSettings.skipVersion); + logger.debug('auto-updater', `Set to skip version ${updateSettings.skipVersion}`); + mainWindow.webContents.send('update/skip', updateSettings.skipVersion); } autoUpdater.on('checking-for-update', () => { - window.webContents.send('update/checking'); + logger.info('auto-updater', 'Checking for update'); + mainWindow.webContents.send('update/checking'); }); autoUpdater.on('update-available', ({ version, releaseDate }) => { if (updateSettings.skipVersion === version) { + logger.warn('auto-updater', [ + 'Update is available but was skipped:', + `- Update version: ${version}`, + `- Skip version: ${updateSettings.skipVersion}`, + ]); return; } + logger.warn('auto-updater', [ + 'Update is available:', + `- Update version: ${version}`, + `- Release date: ${releaseDate}`, + `- Manual check: ${b2t(isManualCheck)}`, + ]); + latestVersion = { version, releaseDate, isManualCheck }; - window.webContents.send('update/available', latestVersion); + mainWindow.webContents.send('update/available', latestVersion); // Reset manual check flag isManualCheck = false; }); autoUpdater.on('update-not-available', ({ version, releaseDate }) => { + logger.info('auto-updater', [ + 'No new update is available:', + `- Last version: ${version}`, + `- Last release date: ${releaseDate}`, + `- Manual check: ${b2t(isManualCheck)}`, + ]); + latestVersion = { version, releaseDate, isManualCheck }; - window.webContents.send('update/not-available', latestVersion); + mainWindow.webContents.send('update/not-available', latestVersion); // Reset manual check flag isManualCheck = false; }); autoUpdater.on('error', err => { - window.webContents.send('update/error', err); + logger.error('auto-updater', `An error happened: ${err.toString()}`); + mainWindow.webContents.send('update/error', err); }); autoUpdater.on('download-progress', progressObj => { - window.webContents.send('update/downloading', { ...progressObj }); + logger.debug( + 'auto-updater', + `Downloading ${progressObj.percent}% (${toHumanReadable( + progressObj.transferred, + )}/${toHumanReadable(progressObj.total)})`, + ); + mainWindow.webContents.send('update/downloading', { ...progressObj }); }); autoUpdater.on('update-downloaded', ({ version, releaseDate, downloadedFile }) => { - window.webContents.send('update/downloaded', { version, releaseDate, downloadedFile }); + logger.info('auto-updater', [ + 'Update downloaded:', + `- Last version: ${version}`, + `- Last release date: ${releaseDate}`, + `- Downloaded file: ${downloadedFile}`, + ]); + mainWindow.webContents.send('update/downloaded', { version, releaseDate, downloadedFile }); }); ipcMain.on('update/check', (_, isManual?: boolean) => { @@ -63,19 +104,25 @@ const init = (window: BrowserWindow, store: LocalStore) => { isManualCheck = true; } + logger.info('auto-updater', `Update checking request (manual: ${b2t(isManualCheck)})`); autoUpdater.checkForUpdates(); }); ipcMain.on('update/download', () => { - window.webContents.send('update/downloading', { + logger.info('auto-updater', 'Download requested'); + mainWindow.webContents.send('update/downloading', { percent: 0, bytesPerSecond: 0, total: 0, transferred: 0, }); updateCancellationToken = new CancellationToken(); - autoUpdater.downloadUpdate(updateCancellationToken).catch(() => null); // Suppress error in console + autoUpdater + .downloadUpdate(updateCancellationToken) + .then(() => logger.info('auto-updater', 'Update cancelled')) + .catch(() => null); // Suppress error in console }); ipcMain.on('update/install', () => { + logger.info('auto-updater', 'Installation request'); // This will force the closing of the window to quit the app on Mac global.quitOnWindowClose = true; // https://www.electron.build/auto-update#module_electron-updater.AppUpdater+quitAndInstall @@ -85,11 +132,13 @@ const init = (window: BrowserWindow, store: LocalStore) => { autoUpdater.quitAndInstall(true, true); }); ipcMain.on('update/cancel', () => { - window.webContents.send('update/available', latestVersion); + logger.info('auto-updater', 'Cancel update request'); + mainWindow.webContents.send('update/available', latestVersion); updateCancellationToken.cancel(); }); ipcMain.on('update/skip', (_, version) => { - window.webContents.send('update/skip', version); + logger.info('auto-updater', `Skip version (${version}) request`); + mainWindow.webContents.send('update/skip', version); updateSettings.skipVersion = version; store.setUpdateSettings(updateSettings); }); diff --git a/packages/suite-desktop/src-electron/modules/bridge.ts b/packages/suite-desktop/src-electron/modules/bridge.ts index 1fbfbcae8f..0496b2347b 100644 --- a/packages/suite-desktop/src-electron/modules/bridge.ts +++ b/packages/suite-desktop/src-electron/modules/bridge.ts @@ -3,32 +3,40 @@ */ import { app, session } from 'electron'; import BridgeProcess from '@lib/processes/BridgeProcess'; +import { b2t } from '@lib/utils'; const filter = { urls: ['http://127.0.0.1:21325/*'], }; const bridgeDev = app.commandLine.hasSwitch('bridge-dev'); -const bridge = new BridgeProcess(); const init = async () => { + const { logger } = global; + const bridge = new BridgeProcess(); + session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => { // @ts-ignore electron declares requestHeaders as an empty interface details.requestHeaders.Origin = 'https://electron.trezor.io'; + logger.debug('bridge', `Setting header for ${details.url}`); callback({ cancel: false, requestHeaders: details.requestHeaders }); }); try { + logger.info('bridge', `Starting (Dev: ${b2t(bridgeDev)})`); if (bridgeDev) { await bridge.startDev(); } else { await bridge.start(); } - } catch { - // + } catch (err) { + logger.error('bridge', `Start failed: ${err.message}`); } - app.on('before-quit', () => bridge.stop()); + app.on('before-quit', () => { + logger.info('bridge', 'Stopping (app quit)'); + bridge.stop(); + }); }; export default init; diff --git a/packages/suite-desktop/src-electron/modules/csp.ts b/packages/suite-desktop/src-electron/modules/csp.ts index 30dccc90d0..3c96d4d799 100644 --- a/packages/suite-desktop/src-electron/modules/csp.ts +++ b/packages/suite-desktop/src-electron/modules/csp.ts @@ -2,15 +2,18 @@ * Adds a CSP (Content Security Policy) header to all requests */ -import { app, dialog, BrowserWindow, session } from 'electron'; +import { app, dialog, session } from 'electron'; import * as config from '../config'; const disableCspFlag = app.commandLine.hasSwitch('disable-csp'); -const init = (window: BrowserWindow) => { +const init = ({ mainWindow }: Dependencies) => { + const { logger } = global; + if (disableCspFlag) { - dialog.showMessageBox(window, { + logger.warn('csp', 'The application was launched with CSP disabled'); + dialog.showMessageBox(mainWindow, { type: 'warning', message: 'The application is running with CSP disabled. This is a security risk! If this is not intentional, please close the application immediately.', @@ -18,6 +21,7 @@ const init = (window: BrowserWindow) => { }); } else { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + logger.debug('csp', `Header applied to ${details.url}`); callback({ responseHeaders: { 'Content-Security-Policy': [config.cspRules.join(';')], diff --git a/packages/suite-desktop/src-electron/modules/dev-tools.ts b/packages/suite-desktop/src-electron/modules/dev-tools.ts index 47c1303b79..1c7b25bf8e 100644 --- a/packages/suite-desktop/src-electron/modules/dev-tools.ts +++ b/packages/suite-desktop/src-electron/modules/dev-tools.ts @@ -1,11 +1,9 @@ /** * Enable development tools */ -import { BrowserWindow } from 'electron'; - -const init = (window: BrowserWindow) => { - window.webContents.once('dom-ready', () => { - window.webContents.openDevTools(); +const init = ({ mainWindow }: Dependencies) => { + mainWindow.webContents.once('dom-ready', () => { + mainWindow.webContents.openDevTools(); }); }; diff --git a/packages/suite-desktop/src-electron/modules/event-logging/app.ts b/packages/suite-desktop/src-electron/modules/event-logging/app.ts new file mode 100644 index 0000000000..c63513b4d7 --- /dev/null +++ b/packages/suite-desktop/src-electron/modules/event-logging/app.ts @@ -0,0 +1,23 @@ +import { app } from 'electron'; + +const init = () => { + const { logger } = global; + + app.on('ready', () => { + logger.info('app', 'Ready'); + }); + + app.on('before-quit', () => { + logger.info('app', 'Quitting'); + }); + + app.on('window-all-closed', () => { + logger.info('app', 'All windows closed'); + }); + + app.on('child-process-gone', (_, { type, reason }) => { + logger.error('app', `Child process (${type}) gone (reason: ${reason})`); + }); +}; + +export default init; diff --git a/packages/suite-desktop/src-electron/modules/event-logging/contents.ts b/packages/suite-desktop/src-electron/modules/event-logging/contents.ts new file mode 100644 index 0000000000..d721065e6b --- /dev/null +++ b/packages/suite-desktop/src-electron/modules/event-logging/contents.ts @@ -0,0 +1,63 @@ +const init = ({ mainWindow }: Dependencies) => { + const { logger } = global; + + mainWindow.webContents.on('did-fail-load', (_, errorCode, errorDescription, validatedUrl) => { + logger.error( + 'content', + `Failure to load ${validatedUrl} (${errorCode} - ${errorDescription})`, + ); + }); + + mainWindow.webContents.on('will-navigate', (_, url) => { + logger.info('content', `Navigate to ${url}`); + }); + + mainWindow.webContents.on('render-process-gone', (_, { reason }) => { + logger.error('content', `Render process gone (reason: ${reason}`); + }); + + let unresponsiveStart = 0; + mainWindow.webContents.on('unresponsive', () => { + unresponsiveStart = +new Date(); + logger.warn('content', 'Unresponsive'); + }); + + mainWindow.webContents.on('responsive', () => { + if (unresponsiveStart !== 0) { + logger.warn( + 'content', + `Responsive again after ${(+new Date() - unresponsiveStart / 1000).toFixed(1)}s`, + ); + unresponsiveStart = 0; + } + }); + + mainWindow.webContents.on('devtools-opened', () => { + logger.info('content', `Dev tools opened`); + }); + + mainWindow.webContents.on('devtools-closed', () => { + logger.info('content', `Dev tools closed`); + }); + + mainWindow.webContents.on('console-message', (_, level, message, line, sourceId) => { + const msg = `${message} - ${sourceId}:${line}`; + switch (level) { + case 0: + logger.debug('console-log', msg); + break; + case 1: + logger.info('console-log', msg); + break; + case 2: + logger.warn('console-log', msg); + break; + case 3: + logger.error('console-log', msg); + break; + // no default + } + }); +}; + +export default init; diff --git a/packages/suite-desktop/src-electron/modules/event-logging/process.ts b/packages/suite-desktop/src-electron/modules/event-logging/process.ts new file mode 100644 index 0000000000..1a6cedb926 --- /dev/null +++ b/packages/suite-desktop/src-electron/modules/event-logging/process.ts @@ -0,0 +1,13 @@ +const init = () => { + const { logger } = global; + + process.on('uncaughtException', e => { + logger.error('exception', e.message); + }); + + process.on('unhandledRejection', e => { + logger.warn('rejection', `Unhandled Rejection: ${e.toString()}`); + }); +}; + +export default init; diff --git a/packages/suite-desktop/src-electron/modules/external-links.ts b/packages/suite-desktop/src-electron/modules/external-links.ts index a7f6583f94..8e35089d80 100644 --- a/packages/suite-desktop/src-electron/modules/external-links.ts +++ b/packages/suite-desktop/src-electron/modules/external-links.ts @@ -1,37 +1,48 @@ /** * Opens external links in the default browser (displays a warning when using Tor) */ -import { shell, dialog, BrowserWindow } from 'electron'; +import { shell, dialog } from 'electron'; import * as config from '../config'; -const init = (window: BrowserWindow, store: LocalStore) => { +const init = ({ mainWindow, store }: Dependencies) => { + const { logger } = global; + const handleExternalLink = (event: Event, url: string) => { if (config.oauthUrls.some(u => url.startsWith(u))) { event.preventDefault(); + + logger.info('external-links', `${url} was allowed (OAuth list)`); return shell.openExternal(url); } - if (url !== window.webContents.getURL()) { + if (url !== mainWindow.webContents.getURL()) { event.preventDefault(); const torSettings = store.getTorSettings(); if (torSettings.running) { // TODO: Replace with in-app modal - const result = dialog.showMessageBoxSync(window, { + const result = dialog.showMessageBoxSync(mainWindow, { type: 'warning', message: `The following URL is going to be opened in your browser\n\n${url}`, buttons: ['Cancel', 'Continue'], }); + const cancel = result === 0; + logger.info( + 'external-links', + `${url} was ${cancel ? 'not ' : ''}allowed by user in TOR mode`, + ); + // Cancel - if (result === 0) return; + if (cancel) return; } + logger.info('external-links', `${url} opened in default browser`); shell.openExternal(url); } }; - window.webContents.on('new-window', handleExternalLink); - window.webContents.on('will-navigate', handleExternalLink); + mainWindow.webContents.on('new-window', handleExternalLink); + mainWindow.webContents.on('will-navigate', handleExternalLink); }; export default init; diff --git a/packages/suite-desktop/src-electron/modules/file-protocol.ts b/packages/suite-desktop/src-electron/modules/file-protocol.ts index df99b46ce3..e963129a3c 100644 --- a/packages/suite-desktop/src-electron/modules/file-protocol.ts +++ b/packages/suite-desktop/src-electron/modules/file-protocol.ts @@ -2,21 +2,21 @@ * Helps pointing to the right folder to load */ import path from 'path'; -import { session, BrowserWindow } from 'electron'; +import { session } from 'electron'; import { PROTOCOL } from '@lib/constants'; -const init = (window: BrowserWindow, src: string) => { +const init = ({ mainWindow, src }: Dependencies) => { // Point to the right directory for file protocol requests session.defaultSession.protocol.interceptFileProtocol(PROTOCOL, (request, callback) => { let url = request.url.substr(PROTOCOL.length + 1); - url = path.join(__dirname, '..', 'build', url); + url = path.join(__dirname, '..', '..', 'build', url); callback(url); }); // Refresh if it failed to load - window.webContents.on('did-fail-load', () => { - window.loadURL(src); + mainWindow.webContents.on('did-fail-load', () => { + mainWindow.loadURL(src); }); }; diff --git a/packages/suite-desktop/src-electron/modules/http-receiver.ts b/packages/suite-desktop/src-electron/modules/http-receiver.ts index a619ae34a0..9375b2bf60 100644 --- a/packages/suite-desktop/src-electron/modules/http-receiver.ts +++ b/packages/suite-desktop/src-electron/modules/http-receiver.ts @@ -1,7 +1,7 @@ /** * Local web server for handling requests to app */ -import { app, ipcMain, BrowserWindow } from 'electron'; +import { app, ipcMain } from 'electron'; import { buyRedirectHandler } from '@lib/buy'; import { HttpReceiver } from '@lib/http-receiver'; @@ -9,31 +9,35 @@ import { HttpReceiver } from '@lib/http-receiver'; // External request handler const httpReceiver = new HttpReceiver(); -const init = (window: BrowserWindow, src: string) => { +const init = ({ mainWindow, src }: Dependencies) => { + const { logger } = global; + // wait for httpReceiver to start accepting connections then register event handlers httpReceiver.on('server/listening', () => { // when httpReceiver accepted oauth response httpReceiver.on('oauth/response', message => { - window.webContents.send('oauth/response', message); + mainWindow.webContents.send('oauth/response', message); app.focus(); }); httpReceiver.on('buy/redirect', url => { - buyRedirectHandler(url, window, src); + buyRedirectHandler(url, mainWindow, src); }); httpReceiver.on('spend/message', event => { - window.webContents.send('spend/message', event); + mainWindow.webContents.send('spend/message', event); }); // when httpReceiver was asked to provide current address for given pathname - ipcMain.handle('server/request-address', (_event, pathname) => + ipcMain.handle('server/request-address', (_, pathname) => httpReceiver.getRouteAddress(pathname), ); }); + logger.info('http-receiver', 'Starting server'); httpReceiver.start(); app.on('before-quit', () => { + logger.info('http-receiver', 'Stopping server (app quit)'); httpReceiver.stop(); }); }; diff --git a/packages/suite-desktop/src-electron/modules/menu.ts b/packages/suite-desktop/src-electron/modules/menu.ts index dd68c0a6cd..5a11c557ce 100644 --- a/packages/suite-desktop/src-electron/modules/menu.ts +++ b/packages/suite-desktop/src-electron/modules/menu.ts @@ -1,16 +1,26 @@ -import { BrowserWindow, Menu } from 'electron'; +import { Menu } from 'electron'; import { buildMainMenu, inputMenu, selectionMenu } from '@lib/menu'; +import { b2t } from '@lib/utils'; + +const init = ({ mainWindow }: Dependencies) => { + const { logger } = global; -const init = (window: BrowserWindow) => { Menu.setApplicationMenu(buildMainMenu()); - window.setMenuBarVisibility(false); + mainWindow.setMenuBarVisibility(false); + + mainWindow.webContents.on('context-menu', (_, props) => { + const isTextSelected = props.selectionText && props.selectionText.trim() !== ''; + logger.debug('menu', [ + 'Context menu:', + `- Editable: ${b2t(props.isEditable)}`, + `- Text Selected: ${b2t(isTextSelected)}`, + ]); - window.webContents.on('context-menu', (_e, props) => { if (props.isEditable) { // right click on the input/textarea should open a context menu with text editing options (copy, cut, paste,...) inputMenu.popup(); - } else if (props.selectionText && props.selectionText.trim() !== '') { + } else if (isTextSelected) { // right click with active text selection should open context menu with a copy option selectionMenu.popup(); } diff --git a/packages/suite-desktop/src-electron/modules/metadata.ts b/packages/suite-desktop/src-electron/modules/metadata.ts index 6c452fc333..50310c7851 100644 --- a/packages/suite-desktop/src-electron/modules/metadata.ts +++ b/packages/suite-desktop/src-electron/modules/metadata.ts @@ -7,12 +7,16 @@ import { save, read } from '@lib/user-data'; const DATA_DIR = '/metadata'; export const init = () => { + const { logger } = global; + ipcMain.handle('metadata/write', async (_, message) => { + logger.info('metadata', `Writing metadata to ${DATA_DIR}/${message.file}`); const resp = await save(DATA_DIR, message.file, message.content); return resp; }); ipcMain.handle('metadata/read', async (_, message) => { + logger.info('metadata', `Reading metadata from ${DATA_DIR}/${message.file}`); const resp = await read(DATA_DIR, message.file); return resp; }); diff --git a/packages/suite-desktop/src-electron/modules/request-filter.ts b/packages/suite-desktop/src-electron/modules/request-filter.ts index 646b956162..33fe571bb9 100644 --- a/packages/suite-desktop/src-electron/modules/request-filter.ts +++ b/packages/suite-desktop/src-electron/modules/request-filter.ts @@ -1,37 +1,49 @@ /** * Request Filter feature (blocks non-allowed requests and displays a warning) */ -import { dialog, session, BrowserWindow } from 'electron'; +import { dialog, session } from 'electron'; import * as config from '../config'; -const init = (window: BrowserWindow) => { +const init = ({ mainWindow }: Dependencies) => { + const { logger } = global; + const resourceTypeFilter = ['xhr']; // What resource types we want to filter const caughtDomainExceptions: string[] = []; // Domains that have already shown an exception session.defaultSession.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, cb) => { if (!resourceTypeFilter.includes(details.resourceType)) { + logger.debug( + 'request-filter', + `${details.url} was allowed because its resource type (${details.resourceType}) is not filtered`, + ); cb({ cancel: false }); return; } const { hostname } = new URL(details.url); - // Cancel requests that aren't allowed if (config.allowedDomains.find(d => hostname.endsWith(d)) !== undefined) { + logger.info( + 'request-filter', + `${details.url} was allowed because ${hostname} is in the exception list`, + ); cb({ cancel: false }); return; } if (caughtDomainExceptions.find(d => d === hostname) === undefined) { caughtDomainExceptions.push(hostname); - dialog.showMessageBox(window, { + dialog.showMessageBox(mainWindow, { type: 'warning', message: `Suite blocked a request to ${hostname}.\n\nIf you believe this is an error, please contact our support.`, buttons: ['OK'], }); } - console.warn(`[Warning] Domain '${hostname}' was blocked.`); + logger.warn( + 'request-filter', + `${details.url} was blocked because ${hostname} is not in the exception list`, + ); cb({ cancel: true }); }); }; diff --git a/packages/suite-desktop/src-electron/modules/shortcuts.ts b/packages/suite-desktop/src-electron/modules/shortcuts.ts index 15663db8b1..702a8c9ef2 100644 --- a/packages/suite-desktop/src-electron/modules/shortcuts.ts +++ b/packages/suite-desktop/src-electron/modules/shortcuts.ts @@ -1,21 +1,26 @@ -import { app, BrowserWindow } from 'electron'; +import { app } from 'electron'; import electronLocalshortcut from 'electron-localshortcut'; -const init = (window: BrowserWindow, src: string) => { - electronLocalshortcut.register(window, 'CommandOrControl+Alt+I', () => { - window.webContents.openDevTools(); +const init = ({ mainWindow, src }: Dependencies) => { + const { logger } = global; + + electronLocalshortcut.register(mainWindow, 'CommandOrControl+Alt+I', () => { + logger.info('shortcuts', 'CTRL+ALT+I pressed'); + mainWindow.webContents.openDevTools(); }); - electronLocalshortcut.register(window, 'F5', () => { - window.loadURL(src); + electronLocalshortcut.register(mainWindow, 'F5', () => { + logger.info('shortcuts', 'F5 pressed'); + mainWindow.loadURL(src); }); - electronLocalshortcut.register(window, 'CommandOrControl+R', () => { - window.loadURL(src); + electronLocalshortcut.register(mainWindow, 'CommandOrControl+R', () => { + logger.info('shortcuts', 'CTRL+R pressed'); + mainWindow.loadURL(src); }); app.on('before-quit', () => { - electronLocalshortcut.unregisterAll(window); + electronLocalshortcut.unregisterAll(mainWindow); }); }; diff --git a/packages/suite-desktop/src-electron/modules/tor.ts b/packages/suite-desktop/src-electron/modules/tor.ts index 33e995b411..852a8c9fc2 100644 --- a/packages/suite-desktop/src-electron/modules/tor.ts +++ b/packages/suite-desktop/src-electron/modules/tor.ts @@ -1,49 +1,68 @@ /** * Tor feature (toggle, configure) */ -import { app, session, ipcMain, BrowserWindow, IpcMainEvent } from 'electron'; +import { app, session, ipcMain, IpcMainEvent } from 'electron'; import TorProcess from '@lib/processes/TorProcess'; +import { b2t } from '@lib/utils'; import { onionDomain } from '../config'; -const tor = new TorProcess(); - const torFlag = app.commandLine.hasSwitch('tor'); -const init = async (window: BrowserWindow, store: LocalStore) => { +const init = async ({ mainWindow, store }: Dependencies) => { + const { logger } = global; + const tor = new TorProcess(); const torSettings = store.getTorSettings(); const toggleTor = async (start: boolean) => { if (start) { if (torSettings.running) { + logger.info('tor', 'Restarting'); await tor.restart(); } else { + logger.info('tor', 'Starting'); await tor.start(); } } else { + logger.info('tor', 'Stopping'); await tor.stop(); } torSettings.running = start; store.setTorSettings(torSettings); - window.webContents.send('tor/status', start); + mainWindow.webContents.send('tor/status', start); + + const proxy = start ? `socks5://${torSettings.address}` : ''; + logger.info('tor', `Setting proxy to "${proxy}"`); session.defaultSession.setProxy({ - proxyRules: start ? `socks5://${torSettings.address}` : '', + proxyRules: proxy, }); }; if (torFlag || torSettings.running) { + logger.info('tor', [ + 'Auto starting:', + `- Running with flag: ${b2t(torFlag)}`, + `- Running with settings: ${b2t(torSettings.running)}`, + ]); await toggleTor(true); } ipcMain.on('tor/toggle', async (_, start: boolean) => { + logger.info('tor', `Toggling ${start ? 'ON' : 'OFF'}`); await toggleTor(start); }); ipcMain.on('tor/set-address', () => async (_: IpcMainEvent, address: string) => { if (torSettings.address !== address) { + logger.debug('tor', [ + 'Updating address:', + `- From: ${torSettings.address}`, + `- To: ${address}`, + ]); + torSettings.address = address; store.setTorSettings(torSettings); @@ -54,10 +73,12 @@ const init = async (window: BrowserWindow, store: LocalStore) => { }); ipcMain.on('tor/get-status', () => { - window.webContents.send('tor/status', torSettings.running); + logger.debug('tor', `Getting status (${torSettings.running ? 'ON' : 'OFF'})`); + mainWindow.webContents.send('tor/status', torSettings.running); }); ipcMain.handle('tor/get-address', () => { + logger.debug('tor', `Getting address (${torSettings.address})`); return torSettings.address; }); @@ -66,6 +87,7 @@ const init = async (window: BrowserWindow, store: LocalStore) => { // Redirect outgoing trezor.io requests to .onion domain if (torSettings.running && hostname.endsWith('trezor.io') && protocol === 'https:') { + logger.info('tor', `Rewriting ${details.url} to .onion URL`); cb({ redirectURL: details.url.replace( /https:\/\/(([a-z0-9]+\.)*)trezor\.io(.*)/, diff --git a/packages/suite-desktop/src-electron/modules/window-controls.ts b/packages/suite-desktop/src-electron/modules/window-controls.ts index 3531e6d61b..e811edd17f 100644 --- a/packages/suite-desktop/src-electron/modules/window-controls.ts +++ b/packages/suite-desktop/src-electron/modules/window-controls.ts @@ -15,93 +15,121 @@ const notifyWindowActive = (window: BrowserWindow, state: boolean) => { window.webContents.send('window/is-active', state); }; -const init = (window: BrowserWindow, store: LocalStore) => { +const init = ({ mainWindow, store }: Dependencies) => { + const { logger } = global; + if (process.platform === 'darwin') { // macOS specific window behavior // it is common for applications and their context menu to stay active until the user quits explicitly // with Cmd + Q or right-click > Quit from the context menu. // restore window after click on the Dock icon - app.on('activate', () => window.show()); + app.on('activate', () => { + logger.info('window-control', 'Showing main window on activate'); + mainWindow.show(); + }); // hide window to the Dock // this event listener will be removed by app.on('before-quit') - window.on('close', event => { + mainWindow.on('close', event => { if (global.quitOnWindowClose) { + logger.info( + 'window-control', + 'Force quitting the app after the main window has been closed', + ); app.quit(); return; } + logger.info('window-control', 'Hiding the app after the main window has been closed'); + event.preventDefault(); - window.hide(); + mainWindow.hide(); }); } else { // other platform just kills the app - app.on('window-all-closed', () => app.quit()); + app.on('window-all-closed', () => { + logger.info('window-control', 'Quitting app after all windows have been closed'); + app.quit(); + }); } - window.on('page-title-updated', evt => { + mainWindow.on('page-title-updated', evt => { // prevent updating window title evt.preventDefault(); }); - window.on('maximize', () => { - notifyWindowMaximized(window); + mainWindow.on('maximize', () => { + logger.debug('window-control', 'Maximize'); + notifyWindowMaximized(mainWindow); }); - window.on('unmaximize', () => { - notifyWindowMaximized(window); + mainWindow.on('unmaximize', () => { + logger.debug('window-control', 'Unmaximize'); + notifyWindowMaximized(mainWindow); }); - window.on('enter-full-screen', () => { - notifyWindowMaximized(window); + mainWindow.on('enter-full-screen', () => { + logger.debug('window-control', 'Enter full screen'); + notifyWindowMaximized(mainWindow); }); - window.on('leave-full-screen', () => { - notifyWindowMaximized(window); + mainWindow.on('leave-full-screen', () => { + logger.debug('window-control', 'Leave full screen'); + notifyWindowMaximized(mainWindow); }); - window.on('moved', () => { - notifyWindowMaximized(window); + mainWindow.on('moved', () => { + logger.debug('window-control', 'Moved'); + notifyWindowMaximized(mainWindow); }); - window.on('focus', () => { - notifyWindowActive(window, true); + mainWindow.on('focus', () => { + logger.debug('window-control', 'Focus'); + notifyWindowActive(mainWindow, true); }); - window.on('blur', () => { - notifyWindowActive(window, false); + mainWindow.on('blur', () => { + logger.debug('window-control', 'Blur'); + notifyWindowActive(mainWindow, false); }); ipcMain.on('window/close', () => { + logger.debug('window-control', 'Close requested'); // Keeping the devtools open might prevent the app from closing - if (window.webContents.isDevToolsOpened()) { - window.webContents.closeDevTools(); + if (mainWindow.webContents.isDevToolsOpened()) { + mainWindow.webContents.closeDevTools(); } // store window bounds on close btn click - const winBound = window.getBounds() as WinBounds; + const winBound = mainWindow.getBounds() as WinBounds; store.setWinBounds(winBound); - window.close(); + mainWindow.close(); }); ipcMain.on('window/minimize', () => { - window.minimize(); + logger.debug('window-control', 'Minimize requested'); + mainWindow.minimize(); }); ipcMain.on('window/maximize', () => { + logger.debug('window-control', 'Maximize requested'); if (process.platform === 'darwin') { - window.setFullScreen(true); + mainWindow.setFullScreen(true); } else { - window.maximize(); + mainWindow.maximize(); } }); ipcMain.on('window/unmaximize', () => { + logger.debug('window-control', 'Unmaximize requested'); if (process.platform === 'darwin') { - window.setFullScreen(false); + mainWindow.setFullScreen(false); } else { - window.unmaximize(); + mainWindow.unmaximize(); } }); - ipcMain.on('client/ready', () => { - notifyWindowMaximized(window); - }); ipcMain.on('window/focus', () => { + logger.debug('window-control', 'Focus requested'); app.focus({ steal: true }); }); + ipcMain.on('client/ready', () => { + logger.debug('window-control', 'Client ready'); + notifyWindowMaximized(mainWindow); + }); + app.on('before-quit', () => { // store window bounds on cmd/ctrl+q - const winBound = window.getBounds() as WinBounds; + const winBound = mainWindow.getBounds() as WinBounds; store.setWinBounds(winBound); }); }; diff --git a/yarn.lock b/yarn.lock index 7a2fe9afac..92aeee7d42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20959,6 +20959,11 @@ symbol.prototype.description@^1.0.0: has-symbols "^1.0.1" object.getownpropertydescriptors "^2.1.0" +systeminformation@^4.34.7: + version "4.34.7" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-4.34.7.tgz#8b4a00c44781349905881c95dded8bafe7aae47f" + integrity sha512-cS3FiSZasFgVNjO9CP3aZmTO2VHwXKG+JN6Z85nWRyOzxRMNbZe7Xzwrewp42hj+OPMC3hk7MrAFyu/qLM65Mw== + table@^6.0.3, table@^6.0.4: version "6.0.7" resolved "https://registry.yarnpkg.com/table/-/table-6.0.7.tgz#e45897ffbcc1bcf9e8a87bf420f2c9e5a7a52a34"