Files
ente/desktop/src/preload.ts
Manav Rathi 0381cf66dc Rename
2025-06-06 16:08:22 +05:30

428 lines
14 KiB
TypeScript

/**
* @file The preload script
*
* The preload script runs in a renderer process before its web contents begin
* loading. During their execution they have access to a subset of Node.js APIs
* and imports. Its purpose is to expose the relevant imports and other
* 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).
*
* > 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
*
* 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.
*
* [Note: types.ts <-> preload.ts <-> ipc.ts]
*
* The following three files are boilerplatish linkage of the same functions,
* and when changing one of them, remember to see if the other two also need
* changing:
*
* - [renderer] web/packages/base/types/ipc.ts contains docs
* - [preload] desktop/src/preload.ts ↕︎
* - [main] desktop/src/main/ipc.ts contains impl
*/
// This code runs in the (isolated) web layer. Contrary to the impression given
// by the Electron docs (as of 2024), the window object is actually available to
// the preload script, and it is necessary for legitimate uses too.
//
// > The isolated world is connected to the DOM just the same is the main world,
// > it is just the JS contexts that are separated.
// >
// > https://github.com/electron/electron/issues/27024#issuecomment-745618327
//
// Adding this reference here tells TypeScript that DOM typings (in particular,
// window) should be introduced in the ambient scope.
//
// [Note: Node and web stream type mismatch]
//
// Unfortunately, adding this reference causes the ReadableStream typings to
// break since lib.dom.d.ts adds its own incompatible definitions of
// ReadableStream to the global scope.
//
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/68407
/// <reference lib="dom" />
import { contextBridge, ipcRenderer, webUtils } from "electron/renderer";
// While we can't import other code, we can import types since they're just
// needed when compiling and will not be needed or looked around for at runtime.
import type { IpcRendererEvent } from "electron";
import type {
AppUpdate,
CollectionMapping,
FFmpegCommand,
FolderWatch,
PendingUploads,
UtilityProcessType,
ZipItem,
} from "./types/ipc";
// - Infrastructure
// We need to wait until the renderer is ready before sending ports via
// postMessage, and this promise comes handy in such cases. We create the
// promise at the top level so that it is guaranteed to be registered before the
// load event is fired.
//
// See: https://www.electronjs.org/docs/latest/tutorial/message-ports
const windowLoaded = new Promise((resolve) => {
window.onload = resolve;
});
// - General
const appVersion = () => ipcRenderer.invoke("appVersion");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const openDirectory = (dirPath: string) =>
ipcRenderer.invoke("openDirectory", dirPath);
const openLogDirectory = () => ipcRenderer.invoke("openLogDirectory");
const selectDirectory = () => ipcRenderer.invoke("selectDirectory");
// The path that we get back from `webUtils.getPathForFile` on Windows uses "\"
// as the path separator. Convert them to POSIX separators.
const pathForFile =
process.platform == "win32"
? (file: File) => webUtils.getPathForFile(file).replace(/\\/g, "/")
: (file: File) => webUtils.getPathForFile(file);
const logout = () => {
watchRemoveListeners();
return ipcRenderer.invoke("logout");
};
const masterKeyFromSafeStorage = () =>
ipcRenderer.invoke("masterKeyFromSafeStorage");
const saveMasterKeyInSafeStorage = (masterKey: string) =>
ipcRenderer.invoke("saveMasterKeyInSafeStorage", masterKey);
const lastShownChangelogVersion = () =>
ipcRenderer.invoke("lastShownChangelogVersion");
const setLastShownChangelogVersion = (version: number) =>
ipcRenderer.invoke("setLastShownChangelogVersion", version);
const isAutoLaunchEnabled = () => ipcRenderer.invoke("isAutoLaunchEnabled");
const toggleAutoLaunch = () => ipcRenderer.invoke("toggleAutoLaunch");
const onMainWindowFocus = (cb: (() => void) | undefined) => {
ipcRenderer.removeAllListeners("mainWindowFocus");
if (cb) ipcRenderer.on("mainWindowFocus", cb);
};
const onOpenEnteURL = (cb: ((url: string) => void) | undefined) => {
ipcRenderer.removeAllListeners("openEnteURL");
if (cb) ipcRenderer.on("openEnteURL", (_, url: string) => cb(url));
};
// - App update
const onAppUpdateAvailable = (
cb: ((update: AppUpdate) => void) | undefined,
) => {
ipcRenderer.removeAllListeners("appUpdateAvailable");
if (cb) {
ipcRenderer.on("appUpdateAvailable", (_, update: AppUpdate) =>
cb(update),
);
}
};
const updateAndRestart = () => ipcRenderer.send("updateAndRestart");
const updateOnNextRestart = (version: string) =>
ipcRenderer.send("updateOnNextRestart", version);
const skipAppUpdate = (version: string) => {
ipcRenderer.send("skipAppUpdate", version);
};
// - FS
const fsExists = (path: string) => ipcRenderer.invoke("fsExists", path);
const fsMkdirIfNeeded = (dirPath: string) =>
ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
const fsRename = (oldPath: string, newPath: string) =>
ipcRenderer.invoke("fsRename", oldPath, newPath);
const fsRmdir = (path: string) => ipcRenderer.invoke("fsRmdir", path);
const fsRm = (path: string) => ipcRenderer.invoke("fsRm", path);
const fsReadTextFile = (path: string) =>
ipcRenderer.invoke("fsReadTextFile", path);
const fsWriteFile = (path: string, contents: string) =>
ipcRenderer.invoke("fsWriteFile", path, contents);
const fsWriteFileViaBackup = (path: string, contents: string) =>
ipcRenderer.invoke("fsWriteFileViaBackup", path, contents);
const fsIsDir = (dirPath: string) => ipcRenderer.invoke("fsIsDir", dirPath);
const fsStatMtime = (path: string) => ipcRenderer.invoke("fsStatMtime", path);
// - Conversion
const convertToJPEG = (imageData: Uint8Array) =>
ipcRenderer.invoke("convertToJPEG", imageData);
const generateImageThumbnail = (
pathOrZipItem: string | ZipItem,
maxDimension: number,
maxSize: number,
) =>
ipcRenderer.invoke(
"generateImageThumbnail",
pathOrZipItem,
maxDimension,
maxSize,
);
const ffmpegExec = (
command: FFmpegCommand,
pathOrZipItem: string | ZipItem,
outputFileExtension: string,
) =>
ipcRenderer.invoke(
"ffmpegExec",
command,
pathOrZipItem,
outputFileExtension,
);
const ffmpegDetermineVideoDuration = (pathOrZipItem: string | ZipItem) =>
ipcRenderer.invoke("ffmpegDetermineVideoDuration", pathOrZipItem);
// - Utility processes
const triggerCreateUtilityProcess = (type: UtilityProcessType) => {
const portEvent = `utilityProcessPort/${type}`;
const l = (event: IpcRendererEvent) => {
void windowLoaded.then(() => {
// "*"" is the origin to send to.
window.postMessage(portEvent, "*", event.ports);
ipcRenderer.off(portEvent, l);
});
};
ipcRenderer.on(portEvent, l);
ipcRenderer.send("triggerCreateUtilityProcess", type);
};
// - Watch
const watchGet = () => ipcRenderer.invoke("watchGet");
const watchAdd = (folderPath: string, collectionMapping: CollectionMapping) =>
ipcRenderer.invoke("watchAdd", folderPath, collectionMapping);
const watchRemove = (folderPath: string) =>
ipcRenderer.invoke("watchRemove", folderPath);
const watchUpdateSyncedFiles = (
syncedFiles: FolderWatch["syncedFiles"],
folderPath: string,
) => ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
const watchUpdateIgnoredFiles = (
ignoredFiles: FolderWatch["ignoredFiles"],
folderPath: string,
) => ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchAddFile");
ipcRenderer.on("watchAddFile", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const watchOnRemoveFile = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchRemoveFile");
ipcRenderer.on("watchRemoveFile", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchRemoveDir");
ipcRenderer.on("watchRemoveDir", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const fsFindFiles = (folderPath: string) =>
ipcRenderer.invoke("fsFindFiles", folderPath);
const watchRemoveListeners = () => {
ipcRenderer.removeAllListeners("watchAddFile");
ipcRenderer.removeAllListeners("watchRemoveFile");
ipcRenderer.removeAllListeners("watchRemoveDir");
};
// - Upload
const listZipItems = (zipPath: string) =>
ipcRenderer.invoke("listZipItems", zipPath);
const pathOrZipItemSize = (pathOrZipItem: string | ZipItem) =>
ipcRenderer.invoke("pathOrZipItemSize", pathOrZipItem);
const pendingUploads = () => ipcRenderer.invoke("pendingUploads");
const setPendingUploads = (pendingUploads: PendingUploads) =>
ipcRenderer.invoke("setPendingUploads", pendingUploads);
const markUploadedFile = (path: string, associatedPath?: string) =>
ipcRenderer.invoke("markUploadedFile", path, associatedPath);
const markUploadedZipItem = (item: ZipItem, associatedItem?: ZipItem) =>
ipcRenderer.invoke("markUploadedZipItem", item, associatedItem);
const clearPendingUploads = () => ipcRenderer.invoke("clearPendingUploads");
/**
* These objects exposed here will become available to the JS code in our
* renderer (the web/ code) as `window.ElectronAPIs.*`
*
* 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, 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.
* Electron currently only supports transferring MessagePorts:
* https://github.com/electron/electron/issues/34905
*
* 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
*
* The copy itself is relatively fast, but the problem with transferring large
* amounts of data is potentially running out of memory during the copy.
*
* For an alternative, see [Note: IPC streams].
*/
contextBridge.exposeInMainWorld("electron", {
// - General
appVersion,
logToDisk,
openDirectory,
openLogDirectory,
selectDirectory,
pathForFile,
logout,
masterKeyFromSafeStorage,
saveMasterKeyInSafeStorage,
lastShownChangelogVersion,
setLastShownChangelogVersion,
isAutoLaunchEnabled,
toggleAutoLaunch,
onMainWindowFocus,
onOpenEnteURL,
// - App update
onAppUpdateAvailable,
updateAndRestart,
updateOnNextRestart,
skipAppUpdate,
// - FS
fs: {
exists: fsExists,
rename: fsRename,
mkdirIfNeeded: fsMkdirIfNeeded,
rmdir: fsRmdir,
rm: fsRm,
readTextFile: fsReadTextFile,
writeFile: fsWriteFile,
writeFileViaBackup: fsWriteFileViaBackup,
isDir: fsIsDir,
statMtime: fsStatMtime,
findFiles: fsFindFiles,
},
// - Conversion
convertToJPEG,
generateImageThumbnail,
ffmpegExec,
ffmpegDetermineVideoDuration,
// - ML
triggerCreateUtilityProcess,
// - Watch
watch: {
get: watchGet,
add: watchAdd,
remove: watchRemove,
updateSyncedFiles: watchUpdateSyncedFiles,
updateIgnoredFiles: watchUpdateIgnoredFiles,
onAddFile: watchOnAddFile,
onRemoveFile: watchOnRemoveFile,
onRemoveDir: watchOnRemoveDir,
},
// - Upload
listZipItems,
pathOrZipItemSize,
pendingUploads,
setPendingUploads,
markUploadedFile,
markUploadedZipItem,
clearPendingUploads,
});