[photos-desktop] Various changes, bringing the code up to speed (#1227)

See commit titles for a gist.
This commit is contained in:
Manav Rathi 2024-03-27 21:22:54 +05:30 committed by GitHub
commit 3213fe0d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 513 additions and 485 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -12,7 +12,6 @@ linux:
arch: [x64, arm64] arch: [x64, arm64]
- target: pacman - target: pacman
arch: [x64, arm64] arch: [x64, arm64]
icon: ./resources/icon.icns
category: Photography category: Photography
mac: mac:
target: target:

View file

@ -8,27 +8,26 @@
* *
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
*/ */
import log from "electron-log"; import { app, BrowserWindow, Menu } from "electron/main";
import { app, BrowserWindow } from "electron/main";
import serveNextAt from "next-electron-server"; import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isDev } from "./main/general";
import { import {
addAllowOriginHeader, addAllowOriginHeader,
createWindow, createWindow,
handleDockIconHideOnAutoLaunch, handleDockIconHideOnAutoLaunch,
handleDownloads, handleDownloads,
handleExternalLinks, handleExternalLinks,
handleUpdates, logStartupBanner,
logSystemInfo,
setupMacWindowOnDockIconClick, setupMacWindowOnDockIconClick,
setupMainMenu,
setupTrayItem, setupTrayItem,
} from "./main/init"; } from "./main/init";
import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; 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"; import { initWatcher } from "./services/chokidar";
let appIsQuitting = false; let appIsQuitting = false;
@ -135,8 +134,6 @@ function setupAppEventEmitter(mainWindow: BrowserWindow) {
} }
const main = () => { const main = () => {
setupLogging(isDev);
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
app.quit(); app.quit();
@ -145,6 +142,7 @@ const main = () => {
let mainWindow: BrowserWindow; let mainWindow: BrowserWindow;
initLogging();
setupRendererServer(); setupRendererServer();
handleDockIconHideOnAutoLaunch(); handleDockIconHideOnAutoLaunch();
increaseDiskCache(); increaseDiskCache();
@ -161,19 +159,19 @@ const main = () => {
} }
}); });
// This method will be called when Electron has finished // Emitted once, when Electron has finished initializing.
// initialization and is ready to create browser windows. //
// Some APIs can only be used after this event occurs. // Note that some Electron APIs can only be used after this event occurs.
app.on("ready", async () => { app.on("ready", async () => {
logSystemInfo(); logStartupBanner();
mainWindow = await createWindow(); mainWindow = await createWindow();
const watcher = initWatcher(mainWindow); const watcher = initWatcher(mainWindow);
setupTrayItem(mainWindow); setupTrayItem(mainWindow);
setupMacWindowOnDockIconClick(); setupMacWindowOnDockIconClick();
setupMainMenu(mainWindow); Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
attachIPCHandlers(); attachIPCHandlers();
attachFSWatchIPCHandlers(watcher); attachFSWatchIPCHandlers(watcher);
await handleUpdates(mainWindow); if (!isDev) setupAutoUpdater(mainWindow);
handleDownloads(mainWindow); handleDownloads(mainWindow);
handleExternalLinks(mainWindow); handleExternalLinks(mainWindow);
addAllowOriginHeader(mainWindow); addAllowOriginHeader(mainWindow);
@ -184,7 +182,7 @@ const main = () => {
} catch (e) { } catch (e) {
// Log but otherwise ignore errors during non-critical startup // Log but otherwise ignore errors during non-critical startup
// actions // actions
logErrorSentry(e, "Ignoring startup error"); log.error("Ignoring startup error", e);
} }
}); });

View file

