Quellcode durchsuchen

Merge branch 'main' into bundle-ml-demo

Abhinav vor 2 Jahren
Ursprung
Commit
27876e5b6d
49 geänderte Dateien mit 1291 neuen und 610 gelöschten Zeilen
  1. 4 0
      .gitmodules
  2. 18 10
      README.md
  3. 0 87
      main/index.ts
  4. 0 199
      main/preload.ts
  5. 0 52
      main/services/store.ts
  6. 9 6
      package.json
  7. 35 0
      src/api/cache.ts
  8. 10 0
      src/api/common.ts
  9. 4 7
      src/api/electronStore.ts
  10. 72 0
      src/api/export.ts
  11. 8 0
      src/api/fs.ts
  12. 3 3
      src/api/safeStorage.ts
  13. 11 0
      src/api/system.ts
  14. 84 0
      src/api/upload.ts
  15. 117 0
      src/api/watch.ts
  16. 0 0
      src/config/index.ts
  17. 78 0
      src/main.ts
  18. 79 0
      src/preload.ts
  19. 2 2
      src/services/appUpdater.ts
  20. 36 0
      src/services/autoLauncher.ts
  21. 37 0
      src/services/autoLauncherClients/linuxAutoLauncher.ts
  22. 28 0
      src/services/autoLauncherClients/macAndWindowsAutoLauncher.ts
  23. 33 0
      src/services/chokidar.ts
  24. 3 42
      src/services/diskCache.ts
  25. 1 1
      src/services/diskLRU.ts
  26. 77 128
      src/services/fs.ts
  27. 12 14
      src/services/sentry.ts
  28. 73 0
      src/services/upload.ts
  29. 10 0
      src/services/userPreference.ts
  30. 11 0
      src/services/watch.ts
  31. 18 0
      src/stores/keys.store.ts
  32. 13 0
      src/stores/safeStorage.store.ts
  33. 25 0
      src/stores/upload.store.ts
  34. 13 0
      src/stores/userPreferences.store.ts
  35. 47 0
      src/stores/watch.store.ts
  36. 5 0
      src/types/autoLauncher.ts
  37. 22 0
      src/types/index.ts
  38. 0 0
      src/utils/common.ts
  39. 0 0
      src/utils/cors.ts
  40. 23 10
      src/utils/createWindow.ts
  41. 21 12
      src/utils/ipcComms.ts
  42. 0 0
      src/utils/logging.ts
  43. 101 0
      src/utils/main.ts
  44. 84 30
      src/utils/menu.ts
  45. 16 0
      src/utils/preload.ts
  46. 11 0
      src/utils/watch.ts
  47. 1 0
      thirdparty/next-electron-server
  48. 2 2
      tsconfig.json
  49. 34 5
      yarn.lock

+ 4 - 0
.gitmodules

@@ -2,3 +2,7 @@
 	path = ui
 	url = https://github.com/ente-io/bada-frame
 	branch = demo
+[submodule "thirdparty/next-electron-server"]
+	path = thirdparty/next-electron-server
+	url = https://github.com/ente-io/next-electron-server.git
+	branch = desktop

+ 18 - 10
README.md

@@ -9,7 +9,7 @@ We are aware that electron is a sub-optimal choice for building desktop applicat
 The goal of this app was to
 1. provide a stable environment for customers to back up large amounts of data reliably
 2. export uploaded data from our servers to their local hard drives.
