Переглянути джерело

[desktop] Fix export related IPC - Part 3/x (#1439)

Manav Rathi 1 рік тому
батько
коміт
28574e516d

+ 10 - 35
desktop/src/main/fs.ts

@@ -8,6 +8,16 @@ import { Readable } from "node:stream";
 
 export const fsExists = (path: string) => existsSync(path);
 
+export const fsRename = (oldPath: string, newPath: string) =>
+    fs.rename(oldPath, newPath);
+
+export const fsMkdirIfNeeded = (dirPath: string) =>
+    fs.mkdir(dirPath, { recursive: true });
+
+export const fsRmdir = (path: string) => fs.rmdir(path);
+
+export const fsRm = (path: string) => fs.rm(path);
+
 /**
  * Write a (web) ReadableStream to a file at the given {@link filePath}.
  *
@@ -73,9 +83,6 @@ const writeNodeStream = async (
 
 /* TODO: Audit below this  */
 
-export const checkExistsAndCreateDir = (dirPath: string) =>
-    fs.mkdir(dirPath, { recursive: true });
-
 export const saveStreamToDisk = writeStream;
 
 export const saveFileToDisk = (path: string, contents: string) =>
@@ -85,9 +92,6 @@ export const readTextFile = async (filePath: string) =>
     fs.readFile(filePath, "utf-8");
 
 export const moveFile = async (sourcePath: string, destinationPath: string) => {
-    if (!existsSync(sourcePath)) {
-        throw new Error("File does not exist");
-    }
     if (existsSync(destinationPath)) {
         throw new Error("Destination file already exists");
     }
@@ -102,32 +106,3 @@ export const isFolder = async (dirPath: string) => {
     const stats = await fs.stat(dirPath);
     return stats.isDirectory();
 };
-
-export const deleteFolder = async (folderPath: string) => {
-    // 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");
-
-    // 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");
-    await fs.rename(oldPath, newPath);
-};
-
-export const deleteFile = async (filePath: string) => {
-    // 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");
-
-    // rm it
-    return fs.rm(filePath);
-};

+ 14 - 16
desktop/src/main/ipc.ts

@@ -18,14 +18,14 @@ import {
     showUploadZipDialog,
 } from "./dialogs";
 import {
-    checkExistsAndCreateDir,
-    deleteFile,
-    deleteFolder,
+    fsRm,
+    fsRmdir,
     fsExists,
+    fsMkdirIfNeeded,
+    fsRename,
     isFolder,
     moveFile,
     readTextFile,
-    rename,
     saveFileToDisk,
     saveStreamToDisk,
 } from "./fs";
@@ -169,12 +169,18 @@ export const attachIPCHandlers = () => {
 
     ipcMain.handle("fsExists", (_, path) => fsExists(path));
 
-    // - FS Legacy
-
-    ipcMain.handle("checkExistsAndCreateDir", (_, dirPath) =>
-        checkExistsAndCreateDir(dirPath),
+    ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
+        fsRename(oldPath, newPath),
     );
 
+    ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
+
+    ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
+
+    ipcMain.handle("fsRm", (_, path: string) => fsRm(path));
+
+    // - FS Legacy
+
     ipcMain.handle(
         "saveStreamToDisk",
         (_, path: string, fileStream: ReadableStream) =>
@@ -193,14 +199,6 @@ export const attachIPCHandlers = () => {
         moveFile(oldPath, newPath),
     );
 
-    ipcMain.handle("deleteFolder", (_, path: string) => deleteFolder(path));
-
-    ipcMain.handle("deleteFile", (_, path: string) => deleteFile(path));
-
-    ipcMain.handle("rename", (_, oldPath: string, newPath: string) =>
-        rename(oldPath, newPath),
-    );
-
     // - Upload
 
     ipcMain.handle("getPendingUploads", () => getPendingUploads());

+ 13 - 14
desktop/src/preload.ts

@@ -99,6 +99,12 @@ const skipAppUpdate = (version: string) => {
 const fsExists = (path: string): Promise<boolean> =>
     ipcRenderer.invoke("fsExists", path);
 
+const fsMkdirIfNeeded = (dirPath: string): Promise<void> =>
+    ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
+
+const fsRename = (oldPath: string, newPath: string): Promise<void> =>
+    ipcRenderer.invoke("fsRename", oldPath, newPath);
+
 // - AUDIT below this
 
 // - Conversion
@@ -218,9 +224,6 @@ const updateWatchMappingIgnoredFiles = (
 
 // - FS Legacy
 
-const checkExistsAndCreateDir = (dirPath: string): Promise<void> =>
-    ipcRenderer.invoke("checkExistsAndCreateDir", dirPath);
-
 const saveStreamToDisk = (
     path: string,
     fileStream: ReadableStream,
@@ -238,14 +241,10 @@ const isFolder = (dirPath: string): Promise<boolean> =>
 const moveFile = (oldPath: string, newPath: string): Promise<void> =>
     ipcRenderer.invoke("moveFile", oldPath, newPath);
 
-const deleteFolder = (path: string): Promise<void> =>
-    ipcRenderer.invoke("deleteFolder", path);
-
-const deleteFile = (path: string): Promise<void> =>
-    ipcRenderer.invoke("deleteFile", path);
+const fsRmdir = (path: string): Promise<void> =>
+    ipcRenderer.invoke("fsRmdir", path);
 
-const rename = (oldPath: string, newPath: string): Promise<void> =>
-    ipcRenderer.invoke("rename", oldPath, newPath);
+const fsRm = (path: string): Promise<void> => ipcRenderer.invoke("fsRm", path);
 
 // - Upload
 
@@ -348,19 +347,19 @@ contextBridge.exposeInMainWorld("electron", {
     // - FS
     fs: {
         exists: fsExists,
+        rename: fsRename,
+        mkdirIfNeeded: fsMkdirIfNeeded,
+        rmdir: fsRmdir,
+        rm: fsRm,
     },
 
     // - FS legacy
     // TODO: Move these into fs + document + rename if needed
-    checkExistsAndCreateDir,
     saveStreamToDisk,
     saveFileToDisk,
     readTextFile,
     isFolder,
     moveFile,
-    deleteFolder,
-    deleteFile,
-    rename,
 
     // - Upload
 

+ 64 - 68
web/apps/photos/src/services/export/index.ts

@@ -166,13 +166,14 @@ class ExportService {
     }
 
     async changeExportDirectory() {
+        const electron = ensureElectron();
         try {
-            const newRootDir = await ensureElectron().selectDirectory();
+            const newRootDir = await electron.selectDirectory();
             if (!newRootDir) {
                 throw Error(CustomError.SELECT_FOLDER_ABORTED);
             }
             const newExportDir = `${newRootDir}/${exportDirectoryName}`;
-            await ensureElectron().checkExistsAndCreateDir(newExportDir);
+            await electron.fs.mkdirIfNeeded(newExportDir);
             return newExportDir;
         } catch (e) {
             if (e.message !== CustomError.SELECT_FOLDER_ABORTED) {
@@ -485,6 +486,7 @@ class ExportService {
         renamedCollections: Collection[],
         isCanceled: CancellationStatus,
     ) {
+        const fs = ensureElectron().fs;
         try {
             for (const collection of renamedCollections) {
                 try {
@@ -498,6 +500,7 @@ class ExportService {
                     const newCollectionExportName = await safeDirectoryName(
                         exportFolder,
                         getCollectionUserFacingName(collection),
+                        fs.exists,
                     );
                     log.info(
                         `renaming collection with id ${collection.id} from ${oldCollectionExportName} to ${newCollectionExportName}`,
@@ -513,7 +516,7 @@ class ExportService {
                         newCollectionExportName,
                     );
                     try {
-                        await ensureElectron().rename(
+                        await fs.rename(
                             oldCollectionExportPath,
                             newCollectionExportPath,
                         );
@@ -561,6 +564,7 @@ class ExportService {
         exportFolder: string,
         isCanceled: CancellationStatus,
     ) {
+        const fs = ensureElectron().fs;
         try {
             const exportRecord = await this.getExportRecord(exportFolder);
             const collectionIDPathMap =
@@ -595,13 +599,11 @@ class ExportService {
                     );
                     try {
                         // delete the collection metadata folder
-                        await ensureElectron().deleteFolder(
+                        await fs.rmdir(
                             getMetadataFolderExportPath(collectionExportPath),
                         );
                         // delete the collection folder
-                        await ensureElectron().deleteFolder(
-                            collectionExportPath,
-                        );
+                        await fs.rmdir(collectionExportPath);
                     } catch (e) {
                         await this.addCollectionExportedRecord(
                             exportFolder,
@@ -646,6 +648,7 @@ class ExportService {
         incrementFailed: () => void,
         isCanceled: CancellationStatus,
     ): Promise<void> {
+        const fs = ensureElectron().fs;
         try {
             for (const file of files) {
                 log.info(
@@ -681,10 +684,8 @@ class ExportService {
                         );
                     }
                     const collectionExportPath = `${exportDir}/${collectionExportName}`;
-                    await ensureElectron().checkExistsAndCreateDir(
-                        collectionExportPath,
-                    );
-                    await ensureElectron().checkExistsAndCreateDir(
+                    await fs.mkdirIfNeeded(collectionExportPath);
+                    await fs.mkdirIfNeeded(
                         getMetadataFolderExportPath(collectionExportPath),
                     );
                     await this.downloadAndSave(
@@ -731,6 +732,8 @@ class ExportService {
         removedFileUIDs: string[],
         isCanceled: CancellationStatus,
     ): Promise<void> {
+        const electron = ensureElectron();
+        const fs = electron.fs;
         try {
             const exportRecord = await this.getExportRecord(exportDir);
             const fileIDExportNameMap = convertFileIDExportNameObjectToMap(
@@ -760,8 +763,8 @@ class ExportService {
                             log.info(
                                 `moving image file ${imageExportPath} to trash folder`,
                             );
-                            if (await this.exists(imageExportPath)) {
-                                await ensureElectron().moveFile(
+                            if (await fs.exists(imageExportPath)) {
+                                await electron.moveFile(
                                     imageExportPath,
                                     await getTrashedFileExportPath(
                                         exportDir,
@@ -773,10 +776,8 @@ class ExportService {
                             const imageMetadataFileExportPath =
                                 getMetadataFileExportPath(imageExportPath);
 
-                            if (
-                                await this.exists(imageMetadataFileExportPath)
-                            ) {
-                                await ensureElectron().moveFile(
+                            if (await fs.exists(imageMetadataFileExportPath)) {
+                                await electron.moveFile(
                                     imageMetadataFileExportPath,
                                     await getTrashedFileExportPath(
                                         exportDir,
@@ -786,31 +787,7 @@ class ExportService {
                             }
 
                             const videoExportPath = `${collectionExportPath}/${videoExportName}`;
-                            log.info(
-                                `moving video file ${videoExportPath} to trash folder`,
-                            );
-                            if (await this.exists(videoExportPath)) {
-                                await ensureElectron().moveFile(
-                                    videoExportPath,
-                                    await getTrashedFileExportPath(
-                                        exportDir,
-                                        videoExportPath,
-                                    ),
-                                );
-                            }
-                            const videoMetadataFileExportPath =
-                                getMetadataFileExportPath(videoExportPath);
-                            if (
-                                await this.exists(videoMetadataFileExportPath)
-                            ) {
-                                await ensureElectron().moveFile(
-                                    videoMetadataFileExportPath,
-                                    await getTrashedFileExportPath(
-                                        exportDir,
-                                        videoMetadataFileExportPath,
-                                    ),
-                                );
-                            }
+                            await moveToTrash(exportDir, videoExportPath);
                         } else {
                             const fileExportPath = `${collectionExportPath}/${fileExportName}`;
                             const trashedFilePath =
@@ -821,16 +798,16 @@ class ExportService {
                             log.info(
                                 `moving file ${fileExportPath} to ${trashedFilePath} trash folder`,
                             );
-                            if (await this.exists(fileExportPath)) {
-                                await ensureElectron().moveFile(
+                            if (await fs.exists(fileExportPath)) {
+                                await electron.moveFile(
                                     fileExportPath,
                                     trashedFilePath,
                                 );
                             }
                             const metadataFileExportPath =
                                 getMetadataFileExportPath(fileExportPath);
-                            if (await this.exists(metadataFileExportPath)) {
-                                await ensureElectron().moveFile(
+                            if (await fs.exists(metadataFileExportPath)) {
+                                await electron.moveFile(
                                     metadataFileExportPath,
                                     await getTrashedFileExportPath(
                                         exportDir,
@@ -984,14 +961,16 @@ class ExportService {
     }
 
     async getExportRecord(folder: string, retry = true): Promise<ExportRecord> {
+        const electron = ensureElectron();
+        const fs = electron.fs;
         try {
             await this.verifyExportFolderExists(folder);
             const exportRecordJSONPath = `${folder}/${exportRecordFileName}`;
-            if (!(await this.exists(exportRecordJSONPath))) {
+            if (!(await fs.exists(exportRecordJSONPath))) {
                 return this.createEmptyExportRecord(exportRecordJSONPath);
             }
             const recordFile =
-                await ensureElectron().readTextFile(exportRecordJSONPath);
+                await electron.readTextFile(exportRecordJSONPath);
             try {
                 return JSON.parse(recordFile);
             } catch (e) {
@@ -1017,15 +996,17 @@ class ExportService {
         collectionID: number,
         collectionIDNameMap: Map<number, string>,
     ) {
+        const fs = ensureElectron().fs;
         await this.verifyExportFolderExists(exportFolder);
         const collectionName = collectionIDNameMap.get(collectionID);
         const collectionExportName = await safeDirectoryName(
             exportFolder,
             collectionName,
+            fs.exists,
         );
         const collectionExportPath = `${exportFolder}/${collectionExportName}`;
-        await ensureElectron().checkExistsAndCreateDir(collectionExportPath);
-        await ensureElectron().checkExistsAndCreateDir(
+        await fs.mkdirIfNeeded(collectionExportPath);
+        await fs.mkdirIfNeeded(
             getMetadataFolderExportPath(collectionExportPath),
         );
 
@@ -1037,6 +1018,7 @@ class ExportService {
         collectionExportPath: string,
         file: EnteFile,
     ): Promise<void> {
+        const electron = ensureElectron();
         try {
             const fileUID = getExportRecordFileUID(file);
             const originalFileStream = await downloadManager.getFile(file);
@@ -1060,6 +1042,7 @@ class ExportService {
                 const fileExportName = await safeFileName(
                     collectionExportPath,
                     file.metadata.title,
+                    electron.fs.exists,
                 );
                 await this.addFileExportedRecord(
                     exportDir,
@@ -1072,7 +1055,7 @@ class ExportService {
                         fileExportName,
                         file,
                     );
-                    await ensureElectron().saveStreamToDisk(
+                    await electron.saveStreamToDisk(
                         `${collectionExportPath}/${fileExportName}`,
                         updatedFileStream,
                     );
@@ -1094,15 +1077,18 @@ class ExportService {
         fileStream: ReadableStream<any>,
         file: EnteFile,
     ) {
+        const electron = ensureElectron();
         const fileBlob = await new Response(fileStream).blob();
         const livePhoto = await decodeLivePhoto(file, fileBlob);
         const imageExportName = await safeFileName(
             collectionExportPath,
             livePhoto.imageNameTitle,
+            electron.fs.exists,
         );
         const videoExportName = await safeFileName(
             collectionExportPath,
             livePhoto.videoNameTitle,
+            electron.fs.exists,
         );
         const livePhotoExportName = getLivePhotoExportName(
             imageExportName,
@@ -1120,7 +1106,7 @@ class ExportService {
                 imageExportName,
                 file,
             );
-            await ensureElectron().saveStreamToDisk(
+            await electron.saveStreamToDisk(
                 `${collectionExportPath}/${imageExportName}`,
                 imageStream,
             );
@@ -1132,12 +1118,12 @@ class ExportService {
                 file,
             );
             try {
-                await ensureElectron().saveStreamToDisk(
+                await electron.saveStreamToDisk(
                     `${collectionExportPath}/${videoExportName}`,
                     videoStream,
                 );
             } catch (e) {
-                await ensureElectron().deleteFile(
+                await electron.fs.rm(
                     `${collectionExportPath}/${imageExportName}`,
                 );
                 throw e;
@@ -1163,20 +1149,8 @@ class ExportService {
         return this.exportInProgress;
     };
 
-    exists = (path: string) => {
-        return ensureElectron().fs.exists(path);
-    };
-
-    rename = (oldPath: string, newPath: string) => {
-        return ensureElectron().rename(oldPath, newPath);
-    };
-
-    checkExistsAndCreateDir = (path: string) => {
-        return ensureElectron().checkExistsAndCreateDir(path);
-    };
-
     exportFolderExists = async (exportFolder: string) => {
-        return exportFolder && (await this.exists(exportFolder));
+        return exportFolder && (await ensureElectron().fs.exists(exportFolder));
     };
 
     private verifyExportFolderExists = async (exportFolder: string) => {
@@ -1412,7 +1386,7 @@ const getTrashedFileExportPath = async (exportDir: string, path: string) => {
     const fileRelativePath = path.replace(`${exportDir}/`, "");
     let trashedFilePath = `${exportDir}/${exportTrashDirectoryName}/${fileRelativePath}`;
     let count = 1;
-    while (await exportService.exists(trashedFilePath)) {
+    while (await ensureElectron().fs.exists(trashedFilePath)) {
         const trashedFilePathParts = splitFilenameAndExtension(trashedFilePath);
         if (trashedFilePathParts[1]) {
             trashedFilePath = `${trashedFilePathParts[0]}(${count}).${trashedFilePathParts[1]}`;
@@ -1460,3 +1434,25 @@ const parseLivePhotoExportName = (
 
 const isExportInProgress = (exportStage: ExportStage) =>
     exportStage > ExportStage.INIT && exportStage < ExportStage.FINISHED;
+
+const moveToTrash = async (exportDir: string, videoExportPath: string) => {
+    const fs = ensureElectron().fs;
+    log.info(`moving video file ${videoExportPath} to trash folder`);
+    if (await fs.exists(videoExportPath)) {
+        await electron.moveFile(
+            videoExportPath,
+            await getTrashedFileExportPath(exportDir, videoExportPath),
+        );
+    }
+    const videoMetadataFileExportPath =
+        getMetadataFileExportPath(videoExportPath);
+    if (await fs.exists(videoMetadataFileExportPath)) {
+        await electron.moveFile(
+            videoMetadataFileExportPath,
+            await getTrashedFileExportPath(
+                exportDir,
+                videoMetadataFileExportPath,
+            ),
+        );
+    }
+};

+ 23 - 36
web/apps/photos/src/services/export/migration.ts

@@ -1,3 +1,4 @@
+import { ensureElectron } from "@/next/electron";
 import log from "@/next/log";
 import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
 import { User } from "@ente/shared/user/types";
@@ -188,40 +189,29 @@ async function migrationV4ToV5(exportDir: string, exportRecord: ExportRecord) {
     await removeCollectionExportMissingMetadataFolder(exportDir, exportRecord);
 }
 
-/*
-    This updates the folder name of already exported folders from the earlier format of
-    `collectionID_collectionName` to newer `collectionName(numbered)` format
-*/
-async function migrateCollectionFolders(
+/**
+ * Update the folder name of already exported folders from the earlier format of
+ * `collectionID_collectionName` to newer `collectionName(numbered)` format.
+ */
+const migrateCollectionFolders = async (
     collections: Collection[],
     exportDir: string,
     collectionIDPathMap: Map<number, string>,
-) {
+) => {
+    const fs = ensureElectron().fs;
     for (const collection of collections) {
-        const oldCollectionExportPath = getOldCollectionFolderPath(
+        const oldPath = `${exportDir}/${collection.id}_${oldSanitizeName(collection.name)}`;
+        const newPath = await safeDirectoryName(
             exportDir,
-            collection.id,
             collection.name,
+            fs.exists,
         );
-        const newCollectionExportPath = await safeDirectoryName(
-            exportDir,
-            collection.name,
-        );
-        collectionIDPathMap.set(collection.id, newCollectionExportPath);
-        if (!(await exportService.exists(oldCollectionExportPath))) {
-            continue;
-        }
-        await exportService.rename(
-            oldCollectionExportPath,
-            newCollectionExportPath,
-        );
-        await addCollectionExportedRecordV1(
-            exportDir,
-            collection.id,
-            newCollectionExportPath,
-        );
+        collectionIDPathMap.set(collection.id, newPath);
+        if (!(await fs.exists(oldPath))) continue;
+        await fs.rename(oldPath, newPath);
+        await addCollectionExportedRecordV1(exportDir, collection.id, newPath);
     }
-}
+};
 
 /*
     This updates the file name of already exported files from the earlier format of
@@ -231,6 +221,7 @@ async function migrateFiles(
     files: EnteFile[],
     collectionIDPathMap: Map<number, string>,
 ) {
+    const fs = ensureElectron().fs;
     for (const file of files) {
         const collectionPath = collectionIDPathMap.get(file.collectionID);
         const metadataPath = `${collectionPath}/${exportMetadataDirectoryName}`;
@@ -242,14 +233,15 @@ async function migrateFiles(
         const newFileName = await safeFileName(
             collectionPath,
             file.metadata.title,
+            fs.exists,
         );
         const newFilePath = `${collectionPath}/${newFileName}`;
         const newFileMetadataPath = `${metadataPath}/${newFileName}.json`;
 
-        if (!(await exportService.exists(oldFilePath))) continue;
+        if (!(await fs.exists(oldFilePath))) continue;
 
-        await exportService.rename(oldFilePath, newFilePath);
-        await exportService.rename(oldFileMetadataPath, newFileMetadataPath);
+        await fs.rename(oldFilePath, newFilePath);
+        await fs.rename(oldFileMetadataPath, newFileMetadataPath);
     }
 }
 
@@ -409,6 +401,7 @@ async function removeCollectionExportMissingMetadataFolder(
     exportDir: string,
     exportRecord: ExportRecord,
 ) {
+    const fs = ensureElectron().fs;
     if (!exportRecord?.collectionExportNames) {
         return;
     }
@@ -422,7 +415,7 @@ async function removeCollectionExportMissingMetadataFolder(
         collectionExportName,
     ] of properlyExportedCollectionsAll) {
         if (
-            await exportService.exists(
+            await fs.exists(
                 getMetadataFolderExportPath(
                     `${exportDir}/${collectionExportName}`,
                 ),
@@ -493,12 +486,6 @@ const oldSanitizeName = (name: string) =>
 const getFileSavePath = (collectionFolderPath: string, fileSaveName: string) =>
     `${collectionFolderPath}/${fileSaveName}`;
 
-const getOldCollectionFolderPath = (
-    dir: string,
-    collectionID: number,
-    collectionName: string,
-) => `${dir}/${collectionID}_${oldSanitizeName(collectionName)}`;
-
 const getUniqueFileExportNameForMigration = (
     collectionPath: string,
     filename: string,

+ 4 - 2
web/apps/photos/src/utils/collection/index.ts

@@ -1,3 +1,4 @@
+import { ensureElectron } from "@/next/electron";
 import log from "@/next/log";
 import { CustomError } from "@ente/shared/error";
 import { getAlbumsURL } from "@ente/shared/network/api";
@@ -30,7 +31,6 @@ import {
     updatePublicCollectionMagicMetadata,
     updateSharedCollectionMagicMetadata,
 } from "services/collectionService";
-import exportService from "services/export";
 import { getAllLocalFiles, getLocalFiles } from "services/fileService";
 import {
     COLLECTION_ROLE,
@@ -169,12 +169,14 @@ async function createCollectionDownloadFolder(
     downloadDirPath: string,
     collectionName: string,
 ) {
+    const fs = ensureElectron().fs;
     const collectionDownloadName = await safeDirectoryName(
         downloadDirPath,
         collectionName,
+        fs.exists,
     );
     const collectionDownloadPath = `${downloadDirPath}/${collectionDownloadName}`;
-    await exportService.checkExistsAndCreateDir(collectionDownloadPath);
+    await fs.mkdirIfNeeded(collectionDownloadPath);
     return collectionDownloadPath;
 }
 

+ 4 - 1
web/apps/photos/src/utils/file/index.ts

@@ -815,6 +815,7 @@ async function downloadFileDesktop(
         const imageExportName = await safeFileName(
             downloadPath,
             livePhoto.imageNameTitle,
+            electron.fs.exists,
         );
         const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
         await electron.saveStreamToDisk(
@@ -825,6 +826,7 @@ async function downloadFileDesktop(
             const videoExportName = await safeFileName(
                 downloadPath,
                 livePhoto.videoNameTitle,
+                electron.fs.exists,
             );
             const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
             await electron.saveStreamToDisk(
@@ -832,13 +834,14 @@ async function downloadFileDesktop(
                 videoStream,
             );
         } catch (e) {
-            await electron.deleteFile(`${downloadPath}/${imageExportName}`);
+            await electron.fs.rm(`${downloadPath}/${imageExportName}`);
             throw e;
         }
     } else {
         const fileExportName = await safeFileName(
             downloadPath,
             file.metadata.title,
+            electron.fs.exists,
         );
         await electron.saveStreamToDisk(
             `${downloadPath}/${fileExportName}`,

+ 13 - 18
web/apps/photos/src/utils/native-fs.ts

@@ -1,12 +1,10 @@
 /**
- * @file Native filesystem access using custom Node.js functionality provided by
- * our desktop app.
+ * @file Utilities for native filesystem access.
  *
- * Precondition: Unless mentioned otherwise, the functions in these file only
- * work when we are running in our desktop app.
+ * While they don't have any direct dependencies to our desktop app, they were
+ * written for use by the code that runs in our desktop app.
  */
 
-import { ensureElectron } from "@/next/electron";
 import { nameAndExtension } from "@/next/file";
 import sanitize from "sanitize-filename";
 import {
@@ -31,15 +29,15 @@ export const sanitizeFilename = (s: string) =>
  * We also ensure we don't return names which might collide with our own special
  * directories.
  *
- * This function only works when we are running inside an electron app (since it
- * requires permissionless access to the native filesystem to find a new
- * filename that doesn't conflict with any existing items).
+ * @param exists A function to check if an item already exists at the given
+ * path. Usually, you'd pass `fs.exists` from {@link Electron}.
  *
- * See also: {@link safeDirectoryName}
+ * See also: {@link safeFileame}
  */
 export const safeDirectoryName = async (
     directoryPath: string,
     name: string,
+    exists: (path: string) => Promise<boolean>,
 ): Promise<string> => {
     const specialDirectoryNames = [
         exportTrashDirectoryName,
@@ -62,10 +60,13 @@ export const safeDirectoryName = async (
  * Return a new sanitized and unique file name based on {@link name} that is not
  * the same as any existing item in the given {@link directoryPath}.
  *
- * This function only works when we are running inside an electron app.
- * @see {@link safeDirectoryName}.
+ * This is a sibling of {@link safeDirectoryName} for use with file names.
  */
-export const safeFileName = async (directoryPath: string, name: string) => {
+export const safeFileName = async (
+    directoryPath: string,
+    name: string,
+    exists: (path: string) => Promise<boolean>,
+) => {
     let result = sanitizeFilename(name);
     let count = 1;
     while (await exists(`${directoryPath}/${result}`)) {
@@ -76,9 +77,3 @@ export const safeFileName = async (directoryPath: string, name: string) => {
     }
     return result;
 };
-
-/**
- * Return true if an item exists an the given {@link path} on the user's local
- * filesystem.
- */
-export const exists = (path: string) => ensureElectron().fs.exists(path);

+ 29 - 9
web/packages/next/types/ipc.ts

@@ -155,15 +155,39 @@ export interface Electron {
      * or watching some folders for changes and syncing them automatically.
      *
      * Towards this end, this fs object provides some generic file system access
-     * functions that are needed for such features. In addition, there are other
-     * feature specific methods too in the top level electron object.
+     * functions that are needed for such features (in some cases, there are
+     * other feature specific methods too in the top level electron object).
      */
     fs: {
+        /** Return true if there is an item at the given {@link path}. */
+        exists: (path: string) => Promise<boolean>;
+
         /**
-         * Return true if there is a file or directory at the given
-         * {@link path}.
+         * Equivalent of `mkdir -p`.
+         *
+         * Create a directory at the given path if it does not already exist.
+         * Any parent directories in the path that don't already exist will also
+         * be created recursively, i.e. this command is analogous to an running
+         * `mkdir -p`.
          */
-        exists: (path: string) => Promise<boolean>;
+        mkdirIfNeeded: (dirPath: string) => Promise<void>;
+
+        /** Rename {@link oldPath} to {@link newPath} */
+        rename: (oldPath: string, newPath: string) => Promise<void>;
+
+        /**
+         * Equivalent of `rmdir`.
+         *
+         * Delete the directory at the {@link path} if it is empty.
+         */
+        rmdir: (path: string) => Promise<void>;
+
+        /**
+         * Equivalent of `rm`.
+         *
+         * Delete the file at {@link path}.
+         */
+        rm: (path: string) => Promise<void>;
     };
 
     /*
@@ -276,7 +300,6 @@ export interface Electron {
     ) => Promise<void>;
 
     // - FS legacy
-    checkExistsAndCreateDir: (dirPath: string) => Promise<void>;
     saveStreamToDisk: (
         path: string,
         fileStream: ReadableStream,
@@ -285,9 +308,6 @@ export interface Electron {
     readTextFile: (path: string) => Promise<string>;
     isFolder: (dirPath: string) => Promise<boolean>;
     moveFile: (oldPath: string, newPath: string) => Promise<void>;
-    deleteFolder: (path: string) => Promise<void>;
-    deleteFile: (path: string) => Promise<void>;
-    rename: (oldPath: string, newPath: string) => Promise<void>;
 
     // - Upload