@ -5,7 +5,6 @@ import { createWriteStream, existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { logError } from "./log";
export const fsExists = (path: string) => existsSync(path); 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) => { export const isFolder = async (dirPath: string) => {
try { if (!existsSync(dirPath)) return false;
const stats = await fs.stat(dirPath); const stats = await fs.stat(dirPath);
return stats.isDirectory(); 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;
}
}; };
export const deleteFolder = async (folderPath: string) => { export const deleteFolder = async (folderPath: string) => {
if (!existsSync(folderPath)) { // Ensure it is folder
return; if (!isFolder(folderPath)) return;
}
const stat = await fs.stat(folderPath); // Ensure folder is empty
if (!stat.isDirectory()) {
throw new Error("Path is not a folder");
}
// check if folder is empty
const files = await fs.readdir(folderPath); const files = await fs.readdir(folderPath);
if (files.length > 0) { if (files.length > 0) throw new Error("Folder is not empty");
throw new Error("Folder is not empty");
} // rm -rf it
await fs.rmdir(folderPath); await fs.rmdir(folderPath);
}; };
export const rename = async (oldPath: string, newPath: string) => { export const rename = async (oldPath: string, newPath: string) => {
if (!existsSync(oldPath)) { if (!existsSync(oldPath)) throw new Error("Path does not exist");
throw new Error("Path does not exist");
}
await fs.rename(oldPath, newPath); await fs.rename(oldPath, newPath);
}; };
export const deleteFile = async (filePath: string) => { export const deleteFile = async (filePath: string) => {
if (!existsSync(filePath)) { // Ensure it exists
return; if (!existsSync(filePath)) return;
}
// And is a file
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
if (!stat.isFile()) { if (!stat.isFile()) throw new Error("Path is not a file");
throw new Error("Path is not a file");
} // rm it
return fs.rm(filePath); return fs.rm(filePath);
}; };

View file

@ -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\<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());

View file

