Fix preload.ts - Part x/x

This commit is contained in:
Manav Rathi 2024-03-23 10:45:23 +05:30
parent efa49bd2b0
commit 7a3209ebf6
No known key found for this signature in database
29 changed files with 250 additions and 166 deletions

View file

@ -1,5 +0,0 @@
import { ipcRenderer } from "electron/renderer";
import { logError } from "../services/logging";
export { logToDisk, openLogDirectory } from "../services/logging";

View file

@ -1,4 +1,4 @@
import { logError } from "../services/logging";
import { logError } from "../main/log";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";

View file

@ -1,7 +1,7 @@
import { ipcRenderer } from "electron";
import { existsSync } from "fs";
import { writeStream } from "../services/fs";
import { logError } from "../services/logging";
import { logError } from "../main/log";
import { ElectronFile } from "../types";
export async function runFFmpegCmd(

View file

@ -2,7 +2,7 @@ import { ipcRenderer } from "electron/renderer";
import { existsSync } from "fs";
import { CustomErrors } from "../constants/errors";
import { writeStream } from "../services/fs";
import { logError } from "../services/logging";
import { logError } from "../main/log";
import { ElectronFile } from "../types";
import { isPlatform } from "../utils/common/platform";

View file

@ -1,5 +1,5 @@
import { ipcRenderer } from "electron";
import { logError } from "../services/logging";
import { logError } from "../main/log";
import { safeStorageStore } from "../stores/safeStorage.store";
export async function setEncryptionKey(encryptionKey: string) {

View file

@ -1,5 +1,5 @@
import { ipcRenderer } from "electron";
import { logError } from "../services/logging";
import { logError } from "../main/log";
import {
getElectronFilesFromGoogleZip,
getSavedFilePaths,

View file

@ -14,14 +14,13 @@ import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isDev } from "./main/general";
import { logErrorSentry, setupLogging } from "./main/log";
import { initWatcher } from "./services/chokidar";
import { logErrorSentry } from "./services/sentry";
import { isDev } from "./utils/common";
import { addAllowOriginHeader } from "./utils/cors";
import { createWindow } from "./utils/createWindow";
import { setupAppEventEmitter } from "./utils/events";
import setupIpcComs from "./utils/ipcComms";
import { setupLogging } from "./utils/logging";
import {
handleDockIconHideOnAutoLaunch,
handleDownloads,

View file

@ -0,0 +1,42 @@
import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */
import { app } from "electron/main";
import * as path from "node:path";
/** `true` if the app is running in development mode. */
export const isDev = !app.isPackaged;
/**
* Open the given {@link dirPath} in the system's folder viewer.
*
* For example, on macOS this'll open {@link dirPath} in Finder.
*/
export const openDirectory = async (dirPath: string) => {
const res = await shell.openPath(path.normalize(dirPath));
// shell.openPath resolves with a string containing the error message
// corresponding to the failure if a failure occurred, otherwise "".
if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
};
/**
* Return the path where the logs for the app are saved.
*
* [Note: Electron app paths]
*
* By default, these paths are at the following locations:
*
* - macOS: `~/Library/Application Support/ente`
* - Linux: `~/.config/ente`
* - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
* - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
*
* https://www.electronjs.org/docs/latest/api/app
*
*/
const logDirectoryPath = () => app.getPath("logs");
/**
* Open the app's log directory in the system's folder viewer.
*
* @see {@link openDirectory}
*/
export const openLogDirectory = () => openDirectory(logDirectoryPath());

43
desktop/src/main/ipc.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* @file Listen for IPC events sent/invoked by the renderer process, and route
* them to their correct handlers.
*
* This file is meant as a sibling to `preload.ts`, but this one runs in the
* context of the main process, and can import other files from `src/`.
*/
import { ipcMain } from "electron/main";
import { appVersion } from "../services/appUpdater";
import { openDirectory, openLogDirectory } from "./general";
import { logToDisk } from "./log";
// - General
export const attachIPCHandlers = () => {
// Notes:
//
// The first parameter of the handler passed to `ipcMain.handle` is the
// `event`, and is usually ignored. The rest of the parameters are the
// arguments passed to `ipcRenderer.invoke`.
//
// [Note: Catching exception during .send/.on]
//
// While we can use ipcRenderer.send/ipcMain.on for one-way communication,
// that has the disadvantage that any exceptions thrown in the processing of
// the handler are not sent back to the renderer. So we use the
// ipcRenderer.invoke/ipcMain.handle 2-way pattern even for things that are
// conceptually one way. An exception (pun intended) to this is logToDisk,
// which is a primitive, frequently used, operation and shouldn't throw, so
// having its signature by synchronous is a bit convenient.
// - General
ipcMain.handle("appVersion", (_) => appVersion());
ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
ipcMain.handle("openLogDirectory", (_) => openLogDirectory());
// See: [Note: Catching exception during .send/.on]
ipcMain.on("logToDisk", (_, msg) => logToDisk(msg));
};

34
desktop/src/main/log.ts Normal file
View file

@ -0,0 +1,34 @@
import log from "electron-log";
import { isDev } from "./general";
export function setupLogging(isDev?: boolean) {
log.transports.file.fileName = "ente.log";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
if (!isDev) {
log.transports.console.level = false;
}
log.transports.file.format =
"[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {text}";
}
export const logToDisk = (message: string) => {
log.info(message);
};
export const logError = logErrorSentry;
/** Deprecated, but no alternative yet */
export function logErrorSentry(
error: any,
msg: string,
info?: Record<string, unknown>,
) {
logToDisk(
`error: ${error?.name} ${error?.message} ${
error?.stack
} msg: ${msg} info: ${JSON.stringify(info)}`,
);
if (isDev) {
console.log(error, { msg, info });
}
}

View file

@ -7,24 +7,23 @@
* functions as an object on the DOM, so that the renderer process can invoke
* functions that live in the main (Node.js) process if needed.
*
* Ref: https://www.electronjs.org/docs/latest/tutorial/tutorial-preload
*
* Note that this script cannot import other code from `src/` - conceptually it
* can be thought of as running in a separate, third, process different from
* both the main or a renderer process (technically, it runs in a BrowserWindow
* context that runs prior to the renderer process).
*
* That said, this can be split into multiple files if we wished. However,
* that'd require us setting up a bundler to package it back up into a single JS
* file that can be used at runtime.
*
* > Since enabling the sandbox disables Node.js integration in your preload
* > scripts, you can no longer use require("../my-script"). In other words,
* > your preload script needs to be a single file.
* >
* > https://www.electronjs.org/blog/breach-to-barrier
*
* Since most of this is just boilerplate code providing a bridge between the
* main and renderer, we avoid introducing another moving part into the mix and
* just keep the entire preload setup in this single file.
* If we really wanted, we could setup a bundler to package this into a single
* file. However, since this is just boilerplate code providing a bridge between
* the main and renderer, we avoid introducing another moving part into the mix
* and just keep the entire preload setup in this single file.
*/
import { contextBridge, ipcRenderer } from "electron";
@ -32,7 +31,6 @@ import { createWriteStream, existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import { Readable } from "node:stream";
import path from "path";
import { logToDisk, openLogDirectory } from "./api/common";
import { runFFmpegCmd } from "./api/ffmpeg";
import { getDirFiles } from "./api/fs";
import { convertToJPEG, generateImageThumbnail } from "./api/imageProcessor";
@ -58,21 +56,44 @@ import {
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
} from "./api/watch";
import { setupLogging } from "./utils/logging";
import { logErrorSentry, setupLogging } from "./main/log";
/*
Some of the code below has been duplicated to make this file self contained
(see the documentation at the top of why it needs to be a single file).
setupLogging();
Enhancement: consider alternatives
*/
// - General
/** Return the version of the desktop app. */
const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
/**
* Open the given {@link dirPath} in the system's folder viewer.
*
* For example, on macOS this'll open {@link dirPath} in Finder.
*/
const openDirectory = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("openDirectory");
/**
* Open the app's log directory in the system's folder viewer.
*
* @see {@link openDirectory}
*/
const openLogDirectory = (): Promise<void> =>
ipcRenderer.invoke("openLogDirectory");
/**
* Log the given {@link message} to the on-disk log file maintained by the
* desktop app.
*/
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
// - FIXME below this
/* preload: duplicated logError */
export function logError(error: Error, message: string, info?: string): void {
ipcRenderer.invoke("log-error", error, message, info);
}
// -
const logError = (error: Error, message: string, info?: any) => {
logErrorSentry(error, message, info);
};
/* preload: duplicated writeStream */
/**
@ -342,26 +363,6 @@ const selectDirectory = async (): Promise<string> => {
}
};
const getAppVersion = async (): Promise<string> => {
try {
return await ipcRenderer.invoke("get-app-version");
} catch (e) {
logError(e, "failed to get release version");
throw e;
}
};
const openDirectory = async (dirPath: string): Promise<void> => {
try {
await ipcRenderer.invoke("open-dir", dirPath);
} catch (e) {
logError(e, "error while opening directory");
throw e;
}
};
// -
const clearElectronStore = () => {
ipcRenderer.send("clear-electron-store");
};
@ -382,51 +383,52 @@ const muteUpdateNotification = (version: string) => {
// -
setupLogging();
// These objects exposed here will become available to the JS code in our
// renderer (the web/ code) as `window.ElectronAPIs.*`
//
// - Introduction
// https://www.electronjs.org/docs/latest/tutorial/tutorial-preload
//
// There are a few related concepts at play here, and it might be worthwhile to
// read their (excellent) documentation to get an understanding;
//
//`
// - ContextIsolation:
// https://www.electronjs.org/docs/latest/tutorial/context-isolation
//
// - IPC https://www.electronjs.org/docs/latest/tutorial/ipc
//
//
// [Note: Transferring large amount of data over IPC]
//
// Electron's IPC implementation uses the HTML standard Structured Clone
// Algorithm to serialize objects passed between processes.
// https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization
//
// In particular, both ArrayBuffer and the web File types are eligible for
// structured cloning.
// In particular, both ArrayBuffer is eligible for structured cloning.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
//
// Also, ArrayBuffer is "transferable", which means it is a zero-copy operation
// operation when it happens across threads.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
//
// In our case though, we're not dealing with threads but separate processes,
// and it seems like there is a copy involved since the documentation for
// contextBridge explicitly calls out that "parameters, errors and return values
// are **copied** when they're sent over the bridge".
// https://www.electronjs.org/docs/latest/api/context-bridge#methods
// In our case though, we're not dealing with threads but separate processes. So
// the ArrayBuffer will be copied:
// > "parameters, errors and return values are **copied** when they're sent over
// the bridge".
// https://www.electronjs.org/docs/latest/api/context-bridge#methods
//
// Related is a note from one of Electron's committers stating that even with
// copying, the IPC should be fast enough for even moderately large data:
// https://github.com/electron/electron/issues/1948#issuecomment-864191345
//
// The main problem with transfering large amounts of data is potentially
// running out of memory, causing the app to crash as it copies it over across
// the processes.
// The copy itself is relatively fast, but the problem with transfering large
// amounts of data is potentially running out of memory during the copy.
contextBridge.exposeInMainWorld("ElectronAPIs", {
// General
appVersion,
openDirectory,
// Logging
openLogDirectory,
logToDisk,
// - App update
updateAndRestart,
skipAppUpdate,
muteUpdateNotification,
// - Export
exists,
checkExistsAndCreateDir,
@ -453,7 +455,6 @@ contextBridge.exposeInMainWorld("ElectronAPIs", {
isFolder,
updateWatchMappingSyncedFiles,
updateWatchMappingIgnoredFiles,
logToDisk,
convertToJPEG,
registerUpdateEventListener,
@ -465,18 +466,6 @@ contextBridge.exposeInMainWorld("ElectronAPIs", {
rename,
deleteFile,
// General
getAppVersion,
openDirectory,
// Logging
openLogDirectory,
// - App update
updateAndRestart,
skipAppUpdate,
muteUpdateNotification,
// - ML
computeImageEmbedding,
computeTextEmbedding,

View file

@ -3,8 +3,8 @@ import { app, BrowserWindow } from "electron";
import { default as ElectronLog, default as log } from "electron-log";
import { autoUpdater } from "electron-updater";
import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
import { logErrorSentry } from "../main/log";
import { AppUpdateInfo } from "../types";
import { logErrorSentry } from "./sentry";
import {
clearMuteUpdateNotificationVersion,
clearSkipAppVersion,
@ -110,9 +110,12 @@ export function updateAndRestart() {
autoUpdater.quitAndInstall();
}
export function getAppVersion() {
return `v${app.getVersion()}`;
}
/**
* Return the version of the desktop app
*
* The return value is of the form `v1.2.3`.
*/
export const appVersion = () => `v${app.getVersion()}`;
export function skipAppUpdate(version: string) {
setSkipAppVersion(version);

View file

@ -1,7 +1,7 @@
import chokidar from "chokidar";
import { BrowserWindow } from "electron";
import { getWatchMappings } from "../api/watch";
import { logError } from "../services/logging";
import { logError } from "../main/log";
export function initWatcher(mainWindow: BrowserWindow) {
const mappings = getWatchMappings();

View file

@ -7,10 +7,10 @@ import util from "util";
import { CustomErrors } from "../constants/errors";
import { Model } from "../types";
import Tokenizer from "../utils/clip-bpe-ts/mod";
import { isDev } from "../utils/common";
import { isDev } from "../main/general";
import { getPlatform } from "../utils/common/platform";
import { writeStream } from "./fs";
import { logErrorSentry } from "./sentry";
import { logErrorSentry } from "../main/log";
const shellescape = require("any-shell-escape");
const execAsync = util.promisify(require("child_process").exec);
const jpeg = require("jpeg-js");

View file

@ -5,7 +5,7 @@ import * as fs from "node:fs/promises";
import util from "util";
import { CustomErrors } from "../constants/errors";
import { generateTempFilePath, getTempDirPath } from "../utils/temp";
import { logErrorSentry } from "./sentry";
import { logErrorSentry } from "../main/log";
const shellescape = require("any-shell-escape");
const execAsync = util.promisify(require("child_process").exec);

View file

@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Readable } from "stream";
import { ElectronFile } from "../types";
import { logError } from "./logging";
import { logError } from "../main/log";
const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;

View file

@ -5,10 +5,10 @@ import log from "electron-log";
import * as fs from "node:fs/promises";
import path from "path";
import { CustomErrors } from "../constants/errors";
import { isDev } from "../utils/common";
import { isDev } from "../main/general";
import { isPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp";
import { logErrorSentry } from "./sentry";
import { logErrorSentry } from "../main/log";
const shellescape = require("any-shell-escape");
const asyncExec = util.promisify(exec);

View file

@ -1,14 +0,0 @@
import { ipcRenderer } from "electron";
import log from "electron-log";
export function logToDisk(logLine: string) {
log.info(logLine);
}
export function openLogDirectory() {
ipcRenderer.invoke("open-log-dir");
}
export function logError(error: Error, message: string, info?: string): void {
ipcRenderer.invoke("log-error", error, message, info);
}

View file

@ -1,18 +0,0 @@
import { isDev } from "../utils/common";
import { logToDisk } from "./logging";
/** Deprecated, but no alternative yet */
export function logErrorSentry(
error: any,
msg: string,
info?: Record<string, unknown>,
) {
logToDisk(
`error: ${error?.name} ${error?.message} ${
error?.stack
} msg: ${msg} info: ${JSON.stringify(info)}`,
);
if (isDev) {
console.log(error, { msg, info });
}
}

View file

@ -1,2 +0,0 @@
import { app } from "electron";
export const isDev = !app.isPackaged;

View file

@ -3,9 +3,9 @@ import ElectronLog from "electron-log";
import * as path from "path";
import { isAppQuitting, rendererURL } from "../main";
import autoLauncher from "../services/autoLauncher";
import { logErrorSentry } from "../services/sentry";
import { logErrorSentry } from "../main/log";
import { getHideDockIconPreference } from "../services/userPreference";
import { isDev } from "./common";
import { isDev } from "../main/general";
import { isPlatform } from "./common/platform";
/**

View file

@ -10,8 +10,8 @@ import {
} from "electron";
import path from "path";
import { clearElectronStore } from "../api/electronStore";
import { attachIPCHandlers } from "../main/ipc";
import {
getAppVersion,
muteUpdateNotification,
skipAppUpdate,
updateAndRestart,
@ -26,7 +26,6 @@ import {
convertToJPEG,
generateImageThumbnail,
} from "../services/imageProcessor";
import { logErrorSentry } from "../services/sentry";
import { generateTempFilePath } from "./temp";
export default function setupIpcComs(
@ -34,6 +33,8 @@ export default function setupIpcComs(
mainWindow: BrowserWindow,
watcher: chokidar.FSWatcher,
): void {
attachIPCHandlers();
ipcMain.handle("select-dir", async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
@ -79,10 +80,6 @@ export default function setupIpcComs(
watcher.unwatch(args.dir);
});
ipcMain.handle("log-error", (_, err, msg, info?) => {
logErrorSentry(err, msg, info);
});
ipcMain.handle("safeStorage-encrypt", (_, message) => {
return safeStorage.encryptString(message);
});
@ -128,10 +125,6 @@ export default function setupIpcComs(
muteUpdateNotification(version);
});
ipcMain.handle("get-app-version", () => {
return getAppVersion();
});
ipcMain.handle(
"run-ffmpeg-cmd",
(_, cmd, inputFilePath, outputFileName, dontTimeout) => {

View file

@ -1,11 +0,0 @@
import log from "electron-log";
export function setupLogging(isDev?: boolean) {
log.transports.file.fileName = "ente.log";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
if (!isDev) {
log.transports.console.level = false;
}
log.transports.file.format =
"[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {text}";
}

View file

@ -8,7 +8,7 @@ import { rendererURL } from "../main";
import { setupAutoUpdater } from "../services/appUpdater";
import autoLauncher from "../services/autoLauncher";
import { getHideDockIconPreference } from "../services/userPreference";
import { isDev } from "./common";
import { isDev } from "../main/general";
import { isPlatform } from "./common/platform";
import { buildContextMenu, buildMenuBar } from "./menu";
const execAsync = util.promisify(require("child_process").exec);

View file

@ -7,6 +7,7 @@ import {
} from "electron";
import ElectronLog from "electron-log";
import { setIsAppQuitting } from "../main";
import { openDirectory, openLogDirectory } from "../main/general";
import { forceCheckForUpdateAndNotify } from "../services/appUpdater";
import autoLauncher from "../services/autoLauncher";
import {
@ -201,15 +202,11 @@ export async function buildMenuBar(mainWindow: BrowserWindow): Promise<Menu> {
{ type: "separator" },
{
label: "View crash reports",
click: () => {
shell.openPath(app.getPath("crashDumps"));
},
click: () => openDirectory(app.getPath("crashDumps")),
},
{
label: "View logs",
click: () => {
shell.openPath(app.getPath("logs"));
},
click: openLogDirectory,
},
],
},

View file

@ -24,7 +24,7 @@ export default function DebugSection() {
useEffect(() => {
const main = async () => {
if (isElectron()) {
const appVersion = await ElectronAPIs.getAppVersion();
const appVersion = await ElectronAPIs.appVersion();
setAppVersion(appVersion);
}
};

View file

@ -11,7 +11,45 @@ export enum Model {
ONNX_CLIP = "onnx-clip",
}
/**
* Extra APIs provided by the Node.js layer when our code is running in Electron
*
* This list is manually kept in sync with `desktop/src/preload.ts`. In case of
* a mismatch, the types may lie.
*
* These extra objects and functions will only be available when our code is
* running as the renderer process in Electron. So something in the code path
* should check for `isElectron() == true` before invoking these.
*/
export interface ElectronAPIsType {
// - General
/** Return the version of the desktop app. */
appVersion: () => Promise<string>;
/**
* Open the given {@link dirPath} in the system's folder viewer.
*
* For example, on macOS this'll open {@link dirPath} in Finder.
*/
openDirectory: (dirPath: string) => Promise<void>;
/**
* Open the app's log directory in the system's folder viewer.
*
* @see {@link openDirectory}
*/
openLogDirectory: () => Promise<void>;
/**
* Log the given {@link message} to the on-disk log file maintained by the
* desktop app.
*
* Note: Unlike the other functions exposed over the Electron bridge,
* logToDisk is fire-and-forge and does not return a promise.
*/
logToDisk: (message: string) => void;
exists: (path: string) => boolean;
checkExistsAndCreateDir: (dirPath: string) => Promise<void>;
saveStreamToDisk: (
@ -62,18 +100,15 @@ export interface ElectronAPIsType {
clearElectronStore: () => void;
setEncryptionKey: (encryptionKey: string) => Promise<void>;
getEncryptionKey: () => Promise<string>;
logToDisk: (msg: string) => void;
convertToJPEG: (
fileData: Uint8Array,
filename: string,
) => Promise<Uint8Array>;
openLogDirectory: () => void;
registerUpdateEventListener: (
showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
) => void;
updateAndRestart: () => void;
skipAppUpdate: (version: string) => void;
getAppVersion: () => Promise<string>;
runFFmpegCmd: (
cmd: string[],
inputFile: File | ElectronFile,
@ -87,7 +122,6 @@ export interface ElectronAPIsType {
maxSize: number,
) => Promise<Uint8Array>;
registerForegroundEventListener: (onForeground: () => void) => void;
openDirectory: (dirPath: string) => Promise<void>;
moveFile: (oldPath: string, newPath: string) => Promise<void>;
deleteFolder: (path: string) => Promise<void>;
deleteFile: (path: string) => Promise<void>;