Merge branch 'main' into bundle-ml-demo

This commit is contained in:
Abhinav 2022-09-17 18:27:01 +05:30
parent df1a63e384
commit 27876e5b6d
49 changed files with 1320 additions and 639 deletions

4
.gitmodules vendored
View file

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

View file

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

View file

@ -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);
}
});
}

View file

@ -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,
};

View file

@ -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,
});

View file

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

35
src/api/cache.ts Normal file
View file

@ -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
src/api/common.ts Normal file
View file

@ -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');
}
};

View file

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

72
src/api/export.ts Normal file
View file

@ -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
src/api/fs.ts Normal file
View file

@ -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';

View file

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

11
src/api/system.ts Normal file
View file

@ -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
src/api/upload.ts Normal file
View file

@ -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
src/api/watch.ts Normal file
View file

@ -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';

78
src/main.ts Normal file
View file

@ -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
src/preload.ts Normal file
View file

@ -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,
};

View file

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

View file

@ -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();

View file

@ -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();

View file

@ -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
src/services/chokidar.ts Normal file
View file

@ -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;
}

View file

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

View file

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

View file

@ -1,13 +1,12 @@
import path from 'path';
import StreamZip from 'node-stream-zip';
import * as fs from 'promise-fs';
import { FILE_STREAM_CHUNK_SIZE } from '../config';
import { uploadStatusStore } from '../services/store';
import { ElectronFile, FILE_PATH_KEYS, FILE_PATH_TYPE } from '../types';
import { logError } from './logging';
import path from 'path';
import * as fs from 'promise-fs';
import { ElectronFile } from '../types';
import StreamZip from 'node-stream-zip';
import { Readable } from 'stream';
// https://stackoverflow.com/a/63111390
export const getFilesFromDir = async (dirPath: string) => {
export const getDirFilePaths = async (dirPath: string) => {
if (!(await fs.stat(dirPath)).isDirectory()) {
return [dirPath];
}
@ -17,20 +16,19 @@ export const getFilesFromDir = async (dirPath: string) => {
for (const filePath of filePaths) {
const absolute = path.join(dirPath, filePath);
files = files.concat(await getFilesFromDir(absolute));
files = files.concat(await getDirFilePaths(absolute));
}
return files;
};
const getFileStream = async (filePath: string) => {
export const getFileStream = async (filePath: string) => {
const file = await fs.open(filePath, 'r');
let offset = 0;
const readableStream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
// original types were not working correctly
const bytesRead = (await fs.read(
file,
@ -44,10 +42,9 @@ const getFileStream = async (filePath: string) => {
controller.close();
await fs.close(file);
} else {
controller.enqueue(buff);
controller.enqueue(buff.slice(0, bytesRead));
}
} catch (e) {
logError(e, 'stream pull failed');
await fs.close(file);
}
},
@ -55,92 +52,6 @@ const getFileStream = async (filePath: string) => {
return readableStream;
};
const getZipFileStream = async (
zip: StreamZip.StreamZipAsync,
filePath: string
) => {
const stream = await zip.stream(filePath);
const done = { current: false };
let resolveObj: (value?: any) => void = null;
let rejectObj: (reason?: any) => void = null;
stream.on('readable', () => {
if (resolveObj) {
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
if (chunk) {
resolveObj(new Uint8Array(chunk));
resolveObj = null;
}
}
});
stream.on('end', () => {
done.current = true;
});
stream.on('error', (e) => {
done.current = true;
if (rejectObj) {
rejectObj(e);
rejectObj = null;
}
});
const readStreamData = () => {
return new Promise<Uint8Array>((resolve, reject) => {
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
if (chunk || done.current) {
resolve(chunk);
} else {
resolveObj = resolve;
rejectObj = reject;
}
});
};
const readableStream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const data = await readStreamData();
if (data) {
controller.enqueue(data);
} else {
controller.close();
}
} catch (e) {
logError(e, 'stream reading failed');
controller.close();
}
},
});
return readableStream;
};
async function getZipEntryAsElectronFile(
zip: StreamZip.StreamZipAsync,
entry: StreamZip.ZipEntry
): Promise<ElectronFile> {
return {
path: entry.name,
name: path.basename(entry.name),
size: entry.size,
lastModified: entry.time,
stream: async () => {
return await getZipFileStream(zip, entry.name);
},
blob: async () => {
const buffer = await zip.entryData(entry.name);
return new Blob([new Uint8Array(buffer)]);
},
arrayBuffer: async () => {
const buffer = await zip.entryData(entry.name);
return new Uint8Array(buffer);
},
};
}
export async function getElectronFile(filePath: string): Promise<ElectronFile> {
const fileStats = await fs.stat(filePath);
return {
@ -162,77 +73,115 @@ export async function getElectronFile(filePath: string): Promise<ElectronFile> {
};
}
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 getValidPaths = (paths: string[]) => {
if (!paths) {
return [] as string[];
}
};
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) => {
return paths.filter(async (path) => {
try {
await fs.stat(path).then((stat) => stat.isFile());
} catch (e) {
return false;
}
});
setToUploadFiles(type, validPaths);
return validPaths;
};
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)));
}
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 getZipFileStream = async (
zip: StreamZip.StreamZipAsync,
filePath: string
) => {
const stream = await zip.stream(filePath);
const done = {
current: false,
};
};
let resolveObj: (value?: any) => void = null;
let rejectObj: (reason?: any) => void = null;
stream.on('readable', () => {
if (resolveObj) {
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
export const getElectronFilesFromGoogleZip = async (filePath: string) => {
const zip = new StreamZip.async({
file: filePath,
if (chunk) {
resolveObj(new Uint8Array(chunk));
resolveObj = null;
}
}
});
stream.on('end', () => {
done.current = true;
});
stream.on('error', (e) => {
done.current = true;
if (rejectObj) {
rejectObj(e);
rejectObj = null;
}
});
const entries = await zip.entries();
const files: ElectronFile[] = [];
const readStreamData = () => {
return new Promise<Uint8Array>((resolve, reject) => {
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
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));
}
}
if (chunk || done.current) {
resolve(chunk);
} else {
resolveObj = resolve;
rejectObj = reject;
}
});
};
return files;
const readableStream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const data = await readStreamData();
if (data) {
controller.enqueue(data);
} else {
controller.close();
}
} catch (e) {
controller.close();
}
},
});
return readableStream;
};
export async function isFolder(dirPath: string) {
return await fs
.stat(dirPath)
.then((stats) => {
return stats.isDirectory();
})
.catch(() => false);
}
export const convertBrowserStreamToNode = (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;
};
export function writeStream(filePath: string, fileStream: any) {
const writeable = fs.createWriteStream(filePath);
const readable = convertBrowserStreamToNode(fileStream);
readable.pipe(writeable);
}
export async function readTextFile(filePath: string) {
return await fs.readFile(filePath, 'utf-8');
}

View file

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

73
src/services/upload.ts Normal file
View file

@ -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;
};

View file

@ -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
src/services/watch.ts Normal file
View file

@ -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
src/stores/keys.store.ts Normal file
View file

@ -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,
});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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
src/stores/watch.store.ts Normal file
View file

@ -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,
});

View file

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

View file

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

View file

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

View file

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

101
src/utils/main.ts Normal file
View file

@ -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');
}

View file

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

16
src/utils/preload.ts Normal file
View file

@ -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
src/utils/watch.ts Normal file
View file

@ -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
thirdparty/next-electron-server vendored Submodule

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

View file

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

View file

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