feat: add electron mode from VoTT project (#260)

This commit is contained in:
kunzheng 2020-05-19 11:16:13 -07:00 коммит произвёл GitHub
Родитель c1c590c463
Коммит 2a3383d4a0
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
17 изменённых файлов: 6174 добавлений и 2975 удалений

32
config/webpack.common.js Normal file
Просмотреть файл

@ -0,0 +1,32 @@
const path = require("path");
module.exports = {
node: {
__dirname: false,
},
target: "electron-main",
entry: "./src/electron/main.ts",
module: {
rules: [
{
test: /\.ts?$/,
use: [{
loader: "ts-loader",
options: {
compilerOptions: {
noEmit: false
}
}
}],
exclude: /node_modules/
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
filename: "main.js",
path: path.resolve(__dirname, "../build")
}
}

7
config/webpack.dev.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "development",
devtool: "inline-source-map",
})

7
config/webpack.prod.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production",
devtool: "cheap-module-source-map",
})

8407
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -24,9 +24,9 @@
"ol": "^5.3.3",
"rc-align": "^2.4.5",
"rc-checkbox": "^2.1.8",
"react": "^16.12.0",
"react": "^16.13.1",
"react-color": "^2.17.3",
"react-dom": "^16.12.0",
"react-dom": "^16.13.1",
"react-jsonschema-form": "^1.3.0",
"react-localization": "^1.0.15",
"react-redux": "^7.1.3",
@ -37,6 +37,7 @@
"reactstrap": "^8.2.0",
"redux": "^4.0.4",
"redux-thunk": "^2.3.0",
"rimraf": "^3.0.2",
"serialize-javascript": "^3.0.0",
"shortid": "^2.2.15",
"utif": "^3.1.0",
@ -49,6 +50,13 @@
"react-start": "react-scripts start",
"test": "react-scripts test --env=jsdom --silent",
"eject": "react-scripts eject",
"webpack:dev": "webpack --config ./config/webpack.dev.js",
"webpack:prod": "webpack --config ./config/webpack.prod.js",
"electron:run:dev": "yarn webpack:dev && electron . --remote-debugging-port=9223",
"electron:run:prod": "yarn webpack:prod && electron . --remote-debugging-port=9223",
"electron:start:dev": "yarn webpack:dev && yarn electron-start",
"electron:start:prod": "yarn webpack:prod && yarn electron-start",
"electron-start": "node src/electron/start",
"tslint": "./node_modules/.bin/tslint 'src/**/*.ts*'",
"tslintfix": "./node_modules/.bin/tslint 'src/**/*.ts*' --fix"
},
@ -83,6 +91,8 @@
"@types/reactstrap": "^8.2.0",
"@types/redux-logger": "^3.0.7",
"acorn": "^7.1.1",
"electron": "^8.3.0",
"electron-builder": "^22.6.1",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint-utils": "^1.4.3",
@ -91,13 +101,15 @@
"minimist": "^1.2.2",
"node-sass": "^4.14.0",
"pdfjs-dist": "2.3.200",
"react-scripts": "3.1.2",
"react-scripts": "3.4.1",
"redux-immutable-state-invariant": "^2.1.0",
"redux-logger": "^3.0.6",
"redux-mock-store": "^1.5.4",
"ts-loader": "^6.2.1",
"tslint": "^5.20.1",
"typescript": "^3.8.2"
"ts-loader": "^7.0.1",
"tslint": "^6.1.1",
"typescript": "^3.8.3",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.2.2"
},
"engines": {
"node": ">=10.14.2",

30
src/common/deferred.ts Normal file
Просмотреть файл

@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export interface IDeferred<T> {
resolve(result?: T): void;
reject(err?: any): void;
then(value: T): Promise<T>;
catch(err: any): Promise<T>;
}
export class Deferred<T> implements IDeferred<T> {
public promise: Promise<T>;
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
this.then = this.promise.then.bind(this.promise) as (<T>(value: T) => Promise<T>);
this.catch = this.promise.catch.bind(this.promise);
}
// tslint:disable-next-line:no-empty
public resolve = (result?: T) => {};
// tslint:disable-next-line:no-empty
public reject = (err?: any) => {};
public then = (value: T): Promise<T> => { throw new Error("Not implemented yet"); };
public catch = (err: any): Promise<T> => { throw new Error("Not implemented yet"); };
}

Просмотреть файл

