diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 4837210c8..8526e2363 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -9,7 +9,7 @@ * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process */ import { nativeImage } from "electron"; -import { app, BrowserWindow, Menu, Tray } from "electron/main"; +import { app, BrowserWindow, Menu, protocol, Tray } from "electron/main"; import serveNextAt from "next-electron-server"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; @@ -27,6 +27,7 @@ import { setupAutoUpdater } from "./main/services/app-update"; import autoLauncher from "./main/services/autoLauncher"; import { initWatcher } from "./main/services/chokidar"; import { userPreferences } from "./main/stores/user-preferences"; +import { registerStreamProtocol } from "./main/stream"; import { isDev } from "./main/util"; /** @@ -58,6 +59,21 @@ export const allowWindowClose = (): void => { shouldAllowWindowClose = true; }; +/** + * 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 @@ -74,18 +90,57 @@ export const allowWindowClose = (): void => { const setupRendererServer = () => serveNextAt(rendererURL); /** - * Log a standard startup banner. + * Register privileged schemes. * - * This helps us identify app starts and other environment details in the logs. + * 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 logStartupBanner = () => { - const version = isDev ? "dev" : app.getVersion(); - log.info(`Starting ente-photos-desktop ${version}`); +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: { + // TODO(MR): Remove the commented bits if we don't end up + // needing them by the time the IPC refactoring is done. - const platform = process.platform; - const osRelease = os.release(); - const systemVersion = process.getSystemVersion(); - log.info("Running on", { platform, osRelease, systemVersion }); + // Prevent the insecure origin issues when fetching this + // secure: true, + // Allow the web fetch API in the renderer to use this scheme. + supportFetchAPI: true, + // Allow it to be used with video tags. + // stream: true, + }, + }, + ]); }; /** @@ -251,8 +306,10 @@ const main = () => { let mainWindow: BrowserWindow | undefined; initLogging(); - setupRendererServer(); logStartupBanner(); + // The order of the next two calls is important + setupRendererServer(); + registerPrivilegedSchemes(); increaseDiskCache(); app.on("second-instance", () => { @@ -269,11 +326,11 @@ const main = () => { // Note that some Electron APIs can only be used after this event occurs. app.on("ready", async () => { mainWindow = await createMainWindow(); - const watcher = initWatcher(mainWindow); - setupTrayItem(mainWindow); Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); + setupTrayItem(mainWindow); attachIPCHandlers(); - attachFSWatchIPCHandlers(watcher); + attachFSWatchIPCHandlers(initWatcher(mainWindow)); + registerStreamProtocol(); if (!isDev) setupAutoUpdater(mainWindow); handleDownloads(mainWindow); handleExternalLinks(mainWindow); diff --git a/desktop/src/main/fs.ts b/desktop/src/main/fs.ts index 11ab36049..36de710c3 100644 --- a/desktop/src/main/fs.ts +++ b/desktop/src/main/fs.ts @@ -1,9 +1,8 @@ /** * @file file system related functions exposed over the context bridge. */ -import { createWriteStream, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; -import { Readable } from "node:stream"; export const fsExists = (path: string) => existsSync(path); @@ -17,78 +16,13 @@ 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}. - * - * The returned promise resolves when the write completes. - * - * @param filePath The local filesystem path where the file should be written. - * @param readableStream A [web - * ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) - */ -export const writeStream = (filePath: string, readableStream: ReadableStream) => - writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream)); +export const fsReadTextFile = async (filePath: string) => + fs.readFile(filePath, "utf-8"); -/** - * Convert a Web ReadableStream into a Node.js ReadableStream - * - * This can be used to, for example, write a ReadableStream obtained via - * `net.fetch` into a file using the Node.js `fs` APIs - */ -const convertWebReadableStreamToNode = (readableStream: ReadableStream) => { - const reader = readableStream.getReader(); - const rs = new Readable(); - - rs._read = async () => { - try { - const result = await reader.read(); - - if (!result.done) { - rs.push(Buffer.from(result.value)); - } else { - rs.push(null); - return; - } - } catch (e) { - rs.emit("error", e); - } - }; - - return rs; -}; - -const writeNodeStream = async ( - filePath: string, - fileStream: NodeJS.ReadableStream, -) => { - const writeable = createWriteStream(filePath); - - fileStream.on("error", (error) => { - writeable.destroy(error); // Close the writable stream with an error - }); - - fileStream.pipe(writeable); - - await new Promise((resolve, reject) => { - writeable.on("finish", resolve); - writeable.on("error", async (e: unknown) => { - if (existsSync(filePath)) { - await fs.unlink(filePath); - } - reject(e); - }); - }); -}; - -/* TODO: Audit below this */ - -export const saveStreamToDisk = writeStream; - -export const saveFileToDisk = (path: string, contents: string) => +export const fsWriteFile = (path: string, contents: string) => fs.writeFile(path, contents); -export const readTextFile = async (filePath: string) => - fs.readFile(filePath, "utf-8"); +/* TODO: Audit below this */ export const isFolder = async (dirPath: string) => { if (!existsSync(dirPath)) return false; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 36e13ec60..bd29057da 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -20,13 +20,12 @@ import { import { fsExists, fsMkdirIfNeeded, + fsReadTextFile, fsRename, fsRm, fsRmdir, + fsWriteFile, isFolder, - readTextFile, - saveFileToDisk, - saveStreamToDisk, } from "./fs"; import { logToDisk } from "./log"; import { @@ -113,6 +112,26 @@ export const attachIPCHandlers = () => { ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version)); + // - FS + + ipcMain.handle("fsExists", (_, path) => fsExists(path)); + + 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)); + + ipcMain.handle("fsReadTextFile", (_, path: string) => fsReadTextFile(path)); + + ipcMain.handle("fsWriteFile", (_, path: string, contents: string) => + fsWriteFile(path, contents), + ); + // - Conversion ipcMain.handle("convertToJPEG", (_, fileData, filename) => @@ -164,34 +183,8 @@ export const attachIPCHandlers = () => { ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog()); - // - FS - - ipcMain.handle("fsExists", (_, path) => fsExists(path)); - - 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) => - saveStreamToDisk(path, fileStream), - ); - - ipcMain.handle("saveFileToDisk", (_, path: string, contents: string) => - saveFileToDisk(path, contents), - ); - - ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path)); - ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath)); // - Upload diff --git a/desktop/src/main/services/ffmpeg.ts b/desktop/src/main/services/ffmpeg.ts index e9639a26f..3072d5ee7 100644 --- a/desktop/src/main/services/ffmpeg.ts +++ b/desktop/src/main/services/ffmpeg.ts @@ -2,7 +2,7 @@ import pathToFfmpeg from "ffmpeg-static"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import { ElectronFile } from "../../types/ipc"; -import { writeStream } from "../fs"; +import { writeStream } from "../stream"; import log from "../log"; import { generateTempFilePath, getTempDirPath } from "../temp"; import { execAsync } from "../util"; diff --git a/desktop/src/main/services/imageProcessor.ts b/desktop/src/main/services/imageProcessor.ts index 890e0e634..d87fb0c5f 100644 --- a/desktop/src/main/services/imageProcessor.ts +++ b/desktop/src/main/services/imageProcessor.ts @@ -2,7 +2,7 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "path"; import { CustomErrors, ElectronFile } from "../../types/ipc"; -import { writeStream } from "../fs"; +import { writeStream } from "../stream"; import log from "../log"; import { isPlatform } from "../platform"; import { generateTempFilePath } from "../temp"; diff --git a/desktop/src/main/services/ml-clip.ts b/desktop/src/main/services/ml-clip.ts index 63fa75148..af8198a3c 100644 --- a/desktop/src/main/services/ml-clip.ts +++ b/desktop/src/main/services/ml-clip.ts @@ -11,7 +11,7 @@ import fs from "node:fs/promises"; import * as ort from "onnxruntime-node"; import Tokenizer from "../../thirdparty/clip-bpe-ts/mod"; import { CustomErrors } from "../../types/ipc"; -import { writeStream } from "../fs"; +import { writeStream } from "../stream"; import log from "../log"; import { generateTempFilePath } from "../temp"; import { deleteTempFile } from "./ffmpeg"; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 10402db21..e1d68e2dd 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -15,7 +15,7 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; -import { writeStream } from "../fs"; +import { writeStream } from "../stream"; import log from "../log"; /** diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts new file mode 100644 index 000000000..8ddb80dc6 --- /dev/null +++ b/desktop/src/main/stream.ts @@ -0,0 +1,116 @@ +/** + * @file stream data to-from renderer using a custom protocol handler. + */ +import { protocol } from "electron/main"; +import { createWriteStream, existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { Readable } from "node:stream"; +import log from "./log"; + +/** + * Register a protocol handler that we use for streaming large files between the + * main process (node) and the renderer process (browser) layer. + * + * [Note: IPC streams] + * + * When running without node integration, there is no direct way to pass streams + * across IPC. And passing the entire contents of the file is not feasible for + * large video files because of the memory pressure the copying would entail. + * + * As an alternative, we register a custom protocol handler that can provided a + * bi-directional stream. The renderer can stream data to the node side by + * streaming the request. The node side can stream to the renderer side by + * streaming the response. + * + * See also: [Note: Transferring large amount of data over IPC] + * + * Depends on {@link registerPrivilegedSchemes}. + */ +export const registerStreamProtocol = () => { + protocol.handle("stream", async (request: Request) => { + const url = request.url; + const { host, pathname } = new URL(url); + // Convert e.g. "%20" to spaces. + const path = decodeURIComponent(pathname); + switch (host) { + /* stream://write/path/to/file */ + /* host-pathname----- */ + case "write": + try { + await writeStream(path, request.body); + return new Response("", { status: 200 }); + } catch (e) { + log.error(`Failed to write stream for ${url}`, e); + return new Response( + `Failed to write stream: ${e.message}`, + { status: 500 }, + ); + } + default: + return new Response("", { status: 404 }); + } + }); +}; + +/** + * Write a (web) ReadableStream to a file at the given {@link filePath}. + * + * The returned promise resolves when the write completes. + * + * @param filePath The local filesystem path where the file should be written. + * @param readableStream A [web + * ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) + */ +export const writeStream = (filePath: string, readableStream: ReadableStream) => + writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream)); + +/** + * Convert a Web ReadableStream into a Node.js ReadableStream + * + * This can be used to, for example, write a ReadableStream obtained via + * `net.fetch` into a file using the Node.js `fs` APIs + */ +const convertWebReadableStreamToNode = (readableStream: ReadableStream) => { + const reader = readableStream.getReader(); + const rs = new Readable(); + + rs._read = async () => { + try { + const result = await reader.read(); + + if (!result.done) { + rs.push(Buffer.from(result.value)); + } else { + rs.push(null); + return; + } + } catch (e) { + rs.emit("error", e); + } + }; + + return rs; +}; + +const writeNodeStream = async ( + filePath: string, + fileStream: NodeJS.ReadableStream, +) => { + const writeable = createWriteStream(filePath); + + fileStream.on("error", (error) => { + writeable.destroy(error); // Close the writable stream with an error + }); + + fileStream.pipe(writeable); + + await new Promise((resolve, reject) => { + writeable.on("finish", resolve); + writeable.on("error", async (e: unknown) => { + if (existsSync(filePath)) { + await fs.unlink(filePath); + } + reject(e); + }); + }); +}; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 1a344e832..ff2cf505a 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -96,6 +96,8 @@ const skipAppUpdate = (version: string) => { ipcRenderer.send("skipAppUpdate", version); }; +// - FS + const fsExists = (path: string): Promise => ipcRenderer.invoke("fsExists", path); @@ -110,6 +112,12 @@ const fsRmdir = (path: string): Promise => const fsRm = (path: string): Promise => ipcRenderer.invoke("fsRm", path); +const fsReadTextFile = (path: string): Promise => + ipcRenderer.invoke("fsReadTextFile", path); + +const fsWriteFile = (path: string, contents: string): Promise => + ipcRenderer.invoke("fsWriteFile", path, contents); + // - AUDIT below this // - Conversion @@ -229,17 +237,6 @@ const updateWatchMappingIgnoredFiles = ( // - FS Legacy -const saveStreamToDisk = ( - path: string, - fileStream: ReadableStream, -): Promise => ipcRenderer.invoke("saveStreamToDisk", path, fileStream); - -const saveFileToDisk = (path: string, contents: string): Promise => - ipcRenderer.invoke("saveFileToDisk", path, contents); - -const readTextFile = (path: string): Promise => - ipcRenderer.invoke("readTextFile", path); - const isFolder = (dirPath: string): Promise => ipcRenderer.invoke("isFolder", dirPath); @@ -298,7 +295,8 @@ const getDirFiles = (dirPath: string): Promise => // https://www.electronjs.org/docs/latest/api/context-bridge#methods // // The copy itself is relatively fast, but the problem with transfering large -// amounts of data is potentially running out of memory during the copy. +// amounts of data is potentially running out of memory during the copy. For an +// alternative, see [Note: IPC streams]. contextBridge.exposeInMainWorld("electron", { // - General appVersion, @@ -316,6 +314,17 @@ contextBridge.exposeInMainWorld("electron", { updateOnNextRestart, skipAppUpdate, + // - FS + fs: { + exists: fsExists, + rename: fsRename, + mkdirIfNeeded: fsMkdirIfNeeded, + rmdir: fsRmdir, + rm: fsRm, + readTextFile: fsReadTextFile, + writeFile: fsWriteFile, + }, + // - Conversion convertToJPEG, generateImageThumbnail, @@ -341,20 +350,8 @@ contextBridge.exposeInMainWorld("electron", { updateWatchMappingSyncedFiles, updateWatchMappingIgnoredFiles, - // - FS - fs: { - exists: fsExists, - rename: fsRename, - mkdirIfNeeded: fsMkdirIfNeeded, - rmdir: fsRmdir, - rm: fsRm, - }, - // - FS legacy // TODO: Move these into fs + document + rename if needed - saveStreamToDisk, - saveFileToDisk, - readTextFile, isFolder, // - Upload diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index cebeb1391..f30be877b 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -22,6 +22,7 @@ linter: - use_key_in_widget_constructors - cancel_subscriptions + - avoid_empty_else - exhaustive_cases @@ -59,6 +60,7 @@ analyzer: prefer_final_locals: warning unnecessary_const: error cancel_subscriptions: error + unrelated_type_equality_checks: error unawaited_futures: warning # convert to warning after fixing existing issues diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 09f9ded3c..102c04e6a 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,12 +3,9 @@ PODS: - Flutter - battery_info (0.0.1): - Flutter - - bonsoir_darwin (3.0.0): - - Flutter - - FlutterMacOS - connectivity_plus (0.0.1): - Flutter - - ReachabilitySwift + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - file_saver (0.0.1): @@ -171,7 +168,6 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - ReachabilitySwift (5.2.1) - receive_sharing_intent (1.6.8): - Flutter - screen_brightness_ios (0.1.0): @@ -231,8 +227,7 @@ PODS: DEPENDENCIES: - background_fetch (from `.symlinks/plugins/background_fetch/ios`) - battery_info (from `.symlinks/plugins/battery_info/ios`) - - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_saver (from `.symlinks/plugins/file_saver/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -296,7 +291,6 @@ SPEC REPOS: - onnxruntime-objc - OrderedSet - PromisesObjC - - ReachabilitySwift - SDWebImage - SDWebImageWebPCoder - Sentry @@ -309,10 +303,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/background_fetch/ios" battery_info: :path: ".symlinks/plugins/battery_info/ios" - bonsoir_darwin: - :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + :path: ".symlinks/plugins/connectivity_plus/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_saver: @@ -409,8 +401,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: background_fetch: 2319bf7e18237b4b269430b7f14d177c0df09c5a battery_info: 09f5c9ee65394f2291c8c6227bedff345b8a730c - bonsoir_darwin: 127bdc632fdc154ae2f277a4d5c86a6212bc75be - connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a + connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Firebase: 797fd7297b7e1be954432743a0b3f90038e45a71 @@ -458,7 +449,6 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66 receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 72c5ef5cf..89c492629 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -285,7 +285,6 @@ "${BUILT_PRODUCTS_DIR}/Mantle/Mantle.framework", "${BUILT_PRODUCTS_DIR}/OrderedSet/OrderedSet.framework", "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/ReachabilitySwift/Reachability.framework", "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", "${BUILT_PRODUCTS_DIR}/SDWebImageWebPCoder/SDWebImageWebPCoder.framework", "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", @@ -293,7 +292,6 @@ "${BUILT_PRODUCTS_DIR}/Toast/Toast.framework", "${BUILT_PRODUCTS_DIR}/background_fetch/background_fetch.framework", "${BUILT_PRODUCTS_DIR}/battery_info/battery_info.framework", - "${BUILT_PRODUCTS_DIR}/bonsoir_darwin/bonsoir_darwin.framework", "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", "${BUILT_PRODUCTS_DIR}/file_saver/file_saver.framework", @@ -369,7 +367,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Mantle.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OrderedSet.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reachability.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageWebPCoder.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", @@ -377,7 +374,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Toast.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/background_fetch.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/battery_info.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/bonsoir_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_saver.framework", diff --git a/mobile/lib/models/file/file.dart b/mobile/lib/models/file/file.dart index 607988a1b..75a40c99b 100644 --- a/mobile/lib/models/file/file.dart +++ b/mobile/lib/models/file/file.dart @@ -85,13 +85,24 @@ class EnteFile { static int parseFileCreationTime(String? fileTitle, AssetEntity asset) { int creationTime = asset.createDateTime.microsecondsSinceEpoch; + final int modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch; if (creationTime >= jan011981Time) { // assuming that fileSystem is returning correct creationTime. // During upload, this might get overridden with exif Creation time + // When the assetModifiedTime is less than creationTime, than just use + // that as creationTime. This is to handle cases where file might be + // copied to the fileSystem from somewhere else See #https://superuser.com/a/1091147 + if (modificationTime >= jan011981Time && + modificationTime < creationTime) { + _logger.info( + 'LocalID: ${asset.id} modification time is less than creation time. Using modification time as creation time', + ); + creationTime = modificationTime; + } return creationTime; } else { - if (asset.modifiedDateTime.microsecondsSinceEpoch >= jan011981Time) { - creationTime = asset.modifiedDateTime.microsecondsSinceEpoch; + if (modificationTime >= jan011981Time) { + creationTime = modificationTime; } else { creationTime = DateTime.now().toUtc().microsecondsSinceEpoch; } @@ -106,7 +117,6 @@ class EnteFile { // ignore } } - return creationTime; } diff --git a/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart b/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart index 3abd57db7..d3736d768 100644 --- a/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart +++ b/mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart @@ -4,7 +4,6 @@ import "dart:io"; import "package:connectivity_plus/connectivity_plus.dart"; import "package:logging/logging.dart"; import "package:photos/core/errors.dart"; - import "package:photos/core/event_bus.dart"; import "package:photos/events/event.dart"; import "package:photos/services/remote_assets_service.dart"; @@ -23,7 +22,7 @@ abstract class MLFramework { MLFramework(this.shouldDownloadOverMobileData) { Connectivity() .onConnectivityChanged - .listen((ConnectivityResult result) async { + .listen((List result) async { _logger.info("Connectivity changed to $result"); if (_state == InitializationState.waitingForNetwork && await _canDownload()) { @@ -135,9 +134,11 @@ abstract class MLFramework { } Future _canDownload() async { - final connectivityResult = await (Connectivity().checkConnectivity()); - return connectivityResult != ConnectivityResult.mobile || - shouldDownloadOverMobileData; + final List connections = + await (Connectivity().checkConnectivity()); + final bool isConnectedToMobile = + connections.contains(ConnectivityResult.mobile); + return !isConnectedToMobile || shouldDownloadOverMobileData; } } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index 057e600df..873270f34 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -45,7 +45,9 @@ class SyncService { sync(); }); - Connectivity().onConnectivityChanged.listen((ConnectivityResult result) { + Connectivity() + .onConnectivityChanged + .listen((List result) { _logger.info("Connectivity change detected " + result.toString()); if (Configuration.instance.hasConfiguredAccount()) { sync(); diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index a545605e9..d877ce522 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -355,9 +355,10 @@ class FileUploader { if (isForceUpload) { return; } - final connectivityResult = await (Connectivity().checkConnectivity()); + final List connections = + await (Connectivity().checkConnectivity()); bool canUploadUnderCurrentNetworkConditions = true; - if (connectivityResult == ConnectivityResult.mobile) { + if (connections.any((element) => element == ConnectivityResult.mobile)) { canUploadUnderCurrentNetworkConditions = Configuration.instance.shouldBackupOverMobileData(); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 320a07f18..393dadc23 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -113,38 +113,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" - bonsoir: - dependency: transitive - description: - name: bonsoir - sha256: "800d77c0581fff06cc43ef2b7723dfe5ee9b899ab0fdf80fb1c7b8829a5deb5c" - url: "https://pub.dev" - source: hosted - version: "3.0.0+1" - bonsoir_android: - dependency: transitive - description: - name: bonsoir_android - sha256: "7207c36fd7e0f3c7c2d8cf353f02bd640d96e2387d575837f8ac051c9cbf4aa7" - url: "https://pub.dev" - source: hosted - version: "3.0.0+1" - bonsoir_darwin: - dependency: transitive - description: - name: bonsoir_darwin - sha256: "7211042c85da2d6efa80c0976bbd9568f2b63624097779847548ed4530675ade" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - bonsoir_platform_interface: - dependency: transitive - description: - name: bonsoir_platform_interface - sha256: "64d57cd52bd477b4891e9b9d419e6408da171ed9e0efc8aa716e7e343d5d93ad" - url: "https://pub.dev" - source: hosted - version: "3.0.0" boolean_selector: dependency: transitive description: @@ -241,14 +209,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cast: - dependency: "direct main" - description: - name: cast - sha256: b70f6be547a53481dffec93ad3cc4974fae5ed707f0b677d4a50c329d7299b98 - url: "https://pub.dev" - source: hosted - version: "2.0.0" characters: dependency: transitive description: @@ -326,18 +286,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: ebe15d94de9dd7c31dc2ac54e42780acdf3384b1497c69290c9f3c5b0279fc57 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "6.0.2" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.0.0" convert: dependency: transitive description: @@ -1769,14 +1729,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" provider: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9f26ef1e1..b095b90c4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -27,7 +27,6 @@ dependencies: battery_info: ^1.1.1 bip39: ^1.0.6 cached_network_image: ^3.0.0 - cast: ^2.0.0 chewie: git: url: https://github.com/ente-io/chewie.git @@ -37,11 +36,7 @@ dependencies: collection: # dart computer: git: "https://github.com/ente-io/computer.git" - connectivity_plus: - git: - url: https://github.com/ente-io/plus_plugins.git - ref: check_mobile_first - path: packages/connectivity_plus/connectivity_plus/ + connectivity_plus: ^6.0.2 cross_file: ^0.3.3 crypto: ^3.0.2 cupertino_icons: ^1.0.0 @@ -175,7 +170,6 @@ dependencies: xml: ^6.3.0 dependency_overrides: - connectivity_plus: ^4.0.0 # Remove this after removing dependency from flutter_sodium. # Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0 ffi: 2.1.0 diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx index ca52b9cad..4e9552a1d 100644 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx @@ -56,8 +56,11 @@ export default function SearchInput(props: Iprops) { const [value, setValue] = useState(null); const appContext = useContext(AppContext); const handleChange = (value: SearchOption) => { - setValue(value); - setQuery(value.label); + if (value) { + setValue(value); + setQuery(value.label); + } + blur(); }; const handleInputChange = (value: string, actionMeta: InputActionMeta) => { diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index 124d5b4b2..41af5c055 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -7,7 +7,6 @@ import { CustomError } from "@ente/shared/error"; import { Events, eventBus } from "@ente/shared/events"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; -import isElectron from "is-electron"; import { EnteFile } from "types/file"; import { generateStreamFromArrayBuffer, @@ -89,11 +88,12 @@ class DownloadManagerImpl { e, ); } - try { - if (isElectron()) this.fileCache = await openCache("files"); - } catch (e) { - log.error("Failed to open file cache, will continue without it", e); - } + // TODO (MR): Revisit full file caching cf disk space usage + // try { + // if (isElectron()) this.fileCache = await openCache("files"); + // } catch (e) { + // log.error("Failed to open file cache, will continue without it", e); + // } this.cryptoWorker = await ComlinkCryptoWorker.getInstance(); this.ready = true; eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this); diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index f7a0c3f3e..7d6279882 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -34,6 +34,7 @@ import { mergeMetadata, } from "utils/file"; import { safeDirectoryName, safeFileName } from "utils/native-fs"; +import { writeStream } from "utils/native-stream"; import { getAllLocalCollections } from "../collectionService"; import downloadManager from "../download"; import { getAllLocalFiles } from "../fileService"; @@ -884,7 +885,7 @@ class ExportService { try { const exportRecord = await this.getExportRecord(folder); const newRecord: ExportRecord = { ...exportRecord, ...newData }; - await ensureElectron().saveFileToDisk( + await ensureElectron().fs.writeFile( `${folder}/${exportRecordFileName}`, JSON.stringify(newRecord, null, 2), ); @@ -907,8 +908,7 @@ class ExportService { if (!(await fs.exists(exportRecordJSONPath))) { return this.createEmptyExportRecord(exportRecordJSONPath); } - const recordFile = - await electron.readTextFile(exportRecordJSONPath); + const recordFile = await fs.readTextFile(exportRecordJSONPath); try { return JSON.parse(recordFile); } catch (e) { @@ -993,7 +993,7 @@ class ExportService { fileExportName, file, ); - await electron.saveStreamToDisk( + await writeStream( `${collectionExportPath}/${fileExportName}`, updatedFileStream, ); @@ -1044,7 +1044,7 @@ class ExportService { imageExportName, file, ); - await electron.saveStreamToDisk( + await writeStream( `${collectionExportPath}/${imageExportName}`, imageStream, ); @@ -1056,7 +1056,7 @@ class ExportService { file, ); try { - await electron.saveStreamToDisk( + await writeStream( `${collectionExportPath}/${videoExportName}`, videoStream, ); @@ -1077,7 +1077,7 @@ class ExportService { fileExportName: string, file: EnteFile, ) { - await ensureElectron().saveFileToDisk( + await ensureElectron().fs.writeFile( getFileMetadataExportPath(collectionExportPath, fileExportName), getGoogleLikeMetadataFile(fileExportName, file), ); @@ -1106,7 +1106,7 @@ class ExportService { private createEmptyExportRecord = async (exportRecordJSONPath: string) => { const exportRecord: ExportRecord = NULL_EXPORT_RECORD; - await ensureElectron().saveFileToDisk( + await ensureElectron().fs.writeFile( exportRecordJSONPath, JSON.stringify(exportRecord, null, 2), ); diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 8f72cb450..785921cc9 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -53,6 +53,7 @@ import { VISIBILITY_STATE } from "types/magicMetadata"; import { FileTypeInfo } from "types/upload"; import { isArchivedFile, updateMagicMetadata } from "utils/magicMetadata"; import { safeFileName } from "utils/native-fs"; +import { writeStream } from "utils/native-stream"; const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000; @@ -798,55 +799,47 @@ async function downloadFileDesktop( electron: Electron, fileReader: FileReader, file: EnteFile, - downloadPath: string, + downloadDir: string, ) { - const fileStream = (await DownloadManager.getFile( + const fs = electron.fs; + const stream = (await DownloadManager.getFile( file, )) as ReadableStream; - const updatedFileStream = await getUpdatedEXIFFileForDownload( + const updatedStream = await getUpdatedEXIFFileForDownload( fileReader, file, - fileStream, + stream, ); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const fileBlob = await new Response(updatedFileStream).blob(); + const fileBlob = await new Response(updatedStream).blob(); const livePhoto = await decodeLivePhoto(file, fileBlob); const imageExportName = await safeFileName( - downloadPath, + downloadDir, livePhoto.imageNameTitle, - electron.fs.exists, + fs.exists, ); const imageStream = generateStreamFromArrayBuffer(livePhoto.image); - await electron.saveStreamToDisk( - `${downloadPath}/${imageExportName}`, - imageStream, - ); + await writeStream(`${downloadDir}/${imageExportName}`, imageStream); try { const videoExportName = await safeFileName( - downloadPath, + downloadDir, livePhoto.videoNameTitle, - electron.fs.exists, + fs.exists, ); const videoStream = generateStreamFromArrayBuffer(livePhoto.video); - await electron.saveStreamToDisk( - `${downloadPath}/${videoExportName}`, - videoStream, - ); + await writeStream(`${downloadDir}/${videoExportName}`, videoStream); } catch (e) { - await electron.fs.rm(`${downloadPath}/${imageExportName}`); + await fs.rm(`${downloadDir}/${imageExportName}`); throw e; } } else { const fileExportName = await safeFileName( - downloadPath, + downloadDir, file.metadata.title, - electron.fs.exists, - ); - await electron.saveStreamToDisk( - `${downloadPath}/${fileExportName}`, - updatedFileStream, + fs.exists, ); + await writeStream(`${downloadDir}/${fileExportName}`, updatedStream); } } diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts new file mode 100644 index 000000000..809aa9e20 --- /dev/null +++ b/web/apps/photos/src/utils/native-stream.ts @@ -0,0 +1,39 @@ +/** + * @file Streaming IPC communication with the Node.js layer of our desktop app. + * + * NOTE: These functions only work when we're running in our desktop app. + */ + +/** + * Write the given stream to a file on the local machine. + * + * **This only works when we're running in our desktop app**. It uses the + * "stream://" protocol handler exposed by our custom code in the Node.js layer. + * See: [Note: IPC streams]. + * + * @param path The path on the local machine where to write the file to. + * @param stream The stream which should be written into the file. + * */ +export const writeStream = async (path: string, stream: ReadableStream) => { + // The duplex parameter needs to be set to 'half' when streaming requests. + // + // Currently browsers, and specifically in our case, since this code runs + // only within our desktop (Electron) app, Chromium, don't support 'full' + // duplex mode (i.e. streaming both the request and the response). + // https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests + // + // In another twist, the TypeScript libdom.d.ts does not include the + // "duplex" parameter, so we need to cast to get TypeScript to let this code + // through. e.g. see https://github.com/node-fetch/node-fetch/issues/1769 + const req = new Request(`stream://write${path}`, { + // GET can't have a body + method: "POST", + body: stream, + duplex: "half", + } as unknown as RequestInit); + const res = await fetch(req); + if (!res.ok) + throw new Error( + `Failed to write stream to ${path}: HTTP ${res.status}`, + ); +}; diff --git a/web/packages/next/next.config.base.js b/web/packages/next/next.config.base.js index f0d1481b4..a3076fa5c 100644 --- a/web/packages/next/next.config.base.js +++ b/web/packages/next/next.config.base.js @@ -59,11 +59,21 @@ const nextConfig = { GIT_SHA: gitSHA(), }, - // https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j + // Customize the webpack configuration used by Next.js webpack: (config, { isServer }) => { + // https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j if (!isServer) { config.resolve.fallback.fs = false; } + + // Suppress the warning "Critical dependency: require function is used + // in a way in which dependencies cannot be statically extracted" when + // import heic-convert. + // + // Upstream issue, which currently doesn't have a workaround. + // https://github.com/catdad-experiments/libheif-js/issues/23 + config.ignoreWarnings = [{ module: /libheif-js/ }]; + return config; }, }; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 69b0c3593..3477d745e 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -188,6 +188,17 @@ export interface Electron { * Delete the file at {@link path}. */ rm: (path: string) => Promise; + + /** Read the string contents of a file at {@link path}. */ + readTextFile: (path: string) => Promise; + + /** + * Write a string to a file, replacing the file if it already exists. + * + * @param path The path of the file. + * @param contents The string contents to write. + */ + writeFile: (path: string, contents: string) => Promise; }; /* @@ -300,12 +311,6 @@ export interface Electron { ) => Promise; // - FS legacy - saveStreamToDisk: ( - path: string, - fileStream: ReadableStream, - ) => Promise; - saveFileToDisk: (path: string, contents: string) => Promise; - readTextFile: (path: string) => Promise; isFolder: (dirPath: string) => Promise; // - Upload diff --git a/web/packages/shared/utils/index.ts b/web/packages/shared/utils/index.ts index 1ed02fabe..c027b6cb6 100644 --- a/web/packages/shared/utils/index.ts +++ b/web/packages/shared/utils/index.ts @@ -1,7 +1,11 @@ -export async function sleep(time: number) { - await new Promise((resolve) => { - setTimeout(() => resolve(null), time); - }); +/** + * Wait for {@link ms} milliseconds + * + * This function is a promisified `setTimeout`. It returns a promise that + * resolves after {@link ms} milliseconds. + */ +export async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); } export function downloadAsFile(filename: string, content: string) {