@ -1,18 +1,14 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron"; import { app, BrowserWindow, nativeImage, Tray } from "electron";
import ElectronLog from "electron-log";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import os from "os"; import os from "node:os";
import path from "path"; import path from "node:path";
import util from "util";
import { isAppQuitting, rendererURL } from "../main"; import { isAppQuitting, rendererURL } from "../main";
import { setupAutoUpdater } from "../services/appUpdater";
import autoLauncher from "../services/autoLauncher"; import autoLauncher from "../services/autoLauncher";
import { getHideDockIconPreference } from "../services/userPreference"; import { getHideDockIconPreference } from "../services/userPreference";
import { isPlatform } from "../utils/common/platform"; import { isPlatform } from "../utils/common/platform";
import { buildContextMenu, buildMenuBar } from "../utils/menu"; import log from "./log";
import { isDev } from "./general"; import { createTrayContextMenu } from "./menu";
import { logErrorSentry } from "./log"; import { isDev } from "./util";
const execAsync = util.promisify(require("child_process").exec);
/** /**
* Create an return the {@link BrowserWindow} that will form our app's UI. * 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}. * This window will show the HTML served from {@link rendererURL}.
*/ */
export const createWindow = async () => { 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. // Create the main window. This'll show our web content.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
webPreferences: { webPreferences: {
preload: path.join(app.getAppPath(), "preload.js"), preload: path.join(app.getAppPath(), "preload.js"),
}, },
icon: appIcon,
// The color to show in the window until the web content gets loaded. // 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 // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
backgroundColor: "black", backgroundColor: "black",
// We'll show conditionally depending on `wasAutoLaunched` later // We'll show it conditionally depending on `wasAutoLaunched` later.
show: false, show: false,
}); });
@ -53,19 +43,14 @@ export const createWindow = async () => {
// Open the DevTools automatically when running in dev mode // Open the DevTools automatically when running in dev mode
if (isDev) mainWindow.webContents.openDevTools(); 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(); 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", () => { mainWindow.webContents.on("unresponsive", () => {
log.error("webContents unresponsive");
mainWindow.webContents.forcefullyCrashRenderer(); mainWindow.webContents.forcefullyCrashRenderer();
ElectronLog.log("webContents event unresponsive");
}); });
mainWindow.on("close", function (event) { mainWindow.on("close", function (event) {
@ -92,12 +77,7 @@ export const createWindow = async () => {
return mainWindow; return mainWindow;
}; };
export async function handleUpdates(mainWindow: BrowserWindow) { export async function handleUpdates(mainWindow: BrowserWindow) {}
const isInstalledViaBrew = await checkIfInstalledViaBrew();
if (!isDev && !isInstalledViaBrew) {
setupAutoUpdater(mainWindow);
}
}
export const setupTrayItem = (mainWindow: BrowserWindow) => { export const setupTrayItem = (mainWindow: BrowserWindow) => {
const iconName = isPlatform("mac") const iconName = isPlatform("mac")
@ -110,7 +90,7 @@ export const setupTrayItem = (mainWindow: BrowserWindow) => {
const trayIcon = nativeImage.createFromPath(trayImgPath); const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon); const tray = new Tray(trayIcon);
tray.setToolTip("ente"); tray.setToolTip("ente");
tray.setContextMenu(buildContextMenu(mainWindow)); tray.setContextMenu(createTrayContextMenu(mainWindow));
}; };
export function handleDownloads(mainWindow: BrowserWindow) { 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() { export async function handleDockIconHideOnAutoLaunch() {
const shouldHideDockIcon = getHideDockIconPreference(); const shouldHideDockIcon = getHideDockIconPreference();
const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
@ -173,27 +149,14 @@ export async function handleDockIconHideOnAutoLaunch() {
} }
} }
export function logSystemInfo() { export function logStartupBanner() {
const systemVersion = process.getSystemVersion(); const version = isDev ? "dev" : app.getVersion();
const osName = process.platform; log.info(`Hello from ente-photos-desktop ${version}`);
const osRelease = os.release();
ElectronLog.info({ osName, osRelease, systemVersion });
const appVersion = app.getVersion();
ElectronLog.info({ appVersion });
}
export async function checkIfInstalledViaBrew() { const platform = process.platform;
if (!isPlatform("mac")) { const osRelease = os.release();
return false; const systemVersion = process.getSystemVersion();
} log.info("Running on", { platform, osRelease, systemVersion });
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;
}
} }
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) { function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {

View file

@ -65,8 +65,8 @@ import {
saveFileToDisk, saveFileToDisk,
saveStreamToDisk, saveStreamToDisk,
} from "./fs"; } from "./fs";
import { openDirectory, openLogDirectory } from "./general";
import { logToDisk } from "./log"; import { logToDisk } from "./log";
import { openDirectory, openLogDirectory } from "./util";
/** /**
* Listen for IPC events sent/invoked by the renderer process, and route them to * Listen for IPC events sent/invoked by the renderer process, and route them to

View file

@ -1,18 +1,34 @@
import log from "electron-log"; 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.fileName = "ente.log";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB; log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
if (!isDev) { log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}";
log.transports.console.level = false;
}
log.transports.file.format =
"[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {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) => { export const logToDisk = (message: string) => {
log.info(message); log.info(`[rndr] ${message}`);
}; };
export const logError = logErrorSentry; export const logError = logErrorSentry;
@ -32,3 +48,84 @@ export function logErrorSentry(
console.log(error, { msg, info }); 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,
};

214
desktop/src/main/menu.ts Normal file
View file

@ -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,
},
]);
};

81
desktop/src/main/util.ts Normal file
View file

@ -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\<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());

View file

@ -1,20 +1,16 @@
import log from "electron-log";
import { app, net } from "electron/main"; import { app, net } from "electron/main";
import { existsSync } from "fs"; import { existsSync } from "fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import util from "util";
import { CustomErrors } from "../constants/errors"; import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs"; import { writeStream } from "../main/fs";
import { isDev } from "../main/general"; import log, { logErrorSentry } from "../main/log";
import { logErrorSentry } from "../main/log"; import { execAsync, isDev } from "../main/util";
import { Model } from "../types/ipc"; import { Model } from "../types/ipc";
import Tokenizer from "../utils/clip-bpe-ts/mod"; import Tokenizer from "../utils/clip-bpe-ts/mod";
import { getPlatform } from "../utils/common/platform"; import { getPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp"; import { generateTempFilePath } from "../utils/temp";
import { deleteTempFile } from "./ffmpeg"; import { deleteTempFile } from "./ffmpeg";
const shellescape = require("any-shell-escape");
const execAsync = util.promisify(require("child_process").exec);
const jpeg = require("jpeg-js"); const jpeg = require("jpeg-js");
const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL"; 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; const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) { if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) {
log.info( log.info(
"clip image model size mismatch, downloading again got:", `clip image model size mismatch, downloading again got: ${localFileSize}`,
localFileSize,
); );
imageModelDownloadInProgress = downloadModel( imageModelDownloadInProgress = downloadModel(
modelSavePath, modelSavePath,
@ -139,8 +134,7 @@ export async function getClipTextModelPath(type: "ggml" | "onnx") {
const localFileSize = (await fs.stat(modelSavePath)).size; const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) { if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) {
log.info( log.info(
"clip text model size mismatch, downloading again got:", `clip text model size mismatch, downloading again got: ${localFileSize}`,
localFileSize,
); );
textModelDownloadInProgress = true; textModelDownloadInProgress = true;
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type]) downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
@ -278,11 +272,7 @@ export async function computeGGMLImageEmbedding(
} }
}); });
const escapedCmd = shellescape(cmd); const { stdout } = await execAsync(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);
// parse stdout and return embedding // parse stdout and return embedding
// get the last line of stdout // get the last line of stdout
const lines = stdout.split("\n"); const lines = stdout.split("\n");
@ -291,7 +281,7 @@ export async function computeGGMLImageEmbedding(
const embeddingArray = new Float32Array(embedding); const embeddingArray = new Float32Array(embedding);
return embeddingArray; return embeddingArray;
} catch (err) { } catch (err) {
logErrorSentry(err, "Error in computeGGMLImageEmbedding"); log.error("Failed to compute GGML image embedding", err);
throw err; throw err;
} }
} }
@ -316,7 +306,7 @@ export async function computeONNXImageEmbedding(
const imageEmbedding = results["output"].data; // Float32Array const imageEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(imageEmbedding); return normalizeEmbedding(imageEmbedding);
} catch (err) { } catch (err) {
logErrorSentry(err, "Error in computeONNXImageEmbedding"); log.error("Failed to compute ONNX image embedding", err);
throw err; throw err;
} }
} }
@ -367,11 +357,7 @@ export async function computeGGMLTextEmbedding(
} }
}); });
const escapedCmd = shellescape(cmd); const { stdout } = await execAsync(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);
// parse stdout and return embedding // parse stdout and return embedding
// get the last line of stdout // get the last line of stdout
const lines = stdout.split("\n"); const lines = stdout.split("\n");
@ -383,7 +369,7 @@ export async function computeGGMLTextEmbedding(
if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) { if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) {
log.info(CustomErrors.MODEL_DOWNLOAD_PENDING); log.info(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else { } else {
logErrorSentry(err, "Error in computeGGMLTextEmbedding"); log.error("Failed to compute GGML text embedding", err);
} }
throw err; throw err;
} }

View file

@ -1,18 +1,13 @@
import log from "electron-log";
import pathToFfmpeg from "ffmpeg-static"; import pathToFfmpeg from "ffmpeg-static";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import util from "util";
import { CustomErrors } from "../constants/errors"; import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs"; 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 { ElectronFile } from "../types/ipc";
import { generateTempFilePath, getTempDirPath } from "../utils/temp"; 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 INPUT_PATH_PLACEHOLDER = "INPUT";
const FFMPEG_PLACEHOLDER = "FFMPEG"; const FFMPEG_PLACEHOLDER = "FFMPEG";
const OUTPUT_PATH_PLACEHOLDER = "OUTPUT"; const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
@ -70,11 +65,7 @@ export async function runFFmpegCmd(
return new File([outputFileData], outputFileName); return new File([outputFileData], outputFileName);
} finally { } finally {
if (createdTempInputFile) { if (createdTempInputFile) {
try {
await deleteTempFile(inputFilePath); await deleteTempFile(inputFilePath);
} catch (e) {
logError(e, "failed to deleteTempFile");
}
} }
} }
} }
@ -100,35 +91,23 @@ export async function runFFmpegCmd_(
return cmdPart; return cmdPart;
} }
}); });
const escapedCmd = shellescape(cmd);
log.info("running ffmpeg command", escapedCmd);
const startTime = Date.now();
if (dontTimeout) { if (dontTimeout) {
await execAsync(escapedCmd); await execAsync(cmd);
} else { } else {
await promiseWithTimeout(execAsync(escapedCmd), 30 * 1000); await promiseWithTimeout(execAsync(cmd), 30 * 1000);
} }
if (!existsSync(tempOutputFilePath)) { if (!existsSync(tempOutputFilePath)) {
throw new Error("ffmpeg output file not found"); 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); const outputFile = await fs.readFile(tempOutputFilePath);
return new Uint8Array(outputFile); return new Uint8Array(outputFile);
} catch (e) { } catch (e) {
logErrorSentry(e, "ffmpeg run command error"); log.error("FFMPEG command failed", e);
throw e; throw e;
} finally { } finally {
try { await deleteTempFile(tempOutputFilePath);
await fs.rm(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, "failed to remove tempOutputFile");
}
} }
} }
@ -153,16 +132,12 @@ export async function writeTempFile(fileStream: Uint8Array, fileName: string) {
export async function deleteTempFile(tempFilePath: string) { export async function deleteTempFile(tempFilePath: string) {
const tempDirPath = await getTempDirPath(); const tempDirPath = await getTempDirPath();
if (!tempFilePath.startsWith(tempDirPath)) { if (!tempFilePath.startsWith(tempDirPath))
logErrorSentry( log.error("Attempting to delete a non-temp file ${tempFilePath}");
Error("not a temp file"),
"tried to delete a non temp file",
);
}
await fs.rm(tempFilePath, { force: true }); await fs.rm(tempFilePath, { force: true });
} }
export const promiseWithTimeout = async <T>( const promiseWithTimeout = async <T>(
request: Promise<T>, request: Promise<T>,
timeout: number, timeout: number,
): Promise<T> => { ): Promise<T> => {

View file

@ -1,22 +1,15 @@
import { exec } from "child_process";
import log from "electron-log";
import { existsSync } from "fs"; import { existsSync } from "fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "path"; import path from "path";
import util from "util";
import { CustomErrors } from "../constants/errors"; import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs"; import { writeStream } from "../main/fs";
import { isDev } from "../main/general";
import { logError, logErrorSentry } from "../main/log"; import { logError, logErrorSentry } from "../main/log";
import { execAsync, isDev } from "../main/util";
import { ElectronFile } from "../types/ipc"; import { ElectronFile } from "../types/ipc";
import { isPlatform } from "../utils/common/platform"; import { isPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp"; import { generateTempFilePath } from "../utils/temp";
import { deleteTempFile } from "./ffmpeg"; import { deleteTempFile } from "./ffmpeg";
const shellescape = require("any-shell-escape");
const asyncExec = util.promisify(exec);
const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK"; const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK";
const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION"; const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION";
const SAMPLE_SIZE_PLACEHOLDER = "SAMPLE_SIZE"; const SAMPLE_SIZE_PLACEHOLDER = "SAMPLE_SIZE";
@ -104,7 +97,9 @@ async function convertToJPEG_(
await fs.writeFile(tempInputFilePath, fileData); await fs.writeFile(tempInputFilePath, fileData);
await runConvertCommand(tempInputFilePath, tempOutputFilePath); await execAsync(
constructConvertCommand(tempInputFilePath, tempOutputFilePath),
);
return new Uint8Array(await fs.readFile(tempOutputFilePath)); return new Uint8Array(await fs.readFile(tempOutputFilePath));
} catch (e) { } 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( function constructConvertCommand(
tempInputFilePath: string, tempInputFilePath: string,
tempOutputFilePath: string, tempOutputFilePath: string,
@ -222,13 +204,14 @@ async function generateImageThumbnail_(
tempOutputFilePath = await generateTempFilePath("thumb.jpeg"); tempOutputFilePath = await generateTempFilePath("thumb.jpeg");
let thumbnail: Uint8Array; let thumbnail: Uint8Array;
do { do {
await runThumbnailGenerationCommand( await execAsync(
constructThumbnailGenerationCommand(
inputFilePath, inputFilePath,
tempOutputFilePath, tempOutputFilePath,
width, width,
quality, quality,
),
); );
thumbnail = new Uint8Array(await fs.readFile(tempOutputFilePath)); thumbnail = new Uint8Array(await fs.readFile(tempOutputFilePath));
quality -= 10; quality -= 10;
} while (thumbnail.length > maxSize && quality > MIN_QUALITY); } 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( function constructThumbnailGenerationCommand(
inputFilePath: string, inputFilePath: string,
tempOutputFilePath: string, tempOutputFilePath: string,

25
desktop/src/types/any-shell-escape.d.ts vendored Normal file
View file

@ -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;
}

View file

@ -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<Menu> {
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);
}

View file

@ -9,12 +9,12 @@
/* Recommended target, lib and other settings for code running in the /* Recommended target, lib and other settings for code running in the
version of Node.js bundled with Electron. version of Node.js bundled with Electron.
Currently, with Electron 25, this is Node.js 18 Currently, with Electron 29, this is Node.js 20.9
https://www.electronjs.org/blog/electron-25-0 https://www.electronjs.org/blog/electron-29-0
Note that we cannot do 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 because that sets "lib": ["es2023"]. However (and I don't fully
understand what's going on here), that breaks our compilation since understand what's going on here), that breaks our compilation since