Bläddra i källkod

Merge branch 'main' into bundle-ml-demo

Abhinav 2 år sedan
förälder
incheckning
27876e5b6d
49 ändrade filer med 1291 tillägg och 610 borttagningar
  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
 	path = ui
 	url = https://github.com/ente-io/bada-frame
 	url = https://github.com/ente-io/bada-frame
 	branch = demo
 	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
 The goal of this app was to
 1. provide a stable environment for customers to back up large amounts of data reliably
 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.
 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).
 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.
 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):
 - [AUR](https://aur.archlinux.org/packages/ente-desktop-appimage):
   `yay -S 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
 ```bash
 # Clone this repository
 # Clone this repository
 git clone https://github.com/ente-io/bhari-frame
 git clone https://github.com/ente-io/bhari-frame
+
 # Go into the repository
 # Go into the repository
 cd bhari-frame
 cd bhari-frame
-# Install dependencies
-npm install
+
+# Clone submodules (recursively)
+git submodule update --init --recursive
+
+# Install packages
+yarn
+
 # Run the app
 # 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
 ### 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
 ```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",
   "version": "1.7.0-alpha.7",
   "private": true,
   "private": true,
   "description": "Desktop client for ente.io",
   "description": "Desktop client for ente.io",
-  "main": "app/index.js",
+  "main": "app/main.js",
   "build": {
   "build": {
     "appId": "io.ente.bhari-frame",
     "appId": "io.ente.bhari-frame",
     "artifactName": "${productName}-${version}.${ext}",
     "artifactName": "${productName}-${version}.${ext}",
@@ -53,12 +53,12 @@
   },
   },
   "scripts": {
   "scripts": {
     "postinstall": "electron-builder install-app-deps",
     "postinstall": "electron-builder install-app-deps",
-    "prebuild": "eslint \"main/**/*.{js,jsx,ts,tsx}\"",
+    "prebuild": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
     "prepare": "husky install",
     "prepare": "husky install",
-    "lint": "eslint -c .eslintrc --ext .ts ./main",
+    "lint": "eslint -c .eslintrc --ext .ts src",
     "watch": "tsc -w",
     "watch": "tsc -w",
     "build-main": "yarn install && tsc",
     "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-renderer": "cd ui && yarn install && yarn dev",
     "start": "concurrently \"yarn start-main\" \"yarn start-renderer\"",
     "start": "concurrently \"yarn start-main\" \"yarn start-renderer\"",
     "build-renderer": "cd ui && yarn install && yarn build && cd ..",
     "build-renderer": "cd ui && yarn install && yarn build && cd ..",
@@ -68,6 +68,7 @@
   "author": "ente <code@ente.io>",
   "author": "ente <code@ente.io>",
   "devDependencies": {
   "devDependencies": {
     "@sentry/cli": "^1.68.0",
     "@sentry/cli": "^1.68.0",
+    "@types/auto-launch": "^5.0.2",
     "@types/get-folder-size": "^2.0.0",
     "@types/get-folder-size": "^2.0.0",
     "@typescript-eslint/eslint-plugin": "^5.28.0",
     "@typescript-eslint/eslint-plugin": "^5.28.0",
     "@typescript-eslint/parser": "^5.28.0",
     "@typescript-eslint/parser": "^5.28.0",
@@ -88,12 +89,14 @@
     "@sentry/electron": "^2.5.1",
     "@sentry/electron": "^2.5.1",
     "@types/node": "^14.14.37",
     "@types/node": "^14.14.37",
     "@types/promise-fs": "^2.1.1",
     "@types/promise-fs": "^2.1.1",
+    "chokidar": "^3.5.3",
+    "auto-launch": "^5.0.5",
     "electron-log": "^4.3.5",
     "electron-log": "^4.3.5",
     "electron-reload": "^2.0.0-alpha.1",
     "electron-reload": "^2.0.0-alpha.1",
     "electron-store": "^8.0.1",
     "electron-store": "^8.0.1",
     "electron-updater": "^4.3.8",
     "electron-updater": "^4.3.8",
     "get-folder-size": "^2.0.1",
     "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",
     "node-stream-zip": "^1.15.0",
     "promise-fs": "^2.1.1"
     "promise-fs": "^2.1.1"
   },
   },
@@ -101,7 +104,7 @@
     "parser": "babel-eslint"
     "parser": "babel-eslint"
   },
   },
   "lint-staged": {
   "lint-staged": {
-    "main/**/*.{js,jsx,ts,tsx}": [
+    "src/**/*.{js,jsx,ts,tsx}": [
       "eslint --fix",
       "eslint --fix",
       "prettier --write --ignore-unknown"
       "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 = () => {
 export const clearElectronStore = () => {
     try {
     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 { 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) {
 export async function setEncryptionKey(encryptionKey: string) {
     try {
     try {
@@ -16,7 +16,7 @@ export async function setEncryptionKey(encryptionKey: string) {
     }
     }
 }
 }
 
 
-export async function getEncryptionKey() {
+export async function getEncryptionKey(): Promise<string> {
     try {
     try {
         const b64EncryptedKey = safeStorageStore.get('encryptionKey');
         const b64EncryptedKey = safeStorageStore.get('encryptionKey');
         if (b64EncryptedKey) {
         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 { BrowserWindow, dialog, Tray } from 'electron';
 import { autoUpdater } from 'electron-updater';
 import { autoUpdater } from 'electron-updater';
 import log from 'electron-log';
 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 {
 class AppUpdater {
     constructor() {
     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 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 crypto from 'crypto';
-import DiskLRUService from './diskLRU';
 
 
-const CACHE_DIR = 'ente';
 const MAX_CACHE_SIZE = 1000 * 1000 * 1000; // 1GB
 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) {}
     constructor(private cacheBucketDir: string) {}
 
 
     async put(cacheKey: string, response: Response): Promise<void> {
     async put(cacheKey: string, response: Response): Promise<void> {
@@ -77,7 +39,6 @@ class DiskCache {
         }
         }
     }
     }
 }
 }
-
 function getAssetCachePath(cacheDir: string, cacheKey: string) {
 function getAssetCachePath(cacheDir: string, cacheKey: string) {
     // hashing the key to prevent illegal filenames
     // hashing the key to prevent illegal filenames
     const cacheKeyHash = crypto
     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 { readdir, stat, unlink } from 'promise-fs';
 import getFolderSize from 'get-folder-size';
 import getFolderSize from 'get-folder-size';
 import { utimes, close, open } from 'promise-fs';
 import { utimes, close, open } from 'promise-fs';
-import { logError } from './logging';
+import { logError } from '../utils/logging';
 
 
 export interface LeastRecentlyUsedResult {
 export interface LeastRecentlyUsedResult {
     atime: Date;
     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 path from 'path';
-import StreamZip from 'node-stream-zip';
 import * as fs from 'promise-fs';
 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
 // https://stackoverflow.com/a/63111390
-export const getFilesFromDir = async (dirPath: string) => {
+export const getDirFilePaths = async (dirPath: string) => {
     if (!(await fs.stat(dirPath)).isDirectory()) {
     if (!(await fs.stat(dirPath)).isDirectory()) {
         return [dirPath];
         return [dirPath];
     }
     }
@@ -17,20 +16,19 @@ export const getFilesFromDir = async (dirPath: string) => {
 
 
     for (const filePath of filePaths) {
     for (const filePath of filePaths) {
         const absolute = path.join(dirPath, filePath);
         const absolute = path.join(dirPath, filePath);
-        files = files.concat(await getFilesFromDir(absolute));
+        files = files.concat(await getDirFilePaths(absolute));
     }
     }
 
 
     return files;
     return files;
 };
 };
 
 
-const getFileStream = async (filePath: string) => {
+export const getFileStream = async (filePath: string) => {
     const file = await fs.open(filePath, 'r');
     const file = await fs.open(filePath, 'r');
     let offset = 0;
     let offset = 0;
     const readableStream = new ReadableStream<Uint8Array>({
     const readableStream = new ReadableStream<Uint8Array>({
         async pull(controller) {
         async pull(controller) {
             try {
             try {
                 const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
                 const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
-
                 // original types were not working correctly
                 // original types were not working correctly
                 const bytesRead = (await fs.read(
                 const bytesRead = (await fs.read(
                     file,
                     file,
@@ -44,10 +42,9 @@ const getFileStream = async (filePath: string) => {
                     controller.close();
                     controller.close();
                     await fs.close(file);
                     await fs.close(file);
                 } else {
                 } else {
-                    controller.enqueue(buff);
+                    controller.enqueue(buff.slice(0, bytesRead));
                 }
                 }
             } catch (e) {
             } catch (e) {
-                logError(e, 'stream pull failed');
                 await fs.close(file);
                 await fs.close(file);
             }
             }
         },
         },
@@ -55,32 +52,66 @@ const getFileStream = async (filePath: string) => {
     return readableStream;
     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,
     zip: StreamZip.StreamZipAsync,
     filePath: string
     filePath: string
 ) => {
 ) => {
     const stream = await zip.stream(filePath);
     const stream = await zip.stream(filePath);
-    const done = { current: false };
-
+    const done = {
+        current: false,
+    };
     let resolveObj: (value?: any) => void = null;
     let resolveObj: (value?: any) => void = null;
     let rejectObj: (reason?: any) => void = null;
     let rejectObj: (reason?: any) => void = null;
-
     stream.on('readable', () => {
     stream.on('readable', () => {
         if (resolveObj) {
         if (resolveObj) {
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
+
             if (chunk) {
             if (chunk) {
                 resolveObj(new Uint8Array(chunk));
                 resolveObj(new Uint8Array(chunk));
                 resolveObj = null;
                 resolveObj = null;
             }
             }
         }
         }
     });
     });
-
     stream.on('end', () => {
     stream.on('end', () => {
         done.current = true;
         done.current = true;
     });
     });
-
     stream.on('error', (e) => {
     stream.on('error', (e) => {
         done.current = true;
         done.current = true;
+
         if (rejectObj) {
         if (rejectObj) {
             rejectObj(e);
             rejectObj(e);
             rejectObj = null;
             rejectObj = null;
@@ -90,6 +121,7 @@ const getZipFileStream = async (
     const readStreamData = () => {
     const readStreamData = () => {
         return new Promise<Uint8Array>((resolve, reject) => {
         return new Promise<Uint8Array>((resolve, reject) => {
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
             const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
+
             if (chunk || done.current) {
             if (chunk || done.current) {
                 resolve(chunk);
                 resolve(chunk);
             } else {
             } else {
@@ -103,136 +135,53 @@ const getZipFileStream = async (
         async pull(controller) {
         async pull(controller) {
             try {
             try {
                 const data = await readStreamData();
                 const data = await readStreamData();
+
                 if (data) {
                 if (data) {
                     controller.enqueue(data);
                     controller.enqueue(data);
                 } else {
                 } else {
                     controller.close();
                     controller.close();
                 }
                 }
             } catch (e) {
             } catch (e) {
-                logError(e, 'stream reading failed');
                 controller.close();
                 controller.close();
             }
             }
         },
         },
     });
     });
-
     return readableStream;
     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 * 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';
 const SENTRY_DSN = 'https://e9268b784d1042a7a116f53c58ad2165@sentry.ente.io/5';
 
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const version = require('../../package.json').version;
 const version = require('../../package.json').version;
 
 
-function initSentry(): void {
+export function initSentry(): void {
     Sentry.init({
     Sentry.init({
         dsn: SENTRY_DSN,
         dsn: SENTRY_DSN,
         release: version,
         release: version,
@@ -46,6 +46,15 @@ function errorWithContext(originalError: Error, context: string) {
     return errorWithContext;
     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) {
 function makeID(length: number) {
     let result = '';
     let result = '';
     const characters =
     const characters =
@@ -58,14 +67,3 @@ function makeID(length: number) {
     }
     }
     return result;
     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 {
 export enum FILE_PATH_TYPE {
     FILES = 'files',
     FILES = 'files',
     ZIPS = 'zips',
     ZIPS = 'zips',
@@ -35,3 +53,7 @@ export const FILE_PATH_KEYS: {
 export interface SafeStorageStoreType {
 export interface SafeStorageStoreType {
     encryptionKey: string;
     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 { app, BrowserWindow, nativeImage } from 'electron';
 import * as path from 'path';
 import * as path from 'path';
 import { isDev } from './common';
 import { isDev } from './common';
-import { isAppQuitting } from '..';
-import { addAllowOriginHeader } from './cors';
+import { isAppQuitting } from '../main';
 import { PROD_HOST_URL } from '../config';
 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
     const appImgPath = isDev
         ? 'build/window-icon.png'
         ? 'build/window-icon.png'
         : path.join(process.resourcesPath, '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
         show: false, // don't show the main window on load
     });
     });
     mainWindow.maximize();
     mainWindow.maximize();
+    const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
+
     const splash = new BrowserWindow({
     const splash = new BrowserWindow({
         height: 600,
         height: 600,
         width: 800,
         width: 800,
         transparent: true,
         transparent: true,
+        show: !wasAutoLaunched,
     });
     });
     splash.maximize();
     splash.maximize();
 
 
-    addAllowOriginHeader(mainWindow);
-
     if (isDev) {
     if (isDev) {
         splash.loadFile(`../build/splash.html`);
         splash.loadFile(`../build/splash.html`);
         mainWindow.loadURL(PROD_HOST_URL);
         mainWindow.loadURL(PROD_HOST_URL);
@@ -45,25 +48,35 @@ export function createWindow(): BrowserWindow {
     }
     }
     mainWindow.webContents.on('did-fail-load', () => {
     mainWindow.webContents.on('did-fail-load', () => {
         splash.close();
         splash.close();
-        mainWindow.show();
         isDev
         isDev
             ? mainWindow.loadFile(`../../build/error.html`)
             ? mainWindow.loadFile(`../../build/error.html`)
             : splash.loadURL(
             : splash.loadURL(
                   `file://${path.join(process.resourcesPath, 'error.html')}`
                   `file://${path.join(process.resourcesPath, 'error.html')}`
               );
               );
     });
     });