@ -12,7 +12,7 @@ describe("Map Extensions", () => {
beforeAll(registerMixins);
describe("forEachAsync", () => {
const map = testArray.map((asset) => [asset.id, asset]) as Array<[string, IAsset]>;
const map = testArray.map((asset) => [asset.id, asset]) as [string, IAsset][];
const testMap = new Map<string, IAsset>(map);
const output = [];

Просмотреть файл

@ -17,10 +17,10 @@ export async function forEachAsync<K, V>(
Guard.null(action);
Guard.expression(batchSize, (value) => value > 0);
const all: Array<[K, V]> = [...this.entries()];
const all: [K, V][] = [...this.entries()];
while (all.length > 0) {
const batch: Array<[K, V]> = [];
const batch: [K, V][] = [];
while (all.length > 0 && batch.length < batchSize) {
batch.push(all.pop());

16
src/common/ipcProxy.ts Normal file
Просмотреть файл

@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export const IpcEventNames = {
Renderer: "ipc-renderer-proxy",
Main: "ipc-main-proxy",
};
export interface IIpcProxyMessage<TResult> {
id: string;
type: string;
args?: any;
error?: string;
result?: TResult;
debug?: string;
}

Просмотреть файл

@ -0,0 +1,57 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as shortid from "shortid";
import { IIpcProxyMessage, IpcEventNames } from "./ipcProxy";
import { Deferred } from "./deferred";
export class IpcRendererProxy {
public static pending: { [id: string]: Deferred<any> } = {};
public static initialize() {
if (IpcRendererProxy.initialized) {
return;
}
IpcRendererProxy.ipcRenderer = (window as any).require("electron").ipcRenderer;
IpcRendererProxy.ipcRenderer.on(IpcEventNames.Renderer, (sender: any, message: IIpcProxyMessage<any>) => {
const deferred = IpcRendererProxy.pending[message.id];
if (!deferred) {
throw new Error(`Cannot find deferred with id '${message.id}'`);
}
if (message.error) {
deferred.reject(message.error);
} else {
deferred.resolve(message.result);
}
delete IpcRendererProxy.pending[message.id];
});
IpcRendererProxy.initialized = true;
}
public static send<TResult, TArgs>(type: string, args?: TArgs): Promise<TResult> {
IpcRendererProxy.initialize();
const id = shortid.generate();
const deferred = new Deferred<TResult>();
IpcRendererProxy.pending[id] = deferred;
const outgoingArgs: IIpcProxyMessage<TArgs> = {
id,
type,
args,
};
IpcRendererProxy.ipcRenderer.send(IpcEventNames.Main, outgoingArgs);
return deferred.promise;
}
private static ipcRenderer: any;
private static initialized: boolean = false;
}

Просмотреть файл

@ -168,8 +168,8 @@ async function decryptString(str: string | ISecureString, secret) {
}
export async function throttle<T>(max: number, arr: T[], worker: (payload: T) => Promise<any>) {
const allPromises: Array<Promise<any>> = [];
const runningPromises: Array<Promise<any>> = [];
const allPromises: Promise<any>[] = [];
const runningPromises: Promise<any>[] = [];
let i = 0;
while (i < arr.length) {
const payload = arr[i];

Просмотреть файл

@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { BrowserWindow, IpcMain } from "electron";
import { IIpcProxyMessage, IpcEventNames } from "../../common/ipcProxy";
export type IpcProxyHandler<T> = (sender: any, args: T) => any;
export class IpcMainProxy {
public handlers: { [type: string]: IpcProxyHandler<any> } = {};
constructor(private ipcMain: IpcMain, private browserWindow: BrowserWindow) {
this.init();
}
public register<T>(type: string, handler: IpcProxyHandler<T>) {
this.handlers[type] = handler;
}
public registerProxy(proxyPrefix: string, provider: any) {
Object.getOwnPropertyNames(provider.__proto__).forEach((memberName) => {
if (typeof (provider[memberName]) === "function") {
this.register(`${proxyPrefix}:${memberName}`, (sender: any, eventArgs: any[]) => {
return provider[memberName].apply(provider, eventArgs);
});
}
});
}
public unregisterAll() {
this.handlers = {};
}
private init() {
this.ipcMain.on(IpcEventNames.Main, (sender: any, message: IIpcProxyMessage<any>) => {
const handler = this.handlers[message.type];
if (!handler) {
console.log(`No IPC proxy handler defined for event type '${message.type}'`);
}
const returnArgs: IIpcProxyMessage<any> = {
id: message.id,
type: message.type,
};
try {
returnArgs.debug = JSON.stringify(message.args);
const handlerValue = handler(sender, message.args);
if (handlerValue && handlerValue.then) {
handlerValue.
then((result: any) => {
returnArgs.result = result;
this.browserWindow.webContents.send(IpcEventNames.Renderer, returnArgs);
})
.catch((err: string) => {
returnArgs.error = err;
this.browserWindow.webContents.send(IpcEventNames.Renderer, returnArgs);
});
} else {
returnArgs.result = handlerValue;
this.browserWindow.webContents.send(IpcEventNames.Renderer, returnArgs);
}
} catch (err) {
returnArgs.error = err;
this.browserWindow.webContents.send(IpcEventNames.Renderer, returnArgs);
}
});
}
}

73
src/electron/main.ts Normal file
Просмотреть файл

@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { app, ipcMain, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { IpcMainProxy } from "./common/ipcMainProxy";
import LocalFileSystem from "./providers/storage/localFileSystem";
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow: BrowserWindow | null;
let ipcMainProxy: IpcMainProxy;
async function createWindow() {
const windowOptions: BrowserWindowConstructorOptions = {
width: 1024,
height: 768,
frame: process.platform === "linux",
titleBarStyle: "hidden",
backgroundColor: "#272B30",
show: false,
};
const staticUrl = process.env.ELECTRON_START_URL || `file:///${__dirname}/index.html`;
if (process.env.ELECTRON_START_URL) {
windowOptions.webPreferences = {
nodeIntegration: true,
webSecurity: false,
};
}
mainWindow = new BrowserWindow(windowOptions);
mainWindow.loadURL(staticUrl);
mainWindow.maximize();
mainWindow.on("closed", () => {
mainWindow = null;
ipcMainProxy.unregisterAll();
});
mainWindow.on("ready-to-show", () => {
mainWindow!.show();
});
if (!ipcMainProxy) {
ipcMainProxy = new IpcMainProxy(ipcMain, mainWindow);
}
const localFileSystem = new LocalFileSystem(mainWindow);
ipcMainProxy.registerProxy("LocalFileSystem", localFileSystem);
}
// 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", createWindow);
// Quit when all windows are closed.
app.on("window-all-closed", () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
// On OS X 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 (mainWindow === null) {
createWindow();
}
});

Просмотреть файл

@ -0,0 +1,224 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import fs from "fs";
import path from "path";
import rimraf from "rimraf";
import { BrowserWindow, dialog } from "electron";
import { IStorageProvider } from "../../../providers/storage/storageProviderFactory";
import { IAsset, AssetState, AssetType, StorageType } from "../../../models/applicationState";
import { AssetService } from "../../../services/assetService";
import { constants } from "../../../common/constants";
import { strings } from "../../../common/strings";
export default class LocalFileSystem implements IStorageProvider {
public storageType: StorageType.Local;
constructor(private browserWindow: BrowserWindow) {
}
public selectContainer(): Promise<string> {
return new Promise<string>(async (resolve, reject) => {
const result = await dialog.showOpenDialog(this.browserWindow, {
title: strings.connections.providers.local.selectFolder,
buttonLabel: strings.connections.providers.local.chooseFolder,
properties: ["openDirectory", "createDirectory"],
});
if (!result.filePaths || result.filePaths.length !== 1) {
return reject();
}
resolve(result.filePaths[0]);
});
}
public readText(filePath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
fs.readFile(path.normalize(filePath), (err: NodeJS.ErrnoException, data: Buffer) => {
if (err) {
return reject(err);
}
resolve(data.toString());
});
});
}
public readBinary(filePath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
fs.readFile(path.normalize(filePath), (err: NodeJS.ErrnoException, data: Buffer) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
public writeBinary(filePath: string, contents: Buffer): Promise<void> {
return new Promise<void>((resolve, reject) => {
const containerName: fs.PathLike = path.normalize(path.dirname(filePath));
const exists = fs.existsSync(containerName);
if (!exists) {
fs.mkdirSync(containerName);
}
fs.writeFile(path.normalize(filePath), contents, (err) => {
if (err) {
return reject(err);
}
resolve();
});
});
}
public writeText(filePath: string, contents: string): Promise<void> {
const buffer = Buffer.from(contents);
return this.writeBinary(filePath, buffer);
}
public deleteFile(filePath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const exists = fs.existsSync(path.normalize(filePath));
if (!exists) {
resolve();
}
fs.unlink(filePath, (err) => {
if (err) {
return reject(err);
}
resolve();
});
});
}
public listFiles(folderPath: string): Promise<string[]> {
return this.listItems(path.normalize(folderPath), (stats) => !stats.isDirectory());
}
public listContainers(folderPath: string): Promise<string[]> {
return this.listItems(path.normalize(folderPath), (stats) => stats.isDirectory());
}
public createContainer(folderPath: string): Promise<void> {
return new Promise((resolve, reject) => {
fs.exists(path.normalize(folderPath), (exists) => {
if (exists) {
resolve();
} else {
fs.mkdir(path.normalize(folderPath), (err) => {
if (err) {
return reject(err);
}
resolve();
});
}
});
});
}
public deleteContainer(folderPath: string): Promise<void> {
return new Promise((resolve, reject) => {
fs.exists(path.normalize(folderPath), (exists) => {
if (exists) {
rimraf(path.normalize(folderPath), (err) => {
if (err) {
return reject(err);
}
resolve();
});
} else {
resolve();
}
});
});
}
public async getAssets(folderPath?: string): Promise<IAsset[]> {
const result: IAsset[] = [];
const files = await this.listFiles(path.normalize(folderPath));
for (const file of files) {
const asset = await AssetService.createAssetFromFilePath(file);
if (this.isSupportedAssetType(asset.type)) {
const labelFileName = decodeURIComponent(`${asset.name}${constants.labelFileExtension}`);
const ocrFileName = decodeURIComponent(`${asset.name}${constants.ocrFileExtension}`);
if (files.find((str) => str === labelFileName)) {
asset.state = AssetState.Tagged;
} else if (files.find((str) => str === ocrFileName)) {
asset.state = AssetState.Visited;
} else {
asset.state = AssetState.NotVisited;
}
result.push(asset);
}
}
return result;
}
/**
* Gets a list of file system items matching the specified predicate within the folderPath
* @param {string} folderPath
* @param {(stats:fs.Stats)=>boolean} predicate
* @returns {Promise} Resolved list of matching file system items
*/
private listItems(folderPath: string, predicate: (stats: fs.Stats) => boolean) {
return new Promise<string[]>((resolve, reject) => {
fs.readdir(path.normalize(folderPath), async (err: NodeJS.ErrnoException, fileSystemItems: string[]) => {
if (err) {
return reject(err);
}
const getStatsTasks = fileSystemItems.map((name) => {
const filePath = path.join(folderPath, name);
return this.getStats(filePath);
});
try {
const statsResults = await Promise.all(getStatsTasks);
const filteredItems = statsResults
.filter((result) => predicate(result.stats))
.map((result) => result.path);
resolve(filteredItems);
} catch (err) {
reject(err);
}
});
});
}
/**
* Gets the node file system stats for the specified path
* @param {string} path
* @returns {Promise} Resolved path and stats
*/
private getStats(path: string): Promise<{ path: string, stats: fs.Stats }> {
return new Promise((resolve, reject) => {
fs.stat(path, (err, stats: fs.Stats) => {
if (err) {
reject(err);
}
resolve({
path,
stats,
});
});
});
}
private isSupportedAssetType(assetType: AssetType) {
return assetType === AssetType.Image || assetType === AssetType.TIFF || assetType === AssetType.PDF;
}
}

54
src/electron/start.js Normal file
Просмотреть файл

@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
const net = require("net");
const port = process.env.PORT ? (process.env.PORT - 100) : 3000;
process.env.ELECTRON_START_URL = `http://localhost:${port}`;
const client = new net.Socket();
let startedElectron = false;
const tryConnection = () => client.connect({ port: port }, () => {
client.end();
if (!startedElectron) {
console.log("starting electron");
startedElectron = true;
const exec = require("child_process").exec;
const electron = exec("yarn electron:run:dev", (error, stdout, stderr) => {
console.log("Electron Process Terminated");
});
electron.stdout.on("data", (data) => {
console.log(data);
});
electron.on("message", (message, sendHandle) => {
console.log(message);
});
electron.on("error", (err) => {
console.log(err);
});
electron.on("exit", (code, signal) => {
console.log(`Exit-Code: ${code}`);
console.log(`Exit-Signal: ${signal}`);
});
electron.on("close", (code, signal) => {
console.log(`Close-Code: ${code}`);
console.log(`Close-Signal: ${signal}`);
});
electron.on("disconnect", () => {
console.log("Electron Process Disconnect");
});
}
});
tryConnection();
client.on("error", (error) => {
setTimeout(tryConnection, 1000);
});

Просмотреть файл

@ -0,0 +1,135 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { IpcRendererProxy } from "../../common/ipcRendererProxy";
import { IStorageProvider } from "./storageProviderFactory";
import { IAssetProvider } from "./assetProviderFactory";
import { IAsset, StorageType } from "../../models/applicationState";
const PROXY_NAME = "LocalFileSystem";
/**
* Options for Local File System
* @member folderPath - Path to folder being used in provider
*/
export interface ILocalFileSystemProxyOptions {
folderPath: string;
}
/**
* Storage Provider for Local File System. Only available in Electron application
* Leverages the IpcRendererProxy
*/
export class LocalFileSystemProxy implements IStorageProvider, IAssetProvider {
/**
* @returns - StorageType.Local
*/
public storageType: StorageType.Local;
constructor(private options?: ILocalFileSystemProxyOptions) {
if (!this.options) {
this.options = {
folderPath: null,
};
}
}
/**
* Select container for use in provider
*/
public selectContainer(): Promise<string> {
return IpcRendererProxy.send(`${PROXY_NAME}:selectContainer`);
}
/**
* Read text from file
* @param fileName - Name of file from which to read text
*/
public readText(fileName: string): Promise<string> {
const filePath = [this.options.folderPath, fileName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:readText`, [filePath]);
}
/**
* Read buffer from file
* @param fileName Name of file from which to read buffer
*/
public readBinary(fileName: string): Promise<Buffer> {
const filePath = [this.options.folderPath, fileName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:readBinary`, [filePath]);
}
/**
* Delete file
* @param fileName Name of file to delete
*/
public deleteFile(fileName: string): Promise<void> {
const filePath = [this.options.folderPath, fileName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:deleteFile`, [filePath]);
}
/**
* Write text to file
* @param fileName Name of target file
* @param contents Contents to be written
*/
public writeText(fileName: string, contents: string): Promise<void> {
const filePath = [this.options.folderPath, fileName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:writeText`, [filePath, contents]);
}
/**
* Write buffer to file
* @param fileName Name of target file
* @param contents Contents to be written
*/
public writeBinary(fileName: string, contents: Buffer): Promise<void> {
const filePath = [this.options.folderPath, fileName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:writeBinary`, [filePath, contents]);
}
/**
* List files in directory
* @param folderName - Name of folder from which to list files
* @param ext - NOT CURRENTLY USED IN IMPLEMENTATION.
*/
public listFiles(folderName?: string, ext?: string): Promise<string[]> {
const folderPath = folderName ? [this.options.folderPath, folderName].join("/") : this.options.folderPath;
return IpcRendererProxy.send(`${PROXY_NAME}:listFiles`, [folderPath]);
}
/**
* List directories inside another directory
* @param folderName - Directory from which to list directories
*/
public listContainers(folderName?: string): Promise<string[]> {
const folderPath = folderName ? [this.options.folderPath, folderName].join("/") : this.options.folderPath;
return IpcRendererProxy.send(`${PROXY_NAME}:listContainers`, [folderPath]);
}
/**
* Create local directory
* @param folderName - Name of directory to create
*/
public createContainer(folderName: string): Promise<void> {
const folderPath = [this.options.folderPath, folderName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:createContainer`, [folderPath]);
}
/**
* Delete directory
* @param folderName - Name of directory to delete
*/
public deleteContainer(folderName: string): Promise<void> {
const folderPath = [this.options.folderPath, folderName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:deleteContainer`, [folderPath]);
}
/**
* Retrieve assets from directory
* @param folderName - Directory containing assets
*/
public getAssets(folderName?: string): Promise<IAsset[]> {
const folderPath = [this.options.folderPath, folderName].join("/");
return IpcRendererProxy.send(`${PROXY_NAME}:getAssets`, [folderPath]);
}
}

Просмотреть файл

@ -129,7 +129,7 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
private selectedRegionIds: string[] = [];
private regionOrders: Array<Record<string, number>> = [];
private regionOrders: Record<string, number>[] = [];
private regionOrderById: string[][] = [];