Files
ente/desktop/src/main.ts
2025-03-17 12:35:22 +05:30

744 lines
29 KiB
TypeScript

/**
* @file Entry point for the main (Node.js) process of our Electron app.
*
* The code in this file is invoked by Electron when our app starts -
* Conceptually (after all the transpilation etc has happened) this can be
* thought of `electron main.ts`. We're running in the context of the so called
* "main" process which runs in a Node.js environment.
*
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
*/
import { nativeImage, shell } from "electron/common";
import {
BrowserWindow,
Menu,
Tray,
app,
dialog,
nativeTheme,
protocol,
type WebContents,
} from "electron/main";
import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
attachFSWatchIPCHandlers,
attachIPCHandlers,
attachLogoutIPCHandler,
attachMainWindowIPCHandlers,
} from "./main/ipc";
import log, { initLogging } from "./main/log";
import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
import { setupAutoUpdater } from "./main/services/app-update";
import autoLauncher from "./main/services/auto-launcher";
import { createWatcher } from "./main/services/watch";
import { userPreferences } from "./main/stores/user-preferences";
import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch";
import { registerStreamProtocol } from "./main/stream";
import { wait } from "./main/utils/common";
import { isDev } from "./main/utils/electron";
/**
* The URL where the renderer HTML is being served from.
*/
const rendererURL = "ente://app";
/**
* We want to hide our window instead of closing it when the user presses the
* cross button on the window.
*
* > This is because there is 1. a perceptible initial window creation time for
* > our app, and 2. because the long running processes like export and watch
* > folders are tied to the lifetime of the window and otherwise won't run in
* > the background.
*
* Intercepting the window close event and using that to instead hide it is
* easy, however that prevents the actual app quit to stop working (since the
* window never gets closed).
*
* So to achieve our original goal (hide window instead of closing) without
* disabling expected app quits, we keep a flag, and we turn it on when we're
* part of the quit sequence. When this flag is on, we bypass the code that
* prevents the window from being closed.
*/
let shouldAllowWindowClose = false;
export const allowWindowClose = (): void => {
shouldAllowWindowClose = true;
};
/**
* The app's entry point.
*
* We call this at the end of this file.
*/
const main = () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
let mainWindow: BrowserWindow | undefined;
initLogging();
logStartupBanner();
registerForEnteLinks();
// The order of the next two calls is important
setupRendererServer();
registerPrivilegedSchemes();
migrateLegacyWatchStoreIfNeeded();
/**
* Handle an open URL request, but ensuring that we have a mainWindow.
*/
const handleOpenEnteURLEnsuringWindow = (url: string) => {
log.info(`Attempting to handle request to open URL: ${url}`);
if (mainWindow) handleEnteLinks(mainWindow, url);
else setTimeout(() => handleOpenEnteURLEnsuringWindow(url), 1000);
};
app.on("second-instance", (_, argv: string[]) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// On Windows and Linux, this is how we get deeplinks.
//
// See: registerForEnteLinks
//
// Note that Chromium reserves the right to fudge with the order of the
// command line arguments, including inserting things in arbitrary
// places, so we need to go through the args to find the one that is
// pertinent to us (if any) instead of looking at a fixed position.
const url = argv.find((arg) => arg.startsWith("ente://app"));
if (url) handleOpenEnteURLEnsuringWindow(url);
});
// Emitted once, when Electron has finished initializing.
//
// Note that some Electron APIs can only be used after this event occurs.
void app.whenReady().then(() => {
attachProcessHandlers();
void (async () => {
if (isDev) await waitForRendererDevServer();
// Create window and prepare for the renderer.
mainWindow = createMainWindow();
// Setup IPC and streams.
const watcher = createWatcher(mainWindow);
attachIPCHandlers();
attachMainWindowIPCHandlers(mainWindow);
attachFSWatchIPCHandlers(watcher);
attachLogoutIPCHandler(watcher);
registerStreamProtocol();
// Configure the renderer's environment.
const webContents = mainWindow.webContents;
setDownloadPath(webContents);
allowExternalLinks(webContents);
handleBackOnStripeCheckout(mainWindow);
allowAllCORSOrigins(webContents);
// Start loading the renderer.
void mainWindow.loadURL(rendererURL);
// Continue on with the rest of the startup sequence.
Menu.setApplicationMenu(createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
setupAutoUpdater(mainWindow);
try {
await deleteLegacyDiskCacheDirIfExists();
await deleteLegacyKeysStoreIfExists();
} catch (e) {
// Log but otherwise ignore errors during non-critical startup
// actions.
log.error("Ignoring startup error", e);
}
})();
});
// This is a macOS only event. Show our window when the user activates the
// app, e.g. by clicking on its dock icon.
app.on("activate", () => mainWindow?.show());
app.on("before-quit", () => {
if (mainWindow) saveWindowBounds(mainWindow);
allowWindowClose();
});
// On macOS, this is how we get deeplinks. See: registerForEnteLinks
app.on("open-url", (_, url) => handleOpenEnteURLEnsuringWindow(url));
};
/**
* Log a standard startup banner.
*
* This helps us identify app starts and other environment details in the logs.
*/
const logStartupBanner = () => {
const version = isDev ? "dev" : app.getVersion();
log.info(`Starting ente-photos-desktop ${version}`);
const platform = process.platform;
const osRelease = os.release();
const systemVersion = process.getSystemVersion();
log.info("Running on", { platform, osRelease, systemVersion });
};
/**
* next-electron-server allows up to directly use the output of `next build` in
* production mode and `next dev` in development mode, whilst keeping the rest
* of our code the same.
*
* It uses protocol handlers to serve files from the "ente://" protocol.
*
* - In development this is proxied to http://localhost:3008
* - In production it serves files from the `/out` directory
*
* For more details, see this comparison:
* https://github.com/HaNdTriX/next-electron-server/issues/5
*/
const setupRendererServer = () => serveNextAt(rendererURL, { port: 3008 });
/**
* Register privileged schemes.
*
* We have two privileged schemes:
*
* 1. "ente", used for serving our web app (@see {@link setupRendererServer}).
*
* 2. "stream", used for streaming IPC (@see {@link registerStreamProtocol}).
*
* Both of these need some privileges, however, the documentation for Electron's
* [registerSchemesAsPrivileged](https://www.electronjs.org/docs/latest/api/protocol)
* says:
*
* > This method ... can be called only once.
*
* The library we use for the "ente" scheme, next-electron-server, already calls
* it once when we invoke {@link setupRendererServer}.
*
* In practice calling it multiple times just causes the values to be
* overwritten, and the last call wins. So we don't need to modify
* next-electron-server to prevent it from calling registerSchemesAsPrivileged.
* Instead, we (a) repeat what next-electron-server had done here, and (b)
* ensure that we're called after {@link setupRendererServer}.
*/
const registerPrivilegedSchemes = () => {
protocol.registerSchemesAsPrivileged([
{
// Taken verbatim from next-electron-server's code (index.js)
scheme: "ente",
privileges: {
standard: true,
secure: true,
allowServiceWorkers: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
{ scheme: "stream", privileges: { supportFetchAPI: true } },
]);
};
/**
* Register a handler for deeplinks, for the "ente://" protocol.
*
* See: [Note: Passkey verification in the desktop app].
*
* Implementation notes:
* - https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app
* - This works only when the app is packaged.
* - On Windows and Linux, we get the deeplink in the "second-instance" event.
* - On macOS, we get the deeplink in the "open-url" event.
*/
const registerForEnteLinks = () => app.setAsDefaultProtocolClient("ente");
/** Sibling of {@link registerForEnteLinks}. */
const handleEnteLinks = (mainWindow: BrowserWindow, url: string) => {
// [Note: Using deeplinks to navigate in desktop app]
//
// Both
//
// - our deeplink protocol, and
// - the protocol we're using to serve/ our bundled web app
//
// use the same scheme ("ente://"), so the URL can directly be forwarded.
mainWindow.webContents.send("openEnteURL", url);
};
/** Attach handlers to the (node) process. */
const attachProcessHandlers = () => {
// Gracefully quit the app if we get a SIGINT.
//
// This is meant to allow graceful shutdowns during development, when the
// app is launched using `yarn dev`. In such cases, pressing CTRL-C sends a
// SIGINT to the process. The default handling of SIGINT is not graceful
// enough (apparently), since I can observe that sometimes recent writes to
// local storage are lost. This has also been reported by other people:
// https://github.com/electron/electron/issues/22048
//
// Hopefully handling SIGINT prevents that issue. But beyond that, it allows
// us to also write out `userPreferences.json` (as would happen during a
// normal quit sequence), so this is an improvement either ways.
process.on("SIGINT", () => app.quit());
};
/**
* Wait for the renderer process' dev server to be ready.
*
* After creating the main window, we load the web app into it using `loadURL`.
* In production, these are served directly from the SSR-ed static files bundled
* with the app, and so can be served instantly. However, during development, we
* start a dev server for serving the HMR-ed files.
*
* This Next.js HMR server takes time to startup and is sometimes not ready to
* handle incoming requests when the main window tries to load it. In such
* cases, Electron just hangs with this:
*
* [main] Error: net::ERR_CONNECTION_REFUSED
* [main] at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/browser_init:2:114482)
* [main] at SimpleURLLoaderWrapper.emit (node:events:519:28)
*
* As a workaround, we wait for 1 second.
*
* I'd also tried fancier workaround - polling the URL - but waits until the dev
* server has the response ready, delaying everything many seconds (we just want
* to see if the dev server can accept connections). The 1 second delay seems to
* get the job done for now.
*
* This workaround can likely be removed when we migrate to Vite.
*/
const waitForRendererDevServer = () => wait(1000);
/**
* Create an return the {@link BrowserWindow} that will form our app's UI.
*
* This window will show the HTML served from {@link rendererURL}.
*/
const createMainWindow = () => {
const icon = nativeImage.createFromPath(
path.join(isDev ? "build" : process.resourcesPath, "window-icon.png"),
);
const bounds = windowBounds();
// Create the main window. This'll show our web content.
const window = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, "preload.js"),
sandbox: true,
},
icon,
// Set the window's position and size (if we have one saved).
...(bounds ?? {}),
// Enforce a minimum size
...minimumWindowSize(),
// [Note: Customize the desktop title bar]
//
// 1. Remove the default title bar.
// 2. Reintroduce the title bar controls.
// 3. Show a custom title bar in the renderer.
//
// For step 3, we use `app-region: drag` to allow dragging the window by
// the title bar, and use the Window Controls Overlay CSS environment
// variables to determine its dimensions. Note that these overlay CSS
// environment vars are only available when titleBarOverlay is true, so
// unlike the tutorial which enables it only for Windows and Linux, we
// do it (Step 2) unconditionally (i.e., on macOS too).
//
// https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#create-a-custom-title-bar
//
// Note that by default on Windows, the color of the WCO title bar
// overlay (three buttons - minimize, maximize, close - on the top
// right) is static, and unlike Linux, doesn't adapt to the theme /
// content. Explicitly choosing a dark background, while it won't work
// always (if the user's theme is light), is better than picking a light
// background since the main image viewer is always dark.
titleBarStyle: "hidden",
titleBarOverlay:
process.platform == "win32"
? { color: "black", symbolColor: "#cdcdcd" }
: true,
// The color to show in the window until the web content gets loaded.
// https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
//
// To avoid a flash, we want to use the same background color as the
// theme of their choice. Unless the user has modified their preference
// to not follow the system, we can deduce it from the current OS theme.
//
// See: https://www.electronjs.org/docs/latest/tutorial/dark-mode
backgroundColor: nativeTheme.shouldUseDarkColors ? "black" : "white",
// We'll show it conditionally depending on `wasAutoLaunched` later.
show: false,
});
const wasAutoLaunched = autoLauncher.wasAutoLaunched();
if (wasAutoLaunched) {
// Don't automatically show the app's window if we were auto-launched.
// On macOS, also hide the dock icon on macOS.
if (process.platform == "darwin") app.dock.hide();
} else {
// Show our window otherwise, maximizing it if we're not asked to set it
// to a specific size.
bounds ? window.show() : window.maximize();
}
// Open the DevTools automatically when running in dev mode
if (isDev) window.webContents.openDevTools();
window.webContents.on("render-process-gone", (_, details) => {
log.error(`render-process-gone: ${details.reason}`);
window.webContents.reload();
});
// "The unresponsive event is fired when Chromium detects that your
// webContents is not responding to input messages for > 30 seconds."
window.webContents.on("unresponsive", () => {
// There is a known case when this can happen: When the user to select a
// folder to upload (Upload > Folder), the browser callback to us takes
// some time. When trying to upload very large folders on slower Windows
// machines, this can take up to 30 seconds.
log.warn("MainWindow's webContents are unresponsive");
});
window.on("close", (event) => {
if (!shouldAllowWindowClose) {
event.preventDefault();
window.hide();
}
return false;
});
window.on("hide", () => {
// On macOS, when hiding the window also hide the app's icon in the dock
// if the user has selected the Settings > Hide dock icon checkbox.
if (process.platform == "darwin" && userPreferences.get("hideDockIcon"))
app.dock.hide();
});
window.on("show", () => {
if (process.platform == "darwin") void app.dock.show();
});
// Let ipcRenderer know when mainWindow is in the foreground so that it can
// in turn inform the renderer process.
window.on("focus", () => window.webContents.send("mainWindowFocus"));
return window;
};
/**
* The position and size to use when showing the main window.
*
* The return value is `undefined` if the app's window was maximized the last
* time around, and so if we should restore it to the maximized state.
*
* Otherwise it returns the position and size of the window the last time the
* app quit.
*
* If there is no such saved value (or if it is the first time the user is
* running the app), return a default size.
*/
const windowBounds = () => {
if (userPreferences.get("isWindowMaximized")) return undefined;
const bounds = userPreferences.get("windowBounds");
if (bounds) return bounds;
// Default size. Picked arbitrarily as something that should look good on
// first launch. We don't provide a position to let Electron center the app.
return { width: 1170, height: 710 };
};
/**
* If for some reason {@link windowBounds} is outside the screen's bounds (e.g.
* if the user's screen resolution has changed), then the previously saved
* bounds might not be appropriate.
*
* Luckily, if we try to set an x/y position that is outside the screen's
* bounds, then Electron automatically clamps x + width and y + height to lie
* within the screen's available space, and we do not need to tackle such out of
* bounds cases specifically.
*
* However there is no minimum window size the Electron enforces by default. As
* a safety valve, provide an (arbitrary) minimum size so that the user can
* resize it back to sanity if something I cannot currently anticipate happens.
*/
const minimumWindowSize = () => ({ minWidth: 200, minHeight: 200 });
/**
* Sibling of {@link windowBounds}, see that function's documentation for more
* details.
*/
const saveWindowBounds = (window: BrowserWindow) => {
if (window.isMaximized()) {
userPreferences.set("isWindowMaximized", true);
userPreferences.delete("windowBounds");
} else {
userPreferences.delete("isWindowMaximized");
userPreferences.set("windowBounds", window.getBounds());
}
};
/**
* Automatically set the save path for user initiated downloads to the system's
* "downloads" directory instead of asking the user to select a save location.
*/
const setDownloadPath = (webContents: WebContents) => {
webContents.session.on("will-download", (_, item) => {
item.setSavePath(
uniqueSavePath(app.getPath("downloads"), item.getFilename()),
);
});
};
const uniqueSavePath = (dirPath: string, fileName: string) => {
const { name, ext } = path.parse(fileName);
let savePath = path.join(dirPath, fileName);
let n = 1;
while (existsSync(savePath)) {
const suffixedName = [`${name}(${n})`, ext].filter((x) => x).join(".");
savePath = path.join(dirPath, suffixedName);
n++;
}
return savePath;
};
/**
* Allow opening external links, e.g. when the user clicks on the "Feature
* requests" button in the sidebar (to open our GitHub repository), or when they
* click the "Support" button to send an email to support.
*
* @param webContents The renderer to configure.
*/
const allowExternalLinks = (webContents: WebContents) =>
// By default, if the user were open a link, say
// https://github.com/ente-io/ente/discussions, then it would open a _new_
// BrowserWindow within our app.
//
// This is not the behaviour we want; what we want is to ask the system to
// handle the link (e.g. open the URL in the default browser, or if it is a
// mailto: link, then open the user's mail client).
//
// Returning `action` "deny" accomplishes this.
webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(rendererURL)) {
// This does not work in Ubuntu currently: mailto links seem to just
// get ignored, and HTTP links open in the text editor instead of in
// the browser.
// https://github.com/electron/electron/issues/31485
void shell.openExternal(url);
return { action: "deny" };
} else {
return { action: "allow" };
}
});
/**
* Handle back button presses on the Stripe checkout page.
*
* For payments, we show the Stripe checkout page to the user in the app's
* window. On this page there is a back button that allows the user to get back
* to the app's contents. Since we're not showing the browser controls, this is
* the only way to get back to the app.
*
* If the user enters something in the text fields on this page (e.g. if they
* start entering their credit card number), and then press back, then the
* browser shows the user a dialog asking them to confirm if they want to
* discard their unsaved changes. However, when running in the context of an
* Electron app, this dialog is not shown, and instead the app just gets stuck
* (the back button stops working, and quitting the app also doesn't work since
* there is an invisible modal dialog).
*
* So we instead intercept these back button presses, and show the same dialog
* that the browser would've shown.
*/
const handleBackOnStripeCheckout = (window: BrowserWindow) =>
window.webContents.on("will-prevent-unload", (event) => {
const url = new URL(window.webContents.getURL());
// Only intercept on Stripe checkout pages.
if (url.host != "checkout.stripe.com") return;
// The dialog copy is similar to what Chrome would've shown.
// https://www.electronjs.org/docs/latest/api/web-contents#event-will-prevent-unload
const choice = dialog.showMessageBoxSync(window, {
type: "question",
buttons: ["Leave", "Stay"],
title: "Leave site?",
message: "Changes that you made may not be saved.",
defaultId: 0,
cancelId: 1,
});
const leave = choice === 0;
if (leave) event.preventDefault();
});
/**
* Allow uploads to arbitrary S3 buckets.
*
* The embedded web app within in the desktop app is served over the ente://
* protocol. When pages in that web app make requests, their originate from this
* "ente://app" origin, which thus serves as the value for the
* "Access-Control-Allow-Origin" header in the CORS preflight requests.
*
* Some S3 providers (B2 is the motivating example for this workaround) do not
* allow whitelisting custom URI schemes. That is, even if we set
* "`allowedOrigin: ["*"]` in our B2 bucket CORS configuration, when the web
* code makes a CORS request with ACAO "ente://app", it gets back
* "Access-Control-Allow-Origin" set to `null` in the response, and thus the
* request fails (since it does not match the origin we sent).
*
* This is not an issue for production apps since they fetches or uploads via a
* worker instead of directly touching an S3 provider.
*
* This is not also an issue for fetches in the self hosted apps since those
* involve a redirection, and during a redirection Chromium sets the ACAO in the
* request to `null` (this is the correct behaviour as per the spec, for more
* details See: [Note: Passing credentials for self-hosted file fetches]).
*
* But this is an issue for uploads in the self hosted apps (or when we
* ourselves are trying to test things by with an arbitrary S3 bucket without
* going via a worker). During upload, theer is no redirection, so the request
* ACAO is "ente://app" but the response ACAO is `null` which don't match,
* causing the request to fail.
*
* As a workaround, we intercept the ACAO header and set it to `*`.
*
* However, an unconditional interception causes problems with requests that use
* credentials, since "*" is not a valid value in such cases. One such example
* is the HCaptcha requests made by Stripe when we initiate a payment within the
* desktop app:
*
* > Access to XMLHttpRequest at 'https://api2.hcaptcha.com/getcaptcha/xxx' from
* > origin 'https://newassets.hcaptcha.com' has been blocked by CORS policy:
* > The value of the 'Access-Control-Allow-Origin' header in the response must
* > not be the wildcard '*' when the request's credentials mode is 'include'.
* > The credentials mode of requests initiated by the XMLHttpRequest is
* > controlled by the withCredentials attribute.
*
* So we only do this workaround if there was either no ACAO specified in the
* response, or if the ACAO in the response was "null" (the string serialization
* of `null`).
*/
const allowAllCORSOrigins = (webContents: WebContents) =>
webContents.session.webRequest.onHeadersReceived(
({ responseHeaders }, callback) => {
const headers: NonNullable<typeof responseHeaders> = {};
headers["Access-Control-Allow-Origin"] = ["*"];
for (const [key, value] of Object.entries(responseHeaders ?? {}))
if (key.toLowerCase() == "access-control-allow-origin") {
headers["Access-Control-Allow-Origin"] =
value[0] == "null" ? ["*"] : value;
} else {
headers[key] = value;
}
callback({ responseHeaders: headers });
},
);
/**
* Add an icon for our app in the system tray.
*
* For example, these are the small icons that appear on the top right of the
* screen in the main menu bar on macOS.
*/
const setupTrayItem = (mainWindow: BrowserWindow) => {
// There are a total of 6 files corresponding to this tray icon.
//
// On macOS, use template images (filename needs to end with "Template.ext")
// https://www.electronjs.org/docs/latest/api/native-image#template-image-macos
//
// And for each (template or otherwise), there are 3 "retina" variants
// https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image
const iconName =
process.platform == "darwin"
? "taskbar-icon-Template.png"
: "taskbar-icon.png";
const trayImgPath = path.join(
isDev ? "build" : process.resourcesPath,
iconName,
);
const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon);
tray.setToolTip("Ente Photos");
tray.setContextMenu(createTrayContextMenu(mainWindow));
};
/**
* Older versions of our app used to maintain a cache dir using the main
* process. This has been removed in favor of cache on the web layer. Delete the
* old cache dir if it exists.
*
* Added May 2024, v1.7.0. This migration code can be removed after some time
* once most people have upgraded to newer versions (tag: Migration).
*/
const deleteLegacyDiskCacheDirIfExists = async () => {
const removeIfExists = async (dirPath: string) => {
if (existsSync(dirPath)) {
log.info(`Removing legacy disk cache from ${dirPath}`);
await fs.rm(dirPath, { recursive: true });
}
};
// [Note: Getting the cache path]
//
// The existing code was passing "cache" as a parameter to getPath.
//
// However, "cache" is not a valid parameter to getPath. It works (for
// example, on macOS I get `~/Library/Caches`), but it is intentionally not
// documented as part of the public API:
//
// - docs: remove "cache" from app.getPath
// https://github.com/electron/electron/pull/33509
//
// Irrespective, we replicate the original behaviour so that we get back the
// same path that the old code was getting.
//
// @ts-expect-error "cache" works but is not part of the public API.
const cacheDir = path.join(app.getPath("cache"), "ente");
if (process.platform == "win32") {
// On Windows the cache dir is the same as the app data (!). So deleting
// the ente subfolder of the cache dir is equivalent to deleting the
// user data dir.
//
// Obviously, that's not good. So instead of Windows we explicitly
// delete the named cache directories.
await removeIfExists(path.join(cacheDir, "thumbs"));
await removeIfExists(path.join(cacheDir, "files"));
await removeIfExists(path.join(cacheDir, "face-crops"));
} else {
await removeIfExists(cacheDir);
}
};
/**
* Older versions of our app used to keep a keys.json. It is not needed anymore,
* remove it if it exists.
*
* This code was added March 2024, and can be removed after some time once most
* people have upgraded to newer versions.
*/
const deleteLegacyKeysStoreIfExists = async () => {
const keysStore = path.join(app.getPath("userData"), "keys.json");
if (existsSync(keysStore)) {
log.info(`Removing legacy keys store at ${keysStore}`);
await fs.rm(keysStore);
}
};
// Go for it.
main();