- 
+
 Electron was the best way to reuse our battle tested code from [bada-frame](https://github.com/ente-io/bada-frame) that powers [web.ente.io](https://web.ente.io).
 
 As an archival solution built by a small team, we are hopeful that this project will help us keep our stack lean, while ensuring a painfree life for our customers.
@@ -25,27 +25,35 @@ If you are running into issues with this app, please drop a mail to [support@ent
 - [AUR](https://aur.archlinux.org/packages/ente-desktop-appimage):
   `yay -S ente-desktop-appimage`
 
-## Develop
+## Building from source
 
-To clone and run this repository you'll need [Git](https://git-scm.com) and [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) installed on your computer. From your command line:
+You'll need to have node (and yarn) installed on your machine. e.g. on macOS you
+can do `brew install node`. After that, you can run the following commands to
+fetch and build from source.
 
 ```bash
 # Clone this repository
 git clone https://github.com/ente-io/bhari-frame
+
 # Go into the repository
 cd bhari-frame
-# Install dependencies
-npm install
+
+# Clone submodules (recursively)
+git submodule update --init --recursive
+
+# Install packages
+yarn
+
 # Run the app
-npm start
+yarn start
 ```
 
-Note: If you're using Linux Bash for Windows, [see this guide](https://www.howtogeek.com/261575/how-to-run-graphical-linux-desktop-applications-from-windows-10s-bash-shell/) or use `node` from the command prompt.
-
 ### Re-compile automatically
 
-To recompile automatically and to allow using [electron-reload](https://github.com/yan-foto/electron-reload), run this in a separate terminal:
+To recompile automatically and to allow using
+[electron-reload](https://github.com/yan-foto/electron-reload), run this in a
+separate terminal:
 
 ```bash
-npm run watch
+yarn watch
 ```

+ 0 - 87
main/index.ts

@@ -1,87 +0,0 @@
-import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron';
-import * as path from 'path';
-import AppUpdater from './utils/appUpdater';
-import { createWindow } from './utils/createWindow';
-import setupIpcComs from './utils/ipcComms';
-import { buildContextMenu, buildMenuBar } from './utils/menuUtil';
-import initSentry from './utils/sentry';
-import electronReload from 'electron-reload';
-import { PROD_HOST_URL, RENDERER_OUTPUT_DIR } from './config';
-import { isDev } from './utils/common';
-import serveNextAt from 'next-electron-server';
-
-if (isDev) {
-    electronReload(__dirname, {});
-}
-
-let tray: Tray;
-let mainWindow: BrowserWindow;
-
-let appIsQuitting = false;
-
-let updateIsAvailable = false;
-
-export const isAppQuitting = (): boolean => {
-    return appIsQuitting;
-};
-
-export const setIsAppQuitting = (value: boolean): void => {
-    appIsQuitting = value;
-};
-
-export const isUpdateAvailable = (): boolean => {
-    return updateIsAvailable;
-};
-export const setIsUpdateAvailable = (value: boolean): void => {
-    updateIsAvailable = value;
-};
-
-serveNextAt(PROD_HOST_URL, {
-    outputDir: RENDERER_OUTPUT_DIR,
-});
-
-const gotTheLock = app.requestSingleInstanceLock();
-if (!gotTheLock) {
-    app.quit();
-} else {
-    app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer');
-    app.on('second-instance', () => {
-        // Someone tried to run a second instance, we should focus our window.
-        if (mainWindow) {
-            mainWindow.show();
-            if (mainWindow.isMinimized()) {
-                mainWindow.restore();
-            }
-            mainWindow.focus();
-        }
-    });
-
-    // This method will be called when Electron has finished
-    // initialization and is ready to create browser windows.
-    // Some APIs can only be used after this event occurs.
-    app.on('ready', () => {
-        initSentry();
-        setIsUpdateAvailable(false);
-        mainWindow = createWindow();
-        Menu.setApplicationMenu(buildMenuBar());
-
-        app.on('activate', function () {
-            // On macOS it's common to re-create a window in the app when the
-            // dock icon is clicked and there are no other windows open.
-            if (BrowserWindow.getAllWindows().length === 0) createWindow();
-        });
-
-        const trayImgPath = isDev
-            ? 'build/taskbar-icon.png'
-            : path.join(process.resourcesPath, 'taskbar-icon.png');
-        const trayIcon = nativeImage.createFromPath(trayImgPath);
-        tray = new Tray(trayIcon);
-        tray.setToolTip('ente');
-        tray.setContextMenu(buildContextMenu(mainWindow));
-
-        setupIpcComs(tray, mainWindow);
-        if (!isDev) {
-            AppUpdater.checkForUpdate(tray, mainWindow);
-        }
-    });
-}

+ 0 - 199
main/preload.ts

@@ -1,199 +0,0 @@
-import { Readable } from 'stream';
-import * as fs from 'promise-fs';
-import { webFrame, ipcRenderer } from 'electron';
-import {
-    getElectronFile,
-    getPendingUploads,
-    setToUploadFiles,
-    getElectronFilesFromGoogleZip,
-    setToUploadCollection,
-} from './utils/upload';
-import { logError } from './utils/logging';
-import { ElectronFile } from './types';
-import { getEncryptionKey, setEncryptionKey } from './utils/safeStorage';
-import { clearElectronStore } from './utils/electronStore';
-import { openDiskCache, deleteDiskCache } from './utils/cache';
-
-// Patch the global WebSocket constructor to use the correct DevServer url
-const fixHotReloadNext12 = () => {
-    webFrame.executeJavaScript(`Object.defineProperty(globalThis, 'WebSocket', {
-    value: new Proxy(WebSocket, {
-      construct: (Target, [url, protocols]) => {
-        if (url.endsWith('/_next/webpack-hmr')) {
-          // Fix the Next.js hmr client url
-          return new Target("ws://localhost:3000/_next/webpack-hmr", protocols)
-        } else {
-          return new Target(url, protocols)
-        }
-      }
-    })
-  });`);
-};
-
-fixHotReloadNext12();
-
-const responseToReadable = (fileStream: any) => {
-    const reader = fileStream.getReader();
-    const rs = new Readable();
-    rs._read = async () => {
-        const result = await reader.read();
-        if (!result.done) {
-            rs.push(Buffer.from(result.value));
-        } else {
-            rs.push(null);
-            return;
-        }
-    };
-    return rs;
-};
-
-const exists = (path: string) => {
-    return fs.existsSync(path);
-};
-
-const checkExistsAndCreateCollectionDir = async (dirPath: string) => {
-    if (!fs.existsSync(dirPath)) {
-        await fs.mkdir(dirPath);
-    }
-};
-
-const checkExistsAndRename = async (oldDirPath: string, newDirPath: string) => {
-    if (fs.existsSync(oldDirPath)) {
-        await fs.rename(oldDirPath, newDirPath);
-    }
-};
-
-const saveStreamToDisk = (path: string, fileStream: ReadableStream<any>) => {
-    const writeable = fs.createWriteStream(path);
-    const readable = responseToReadable(fileStream);
-    readable.pipe(writeable);
-};
-
-const saveFileToDisk = async (path: string, file: any) => {
-    await fs.writeFile(path, file);
-};
-
-const selectRootDirectory = async () => {
-    try {
-        return await ipcRenderer.invoke('select-dir');
-    } catch (e) {
-        logError(e, 'error while selecting root directory');
-    }
-};
-
-const sendNotification = (content: string) => {
-    ipcRenderer.send('send-notification', content);
-};
-const showOnTray = (content: string) => {
-    ipcRenderer.send('update-tray', content);
-};
-
-const registerResumeExportListener = (resumeExport: () => void) => {
-    ipcRenderer.removeAllListeners('resume-export');
-    ipcRenderer.on('resume-export', () => resumeExport());
-};
-const registerStopExportListener = (abortExport: () => void) => {
-    ipcRenderer.removeAllListeners('stop-export');
-    ipcRenderer.on('stop-export', () => abortExport());
-};
-
-const registerPauseExportListener = (pauseExport: () => void) => {
-    ipcRenderer.removeAllListeners('pause-export');
-    ipcRenderer.on('pause-export', () => pauseExport());
-};
-
-const registerRetryFailedExportListener = (retryFailedExport: () => void) => {
-    ipcRenderer.removeAllListeners('retry-export');
-    ipcRenderer.on('retry-export', () => retryFailedExport());
-};
-
-const reloadWindow = () => {
-    ipcRenderer.send('reload-window');
-};
-
-const getExportRecord = async (filePath: string) => {
-    try {
-        const filepath = `${filePath}`;
-        const recordFile = await fs.readFile(filepath, 'utf-8');
-        return recordFile;
-    } catch (e) {
-        // ignore exportFile missing
-        logError(e, 'error while selecting files');
-    }
-};
-
-const setExportRecord = async (filePath: string, data: string) => {
-    const filepath = `${filePath}`;
-    await fs.writeFile(filepath, data);
-};
-
-const showUploadFilesDialog = async () => {
-    try {
-        const filePaths: string[] = await ipcRenderer.invoke(
-            'show-upload-files-dialog'
-        );
-        const files = await Promise.all(filePaths.map(getElectronFile));
-        return files;
-    } catch (e) {
-        logError(e, 'error while selecting files');
-    }
-};
-
-const showUploadDirsDialog = async () => {
-    try {
-        const filePaths: string[] = await ipcRenderer.invoke(
-            'show-upload-dirs-dialog'
-        );
-        const files = await Promise.all(filePaths.map(getElectronFile));
-        return files;
-    } catch (e) {
-        logError(e, 'error while selecting folders');
-    }
-};
-
-const showUploadZipDialog = async () => {
-    try {
-        const filePaths: string[] = await ipcRenderer.invoke(
-            'show-upload-zip-dialog'
-        );
-        const files: ElectronFile[] = [];
-        for (const filePath of filePaths) {
-            files.push(...(await getElectronFilesFromGoogleZip(filePath)));
-        }
-        return { zipPaths: filePaths, files };
-    } catch (e) {
-        logError(e, 'error while selecting zips');
-    }
-};
-
-const windowObject: any = window;
-windowObject['ElectronAPIs'] = {
-    exists,
-    checkExistsAndCreateCollectionDir,
-    checkExistsAndRename,
-    saveStreamToDisk,
-    saveFileToDisk,
-    selectRootDirectory,
-    sendNotification,
-    showOnTray,
-    reloadWindow,
-    registerResumeExportListener,
-    registerStopExportListener,
-    registerPauseExportListener,
-    registerRetryFailedExportListener,
-    getExportRecord,
-    setExportRecord,
-    getElectronFile,
-    showUploadFilesDialog,
-    showUploadDirsDialog,
-    getPendingUploads,
-    setToUploadFiles,
-    showUploadZipDialog,
-    getElectronFilesFromGoogleZip,
-    setToUploadCollection,
-    getEncryptionKey,
-    setEncryptionKey,
-    clearElectronStore,
-    openDiskCache,
-    deleteDiskCache,
-};

+ 0 - 52
main/services/store.ts

@@ -1,52 +0,0 @@
-import Store, { Schema } from 'electron-store';
-import { KeysStoreType, SafeStorageStoreType, UploadStoreType } from '../types';
-
-export const uploadStoreSchema: Schema<UploadStoreType> = {
-    filePaths: {
-        type: 'array',
-        items: {
-            type: 'string',
-        },
-    },
-    zipPaths: {
-        type: 'array',
-        items: {
-            type: 'string',
-        },
-    },
-    collectionName: {
-        type: 'string',
-    },
-};
-
-export const uploadStatusStore = new Store({
-    name: 'upload-status',
-    schema: uploadStoreSchema,
-});
-
-export const keysStoreSchema: Schema<KeysStoreType> = {
-    AnonymizeUserID: {
-        type: 'object',
-        properties: {
-            id: {
-                type: 'string',
-            },
-        },
-    },
-};
-
-export const keysStore = new Store({
-    name: 'keys',
-    schema: keysStoreSchema,
-});
-
-export const safeStorageSchema: Schema<SafeStorageStoreType> = {
-    encryptionKey: {
-        type: 'string',
-    },
-};
-
-export const safeStorageStore = new Store({
-    name: 'safeStorage',
-    schema: safeStorageSchema,
-});

+ 9 - 6
package.json

@@ -4,7 +4,7 @@
   "version": "1.7.0-alpha.7",
   "private": true,
   "description": "Desktop client for ente.io",
-  "main": "app/index.js",
+  "main": "app/main.js",
   "build": {
     "appId": "io.ente.bhari-frame",
     "artifactName": "${productName}-${version}.${ext}",
@@ -53,12 +53,12 @@
   },
   "scripts": {
     "postinstall": "electron-builder install-app-deps",
-    "prebuild": "eslint \"main/**/*.{js,jsx,ts,tsx}\"",
+    "prebuild": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
     "prepare": "husky install",
-    "lint": "eslint -c .eslintrc --ext .ts ./main",
+    "lint": "eslint -c .eslintrc --ext .ts src",
     "watch": "tsc -w",
     "build-main": "yarn install && tsc",
-    "start-main": "yarn build-main && electron app/index.js",
+    "start-main": "yarn build-main && electron app/main.js",
     "start-renderer": "cd ui && yarn install && yarn dev",
     "start": "concurrently \"yarn start-main\" \"yarn start-renderer\"",
     "build-renderer": "cd ui && yarn install && yarn build && cd ..",
@@ -68,6 +68,7 @@
   "author": "ente <code@ente.io>",
   "devDependencies": {
     "@sentry/cli": "^1.68.0",
+    "@types/auto-launch": "^5.0.2",
     "@types/get-folder-size": "^2.0.0",
     "@typescript-eslint/eslint-plugin": "^5.28.0",
     "@typescript-eslint/parser": "^5.28.0",
@@ -88,12 +89,14 @@
     "@sentry/electron": "^2.5.1",
     "@types/node": "^14.14.37",
     "@types/promise-fs": "^2.1.1",
+    "chokidar": "^3.5.3",
+    "auto-launch": "^5.0.5",
     "electron-log": "^4.3.5",
     "electron-reload": "^2.0.0-alpha.1",
     "electron-store": "^8.0.1",
     "electron-updater": "^4.3.8",
     "get-folder-size": "^2.0.1",
-    "next-electron-server": "^0.0.8",
+    "next-electron-server": "file:./thirdparty/next-electron-server",
     "node-stream-zip": "^1.15.0",
     "promise-fs": "^2.1.1"
   },
@@ -101,7 +104,7 @@
     "parser": "babel-eslint"
   },
   "lint-staged": {
-    "main/**/*.{js,jsx,ts,tsx}": [
+    "src/**/*.{js,jsx,ts,tsx}": [
       "eslint --fix",
       "prettier --write --ignore-unknown"
     ]

+ 35 - 0
src/api/cache.ts

@@ -0,0 +1,35 @@
+import { ipcRenderer } from 'electron/renderer';
+import path from 'path';
+import { existsSync, mkdir, rmSync } from 'promise-fs';
+import { DiskCache } from '../services/diskCache';
+
+const CACHE_DIR = 'ente';
+
+const getCacheDir = async () => {
+    const systemCacheDir = await ipcRenderer.invoke('get-path', 'cache');
+    return path.join(systemCacheDir, CACHE_DIR);
+};
+
+const getCacheBucketDir = async (cacheName: string) => {
+    const cacheDir = await getCacheDir();
+    const cacheBucketDir = path.join(cacheDir, cacheName);
+    return cacheBucketDir;
+};
+
+export async function openDiskCache(cacheName: string) {
+    const cacheBucketDir = await getCacheBucketDir(cacheName);
+    if (!existsSync(cacheBucketDir)) {
+        await mkdir(cacheBucketDir, { recursive: true });
+    }
+    return new DiskCache(cacheBucketDir);
+}
+
+export async function deleteDiskCache(cacheName: string) {
+    const cacheBucketDir = await getCacheBucketDir(cacheName);
+    if (existsSync(cacheBucketDir)) {
+        rmSync(cacheBucketDir, { recursive: true, force: true });
+        return true;
+    } else {
+        return false;
+    }
+}

+ 10 - 0
src/api/common.ts

@@ -0,0 +1,10 @@
+import { ipcRenderer } from 'electron/renderer';
+import { logError } from '../utils/logging';
+
+export const selectRootDirectory = async (): Promise<string> => {
+    try {
+        return await ipcRenderer.invoke('select-dir');
+    } catch (e) {
+        logError(e, 'error while selecting root directory');
+    }
+};

+ 4 - 7
main/utils/electronStore.ts → src/api/electronStore.ts

@@ -1,10 +1,7 @@
-import {
-    uploadStatusStore,
-    keysStore,
-    safeStorageStore,
-} from '../services/store';
-
-import { logError } from './logging';
+import { keysStore } from '../stores/keys.store';
+import { safeStorageStore } from '../stores/safeStorage.store';
+import { uploadStatusStore } from '../stores/upload.store';
+import { logError } from '../utils/logging';
 
 export const clearElectronStore = () => {
     try {

+ 72 - 0
src/api/export.ts

@@ -0,0 +1,72 @@
+import { readTextFile, writeStream } from './../services/fs';
+import { ipcRenderer } from 'electron';
+import { logError } from '../utils/logging';
+import * as fs from 'promise-fs';
+
+export const exists = (path: string) => {
+    return fs.existsSync(path);
+};
+
+export const checkExistsAndCreateCollectionDir = async (dirPath: string) => {
+    if (!fs.existsSync(dirPath)) {
+        await fs.mkdir(dirPath);
+    }
+};
+
+export const checkExistsAndRename = async (
+    oldDirPath: string,
+    newDirPath: string
+) => {
+    if (fs.existsSync(oldDirPath)) {
+        await fs.rename(oldDirPath, newDirPath);
+    }
+};
+
+export const saveStreamToDisk = (
+    filePath: string,
+    fileStream: ReadableStream<any>
+) => {
+    writeStream(filePath, fileStream);
+};
+
+export const saveFileToDisk = async (path: string, fileData: any) => {
+    await fs.writeFile(path, fileData);
+};
+
+export const getExportRecord = async (filePath: string) => {
+    try {
+        if (!fs.existsSync(filePath)) {
+            return null;
+        }
+        const recordFile = await readTextFile(filePath);
+        return recordFile;
+    } catch (e) {
+        logError(e, 'error while selecting files');
+    }
+};
+
+export const setExportRecord = async (filePath: string, data: string) => {
+    await fs.writeFile(filePath, data);
+};
+
+export const registerResumeExportListener = (resumeExport: () => void) => {
+    ipcRenderer.removeAllListeners('resume-export');
+    ipcRenderer.on('resume-export', () => resumeExport());
+};
+
+export const registerStopExportListener = (abortExport: () => void) => {
+    ipcRenderer.removeAllListeners('stop-export');
+    ipcRenderer.on('stop-export', () => abortExport());
+};
+
+export const registerPauseExportListener = (pauseExport: () => void) => {
+    ipcRenderer.removeAllListeners('pause-export');
+    ipcRenderer.on('pause-export', () => pauseExport());
+};
+
+export const registerRetryFailedExportListener = (
+    retryFailedExport: () => void
+) => {
+    ipcRenderer.removeAllListeners('retry-export');
+    ipcRenderer.on('retry-export', () => retryFailedExport());
+};

+ 8 - 0
src/api/fs.ts

@@ -0,0 +1,8 @@
+import { getElectronFile, getDirFilePaths } from '../services/fs';
+
+export async function getDirFiles(dirPath: string) {
+    const files = await getDirFilePaths(dirPath);
+    const electronFiles = await Promise.all(files.map(getElectronFile));
+    return electronFiles;
+}
+export { isFolder } from '../services/fs';

+ 3 - 3
main/utils/safeStorage.ts → src/api/safeStorage.ts

@@ -1,6 +1,6 @@
 import { ipcRenderer } from 'electron';
-import { safeStorageStore } from '../services/store';
-import { logError } from './logging';
+import { safeStorageStore } from '../stores/safeStorage.store';
+import { logError } from '../utils/logging';
 
 export async function setEncryptionKey(encryptionKey: string) {
     try {
@@ -16,7 +16,7 @@ export async function setEncryptionKey(encryptionKey: string) {
     }
 }
 
-export async function getEncryptionKey() {
+export async function getEncryptionKey(): Promise<string> {
     try {
         const b64EncryptedKey = safeStorageStore.get('encryptionKey');
         if (b64EncryptedKey) {

+ 11 - 0
src/api/system.ts

@@ -0,0 +1,11 @@
+import { ipcRenderer } from 'electron';
+
+export const sendNotification = (content: string) => {
+    ipcRenderer.send('send-notification', content);
+};
+export const showOnTray = (content: string) => {
+    ipcRenderer.send('update-tray', content);
+};
+export const reloadWindow = () => {
+    ipcRenderer.send('reload-window');
+};

+ 84 - 0
src/api/upload.ts

@@ -0,0 +1,84 @@
+import { getElectronFile } from './../services/fs';
+import { uploadStatusStore } from '../stores/upload.store';
+import { ElectronFile, FILE_PATH_TYPE } from '../types';
+import { logError } from '../utils/logging';
+import { ipcRenderer } from 'electron';
+import {
+    getElectronFilesFromGoogleZip,
+    getSavedFilePaths,
+} from '../services/upload';
+
+export const getPendingUploads = async () => {
+    const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
+    const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
+    const collectionName = uploadStatusStore.get('collectionName');
+
+    let files: ElectronFile[] = [];
+    let type: FILE_PATH_TYPE;
+    if (zipPaths.length) {
+        type = FILE_PATH_TYPE.ZIPS;
+        for (const zipPath of zipPaths) {
+            files.push(...(await getElectronFilesFromGoogleZip(zipPath)));
+        }
+        const pendingFilePaths = new Set(filePaths);
+        files = files.filter((file) => pendingFilePaths.has(file.path));
+    } else if (filePaths.length) {
+        type = FILE_PATH_TYPE.FILES;
+        files = await Promise.all(filePaths.map(getElectronFile));
+    }
+    return {
+        files,
+        collectionName,
+        type,
+    };
+};
+
+export const showUploadDirsDialog = async () => {
+    try {
+        const filePaths: string[] = await ipcRenderer.invoke(
+            'show-upload-dirs-dialog'
+        );
+        const files = await Promise.all(filePaths.map(getElectronFile));
+        return files;
+    } catch (e) {
+        logError(e, 'error while selecting folders');
+    }
+};
+
+export const showUploadFilesDialog = async () => {
+    try {
+        const filePaths: string[] = await ipcRenderer.invoke(
+            'show-upload-files-dialog'
+        );
+        const files = await Promise.all(filePaths.map(getElectronFile));
+        return files;
+    } catch (e) {
+        logError(e, 'error while selecting files');
+    }
+};
+
+export const showUploadZipDialog = async () => {
+    try {
+        const filePaths: string[] = await ipcRenderer.invoke(
+            'show-upload-zip-dialog'
+        );
+        const files: ElectronFile[] = [];
+
+        for (const filePath of filePaths) {
+            files.push(...(await getElectronFilesFromGoogleZip(filePath)));
+        }
+
+        return {
+            zipPaths: filePaths,
+            files,
+        };
+    } catch (e) {
+        logError(e, 'error while selecting zips');
+    }
+};
+
+export {
+    setToUploadFiles,
+    getElectronFilesFromGoogleZip,
+    setToUploadCollection,
+} from '../services/upload';

+ 117 - 0
src/api/watch.ts

@@ -0,0 +1,117 @@
+import { isMappingPresent } from '../utils/watch';
+import path from 'path';
+import { ipcRenderer } from 'electron';
+import { ElectronFile, WatchMapping } from '../types';
+import { getElectronFile } from '../services/fs';
+import { getWatchMappings, setWatchMappings } from '../services/watch';
+
+export async function addWatchMapping(
+    rootFolderName: string,
+    folderPath: string,
+    uploadStrategy: number
+) {
+    folderPath = path.normalize(folderPath);
+    const watchMappings = getWatchMappings();
+    if (isMappingPresent(watchMappings, folderPath)) {
+        throw new Error(`Watch mapping already exists`);
+    }
+
+    await ipcRenderer.invoke('add-watcher', {
+        dir: folderPath,
+    });
+
+    watchMappings.push({
+        rootFolderName,
+        uploadStrategy,
+        folderPath,
+        syncedFiles: [],
+        ignoredFiles: [],
+    });
+
+    setWatchMappings(watchMappings);
+}
+
+export async function removeWatchMapping(folderPath: string) {
+    let watchMappings = getWatchMappings();
+    const watchMapping = watchMappings.find(
+        (mapping) => mapping.folderPath === folderPath
+    );
+
+    if (!watchMapping) {
+        throw new Error(`Watch mapping does not exist`);
+    }
+
+    await ipcRenderer.invoke('remove-watcher', {
+        dir: watchMapping.folderPath,
+    });
+
+    watchMappings = watchMappings.filter(
+        (mapping) => mapping.folderPath !== watchMapping.folderPath
+    );
+
+    setWatchMappings(watchMappings);
+}
+
+export function updateWatchMappingSyncedFiles(
+    folderPath: string,
+    files: WatchMapping['syncedFiles']
+): void {
+    const watchMappings = getWatchMappings();
+    const watchMapping = watchMappings.find(
+        (mapping) => mapping.folderPath === folderPath
+    );
+
+    if (!watchMapping) {
+        throw Error(`Watch mapping not found`);
+    }
+
+    watchMapping.syncedFiles = files;
+    setWatchMappings(watchMappings);
+}
+
+export function updateWatchMappingIgnoredFiles(
+    folderPath: string,
+    files: WatchMapping['ignoredFiles']
+): void {
+    const watchMappings = getWatchMappings();
+    const watchMapping = watchMappings.find(
+        (mapping) => mapping.folderPath === folderPath
+    );
+
+    if (!watchMapping) {
+        throw Error(`Watch mapping not found`);
+    }
+
+    watchMapping.ignoredFiles = files;
+    setWatchMappings(watchMappings);
+}
+
+export function registerWatcherFunctions(
+    addFile: (file: ElectronFile) => Promise<void>,
+    removeFile: (path: string) => Promise<void>,
+    removeFolder: (folderPath: string) => Promise<void>
+) {
+    ipcRenderer.removeAllListeners('watch-add');
+    ipcRenderer.removeAllListeners('watch-change');
+    ipcRenderer.removeAllListeners('watch-unlink');
+    ipcRenderer.on('watch-add', async (_, filePath: string) => {
+        filePath = path.normalize(
+            filePath.split(path.sep).join(path.posix.sep)
+        );
+        await addFile(await getElectronFile(filePath));
+    });
+    ipcRenderer.on('watch-unlink', async (_, filePath: string) => {
+        filePath = path.normalize(
+            filePath.split(path.sep).join(path.posix.sep)
+        );
+        await removeFile(filePath);
+    });
+    ipcRenderer.on('watch-unlink-dir', async (_, folderPath: string) => {
+        folderPath = path.normalize(
+            folderPath.split(path.sep).join(path.posix.sep)
+        );
+        await removeFolder(folderPath);
+    });
+}
+
+export { getWatchMappings } from '../services/watch';

+ 0 - 0
main/config/index.ts → src/config/index.ts


+ 78 - 0
src/main.ts

@@ -0,0 +1,78 @@
+import { app, BrowserWindow } from 'electron';
+import { createWindow } from './utils/createWindow';
+import setupIpcComs from './utils/ipcComms';
+import { initWatcher } from './services/chokidar';
+import { addAllowOriginHeader } from './utils/cors';
+import {
+    setupTrayItem,
+    handleUpdates,
+    handleDownloads,
+    setupMacWindowOnDockIconClick,
+    setupMainMenu,
+    setupMainHotReload,
+    setupNextElectronServe,
+    enableSharedArrayBufferSupport,
+    handleDockIconHideOnAutoLaunch,
+} from './utils/main';
+import { initSentry } from './services/sentry';
+
+let mainWindow: BrowserWindow;
+
+let appIsQuitting = false;
+
+let updateIsAvailable = false;
+
+export const isAppQuitting = (): boolean => {
+    return appIsQuitting;
+};
+
+export const setIsAppQuitting = (value: boolean): void => {
+    appIsQuitting = value;
+};
+
+export const isUpdateAvailable = (): boolean => {
+    return updateIsAvailable;
+};
+export const setIsUpdateAvailable = (value: boolean): void => {
+    updateIsAvailable = value;
+};
+
+setupMainHotReload();
+
+setupNextElectronServe();
+
+const gotTheLock = app.requestSingleInstanceLock();
+if (!gotTheLock) {
+    app.quit();
+} else {
+    handleDockIconHideOnAutoLaunch();
+    enableSharedArrayBufferSupport();
+    app.on('second-instance', () => {
+        // Someone tried to run a second instance, we should focus our window.
+        if (mainWindow) {
+            mainWindow.show();
+            if (mainWindow.isMinimized()) {
+                mainWindow.restore();
+            }
+            mainWindow.focus();
+        }
+    });
+
+    // This method will be called when Electron has finished
+    // initialization and is ready to create browser windows.
+    // Some APIs can only be used after this event occurs.
+    app.on('ready', async () => {
+        mainWindow = await createWindow();
+        const tray = setupTrayItem(mainWindow);
+        const watcher = initWatcher(mainWindow);
+        setupMacWindowOnDockIconClick();
+        initSentry();
+        setupMainMenu();
+        setupIpcComs(tray, mainWindow, watcher);
+        handleUpdates(mainWindow, tray);
+        handleDownloads(mainWindow);
+        addAllowOriginHeader(mainWindow);
+    });
+
+    app.on('before-quit', () => setIsAppQuitting(true));
+}

+ 79 - 0
src/preload.ts

@@ -0,0 +1,79 @@
+import { reloadWindow, sendNotification, showOnTray } from './api/system';
+import {
+    showUploadDirsDialog,
+    showUploadFilesDialog,
+    showUploadZipDialog,
+    getPendingUploads,
+    setToUploadFiles,
+    getElectronFilesFromGoogleZip,
+    setToUploadCollection,
+} from './api/upload';
+import {
+    registerWatcherFunctions,
+    addWatchMapping,
+    removeWatchMapping,
+    updateWatchMappingSyncedFiles,
+    updateWatchMappingIgnoredFiles,
+    getWatchMappings,
+} from './api/watch';
+import { getEncryptionKey, setEncryptionKey } from './api/safeStorage';
+import { clearElectronStore } from './api/electronStore';
+import { openDiskCache, deleteDiskCache } from './api/cache';
+import {
+    checkExistsAndCreateCollectionDir,
+    checkExistsAndRename,
+    saveStreamToDisk,
+    saveFileToDisk,
+    registerResumeExportListener,
+    registerStopExportListener,
+    registerPauseExportListener,
+    registerRetryFailedExportListener,
+    getExportRecord,
+    setExportRecord,
+    exists,
+} from './api/export';
+import { selectRootDirectory } from './api/common';
+import { fixHotReloadNext12 } from './utils/preload';
+import { isFolder, getDirFiles } from './api/fs';
+
+fixHotReloadNext12();
+
+const windowObject: any = window;
+
+windowObject['ElectronAPIs'] = {
+    exists,
+    checkExistsAndCreateCollectionDir,
+    checkExistsAndRename,
+    saveStreamToDisk,
+    saveFileToDisk,
+    selectRootDirectory,
+    clearElectronStore,
+    sendNotification,
+    showOnTray,
+    reloadWindow,
+    registerResumeExportListener,
+    registerStopExportListener,
+    registerPauseExportListener,
+    registerRetryFailedExportListener,
+    getExportRecord,
+    setExportRecord,
+    showUploadFilesDialog,
+    showUploadDirsDialog,
+    getPendingUploads,
+    setToUploadFiles,
+    showUploadZipDialog,
+    getElectronFilesFromGoogleZip,
+    setToUploadCollection,
+    getEncryptionKey,
+    setEncryptionKey,
+    openDiskCache,
+    deleteDiskCache,
+    getDirFiles,
+    getWatchMappings,
+    addWatchMapping,
+    removeWatchMapping,
+    registerWatcherFunctions,
+    isFolder,
+    updateWatchMappingSyncedFiles,
+    updateWatchMappingIgnoredFiles,
+};

+ 2 - 2
main/utils/appUpdater.ts → src/services/appUpdater.ts

@@ -1,8 +1,8 @@
 import { BrowserWindow, dialog, Tray } from 'electron';
 import { autoUpdater } from 'electron-updater';
 import log from 'electron-log';
-import { setIsAppQuitting, setIsUpdateAvailable } from '..';
-import { buildContextMenu } from './menuUtil';
+import { setIsAppQuitting, setIsUpdateAvailable } from '../main';
+import { buildContextMenu } from '../utils/menu';
 
 class AppUpdater {
     constructor() {

+ 36 - 0
src/services/autoLauncher.ts

@@ -0,0 +1,36 @@
+import { AutoLauncherClient } from '../types/autoLauncher';
+import { isPlatformWindows, isPlatformMac } from '../utils/main';
+import linuxAutoLauncher from './autoLauncherClients/linuxAutoLauncher';
+import macAndWindowsAutoLauncher from './autoLauncherClients/macAndWindowsAutoLauncher';
+
+class AutoLauncher {
+    private client: AutoLauncherClient;
+    init() {
+        if (isPlatformMac() || isPlatformWindows()) {
+            this.client = macAndWindowsAutoLauncher;
+        } else {
+            this.client = linuxAutoLauncher;
+        }
+    }
+    async isEnabled() {
+        if (!this.client) {
+            this.init();
+        }
+        return await this.client.isEnabled();
+    }
+    async toggleAutoLaunch() {
+        if (!this.client) {
+            this.init();
+        }
+        await this.client.toggleAutoLaunch();
+    }
+
+    wasAutoLaunched() {
+        if (!this.client) {
+            this.init();
+        }
+        return this.client.wasAutoLaunched();
+    }
+}
+
+export default new AutoLauncher();

+ 37 - 0
src/services/autoLauncherClients/linuxAutoLauncher.ts

@@ -0,0 +1,37 @@
+import AutoLaunch from 'auto-launch';
+import { AutoLauncherClient } from '../../types/autoLauncher';
+
+class LinuxAutoLauncher implements AutoLauncherClient {
+    private instance: AutoLaunch;
+    constructor() {
+        const autoLauncher = new AutoLaunch({
+            name: 'ente',
+            isHidden: true,
+        });
+        this.instance = autoLauncher;
+    }
+    async isEnabled() {
+        return await this.instance.isEnabled();
+    }
+    async toggleAutoLaunch() {
+        if (await this.isEnabled()) {
+            await this.disableAutoLaunch();
+        } else {
+            await this.enableAutoLaunch();
+        }
+    }
+
+    async wasAutoLaunched() {
+        // can't determine if it was auto launched
+        return false;
+    }
+
+    private async disableAutoLaunch() {
+        await this.instance.disable();
+    }
+    private async enableAutoLaunch() {
+        await this.instance.enable();
+    }
+}
+
+export default new LinuxAutoLauncher();

+ 28 - 0
src/services/autoLauncherClients/macAndWindowsAutoLauncher.ts

@@ -0,0 +1,28 @@
+import { app } from 'electron';
+import { AutoLauncherClient } from '../../types/autoLauncher';
+
+class MacAndWindowsAutoLauncher implements AutoLauncherClient {
+    async isEnabled() {
+        return app.getLoginItemSettings().openAtLogin;
+    }
+    async toggleAutoLaunch() {
+        if (await this.isEnabled()) {
+            this.disableAutoLogin();
+        } else {
+            this.enableAutoLogin();
+        }
+    }
+
+    async wasAutoLaunched() {
+        return app.getLoginItemSettings().wasOpenedAtLogin;
+    }
+
+    private disableAutoLogin() {
+        app.setLoginItemSettings({ openAsHidden: true, openAtLogin: false });
+    }
+    private enableAutoLogin() {
+        app.setLoginItemSettings({ openAsHidden: true, openAtLogin: true });
+    }
+}
+
+export default new MacAndWindowsAutoLauncher();

+ 33 - 0
src/services/chokidar.ts

@@ -0,0 +1,33 @@
+import chokidar from 'chokidar';
+import { BrowserWindow } from 'electron';
+import { logError } from '../utils/logging';
+import { getWatchMappings } from '../api/watch';
+
+export function initWatcher(mainWindow: BrowserWindow) {
+    const mappings = getWatchMappings();
+    const folderPaths = mappings.map((mapping) => {
+        return mapping.folderPath;
+    });
+
+    const watcher = chokidar.watch(folderPaths, {
+        awaitWriteFinish: true,
+    });
+    watcher
+        .on('add', (path) => {
+            mainWindow.webContents.send('watch-add', path);
+        })
+        .on('change', (path) => {
+            mainWindow.webContents.send('watch-change', path);
+        })
+        .on('unlink', (path) => {
+            mainWindow.webContents.send('watch-unlink', path);
+        })
+        .on('unlinkDir', (path) => {
+            mainWindow.webContents.send('watch-unlink-dir', path);
+        })
+        .on('error', (error) => {
+            logError(error, 'error while watching files');
+        });
+
+    return watcher;
+}

+ 3 - 42
main/utils/cache.ts → src/services/diskCache.ts

@@ -1,49 +1,11 @@
-import { ipcRenderer } from 'electron/renderer';
 import path from 'path';
-import {
-    readFile,
-    writeFile,
-    existsSync,
-    mkdir,
-    rmSync,
-    unlink,
-} from 'promise-fs';
+import { readFile, writeFile, existsSync, unlink } from 'promise-fs';
+import DiskLRUService from '../services/diskLRU';
 import crypto from 'crypto';
-import DiskLRUService from './diskLRU';
 
-const CACHE_DIR = 'ente';
 const MAX_CACHE_SIZE = 1000 * 1000 * 1000; // 1GB
 
-const getCacheDir = async () => {
-    const systemCacheDir = await ipcRenderer.invoke('get-path', 'cache');
-    return path.join(systemCacheDir, CACHE_DIR);
-};
-
-const getCacheBucketDir = async (cacheName: string) => {
-    const cacheDir = await getCacheDir();
-    const cacheBucketDir = path.join(cacheDir, cacheName);
-    return cacheBucketDir;
-};
-
-export async function openDiskCache(cacheName: string) {
-    const cacheBucketDir = await getCacheBucketDir(cacheName);
-    if (!existsSync(cacheBucketDir)) {
-        await mkdir(cacheBucketDir, { recursive: true });
-    }
-    return new DiskCache(cacheBucketDir);
-}
-
-export async function deleteDiskCache(cacheName: string) {
-    const cacheBucketDir = await getCacheBucketDir(cacheName);
-    if (existsSync(cacheBucketDir)) {
-        rmSync(cacheBucketDir, { recursive: true, force: true });
-        return true;
-    } else {
-        return false;
-    }
-}
-
-class DiskCache {
+export class DiskCache {
     constructor(private cacheBucketDir: string) {}
 
     async put(cacheKey: string, response: Response): Promise<void> {
@@ -77,7 +39,6 @@ class DiskCache {
         }
     }
 }
-
 function getAssetCachePath(cacheDir: string, cacheKey: string) {
     // hashing the key to prevent illegal filenames
     const cacheKeyHash = crypto

+ 1 - 1
main/utils/diskLRU.ts → src/services/diskLRU.ts

@@ -2,7 +2,7 @@ import path from 'path';
 import { readdir, stat, unlink } from 'promise-fs';
 import getFolderSize from 'get-folder-size';
 import { utimes, close, open } from 'promise-fs';
-import { logError } from './logging';
+import { logError } from '../utils/logging';
 
 export interface LeastRecentlyUsedResult {
     atime: Date;

+ 77 - 128
main/utils/upload.ts → src/services/fs.ts

@@ -1,13 +1,12 @@
+import { FILE_STREAM_CHUNK_SIZE } from '../config';
 import path from 'path';
-import StreamZip from 'node-stream-zip';
 import * as fs from 'promise-fs';
-import { FILE_STREAM_CHUNK_SIZE } from '../config';
-import { uploadStatusStore } from '../services/store';
-import { ElectronFile, FILE_PATH_KEYS, FILE_PATH_TYPE } from '../types';
-import { logError } from './logging';
+import { ElectronFile } from '../types';
+import StreamZip from 'node-stream-zip';
+import { Readable } from 'stream';
 
 // https://stackoverflow.com/a/63111390
-export const getFilesFromDir = async (dirPath: string) => {
+export const getDirFilePaths = async (dirPath: string) => {
     if (!(await fs.stat(dirPath)).isDirectory()) {
         return [dirPath];
     }
@@ -17,20 +16,19 @@ export const getFilesFromDir = async (dirPath: string) => {
 
     for (const filePath of filePaths) {
         const absolute = path.join(dirPath, filePath);
-        files = files.concat(await getFilesFromDir(absolute));
+        files = files.concat(await getDirFilePaths(absolute));
     }
 
     return files;
 };
 
-const getFileStream = async (filePath: string) => {
+export const getFileStream = async (filePath: string) => {
     const file = await fs.open(filePath, 'r');
     let offset = 0;
     const readableStream = new ReadableStream<Uint8Array>({
         async pull(controller) {
             try {
                 const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
-
                 // original types were not working correctly
                 const bytesRead = (await fs.read(
                     file,
@@ -44,10 +42,9 @@ const getFileStream = async (filePath: string) => {
                     controller.close();
                     await fs.close(file);
                 } else {
-                    controller.enqueue(buff);
+                    controller.enqueue(buff.slice(0, bytesRead));
                 }
             } catch (e) {
-                logError(e, 'stream pull failed');
                 await fs.close(file);
             }
         },
@@ -55,32 +52,66 @@ const getFileStream = async (filePath: string) => {
     return readableStream;
 };
 
-const getZipFileStream = async (
+export async function getElectronFile(filePath: string): Promise<ElectronFile> {
+    const fileStats = await fs.stat(filePath);
+    return {
+        path: filePath.split(path.sep).join(path.posix.sep),
+        name: path.basename(filePath),
+        size: fileStats.size,
+        lastModified: fileStats.mtime.valueOf(),
+        stream: async () => {
+            return await getFileStream(filePath);
+        },
+        blob: async () => {
+            const blob = await fs.readFile(filePath);
+            return new Blob([new Uint8Array(blob)]);
+        },
+        arrayBuffer: async () => {
+            const blob = await fs.readFile(filePath);
+            return new Uint8Array(blob);
+        },
+    };
+}
+
+export const getValidPaths = (paths: string[]) => {
+    if (!paths) {
+        return [] as string[];
+    }
+    return paths.filter(async (path) => {
+        try {
+            await fs.stat(path).then((stat) => stat.isFile());
+        } catch (e) {
+            return false;
+        }
+    });
+};
+
+export const getZipFileStream = async (
     zip: StreamZip.StreamZipAsync,
     filePath: string
 ) => {
     const stream = await zip.stream(filePath);
-    const done = { current: false };
-
+    const done = {
+        current: false,
+    };
     let resolveObj: (value?: any) => void = null;
     let rejectObj: (reason?: any) => void = null;
-
     stream.on('readable', () => {
         if (resolveObj) {
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
+
             if (chunk) {
                 resolveObj(new Uint8Array(chunk));
                 resolveObj = null;
             }
         }
     });
-
     stream.on('end', () => {
         done.current = true;
     });
-
     stream.on('error', (e) => {
         done.current = true;
+
         if (rejectObj) {
             rejectObj(e);
             rejectObj = null;
@@ -90,6 +121,7 @@ const getZipFileStream = async (
     const readStreamData = () => {
         return new Promise<Uint8Array>((resolve, reject) => {
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
+
             if (chunk || done.current) {
                 resolve(chunk);
             } else {
@@ -103,136 +135,53 @@ const getZipFileStream = async (
         async pull(controller) {
             try {
                 const data = await readStreamData();
+
                 if (data) {
                     controller.enqueue(data);
                 } else {
                     controller.close();
                 }
             } catch (e) {
-                logError(e, 'stream reading failed');
                 controller.close();
             }
         },
     });
-
     return readableStream;
 };
 
-async function getZipEntryAsElectronFile(
-    zip: StreamZip.StreamZipAsync,
-    entry: StreamZip.ZipEntry
-): Promise<ElectronFile> {
-    return {
-        path: entry.name,
-        name: path.basename(entry.name),
-        size: entry.size,
-        lastModified: entry.time,
-        stream: async () => {
-            return await getZipFileStream(zip, entry.name);
-        },
-        blob: async () => {
-            const buffer = await zip.entryData(entry.name);
-            return new Blob([new Uint8Array(buffer)]);
-        },
-        arrayBuffer: async () => {
-            const buffer = await zip.entryData(entry.name);
-            return new Uint8Array(buffer);
-        },
-    };
-}
-
-export async function getElectronFile(filePath: string): Promise<ElectronFile> {
-    const fileStats = await fs.stat(filePath);
-    return {
-        path: filePath.split(path.sep).join(path.posix.sep),
-        name: path.basename(filePath),
-        size: fileStats.size,
-        lastModified: fileStats.mtime.valueOf(),
-        stream: async () => {
-            return await getFileStream(filePath);
-        },
-        blob: async () => {
-            const blob = await fs.readFile(filePath);
-            return new Blob([new Uint8Array(blob)]);
-        },
-        arrayBuffer: async () => {
-            const blob = await fs.readFile(filePath);
-            return new Uint8Array(blob);
-        },
-    };
+export async function isFolder(dirPath: string) {
+    return await fs
+        .stat(dirPath)
+        .then((stats) => {
+            return stats.isDirectory();
+        })
+        .catch(() => false);
 }
 
-export const setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => {
-    const key = FILE_PATH_KEYS[type];
-    if (filePaths) {
-        uploadStatusStore.set(key, filePaths);
-    } else {
-        uploadStatusStore.delete(key);
-    }
-};
+export const convertBrowserStreamToNode = (fileStream: any) => {
+    const reader = fileStream.getReader();
+    const rs = new Readable();
 
-export const setToUploadCollection = (collectionName: string) => {
-    if (collectionName) {
-        uploadStatusStore.set('collectionName', collectionName);
-    } else {
-        uploadStatusStore.delete('collectionName');
-    }
-};
-
-export const getSavedPaths = (type: FILE_PATH_TYPE) => {
-    const paths =
-        (uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[]) ?? [];
-
-    const validPaths = paths.filter(async (path) => {
-        try {
-            await fs.stat(path).then((stat) => stat.isFile());
-        } catch (e) {
-            return false;
-        }
-    });
-    setToUploadFiles(type, validPaths);
-    return validPaths;
-};
+    rs._read = async () => {
+        const result = await reader.read();
 
-export const getPendingUploads = async () => {
-    const filePaths = getSavedPaths(FILE_PATH_TYPE.FILES);
-    const zipPaths = getSavedPaths(FILE_PATH_TYPE.ZIPS);
-    const collectionName = uploadStatusStore.get('collectionName');
-
-    let files: ElectronFile[] = [];
-    let type: FILE_PATH_TYPE;
-    if (zipPaths.length) {
-        type = FILE_PATH_TYPE.ZIPS;
-        for (const zipPath of zipPaths) {
-            files.push(...(await getElectronFilesFromGoogleZip(zipPath)));
+        if (!result.done) {
+            rs.push(Buffer.from(result.value));
+        } else {
+            rs.push(null);
+            return;
         }
-        const pendingFilePaths = new Set(filePaths);
-        files = files.filter((file) => pendingFilePaths.has(file.path));
-    } else if (filePaths.length) {
-        type = FILE_PATH_TYPE.FILES;
-        files = await Promise.all(filePaths.map(getElectronFile));
-    }
-    return {
-        files,
-        collectionName,
-        type,
     };
-};
 
-export const getElectronFilesFromGoogleZip = async (filePath: string) => {
-    const zip = new StreamZip.async({
-        file: filePath,
-    });
-
-    const entries = await zip.entries();
-    const files: ElectronFile[] = [];
+    return rs;
+};
 
-    for (const entry of Object.values(entries)) {
-        const basename = path.basename(entry.name);
-        if (entry.isFile && basename.length > 0 && basename[0] !== '.') {
-            files.push(await getZipEntryAsElectronFile(zip, entry));
-        }
-    }
+export function writeStream(filePath: string, fileStream: any) {
+    const writeable = fs.createWriteStream(filePath);
+    const readable = convertBrowserStreamToNode(fileStream);
+    readable.pipe(writeable);
+}
 
-    return files;
-};
+export async function readTextFile(filePath: string) {
+    return await fs.readFile(filePath, 'utf-8');
+}

+ 12 - 14
main/utils/sentry.ts → src/services/sentry.ts

@@ -1,14 +1,14 @@
 import * as Sentry from '@sentry/electron/dist/main';
+import { keysStore } from '../stores/keys.store';
 
-import { keysStore } from '../services/store';
-import { isDev } from './common';
+import { isDev } from '../utils/common';
 
 const SENTRY_DSN = 'https://e9268b784d1042a7a116f53c58ad2165@sentry.ente.io/5';
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const version = require('../../package.json').version;
 
-function initSentry(): void {
+export function initSentry(): void {
     Sentry.init({
         dsn: SENTRY_DSN,
         release: version,
@@ -46,6 +46,15 @@ function errorWithContext(originalError: Error, context: string) {
     return errorWithContext;
 }
 
+function getUserAnonymizedID() {
+    let anonymizeUserID = keysStore.get('AnonymizeUserID')?.id;
+    if (!anonymizeUserID) {
+        anonymizeUserID = makeID(6);
+        keysStore.set('AnonymizeUserID', { id: anonymizeUserID });
+    }
+    return anonymizeUserID;
+}
+
 function makeID(length: number) {
     let result = '';
     const characters =
@@ -58,14 +67,3 @@ function makeID(length: number) {
     }
     return result;
 }
-
-function getUserAnonymizedID() {
-    let anonymizeUserID = keysStore.get('AnonymizeUserID')?.id;
-    if (!anonymizeUserID) {
-        anonymizeUserID = makeID(6);
-        keysStore.set('AnonymizeUserID', { id: anonymizeUserID });
-    }
-    return anonymizeUserID;
-}
-
-export default initSentry;

+ 73 - 0
src/services/upload.ts

@@ -0,0 +1,73 @@
+import StreamZip from 'node-stream-zip';
+import path from 'path';
+import { uploadStatusStore } from '../stores/upload.store';
+import { FILE_PATH_TYPE, FILE_PATH_KEYS, ElectronFile } from '../types';
+import { getValidPaths, getZipFileStream } from './fs';
+
+export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
+    const paths =
+        getValidPaths(
+            uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[]
+        ) ?? [];
+
+    setToUploadFiles(type, paths);
+    return paths;
+};
+
+export async function getZipEntryAsElectronFile(
+    zip: StreamZip.StreamZipAsync,
+    entry: StreamZip.ZipEntry
+): Promise<ElectronFile> {
+    return {
+        path: entry.name,
+        name: path.basename(entry.name),
+        size: entry.size,
+        lastModified: entry.time,
+        stream: async () => {
+            return await getZipFileStream(zip, entry.name);
+        },
+        blob: async () => {
+            const buffer = await zip.entryData(entry.name);
+            return new Blob([new Uint8Array(buffer)]);
+        },
+        arrayBuffer: async () => {
+            const buffer = await zip.entryData(entry.name);
+            return new Uint8Array(buffer);
+        },
+    };
+}
+
+export const setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => {
+    const key = FILE_PATH_KEYS[type];
+    if (filePaths) {
+        uploadStatusStore.set(key, filePaths);
+    } else {
+        uploadStatusStore.delete(key);
+    }
+};
+
+export const setToUploadCollection = (collectionName: string) => {
+    if (collectionName) {
+        uploadStatusStore.set('collectionName', collectionName);
+    } else {
+        uploadStatusStore.delete('collectionName');
+    }
+};
+
+export const getElectronFilesFromGoogleZip = async (filePath: string) => {
+    const zip = new StreamZip.async({
+        file: filePath,
+    });
+
+    const entries = await zip.entries();
+    const files: ElectronFile[] = [];
+
+    for (const entry of Object.values(entries)) {
+        const basename = path.basename(entry.name);
+        if (entry.isFile && basename.length > 0 && basename[0] !== '.') {
+            files.push(await getZipEntryAsElectronFile(zip, entry));
+        }
+    }
+
+    return files;
+};

+ 10 - 0
src/services/userPreference.ts

@@ -0,0 +1,10 @@
+import { userPreferencesStore } from '../stores/userPreferences.store';
+
+export function getHideDockIconPreference() {
+    const shouldHideDockIcon = userPreferencesStore.get('hideDockIcon');
+    return shouldHideDockIcon;
+}
+
+export function setHideDockIconPreference(shouldHideDockIcon: boolean) {
+    userPreferencesStore.set('hideDockIcon', shouldHideDockIcon);
+}

+ 11 - 0
src/services/watch.ts

@@ -0,0 +1,11 @@
+import { WatchStoreType } from '../types';
+import { watchStore } from '../stores/watch.store';
+
+export function getWatchMappings() {
+    const mappings = watchStore.get('mappings') ?? [];
+    return mappings;
+}
+
+export function setWatchMappings(watchMappings: WatchStoreType['mappings']) {
+    watchStore.set('mappings', watchMappings);
+}

+ 18 - 0
src/stores/keys.store.ts

@@ -0,0 +1,18 @@
+import Store, { Schema } from 'electron-store';
+import { KeysStoreType } from '../types';
+
+const keysStoreSchema: Schema<KeysStoreType> = {
+    AnonymizeUserID: {
+        type: 'object',
+        properties: {
+            id: {
+                type: 'string',
+            },
+        },
+    },
+};
+
+export const keysStore = new Store({
+    name: 'keys',
+    schema: keysStoreSchema,
+});

+ 13 - 0
src/stores/safeStorage.store.ts

@@ -0,0 +1,13 @@
+import Store, { Schema } from 'electron-store';
+import { SafeStorageStoreType } from '../types';
+
+const safeStorageSchema: Schema<SafeStorageStoreType> = {
+    encryptionKey: {
+        type: 'string',
+    },
+};
+
+export const safeStorageStore = new Store({
+    name: 'safeStorage',
+    schema: safeStorageSchema,
+});

+ 25 - 0
src/stores/upload.store.ts

@@ -0,0 +1,25 @@
+import Store, { Schema } from 'electron-store';
+import { UploadStoreType } from '../types';
+
+const uploadStoreSchema: Schema<UploadStoreType> = {
+    filePaths: {
+        type: 'array',
+        items: {
+            type: 'string',
+        },
+    },
+    zipPaths: {
+        type: 'array',
+        items: {
+            type: 'string',
+        },
+    },
+    collectionName: {
+        type: 'string',
+    },
+};
+
+export const uploadStatusStore = new Store({
+    name: 'upload-status',
+    schema: uploadStoreSchema,
+});

+ 13 - 0
src/stores/userPreferences.store.ts

@@ -0,0 +1,13 @@
+import Store, { Schema } from 'electron-store';
+import { UserPreferencesType } from '../types';
+
+const userPreferencesSchema: Schema<UserPreferencesType> = {
+    hideDockIcon: {
+        type: 'boolean',
+    },
+};
+
+export const userPreferencesStore = new Store({
+    name: 'userPreferences',
+    schema: userPreferencesSchema,
+});

+ 47 - 0
src/stores/watch.store.ts

@@ -0,0 +1,47 @@
+import Store, { Schema } from 'electron-store';
+import { WatchStoreType } from '../types';
+
+const watchStoreSchema: Schema<WatchStoreType> = {
+    mappings: {
+        type: 'array',
+        items: {
+            type: 'object',
+            properties: {
+                rootFolderName: {
+                    type: 'string',
+                },
+                uploadStrategy: {
+                    type: 'number',
+                },
+                folderPath: {
+                    type: 'string',
+                },
+                syncedFiles: {
+                    type: 'array',
+                    items: {
+                        type: 'object',
+                        properties: {
+                            path: {
+                                type: 'string',
+                            },
+                            id: {
+                                type: 'number',
+                            },
+                        },
+                    },
+                },
+                ignoredFiles: {
+                    type: 'array',
+                    items: {
+                        type: 'string',
+                    },
+                },
+            },
+        },
+    },
+};
+
+export const watchStore = new Store({
+    name: 'watch-status',
+    schema: watchStoreSchema,
+});

+ 5 - 0
src/types/autoLauncher.ts

@@ -0,0 +1,5 @@
+export interface AutoLauncherClient {
+    isEnabled: () => Promise<boolean>;
+    toggleAutoLaunch: () => Promise<void>;
+    wasAutoLaunched: () => Promise<boolean>;
+}

+ 22 - 0
main/types/index.ts → src/types/index.ts

@@ -20,6 +20,24 @@ export interface KeysStoreType {
     };
 }
 
+interface WatchMappingSyncedFile {
+    path: string;
+    uploadedFileID: number;
+    collectionID: number;
+}
+
+export interface WatchMapping {
+    rootFolderName: string;
+    uploadStrategy: number;
+    folderPath: string;
+    syncedFiles: WatchMappingSyncedFile[];
+    ignoredFiles: string[];
+}
+
+export interface WatchStoreType {
+    mappings: WatchMapping[];
+}
+
 export enum FILE_PATH_TYPE {
     FILES = 'files',
     ZIPS = 'zips',
@@ -35,3 +53,7 @@ export const FILE_PATH_KEYS: {
 export interface SafeStorageStoreType {
     encryptionKey: string;
 }
+
+export interface UserPreferencesType {
+    hideDockIcon: boolean;
+}

+ 0 - 0
main/utils/common.ts → src/utils/common.ts


+ 0 - 0
main/utils/cors.ts → src/utils/cors.ts


+ 23 - 10
main/utils/createWindow.ts → src/utils/createWindow.ts

@@ -1,11 +1,13 @@
 import { app, BrowserWindow, nativeImage } from 'electron';
 import * as path from 'path';
 import { isDev } from './common';
-import { isAppQuitting } from '..';
-import { addAllowOriginHeader } from './cors';
+import { isAppQuitting } from '../main';
 import { PROD_HOST_URL } from '../config';
+import { isPlatformMac } from './main';
+import { getHideDockIconPreference } from '../services/userPreference';
+import autoLauncher from '../services/autoLauncher';
 
-export function createWindow(): BrowserWindow {
+export async function createWindow(): Promise<BrowserWindow> {
     const appImgPath = isDev
         ? 'build/window-icon.png'
         : path.join(process.resourcesPath, 'window-icon.png');
@@ -23,15 +25,16 @@ export function createWindow(): BrowserWindow {
         show: false, // don't show the main window on load
     });
     mainWindow.maximize();
+    const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
+
     const splash = new BrowserWindow({
         height: 600,
         width: 800,
         transparent: true,
+        show: !wasAutoLaunched,
     });
     splash.maximize();
 
-    addAllowOriginHeader(mainWindow);
-
     if (isDev) {
         splash.loadFile(`../build/splash.html`);
         mainWindow.loadURL(PROD_HOST_URL);
@@ -45,25 +48,35 @@ export function createWindow(): BrowserWindow {
     }
     mainWindow.webContents.on('did-fail-load', () => {
         splash.close();
-        mainWindow.show();
         isDev
             ? mainWindow.loadFile(`../../build/error.html`)
             : splash.loadURL(
                   `file://${path.join(process.resourcesPath, 'error.html')}`
               );
     });
-    mainWindow.once('ready-to-show', () => {
-        mainWindow.show();
+    mainWindow.once('ready-to-show', async () => {
         splash.destroy();
+        if (!wasAutoLaunched) {
+            mainWindow.show();
+        }
     });
     mainWindow.on('close', function (event) {
         if (!isAppQuitting()) {
             event.preventDefault();
             mainWindow.hide();
-            const isMac = process.platform === 'darwin';
-            isMac && app.dock.hide();
         }
         return false;
     });
+    mainWindow.on('hide', () => {
+        const shouldHideDockIcon = getHideDockIconPreference();
+        if (isPlatformMac() && shouldHideDockIcon) {
+            app.dock.hide();
+        }
+    });
+    mainWindow.on('show', () => {
+        if (isPlatformMac()) {
+            app.dock.show();
+        }
+    });
     return mainWindow;
 }

+ 21 - 12
main/utils/ipcComms.ts → src/utils/ipcComms.ts

@@ -8,23 +8,24 @@ import {
     app,
 } from 'electron';
 import { createWindow } from './createWindow';
-import { buildContextMenu } from './menuUtil';
-import { logErrorSentry } from './sentry';
-import { getFilesFromDir } from './upload';
+import { buildContextMenu } from './menu';
+import { logErrorSentry } from '../services/sentry';
+import chokidar from 'chokidar';
+import path from 'path';
+import { getDirFilePaths } from '../services/fs';
 
 export default function setupIpcComs(
     tray: Tray,
-    mainWindow: BrowserWindow
+    mainWindow: BrowserWindow,
+    watcher: chokidar.FSWatcher
 ): void {
     ipcMain.handle('select-dir', async () => {
         const result = await dialog.showOpenDialog({
             properties: ['openDirectory'],
         });
-        const dir =
-            result.filePaths &&
-            result.filePaths.length > 0 &&
-            result.filePaths[0];
-        return dir;
+        if (result.filePaths && result.filePaths.length > 0) {
+            return result.filePaths[0]?.split(path.sep)?.join(path.posix.sep);
+        }
     });
 
     ipcMain.on('update-tray', (_, args) => {
@@ -38,8 +39,8 @@ export default function setupIpcComs(
         };
         new Notification(notification).show();
     });
-    ipcMain.on('reload-window', () => {
-        const secondWindow = createWindow();
+    ipcMain.on('reload-window', async () => {
+        const secondWindow = await createWindow();
         mainWindow.destroy();
         mainWindow = secondWindow;
     });
@@ -66,12 +67,20 @@ export default function setupIpcComs(
 
         let files: string[] = [];
         for (const dirPath of dir.filePaths) {
-            files = files.concat(await getFilesFromDir(dirPath));
+            files = files.concat(await getDirFilePaths(dirPath));
         }
 
         return files;
     });
 
+    ipcMain.handle('add-watcher', async (_, args: { dir: string }) => {
+        watcher.add(args.dir);
+    });
+
+    ipcMain.handle('remove-watcher', async (_, args: { dir: string }) => {
+        watcher.unwatch(args.dir);
+    });
+
     ipcMain.handle('log-error', (_, err, msg, info?) => {
         logErrorSentry(err, msg, info);
     });

+ 0 - 0
main/utils/logging.ts → src/utils/logging.ts


+ 101 - 0
src/utils/main.ts

@@ -0,0 +1,101 @@
+import { PROD_HOST_URL, RENDERER_OUTPUT_DIR } from '../config';
+import { nativeImage, Tray, app, BrowserWindow, Menu } from 'electron';
+import electronReload from 'electron-reload';
+import serveNextAt from 'next-electron-server';
+import path from 'path';
+import { existsSync } from 'promise-fs';
+import appUpdater from '../services/appUpdater';
+import { isDev } from './common';
+import { buildContextMenu, buildMenuBar } from './menu';
+import autoLauncher from '../services/autoLauncher';
+import { getHideDockIconPreference } from '../services/userPreference';
+
+export function handleUpdates(mainWindow: BrowserWindow, tray: Tray) {
+    if (!isDev) {
+        appUpdater.checkForUpdate(tray, mainWindow);
+    }
+}
+
+export function setupTrayItem(mainWindow: BrowserWindow) {
+    const trayImgPath = isDev
+        ? 'build/taskbar-icon.png'
+        : path.join(process.resourcesPath, 'taskbar-icon.png');
+    const trayIcon = nativeImage.createFromPath(trayImgPath);
+    const tray = new Tray(trayIcon);
+    tray.setToolTip('ente');
+    tray.setContextMenu(buildContextMenu(mainWindow));
+    return tray;
+}
+
+export function handleDownloads(mainWindow: BrowserWindow) {
+    mainWindow.webContents.session.on('will-download', (_, item) => {
+        item.setSavePath(
+            getUniqueSavePath(item.getFilename(), app.getPath('downloads'))
+        );
+    });
+}
+
+export function getUniqueSavePath(filename: string, directory: string): string {
+    let uniqueFileSavePath = path.join(directory, filename);
+    const { name: filenameWithoutExtension, ext: extension } =
+        path.parse(filename);
+    let n = 0;
+    while (existsSync(uniqueFileSavePath)) {
+        n++;
+        // filter need to remove undefined extension from the array
+        // else [`${fileName}`, undefined].join(".") will lead to `${fileName}.` as joined string
+        const fileNameWithNumberedSuffix = [
+            `${filenameWithoutExtension}(${n})`,
+            extension,
+        ]
+            .filter((x) => x) // filters out undefined/null values
+            .join('.');
+        uniqueFileSavePath = path.join(directory, fileNameWithNumberedSuffix);
+    }
+    return uniqueFileSavePath;
+}
+
+export function setupMacWindowOnDockIconClick() {
+    app.on('activate', function () {
+        const windows = BrowserWindow.getAllWindows();
+        // we allow only one window
+        windows[0].show();
+    });
+}
+
+export async function setupMainMenu() {
+    Menu.setApplicationMenu(await buildMenuBar());
+}
+
+export function setupMainHotReload() {
+    if (isDev) {
+        electronReload(__dirname, {});
+    }
+}
+
+export function setupNextElectronServe() {
+    serveNextAt(PROD_HOST_URL, {
+        outputDir: RENDERER_OUTPUT_DIR,
+    });
+}
+
+export function isPlatformMac() {
+    return process.platform === 'darwin';
+}
+
+export function isPlatformWindows() {
+    return process.platform === 'win32';
+}
+
+export async function handleDockIconHideOnAutoLaunch() {
+    const shouldHideDockIcon = getHideDockIconPreference();
+    const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
+
+    if (isPlatformMac() && shouldHideDockIcon && wasAutoLaunched) {
+        app.dock.hide();
+    }
+}
+
+export function enableSharedArrayBufferSupport() {
+    app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer');
+}

+ 84 - 30
main/utils/menuUtil.ts → src/utils/menu.ts

@@ -5,10 +5,15 @@ import {
     BrowserWindow,
     MenuItemConstructorOptions,
 } from 'electron';
-import { isUpdateAvailable, setIsAppQuitting } from '..';
-import { showUpdateDialog } from './appUpdater';
-
-const isMac = process.platform === 'darwin';
+import {
+    getHideDockIconPreference,
+    setHideDockIconPreference,
+} from '../services/userPreference';
+import { isUpdateAvailable, setIsAppQuitting } from '../main';
+import autoLauncher from '../services/autoLauncher';
+import { isPlatformMac } from './main';
+import { showUpdateDialog } from '../services/appUpdater';
+import { isDev } from './common';
 
 export function buildContextMenu(
     mainWindow: BrowserWindow,
@@ -77,8 +82,6 @@ export function buildContextMenu(
             label: 'Open ente',
             click: function () {
                 mainWindow.show();
-                const isMac = process.platform === 'darwin';
-                isMac && app.dock.show();
             },
         },
         {
@@ -92,34 +95,65 @@ export function buildContextMenu(
     return contextMenu;
 }
 
-export function buildMenuBar(): Menu {
+export async function buildMenuBar(): Promise<Menu> {
+    let isAutoLaunchEnabled = await autoLauncher.isEnabled();
+    const isMac = isPlatformMac();
+    let shouldHideDockIcon = getHideDockIconPreference();
     const template: MenuItemConstructorOptions[] = [
         {
-            label: app.name,
+            label: 'ente',
             submenu: [
                 ...((isMac
                     ? [
                           {
-                              label: 'About',
+                              label: 'About ente',
                               role: 'about',
                           },
                       ]
                     : []) as MenuItemConstructorOptions[]),
+                { type: 'separator' },
                 {
-                    label: 'FAQ',
-                    click: () => shell.openExternal('https://ente.io/faq/'),
-                },
-                {
-                    label: 'Support',
-                    click: () => shell.openExternal('mailto:support@ente.io'),
+                    label: 'Preferences',
+                    submenu: [
+                        {
+                            label: 'Open ente on startup',
+                            type: 'checkbox',
+                            checked: isAutoLaunchEnabled,
+                            click: () => {
+                                autoLauncher.toggleAutoLaunch();
+                                isAutoLaunchEnabled = !isAutoLaunchEnabled;
+                            },
+                        },
+                        {
+                            label: 'Hide dock icon',
+                            type: 'checkbox',
+                            checked: shouldHideDockIcon,
+                            click: () => {
+                                setHideDockIconPreference(!shouldHideDockIcon);
+                                shouldHideDockIcon = !shouldHideDockIcon;
+                            },
+                        },
+                    ],
                 },
+
+                { type: 'separator' },
+                ...((isMac
+                    ? [
+                          {
+                              label: 'Hide ente',
+                              role: 'hide',
+                          },
+                          {
+                              label: 'Hide others',
+                              role: 'hideOthers',
+                          },
+                      ]
+                    : []) as MenuItemConstructorOptions[]),
+
+                { type: 'separator' },
                 {
-                    label: 'Quit',
-                    accelerator: 'CommandOrControl+Q',
-                    click() {
-                        setIsAppQuitting(true);
-                        app.quit();
-                    },
+                    label: 'Quit ente',
+                    role: 'quit',
                 },
             ],
         },
@@ -161,14 +195,17 @@ export function buildMenuBar(): Menu {
                       ]) as MenuItemConstructorOptions[]),
             ],
         },
-        // { role: 'viewMenu' }
         {
             label: 'View',
             submenu: [
-                { role: 'reload', label: 'Reload' },
-                { role: 'forceReload', label: 'Force reload' },
-                { role: 'toggleDevTools', label: 'Toggle dev tools' },
-                { type: 'separator' },
+                ...((isDev
+                    ? [
+                          { role: 'reload', label: 'Reload' },
+                          { role: 'forceReload', label: 'Force reload' },
+                          { role: 'toggleDevTools', label: 'Toggle dev tools' },
+                          { type: 'separator' },
+                      ]
+                    : []) as MenuItemConstructorOptions[]),
                 { role: 'resetZoom', label: 'Reset zoom' },
                 { role: 'zoomIn', label: 'Zoom in' },
                 { role: 'zoomOut', label: 'Zoom out' },
@@ -176,7 +213,6 @@ export function buildMenuBar(): Menu {
                 { role: 'togglefullscreen', label: 'Toggle fullscreen' },
             ],
         },
-        // { role: 'windowMenu' }
         {
             label: 'Window',
             submenu: [
@@ -184,15 +220,33 @@ export function buildMenuBar(): Menu {
                 ...((isMac
                     ? [
                           { type: 'separator' },
-                          { role: 'front', label: 'Front' },
+                          { role: 'front', label: 'Bring to front' },
                           { type: 'separator' },
-                          { role: 'window', label: 'Window' },
+                          { role: 'window', label: 'ente' },
                       ]
                     : [
-                          { role: 'close', label: 'Close' },
+                          { role: 'close', label: 'Close ente' },
                       ]) as MenuItemConstructorOptions[]),
             ],
         },
+        {
+            label: 'Help',
+            submenu: [
+                {
+                    label: 'FAQ',
+                    click: () => shell.openExternal('https://ente.io/faq/'),
+                },
+                { type: 'separator' },
+                {
+                    label: 'Support',
+                    click: () => shell.openExternal('mailto:support@ente.io'),
+                },
+                {
+                    label: 'Product updates',
+                    click: () => shell.openExternal('https://ente.io/blog/'),
+                },
+            ],
+        },
     ];
     return Menu.buildFromTemplate(template);
 }

+ 16 - 0
src/utils/preload.ts

@@ -0,0 +1,16 @@
+import { webFrame } from 'electron';
+
+export const fixHotReloadNext12 = () => {
+    webFrame.executeJavaScript(`Object.defineProperty(globalThis, 'WebSocket', {
+    value: new Proxy(WebSocket, {
+      construct: (Target, [url, protocols]) => {
+        if (url.endsWith('/_next/webpack-hmr')) {
+          // Fix the Next.js hmr client url
+          return new Target("ws://localhost:3000/_next/webpack-hmr", protocols)
+        } else {
+          return new Target(url, protocols)
+        }
+      }
+    })
+  });`);
+};

+ 11 - 0
src/utils/watch.ts

@@ -0,0 +1,11 @@
+import { WatchMapping } from '../types';
+
+export function isMappingPresent(
+    watchMappings: WatchMapping[],
+    folderPath: string
+) {
+    const watchMapping = watchMappings?.find(
+        (mapping) => mapping.folderPath === folderPath
+    );
+    return !!watchMapping;
+}

+ 1 - 0
thirdparty/next-electron-server

@@ -0,0 +1 @@
+Subproject commit a88030295c89dd8f43d9e3a45025678d95c78a45

+ 2 - 2
tsconfig.json

@@ -6,10 +6,10 @@
         "noImplicitAny": true,
         "sourceMap": true,
         "outDir": "app",
-        "baseUrl": "./main",
+        "baseUrl": "src",
         "paths": {
             "*": ["node_modules/*"]
         }
     },
-    "include": ["main/**/*"]
+    "include": ["src/**/*"]
 }

+ 34 - 5
yarn.lock

@@ -265,6 +265,11 @@
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
   integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
 
+"@types/auto-launch@^5.0.2":
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/@types/auto-launch/-/auto-launch-5.0.2.tgz#4970f01e5dd27572489b7fe77590204a19f86bd0"
+  integrity sha512-b03X09+GCM9t6AUECpwA2gUPYs8s5tJHFJw92sK8EiJ7G4QNbsHmXV7nfCfP6G6ivtm230vi4oNfe8AzRgzxMQ==
+
 "@types/debug@^4.1.6":
   version "4.1.7"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
@@ -594,6 +599,11 @@ app-builder-lib@23.1.0:
     tar "^6.1.11"
     temp-file "^3.4.0"
 
+applescript@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/applescript/-/applescript-1.0.0.tgz#bb87af568cad034a4e48c4bdaf6067a3a2701317"
+  integrity sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ==
+
 aproba@^1.0.3:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
@@ -678,6 +688,17 @@ atomically@^1.7.0:
   resolved "https://registry.yarnpkg.com/atomically/-/atomically-1.7.0.tgz#c07a0458432ea6dbc9a3506fffa424b48bccaafe"
   integrity sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==
 
+auto-launch@^5.0.5:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/auto-launch/-/auto-launch-5.0.5.tgz#d14bd002b1ef642f85e991a6195ff5300c8ad3c0"
+  integrity sha512-ppdF4mihhYzMYLuCcx9H/c5TUOCev8uM7en53zWVQhyYAJrurd2bFZx3qQVeJKF2jrc7rsPRNN5cD+i23l6PdA==
+  dependencies:
+    applescript "^1.0.0"
+    mkdirp "^0.5.1"
+    path-is-absolute "^1.0.0"
+    untildify "^3.0.2"
+    winreg "1.2.4"
+
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
@@ -888,7 +909,7 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chokidar@^3.5.2:
+chokidar@^3.5.2, chokidar@^3.5.3:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
   integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -2724,7 +2745,7 @@ minizlib@^2.1.1:
     minipass "^3.0.0"
     yallist "^4.0.0"
 
-mkdirp@^0.5.4, mkdirp@^0.5.5:
+mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@^0.5.5:
   version "0.5.6"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
   integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
@@ -2756,10 +2777,8 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
-next-electron-server@^0.0.8:
+"next-electron-server@file:./thirdparty/next-electron-server":
   version "0.0.8"
-  resolved "https://registry.yarnpkg.com/next-electron-server/-/next-electron-server-0.0.8.tgz#bc17772636a099d678a6f0c3f9bc8e952ee75967"
-  integrity sha512-LQhqf1Lrw9QVPntWN8NtqQk4WMufxXinsfg2ODgfqiHpvhcG58MOVIfvtDL37yGRvswORLXUHYRNB+p59LAd/w==
 
 node-addon-api@^1.6.3:
   version "1.7.2"
@@ -3878,6 +3897,11 @@ universalify@^2.0.0:
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
   integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
 
+untildify@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
+  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
+
 update-notifier@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9"
@@ -3992,6 +4016,11 @@ widest-line@^3.1.0:
   dependencies:
     string-width "^4.0.0"
 
+winreg@1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/winreg/-/winreg-1.2.4.tgz#ba065629b7a925130e15779108cf540990e98d1b"
+  integrity sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==
+
 word-wrap@^1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"