-    mainWindow.once('ready-to-show', () => {
-        mainWindow.show();
+    mainWindow.once('ready-to-show', async () => {
         splash.destroy();
         splash.destroy();
+        if (!wasAutoLaunched) {
+            mainWindow.show();
+        }
     });
     });
     mainWindow.on('close', function (event) {
     mainWindow.on('close', function (event) {
         if (!isAppQuitting()) {
         if (!isAppQuitting()) {
             event.preventDefault();
             event.preventDefault();
             mainWindow.hide();
             mainWindow.hide();
-            const isMac = process.platform === 'darwin';
-            isMac && app.dock.hide();
         }
         }
         return false;
         return false;
     });
     });
+    mainWindow.on('hide', () => {
+        const shouldHideDockIcon = getHideDockIconPreference();
+        if (isPlatformMac() && shouldHideDockIcon) {
+            app.dock.hide();
+        }
+    });
+    mainWindow.on('show', () => {
+        if (isPlatformMac()) {
+            app.dock.show();
+        }
+    });
     return mainWindow;
     return mainWindow;
 }
 }

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

@@ -8,23 +8,24 @@ import {
     app,
     app,
 } from 'electron';
 } from 'electron';
 import { createWindow } from './createWindow';
 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(
 export default function setupIpcComs(
     tray: Tray,
     tray: Tray,
-    mainWindow: BrowserWindow
+    mainWindow: BrowserWindow,
+    watcher: chokidar.FSWatcher
 ): void {
 ): void {
     ipcMain.handle('select-dir', async () => {
     ipcMain.handle('select-dir', async () => {
         const result = await dialog.showOpenDialog({
         const result = await dialog.showOpenDialog({
             properties: ['openDirectory'],
             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) => {
     ipcMain.on('update-tray', (_, args) => {
@@ -38,8 +39,8 @@ export default function setupIpcComs(
         };
         };
         new Notification(notification).show();
         new Notification(notification).show();
     });
     });
-    ipcMain.on('reload-window', () => {
-        const secondWindow = createWindow();
+    ipcMain.on('reload-window', async () => {
+        const secondWindow = await createWindow();
         mainWindow.destroy();
         mainWindow.destroy();
         mainWindow = secondWindow;
         mainWindow = secondWindow;
     });
     });
@@ -66,12 +67,20 @@ export default function setupIpcComs(
 
 
         let files: string[] = [];
         let files: string[] = [];
         for (const dirPath of dir.filePaths) {
         for (const dirPath of dir.filePaths) {
-            files = files.concat(await getFilesFromDir(dirPath));
+            files = files.concat(await getDirFilePaths(dirPath));
         }
         }
 
 
         return files;
         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?) => {
     ipcMain.handle('log-error', (_, err, msg, info?) => {
         logErrorSentry(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,
     BrowserWindow,
     MenuItemConstructorOptions,
     MenuItemConstructorOptions,
 } from 'electron';
 } 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(
 export function buildContextMenu(
     mainWindow: BrowserWindow,
     mainWindow: BrowserWindow,
@@ -77,8 +82,6 @@ export function buildContextMenu(
             label: 'Open ente',
             label: 'Open ente',
             click: function () {
             click: function () {
                 mainWindow.show();
                 mainWindow.show();
-                const isMac = process.platform === 'darwin';
-                isMac && app.dock.show();
             },
             },
         },
         },
         {
         {
@@ -92,34 +95,65 @@ export function buildContextMenu(
     return contextMenu;
     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[] = [
     const template: MenuItemConstructorOptions[] = [
         {
         {
-            label: app.name,
+            label: 'ente',
             submenu: [
             submenu: [
                 ...((isMac
                 ...((isMac
                     ? [
                     ? [
                           {
                           {
-                              label: 'About',
+                              label: 'About ente',
                               role: 'about',
                               role: 'about',
                           },
                           },
                       ]
                       ]
                     : []) as MenuItemConstructorOptions[]),
                     : []) 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[]),
                       ]) as MenuItemConstructorOptions[]),
             ],
             ],
         },
         },
-        // { role: 'viewMenu' }
         {
         {
             label: 'View',
             label: 'View',
             submenu: [
             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: 'resetZoom', label: 'Reset zoom' },
                 { role: 'zoomIn', label: 'Zoom in' },
                 { role: 'zoomIn', label: 'Zoom in' },
                 { role: 'zoomOut', label: 'Zoom out' },
                 { role: 'zoomOut', label: 'Zoom out' },
@@ -176,7 +213,6 @@ export function buildMenuBar(): Menu {
                 { role: 'togglefullscreen', label: 'Toggle fullscreen' },
                 { role: 'togglefullscreen', label: 'Toggle fullscreen' },
             ],
             ],
         },
         },
-        // { role: 'windowMenu' }
         {
         {
             label: 'Window',
             label: 'Window',
             submenu: [
             submenu: [
@@ -184,15 +220,33 @@ export function buildMenuBar(): Menu {
                 ...((isMac
                 ...((isMac
                     ? [
                     ? [
                           { type: 'separator' },
                           { type: 'separator' },
-                          { role: 'front', label: 'Front' },
+                          { role: 'front', label: 'Bring to front' },
                           { type: 'separator' },
                           { type: 'separator' },
-                          { role: 'window', label: 'Window' },
+                          { role: 'window', label: 'ente' },
                       ]
                       ]
                     : [
                     : [
-                          { role: 'close', label: 'Close' },
+                          { role: 'close', label: 'Close ente' },
                       ]) as MenuItemConstructorOptions[]),
                       ]) 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);
     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,
         "noImplicitAny": true,
         "sourceMap": true,
         "sourceMap": true,
         "outDir": "app",
         "outDir": "app",
-        "baseUrl": "./main",
+        "baseUrl": "src",
         "paths": {
         "paths": {
             "*": ["node_modules/*"]
             "*": ["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"
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
   integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
   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":
 "@types/debug@^4.1.6":
   version "4.1.7"
   version "4.1.7"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
   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"
     tar "^6.1.11"
     temp-file "^3.4.0"
     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:
 aproba@^1.0.3:
   version "1.2.0"
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
   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"
   resolved "https://registry.yarnpkg.com/atomically/-/atomically-1.7.0.tgz#c07a0458432ea6dbc9a3506fffa424b48bccaafe"
   integrity sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==
   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:
 aws-sign2@~0.7.0:
   version "0.7.0"
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
   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"
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
     supports-color "^7.1.0"
 
 
-chokidar@^3.5.2:
+chokidar@^3.5.2, chokidar@^3.5.3:
   version "3.5.3"
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
   integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
   integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -2724,7 +2745,7 @@ minizlib@^2.1.1:
     minipass "^3.0.0"
     minipass "^3.0.0"
     yallist "^4.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"
   version "0.5.6"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
   integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
   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"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
 
-next-electron-server@^0.0.8:
+"next-electron-server@file:./thirdparty/next-electron-server":
   version "0.0.8"
   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:
 node-addon-api@^1.6.3:
   version "1.7.2"
   version "1.7.2"
@@ -3878,6 +3897,11 @@ universalify@^2.0.0:
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
   integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
   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:
 update-notifier@^5.1.0:
   version "5.1.0"
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9"
   resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9"
@@ -3992,6 +4016,11 @@ widest-line@^3.1.0:
   dependencies:
   dependencies:
     string-width "^4.0.0"
     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:
 word-wrap@^1.2.3:
   version "1.2.3"
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"