diff --git a/desktop/build/icon.icns b/desktop/build/icon.icns deleted file mode 100644 index ab7eface7a..0000000000 Binary files a/desktop/build/icon.icns and /dev/null differ diff --git a/desktop/build/window-icon.png b/desktop/build/window-icon.png deleted file mode 100644 index 5b0458033d..0000000000 Binary files a/desktop/build/window-icon.png and /dev/null differ diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index baa984e6b3..9189c34355 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -12,7 +12,6 @@ linux: arch: [x64, arm64] - target: pacman arch: [x64, arm64] - icon: ./resources/icon.icns category: Photography mac: target: diff --git a/desktop/src/main.ts b/desktop/src/main.ts index f5bbce835e..4383fa73f6 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -8,27 +8,26 @@ * * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process */ -import log from "electron-log"; -import { app, BrowserWindow } from "electron/main"; +import { app, BrowserWindow, Menu } from "electron/main"; import serveNextAt from "next-electron-server"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { isDev } from "./main/general"; import { addAllowOriginHeader, createWindow, handleDockIconHideOnAutoLaunch, handleDownloads, handleExternalLinks, - handleUpdates, - logSystemInfo, + logStartupBanner, setupMacWindowOnDockIconClick, - setupMainMenu, setupTrayItem, } from "./main/init"; import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; -import { logErrorSentry, setupLogging } from "./main/log"; +import log, { initLogging } from "./main/log"; +import { createApplicationMenu } from "./main/menu"; +import { isDev } from "./main/util"; +import { setupAutoUpdater } from "./services/appUpdater"; import { initWatcher } from "./services/chokidar"; let appIsQuitting = false; @@ -135,8 +134,6 @@ function setupAppEventEmitter(mainWindow: BrowserWindow) { } const main = () => { - setupLogging(isDev); - const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); @@ -145,6 +142,7 @@ const main = () => { let mainWindow: BrowserWindow; + initLogging(); setupRendererServer(); handleDockIconHideOnAutoLaunch(); increaseDiskCache(); @@ -161,19 +159,19 @@ const main = () => { } }); - // This method will be called when Electron has finished - // initialization and is ready to create browser windows. - // Some APIs can only be used after this event occurs. + // Emitted once, when Electron has finished initializing. + // + // Note that some Electron APIs can only be used after this event occurs. app.on("ready", async () => { - logSystemInfo(); + logStartupBanner(); mainWindow = await createWindow(); const watcher = initWatcher(mainWindow); setupTrayItem(mainWindow); setupMacWindowOnDockIconClick(); - setupMainMenu(mainWindow); + Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); attachIPCHandlers(); attachFSWatchIPCHandlers(watcher); - await handleUpdates(mainWindow); + if (!isDev) setupAutoUpdater(mainWindow); handleDownloads(mainWindow); handleExternalLinks(mainWindow); addAllowOriginHeader(mainWindow); @@ -184,7 +182,7 @@ const main = () => { } catch (e) { // Log but otherwise ignore errors during non-critical startup // actions - logErrorSentry(e, "Ignoring startup error"); + log.error("Ignoring startup error", e); } }); diff --git a/desktop/src/main/fs.ts b/desktop/src/main/fs.ts index cf34acb782..0da89fb005 100644 --- a/desktop/src/main/fs.ts +++ b/desktop/src/main/fs.ts @@ -5,7 +5,6 @@ import { createWriteStream, existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; -import { logError } from "./log"; export const fsExists = (path: string) => existsSync(path); @@ -99,54 +98,36 @@ export const moveFile = async (sourcePath: string, destinationPath: string) => { }; export const isFolder = async (dirPath: string) => { - try { - const stats = await fs.stat(dirPath); - return stats.isDirectory(); - } catch (e) { - let err = e; - // if code is defined, it's an error from fs.stat - if (typeof e.code !== "undefined") { - // ENOENT means the file does not exist - if (e.code === "ENOENT") { - return false; - } - err = Error(`fs error code: ${e.code}`); - } - logError(err, "isFolder failed"); - return false; - } + if (!existsSync(dirPath)) return false; + const stats = await fs.stat(dirPath); + return stats.isDirectory(); }; export const deleteFolder = async (folderPath: string) => { - if (!existsSync(folderPath)) { - return; - } - const stat = await fs.stat(folderPath); - if (!stat.isDirectory()) { - throw new Error("Path is not a folder"); - } - // check if folder is empty + // Ensure it is folder + if (!isFolder(folderPath)) return; + + // Ensure folder is empty const files = await fs.readdir(folderPath); - if (files.length > 0) { - throw new Error("Folder is not empty"); - } + if (files.length > 0) throw new Error("Folder is not empty"); + + // rm -rf it await fs.rmdir(folderPath); }; export const rename = async (oldPath: string, newPath: string) => { - if (!existsSync(oldPath)) { - throw new Error("Path does not exist"); - } + if (!existsSync(oldPath)) throw new Error("Path does not exist"); await fs.rename(oldPath, newPath); }; export const deleteFile = async (filePath: string) => { - if (!existsSync(filePath)) { - return; - } + // Ensure it exists + if (!existsSync(filePath)) return; + + // And is a file const stat = await fs.stat(filePath); - if (!stat.isFile()) { - throw new Error("Path is not a file"); - } + if (!stat.isFile()) throw new Error("Path is not a file"); + + // rm it return fs.rm(filePath); }; diff --git a/desktop/src/main/general.ts b/desktop/src/main/general.ts deleted file mode 100644 index e0b7654fe1..0000000000 --- a/desktop/src/main/general.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */ -import { app } from "electron/main"; -import 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\\AppData\Local\ente` - * - Windows: C:\Users\\AppData\Local\ - * - * 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()); diff --git a/desktop/src/main/init.ts b/desktop/src/main/init.ts index fb0e5d1a83..0e94232aa1 100644 --- a/desktop/src/main/init.ts +++ b/desktop/src/main/init.ts @@ -1,18 +1,14 @@ -import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron"; -import ElectronLog from "electron-log"; +import { app, BrowserWindow, nativeImage, Tray } from "electron"; import { existsSync } from "node:fs"; -import os from "os"; -import path from "path"; -import util from "util"; +import os from "node:os"; +import path from "node:path"; import { isAppQuitting, rendererURL } from "../main"; -import { setupAutoUpdater } from "../services/appUpdater"; import autoLauncher from "../services/autoLauncher"; import { getHideDockIconPreference } from "../services/userPreference"; import { isPlatform } from "../utils/common/platform"; -import { buildContextMenu, buildMenuBar } from "../utils/menu"; -import { isDev } from "./general"; -import { logErrorSentry } from "./log"; -const execAsync = util.promisify(require("child_process").exec); +import log from "./log"; +import { createTrayContextMenu } from "./menu"; +import { isDev } from "./util"; /** * Create an return the {@link BrowserWindow} that will form our app's UI. @@ -20,21 +16,15 @@ const execAsync = util.promisify(require("child_process").exec); * This window will show the HTML served from {@link rendererURL}. */ export const createWindow = async () => { - const appImgPath = isDev - ? "../build/window-icon.png" - : path.join(process.resourcesPath, "window-icon.png"); - const appIcon = nativeImage.createFromPath(appImgPath); - // Create the main window. This'll show our web content. const mainWindow = new BrowserWindow({ webPreferences: { preload: path.join(app.getAppPath(), "preload.js"), }, - icon: appIcon, // The color to show in the window until the web content gets loaded. // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property backgroundColor: "black", - // We'll show conditionally depending on `wasAutoLaunched` later + // We'll show it conditionally depending on `wasAutoLaunched` later. show: false, }); @@ -53,19 +43,14 @@ export const createWindow = async () => { // Open the DevTools automatically when running in dev mode if (isDev) mainWindow.webContents.openDevTools(); - mainWindow.webContents.on("render-process-gone", (event, details) => { + mainWindow.webContents.on("render-process-gone", (_, details) => { + log.error(`render-process-gone: ${details}`); mainWindow.webContents.reload(); - logErrorSentry( - Error("render-process-gone"), - "webContents event render-process-gone", - { details }, - ); - ElectronLog.log("webContents event render-process-gone", details); }); mainWindow.webContents.on("unresponsive", () => { + log.error("webContents unresponsive"); mainWindow.webContents.forcefullyCrashRenderer(); - ElectronLog.log("webContents event unresponsive"); }); mainWindow.on("close", function (event) { @@ -92,12 +77,7 @@ export const createWindow = async () => { return mainWindow; }; -export async function handleUpdates(mainWindow: BrowserWindow) { - const isInstalledViaBrew = await checkIfInstalledViaBrew(); - if (!isDev && !isInstalledViaBrew) { - setupAutoUpdater(mainWindow); - } -} +export async function handleUpdates(mainWindow: BrowserWindow) {} export const setupTrayItem = (mainWindow: BrowserWindow) => { const iconName = isPlatform("mac") @@ -110,7 +90,7 @@ export const setupTrayItem = (mainWindow: BrowserWindow) => { const trayIcon = nativeImage.createFromPath(trayImgPath); const tray = new Tray(trayIcon); tray.setToolTip("ente"); - tray.setContextMenu(buildContextMenu(mainWindow)); + tray.setContextMenu(createTrayContextMenu(mainWindow)); }; export function handleDownloads(mainWindow: BrowserWindow) { @@ -160,10 +140,6 @@ export function setupMacWindowOnDockIconClick() { }); } -export async function setupMainMenu(mainWindow: BrowserWindow) { - Menu.setApplicationMenu(await buildMenuBar(mainWindow)); -} - export async function handleDockIconHideOnAutoLaunch() { const shouldHideDockIcon = getHideDockIconPreference(); const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); @@ -173,27 +149,14 @@ export async function handleDockIconHideOnAutoLaunch() { } } -export function logSystemInfo() { - const systemVersion = process.getSystemVersion(); - const osName = process.platform; - const osRelease = os.release(); - ElectronLog.info({ osName, osRelease, systemVersion }); - const appVersion = app.getVersion(); - ElectronLog.info({ appVersion }); -} +export function logStartupBanner() { + const version = isDev ? "dev" : app.getVersion(); + log.info(`Hello from ente-photos-desktop ${version}`); -export async function checkIfInstalledViaBrew() { - if (!isPlatform("mac")) { - return false; - } - try { - await execAsync("brew list --cask ente"); - ElectronLog.info("ente installed via brew"); - return true; - } catch (e) { - ElectronLog.info("ente not installed via brew"); - return false; - } + const platform = process.platform; + const osRelease = os.release(); + const systemVersion = process.getSystemVersion(); + log.info("Running on", { platform, osRelease, systemVersion }); } function lowerCaseHeaders(responseHeaders: Record) { diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index beac3b721f..be97981867 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -65,8 +65,8 @@ import { saveFileToDisk, saveStreamToDisk, } from "./fs"; -import { openDirectory, openLogDirectory } from "./general"; import { logToDisk } from "./log"; +import { openDirectory, openLogDirectory } from "./util"; /** * Listen for IPC events sent/invoked by the renderer process, and route them to diff --git a/desktop/src/main/log.ts b/desktop/src/main/log.ts index 014465df1e..8787a530d2 100644 --- a/desktop/src/main/log.ts +++ b/desktop/src/main/log.ts @@ -1,18 +1,34 @@ import log from "electron-log"; -import { isDev } from "./general"; +import util from "node:util"; +import { isDev } from "./util"; -export function setupLogging(isDev?: boolean) { +/** + * Initialize logging in the main process. + * + * This will set our underlying logger up to log to a file named `ente.log`, + * + * - on Linux at ~/.config/ente/logs/main.log + * - on macOS at ~/Library/Logs/ente/main.log + * - on Windows at %USERPROFILE%\AppData\Roaming\ente\logs\main.log + * + * On dev builds, it will also log to the console. + */ +export const initLogging = () => { 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}"; -} + log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}"; + log.transports.console.level = false; +}; + +/** + * Write a {@link message} to the on-disk log. + * + * This is used by the renderer process (via the contextBridge) to add entries + * in the log that is saved on disk. + */ export const logToDisk = (message: string) => { - log.info(message); + log.info(`[rndr] ${message}`); }; export const logError = logErrorSentry; @@ -32,3 +48,84 @@ export function logErrorSentry( console.log(error, { msg, info }); } } + +const logError1 = (message: string, e?: unknown) => { + if (!e) { + logError_(message); + return; + } + + let es: string; + if (e instanceof Error) { + // In practice, we expect ourselves to be called with Error objects, so + // this is the happy path so to say. + es = `${e.name}: ${e.message}\n${e.stack}`; + } else { + // For the rest rare cases, use the default string serialization of e. + es = String(e); + } + + logError_(`${message}: ${es}`); +}; + +const logError_ = (message: string) => { + log.error(`[main] [error] ${message}`); + if (isDev) console.error(`[error] ${message}`); +}; + +const logInfo = (...params: any[]) => { + const message = params + .map((p) => (typeof p == "string" ? p : util.inspect(p))) + .join(" "); + log.info(`[main] ${message}`); + if (isDev) console.log(message); +}; + +const logDebug = (param: () => any) => { + if (isDev) console.log(`[debug] ${util.inspect(param())}`); +}; + +/** + * Ente's logger. + * + * This is an object that provides three functions to log at the corresponding + * levels - error, info or debug. + * + * {@link initLogging} needs to be called once before using any of these. + */ +export default { + /** + * Log an error message with an optional associated error object. + * + * {@link e} is generally expected to be an `instanceof Error` but it can be + * any arbitrary object that we obtain, say, when in a try-catch handler. + * + * The log is written to disk. In development builds, the log is also + * printed to the (Node.js process') console. + */ + error: logError1, + /** + * Log a message. + * + * This is meant as a replacement of {@link console.log}, and takes an + * arbitrary number of arbitrary parameters that it then serializes. + * + * The log is written to disk. In development builds, the log is also + * printed to the (Node.js process') console. + */ + info: logInfo, + /** + * Log a debug message. + * + * To avoid running unnecessary code in release builds, this takes a + * function to call to get the log message instead of directly taking the + * message. The provided function will only be called in development builds. + * + * The function can return an arbitrary value which is serialied before + * being logged. + * + * This log is not written to disk. It is printed to the (Node.js process') + * console only on development builds. + */ + debug: logDebug, +}; diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts new file mode 100644 index 0000000000..6589329611 --- /dev/null +++ b/desktop/src/main/menu.ts @@ -0,0 +1,214 @@ +import { + app, + BrowserWindow, + Menu, + MenuItemConstructorOptions, + shell, +} from "electron"; +import { setIsAppQuitting } from "../main"; +import { forceCheckForUpdateAndNotify } from "../services/appUpdater"; +import autoLauncher from "../services/autoLauncher"; +import { + getHideDockIconPreference, + setHideDockIconPreference, +} from "../services/userPreference"; +import { openLogDirectory } from "./util"; + +/** Create and return the entries in the app's main menu bar */ +export const createApplicationMenu = async (mainWindow: BrowserWindow) => { + // The state of checkboxes + // + // Whenever the menu is redrawn the current value of these variables is used + // to set the checked state for the various settings checkboxes. + let isAutoLaunchEnabled = await autoLauncher.isEnabled(); + let shouldHideDockIcon = getHideDockIconPreference(); + + const macOSOnly = (options: MenuItemConstructorOptions[]) => + process.platform == "darwin" ? options : []; + + const handleCheckForUpdates = () => + forceCheckForUpdateAndNotify(mainWindow); + + const handleViewChangelog = () => + shell.openExternal( + "https://github.com/ente-io/ente/blob/main/desktop/CHANGELOG.md", + ); + + const toggleAutoLaunch = () => { + autoLauncher.toggleAutoLaunch(); + isAutoLaunchEnabled = !isAutoLaunchEnabled; + }; + + const toggleHideDockIcon = () => { + setHideDockIconPreference(!shouldHideDockIcon); + shouldHideDockIcon = !shouldHideDockIcon; + }; + + const handleHelp = () => shell.openExternal("https://help.ente.io/photos/"); + + const handleSupport = () => shell.openExternal("mailto:support@ente.io"); + + const handleBlog = () => shell.openExternal("https://ente.io/blog/"); + + const handleViewLogs = openLogDirectory; + + return Menu.buildFromTemplate([ + { + label: "ente", + submenu: [ + ...macOSOnly([ + { + label: "About Ente", + role: "about", + }, + ]), + { type: "separator" }, + { + label: "Check for Updates...", + click: handleCheckForUpdates, + }, + { + label: "View Changelog", + click: handleViewChangelog, + }, + { type: "separator" }, + + { + label: "Preferences", + submenu: [ + { + label: "Open Ente on Startup", + type: "checkbox", + checked: isAutoLaunchEnabled, + click: toggleAutoLaunch, + }, + { + label: "Hide Dock Icon", + type: "checkbox", + checked: shouldHideDockIcon, + click: toggleHideDockIcon, + }, + ], + }, + + { type: "separator" }, + ...macOSOnly([ + { + label: "Hide Ente", + role: "hide", + }, + { + label: "Hide Others", + role: "hideOthers", + }, + { type: "separator" }, + ]), + { + label: "Quit", + role: "quit", + }, + ], + }, + { + label: "Edit", + submenu: [ + { label: "Undo", role: "undo" }, + { label: "Redo", role: "redo" }, + { type: "separator" }, + { label: "Cut", role: "cut" }, + { label: "Copy", role: "copy" }, + { label: "Paste", role: "paste" }, + { label: "Select All", role: "selectAll" }, + ...macOSOnly([ + { type: "separator" }, + { + label: "Speech", + submenu: [ + { + role: "startSpeaking", + label: "start speaking", + }, + { + role: "stopSpeaking", + label: "stop speaking", + }, + ], + }, + ]), + ], + }, + { + label: "View", + submenu: [ + { label: "Reload", role: "reload" }, + { label: "Toggle Dev Tools", role: "toggleDevTools" }, + { type: "separator" }, + { label: "Toggle Full Screen", role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { label: "Minimize", role: "minimize" }, + { label: "Zoom", role: "zoom" }, + { label: "Close", role: "close" }, + ...macOSOnly([ + { type: "separator" }, + { label: "Bring All to Front", role: "front" }, + { type: "separator" }, + { label: "Ente", role: "window" }, + ]), + ], + }, + { + label: "Help", + submenu: [ + { + label: "Ente Help", + click: handleHelp, + }, + { type: "separator" }, + { + label: "Support", + click: handleSupport, + }, + { + label: "Product Updates", + click: handleBlog, + }, + { type: "separator" }, + { + label: "View Logs", + click: handleViewLogs, + }, + ], + }, + ]); +}; + +/** + * Create and return a {@link Menu} that is shown when the user clicks on our + * system tray icon (e.g. the icon list at the top right of the screen on macOS) + */ +export const createTrayContextMenu = (mainWindow: BrowserWindow) => { + const handleOpen = () => { + mainWindow.maximize(); + mainWindow.show(); + }; + + const handleClose = () => { + setIsAppQuitting(true); + app.quit(); + }; + + return Menu.buildFromTemplate([ + { + label: "Open Ente", + click: handleOpen, + }, + { + label: "Quit Ente", + click: handleClose, + }, + ]); +}; diff --git a/desktop/src/main/util.ts b/desktop/src/main/util.ts new file mode 100644 index 0000000000..d0c6699e9a --- /dev/null +++ b/desktop/src/main/util.ts @@ -0,0 +1,81 @@ +import shellescape from "any-shell-escape"; +import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */ +import { app } from "electron/main"; +import { exec } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import log from "./log"; + +/** `true` if the app is running in development mode. */ +export const isDev = !app.isPackaged; + +/** + * Run a shell command asynchronously. + * + * This is a convenience promisified version of child_process.exec. It runs the + * command asynchronously and returns its stdout and stderr if there were no + * errors. + * + * If the command is passed as a string, then it will be executed verbatim. + * + * If the command is passed as an array, then the first argument will be treated + * as the executable and the remaining (optional) items as the command line + * parameters. This function will shellescape and join the array to form the + * command that finally gets executed. + * + * > Note: This is not a 1-1 replacement of child_process.exec - if you're + * > trying to run a trivial shell command, say something that produces a lot of + * > output, this might not be the best option and it might be better to use the + * > underlying functions. + */ +export const execAsync = (command: string | string[]) => { + const escapedCommand = Array.isArray(command) + ? shellescape(command) + : command; + const startTime = Date.now(); + log.debug(() => `Running shell command: ${escapedCommand}`); + const result = execAsync_(escapedCommand); + log.debug( + () => + `Completed in ${Math.round(Date.now() - startTime)} ms (${escapedCommand})`, + ); + return result; +}; + +const execAsync_ = promisify(exec); + +/** + * 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\\AppData\Local\ente` + * - Windows: C:\Users\\AppData\Local\ + * + * 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()); diff --git a/desktop/src/services/clipService.ts b/desktop/src/services/clipService.ts index 049b706b6f..41e559a9b7 100644 --- a/desktop/src/services/clipService.ts +++ b/desktop/src/services/clipService.ts @@ -1,20 +1,16 @@ -import log from "electron-log"; import { app, net } from "electron/main"; import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; -import util from "util"; import { CustomErrors } from "../constants/errors"; import { writeStream } from "../main/fs"; -import { isDev } from "../main/general"; -import { logErrorSentry } from "../main/log"; +import log, { logErrorSentry } from "../main/log"; +import { execAsync, isDev } from "../main/util"; import { Model } from "../types/ipc"; import Tokenizer from "../utils/clip-bpe-ts/mod"; import { getPlatform } from "../utils/common/platform"; import { generateTempFilePath } from "../utils/temp"; import { deleteTempFile } from "./ffmpeg"; -const shellescape = require("any-shell-escape"); -const execAsync = util.promisify(require("child_process").exec); const jpeg = require("jpeg-js"); const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL"; @@ -100,8 +96,7 @@ export async function getClipImageModelPath(type: "ggml" | "onnx") { const localFileSize = (await fs.stat(modelSavePath)).size; if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) { log.info( - "clip image model size mismatch, downloading again got:", - localFileSize, + `clip image model size mismatch, downloading again got: ${localFileSize}`, ); imageModelDownloadInProgress = downloadModel( modelSavePath, @@ -139,8 +134,7 @@ export async function getClipTextModelPath(type: "ggml" | "onnx") { const localFileSize = (await fs.stat(modelSavePath)).size; if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) { log.info( - "clip text model size mismatch, downloading again got:", - localFileSize, + `clip text model size mismatch, downloading again got: ${localFileSize}`, ); textModelDownloadInProgress = true; downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type]) @@ -278,11 +272,7 @@ export async function computeGGMLImageEmbedding( } }); - const escapedCmd = shellescape(cmd); - log.info("running clip command", escapedCmd); - const startTime = Date.now(); - const { stdout } = await execAsync(escapedCmd); - log.info("clip command execution time ", Date.now() - startTime); + const { stdout } = await execAsync(cmd); // parse stdout and return embedding // get the last line of stdout const lines = stdout.split("\n"); @@ -291,7 +281,7 @@ export async function computeGGMLImageEmbedding( const embeddingArray = new Float32Array(embedding); return embeddingArray; } catch (err) { - logErrorSentry(err, "Error in computeGGMLImageEmbedding"); + log.error("Failed to compute GGML image embedding", err); throw err; } } @@ -316,7 +306,7 @@ export async function computeONNXImageEmbedding( const imageEmbedding = results["output"].data; // Float32Array return normalizeEmbedding(imageEmbedding); } catch (err) { - logErrorSentry(err, "Error in computeONNXImageEmbedding"); + log.error("Failed to compute ONNX image embedding", err); throw err; } } @@ -367,11 +357,7 @@ export async function computeGGMLTextEmbedding( } }); - const escapedCmd = shellescape(cmd); - log.info("running clip command", escapedCmd); - const startTime = Date.now(); - const { stdout } = await execAsync(escapedCmd); - log.info("clip command execution time ", Date.now() - startTime); + const { stdout } = await execAsync(cmd); // parse stdout and return embedding // get the last line of stdout const lines = stdout.split("\n"); @@ -383,7 +369,7 @@ export async function computeGGMLTextEmbedding( if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) { log.info(CustomErrors.MODEL_DOWNLOAD_PENDING); } else { - logErrorSentry(err, "Error in computeGGMLTextEmbedding"); + log.error("Failed to compute GGML text embedding", err); } throw err; } diff --git a/desktop/src/services/ffmpeg.ts b/desktop/src/services/ffmpeg.ts index b6ac4dbda7..ddb3361cff 100644 --- a/desktop/src/services/ffmpeg.ts +++ b/desktop/src/services/ffmpeg.ts @@ -1,18 +1,13 @@ -import log from "electron-log"; import pathToFfmpeg from "ffmpeg-static"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; -import util from "util"; import { CustomErrors } from "../constants/errors"; import { writeStream } from "../main/fs"; -import { logError, logErrorSentry } from "../main/log"; +import log from "../main/log"; +import { execAsync } from "../main/util"; import { ElectronFile } from "../types/ipc"; import { generateTempFilePath, getTempDirPath } from "../utils/temp"; -const shellescape = require("any-shell-escape"); - -const execAsync = util.promisify(require("child_process").exec); - const INPUT_PATH_PLACEHOLDER = "INPUT"; const FFMPEG_PLACEHOLDER = "FFMPEG"; const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; @@ -70,11 +65,7 @@ export async function runFFmpegCmd( return new File([outputFileData], outputFileName); } finally { if (createdTempInputFile) { - try { - await deleteTempFile(inputFilePath); - } catch (e) { - logError(e, "failed to deleteTempFile"); - } + await deleteTempFile(inputFilePath); } } } @@ -100,35 +91,23 @@ export async function runFFmpegCmd_( return cmdPart; } }); - const escapedCmd = shellescape(cmd); - log.info("running ffmpeg command", escapedCmd); - const startTime = Date.now(); + if (dontTimeout) { - await execAsync(escapedCmd); + await execAsync(cmd); } else { - await promiseWithTimeout(execAsync(escapedCmd), 30 * 1000); + await promiseWithTimeout(execAsync(cmd), 30 * 1000); } + if (!existsSync(tempOutputFilePath)) { throw new Error("ffmpeg output file not found"); } - log.info( - "ffmpeg command execution time ", - escapedCmd, - Date.now() - startTime, - "ms", - ); - const outputFile = await fs.readFile(tempOutputFilePath); return new Uint8Array(outputFile); } catch (e) { - logErrorSentry(e, "ffmpeg run command error"); + log.error("FFMPEG command failed", e); throw e; } finally { - try { - await fs.rm(tempOutputFilePath, { force: true }); - } catch (e) { - logErrorSentry(e, "failed to remove tempOutputFile"); - } + await deleteTempFile(tempOutputFilePath); } } @@ -153,16 +132,12 @@ export async function writeTempFile(fileStream: Uint8Array, fileName: string) { export async function deleteTempFile(tempFilePath: string) { const tempDirPath = await getTempDirPath(); - if (!tempFilePath.startsWith(tempDirPath)) { - logErrorSentry( - Error("not a temp file"), - "tried to delete a non temp file", - ); - } + if (!tempFilePath.startsWith(tempDirPath)) + log.error("Attempting to delete a non-temp file ${tempFilePath}"); await fs.rm(tempFilePath, { force: true }); } -export const promiseWithTimeout = async ( +const promiseWithTimeout = async ( request: Promise, timeout: number, ): Promise => { diff --git a/desktop/src/services/imageProcessor.ts b/desktop/src/services/imageProcessor.ts index ffb86edeaf..f6a567f8ca 100644 --- a/desktop/src/services/imageProcessor.ts +++ b/desktop/src/services/imageProcessor.ts @@ -1,22 +1,15 @@ -import { exec } from "child_process"; -import log from "electron-log"; import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "path"; -import util from "util"; import { CustomErrors } from "../constants/errors"; import { writeStream } from "../main/fs"; -import { isDev } from "../main/general"; import { logError, logErrorSentry } from "../main/log"; +import { execAsync, isDev } from "../main/util"; import { ElectronFile } from "../types/ipc"; import { isPlatform } from "../utils/common/platform"; import { generateTempFilePath } from "../utils/temp"; import { deleteTempFile } from "./ffmpeg"; -const shellescape = require("any-shell-escape"); - -const asyncExec = util.promisify(exec); - const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK"; const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION"; const SAMPLE_SIZE_PLACEHOLDER = "SAMPLE_SIZE"; @@ -104,7 +97,9 @@ async function convertToJPEG_( await fs.writeFile(tempInputFilePath, fileData); - await runConvertCommand(tempInputFilePath, tempOutputFilePath); + await execAsync( + constructConvertCommand(tempInputFilePath, tempOutputFilePath), + ); return new Uint8Array(await fs.readFile(tempOutputFilePath)); } catch (e) { @@ -124,19 +119,6 @@ async function convertToJPEG_( } } -async function runConvertCommand( - tempInputFilePath: string, - tempOutputFilePath: string, -) { - const convertCmd = constructConvertCommand( - tempInputFilePath, - tempOutputFilePath, - ); - const escapedCmd = shellescape(convertCmd); - log.info("running convert command: " + escapedCmd); - await asyncExec(escapedCmd); -} - function constructConvertCommand( tempInputFilePath: string, tempOutputFilePath: string, @@ -222,13 +204,14 @@ async function generateImageThumbnail_( tempOutputFilePath = await generateTempFilePath("thumb.jpeg"); let thumbnail: Uint8Array; do { - await runThumbnailGenerationCommand( - inputFilePath, - tempOutputFilePath, - width, - quality, + await execAsync( + constructThumbnailGenerationCommand( + inputFilePath, + tempOutputFilePath, + width, + quality, + ), ); - thumbnail = new Uint8Array(await fs.readFile(tempOutputFilePath)); quality -= 10; } while (thumbnail.length > maxSize && quality > MIN_QUALITY); @@ -245,23 +228,6 @@ async function generateImageThumbnail_( } } -async function runThumbnailGenerationCommand( - inputFilePath: string, - tempOutputFilePath: string, - maxDimension: number, - quality: number, -) { - const thumbnailGenerationCmd: string[] = - constructThumbnailGenerationCommand( - inputFilePath, - tempOutputFilePath, - maxDimension, - quality, - ); - const escapedCmd = shellescape(thumbnailGenerationCmd); - log.info("running thumbnail generation command: " + escapedCmd); - await asyncExec(escapedCmd); -} function constructThumbnailGenerationCommand( inputFilePath: string, tempOutputFilePath: string, diff --git a/desktop/src/types/any-shell-escape.d.ts b/desktop/src/types/any-shell-escape.d.ts new file mode 100644 index 0000000000..4172cdb1ef --- /dev/null +++ b/desktop/src/types/any-shell-escape.d.ts @@ -0,0 +1,25 @@ +/** + * Escape and stringify an array of arguments to be executed on the shell. + * + * @example + * + * const shellescape = require('any-shell-escape'); + * + * const args = ['curl', '-v', '-H', 'Location;', '-H', "User-Agent: FooBar's so-called \"Browser\"", 'http://www.daveeddy.com/?name=dave&age=24']; + * + * const escaped = shellescape(args); + * console.log(escaped); + * + * yields (on POSIX shells): + * + * curl -v -H 'Location;' -H 'User-Agent: FoorBar'"'"'s so-called "Browser"' 'http://www.daveeddy.com/?name=dave&age=24' + * + * or (on Windows): + * + * curl -v -H "Location;" -H "User-Agent: FooBar's so-called ""Browser""" "http://www.daveeddy.com/?name=dave&age=24" +Which is suitable for being executed by the shell. + */ +declare module "any-shell-escape" { + declare const shellescape: (args: readonly string | string[]) => string; + export default shellescape; +} diff --git a/desktop/src/utils/menu.ts b/desktop/src/utils/menu.ts deleted file mode 100644 index 941d8ae254..0000000000 --- a/desktop/src/utils/menu.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { - app, - BrowserWindow, - Menu, - MenuItemConstructorOptions, - shell, -} 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 { - getHideDockIconPreference, - setHideDockIconPreference, -} from "../services/userPreference"; -import { isPlatform } from "./common/platform"; - -export function buildContextMenu(mainWindow: BrowserWindow): Menu { - // eslint-disable-next-line camelcase - const contextMenu = Menu.buildFromTemplate([ - { - label: "Open ente", - click: function () { - mainWindow.maximize(); - mainWindow.show(); - }, - }, - { - label: "Quit ente", - click: function () { - ElectronLog.log("user quit the app"); - setIsAppQuitting(true); - app.quit(); - }, - }, - ]); - return contextMenu; -} - -export async function buildMenuBar(mainWindow: BrowserWindow): Promise { - let isAutoLaunchEnabled = await autoLauncher.isEnabled(); - const isMac = isPlatform("mac"); - let shouldHideDockIcon = getHideDockIconPreference(); - const template: MenuItemConstructorOptions[] = [ - { - label: "ente", - submenu: [ - ...((isMac - ? [ - { - label: "About ente", - role: "about", - }, - ] - : []) as MenuItemConstructorOptions[]), - { type: "separator" }, - { - label: "Check for updates...", - click: () => { - forceCheckForUpdateAndNotify(mainWindow); - }, - }, - { - label: "View Changelog", - click: () => { - shell.openExternal( - "https://github.com/ente-io/ente/blob/main/desktop/CHANGELOG.md", - ); - }, - }, - { type: "separator" }, - - { - label: "Preferences", - submenu: [ - { - label: "Open ente on startup", - type: "checkbox", - checked: isAutoLaunchEnabled, - click: () => { - autoLauncher.toggleAutoLaunch(); - isAutoLaunchEnabled = !isAutoLaunchEnabled; - }, - }, - { - label: "Hide dock icon", - type: "checkbox", - checked: shouldHideDockIcon, - click: () => { - setHideDockIconPreference(!shouldHideDockIcon); - shouldHideDockIcon = !shouldHideDockIcon; - }, - }, - ], - }, - - { type: "separator" }, - ...((isMac - ? [ - { - label: "Hide ente", - role: "hide", - }, - { - label: "Hide others", - role: "hideOthers", - }, - ] - : []) as MenuItemConstructorOptions[]), - - { type: "separator" }, - { - label: "Quit ente", - role: "quit", - }, - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo", label: "Undo" }, - { role: "redo", label: "Redo" }, - { type: "separator" }, - { role: "cut", label: "Cut" }, - { role: "copy", label: "Copy" }, - { role: "paste", label: "Paste" }, - ...((isMac - ? [ - { - role: "pasteAndMatchStyle", - label: "Paste and match style", - }, - { role: "delete", label: "Delete" }, - { role: "selectAll", label: "Select all" }, - { type: "separator" }, - { - label: "Speech", - submenu: [ - { - role: "startSpeaking", - label: "start speaking", - }, - { - role: "stopSpeaking", - label: "stop speaking", - }, - ], - }, - ] - : [ - { type: "separator" }, - { role: "selectAll", label: "Select all" }, - ]) as MenuItemConstructorOptions[]), - ], - }, - { - label: "View", - submenu: [ - { role: "reload", label: "Reload" }, - { role: "forceReload", label: "Force reload" }, - { role: "toggleDevTools", label: "Toggle dev tools" }, - { type: "separator" }, - { role: "resetZoom", label: "Reset zoom" }, - { role: "zoomIn", label: "Zoom in" }, - { role: "zoomOut", label: "Zoom out" }, - { type: "separator" }, - { role: "togglefullscreen", label: "Toggle fullscreen" }, - ], - }, - { - label: "Window", - submenu: [ - { role: "close", label: "Close" }, - { role: "minimize", label: "Minimize" }, - ...((isMac - ? [ - { type: "separator" }, - { role: "front", label: "Bring to front" }, - { type: "separator" }, - { role: "window", label: "ente" }, - ] - : []) as MenuItemConstructorOptions[]), - ], - }, - { - label: "Help", - submenu: [ - { - label: "Ente Help", - click: () => shell.openExternal("https://help.ente.io/photos/"), - }, - { type: "separator" }, - { - label: "Support", - click: () => shell.openExternal("mailto:support@ente.io"), - }, - { - label: "Product updates", - click: () => shell.openExternal("https://ente.io/blog/"), - }, - { type: "separator" }, - { - label: "View crash reports", - click: () => openDirectory(app.getPath("crashDumps")), - }, - { - label: "View logs", - click: openLogDirectory, - }, - ], - }, - ]; - return Menu.buildFromTemplate(template); -} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index 0f6ad6052f..700ea3fa00 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -9,12 +9,12 @@ /* Recommended target, lib and other settings for code running in the version of Node.js bundled with Electron. - Currently, with Electron 25, this is Node.js 18 - https://www.electronjs.org/blog/electron-25-0 + Currently, with Electron 29, this is Node.js 20.9 + https://www.electronjs.org/blog/electron-29-0 Note that we cannot do - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node20/tsconfig.json", because that sets "lib": ["es2023"]. However (and I don't fully understand what's going on here), that breaks our compilation since