├── src ├── renderer │ ├── vue-shims.d.ts │ ├── components │ │ ├── testComponents.ts │ │ ├── prerequisites.ts │ │ ├── icons.vue │ │ ├── projectInfo.ts │ │ ├── project.vue │ │ ├── prerequisites.vue │ │ └── projectInfo.vue │ ├── index.ts │ ├── App.vue │ ├── iview.d.ts │ ├── router.ts │ ├── initIView.ts │ ├── vue-apply-diff.ts │ └── projectInfoManager.ts ├── rx-ipc │ ├── main.ts │ ├── deep-diff.d.ts │ └── rx-ipc.ts ├── main │ ├── debug.d.ts │ ├── util.ts │ ├── store.ts │ ├── index.ts │ ├── lint │ │ └── prerequisites.ts │ └── ProjectInfoProducer.ts └── common │ └── projectInfo.ts ├── tsconfig.json ├── README.md ├── .gitignore ├── .idea ├── watcherTasks.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── jsLibraryMappings.xml ├── jsLinters │ └── tslint.xml ├── modules.xml ├── misc.xml ├── runConfigurations │ └── dev.xml ├── rc-producer.yml ├── dictionaries │ └── develar.xml ├── electrify.iml ├── inspectionProfiles │ └── Project_Default.xml └── codeStyleSettings.xml ├── test ├── tsconfig.json ├── src │ └── prerequisites.test.ts └── out │ └── __snapshots__ │ └── prerequisites.test.js.snap ├── .yarnclean ├── .travis.yml └── package.json /src/renderer/vue-shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue" 3 | export default Vue 4 | } -------------------------------------------------------------------------------- /src/rx-ipc/main.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron" 2 | import { RxIpc } from "./rx-ipc" 3 | 4 | export default new RxIpc(ipcMain) 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/electron-webpack/tsconfig-base.json", 3 | "compilerOptions": { 4 | "baseUrl": "src" 5 | } 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Electrify 2 | 3 | Step-by-step wizard to prepare Electron app for distribution, from packaging to auto-update. 4 | 5 | Electrify not released yet. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | build/* 4 | !build/icons 5 | node_modules/ 6 | npm-debug.log 7 | npm-debug.log.* 8 | thumbs.db 9 | !.gitkeep 10 | test/out/*.js* -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/renderer/components/testComponents.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | import "../initIView" 3 | 4 | import prerequisites from "./prerequisites.vue" 5 | 6 | export { prerequisites, Vue } -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | import App from "./App.vue" 3 | import "./initIView" 4 | import router from "./router" 5 | 6 | new Vue({ 7 | components: { App }, 8 | router, 9 | template: "" 10 | }).$mount("#app") -------------------------------------------------------------------------------- /.idea/jsLinters/tslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/debug.d.ts: -------------------------------------------------------------------------------- 1 | declare module "debug" { 2 | export interface Debugger { 3 | (formatter: any, ...args: Array): void 4 | 5 | enabled: boolean 6 | namespace: string 7 | } 8 | 9 | export default function debug(namespace: string): Debugger 10 | } -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../node_modules/electron-webpack/tsconfig-base.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "target": "es2015", 6 | "outDir": "out" 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations/dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/rc-producer.yml: -------------------------------------------------------------------------------- 1 | - &defaults 2 | files: ["test/src/**/*", "!**/helpers/**/*"] 3 | script: "node_modules/jest-cli/bin/jest.js" 4 | scriptArgs: ["-i", "--env", "jest-environment-node-debug", &filePattern '--testPathPattern=[/\\]{1}${fileNameWithoutExt}\.\w+$'] 5 | rcName: "${fileNameWithoutExt}" 6 | beforeRun: typescript 7 | 8 | - 9 | <<: *defaults 10 | lineRegExp: '^\s*(?:test|it|testAndIgnoreApiRate)(?:\.[\w.]+)?\("([^"'']+)' 11 | scriptArgs: ["-i", "--env", "jest-environment-node-debug", "-t", "${0regExp}", *filePattern] 12 | rcName: "${fileNameWithoutExt}.${0}" -------------------------------------------------------------------------------- /src/common/projectInfo.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectInfoPrerequisites { 2 | yarn: boolean 3 | electronBuilder: DependencyInfo 4 | dependencies: { [name: string]: any} 5 | } 6 | 7 | export interface DependencyInfo { 8 | installed: boolean 9 | latest: string | null 10 | } 11 | 12 | export interface ProjectInfo { 13 | prerequisites: ProjectInfoPrerequisites 14 | 15 | metadata: ProjectMetadata 16 | } 17 | 18 | export interface ProjectMetadata { 19 | name?: string 20 | productName?: string 21 | appId?: string 22 | description?: string 23 | 24 | author?: string 25 | } -------------------------------------------------------------------------------- /test/src/prerequisites.test.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | 3 | const c = require("/Users/develar/Documents/electrify/dist/test/prerequisites.js") 4 | const v: typeof Vue = c.Vue 5 | 6 | const doTest = (Component: any) => { 7 | const vm = new v({ 8 | el: document.createElement("div"), 9 | render: h => h(Component) 10 | }) 11 | 12 | expect(vm.$el).toBeDefined() 13 | expect(vm.$el).toMatchSnapshot() 14 | } 15 | 16 | describe("preprocessor", () => { 17 | it("should process a `.vue` file", () => { 18 | const component = c.prerequisites 19 | doTest(component) 20 | }) 21 | }) -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | # test directories 2 | __tests__ 3 | node_modules/*/test 4 | node_modules/*/tests 5 | powered-test 6 | 7 | # asset directories 8 | docs 9 | doc 10 | website 11 | images 12 | assets 13 | 14 | # examples 15 | example 16 | examples 17 | 18 | # code coverage directories 19 | coverage 20 | .nyc_output 21 | 22 | # build scripts 23 | Makefile 24 | Gulpfile.js 25 | Gruntfile.js 26 | 27 | # configs 28 | .tern-project 29 | .gitattributes 30 | .editorconfig 31 | .*ignore 32 | .eslintrc 33 | .jshintrc 34 | .flowconfig 35 | .documentup.json 36 | .yarn-metadata.json 37 | 38 | # misc 39 | *.gz 40 | *.md 41 | -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | export class Lazy { 2 | private _value: Promise | null = null 3 | private creator: (() => Promise) | null 4 | 5 | constructor(creator: () => Promise) { 6 | this.creator = creator 7 | } 8 | 9 | get value(): Promise { 10 | if (this.creator == null) { 11 | return this._value!! 12 | } 13 | 14 | this.value = this.creator() 15 | return this._value!! 16 | } 17 | 18 | set value(value: Promise) { 19 | this._value = value 20 | this.creator = null 21 | } 22 | 23 | get hasValue() { 24 | return this.creator == null 25 | } 26 | } -------------------------------------------------------------------------------- /.idea/dictionaries/develar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | axios 5 | babili 6 | deduplication 7 | devtool 8 | devtools 9 | devtron 10 | esnext 11 | imgs 12 | iview 13 | minify 14 | nosources 15 | npmjs 16 | onshape 17 | transpile 18 | unsubscribe 19 | vueify 20 | xstream 21 | yarnpkg 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/electrify.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/components/prerequisites.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | import Component from "vue-class-component" 3 | import { Route } from "vue-router" 4 | import { getInfo } from "../projectInfoManager" 5 | 6 | @Component 7 | export default class extends Vue { 8 | yarn = false 9 | electronBuilder = {} 10 | dependencies = {} 11 | 12 | beforeRouteEnter(to: Route, from: Route, next: (r: Error | ((vm: Vue) => void)) => any) { 13 | // catch before then to not handle error in the then handler 14 | getInfo() 15 | .catch(error => next(error)) 16 | .then(it => next(vm => { 17 | Object.assign(vm, it.prerequisites) 18 | })) 19 | } 20 | } -------------------------------------------------------------------------------- /src/renderer/components/icons.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 20 | 32 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | -------------------------------------------------------------------------------- /src/renderer/components/projectInfo.ts: -------------------------------------------------------------------------------- 1 | import { ProjectMetadata } from "common/projectInfo" 2 | import Vue from "vue" 3 | import Component from "vue-class-component" 4 | import { Route } from "vue-router" 5 | import { getInfo } from "../projectInfoManager" 6 | 7 | @Component 8 | export default class extends Vue implements ProjectMetadata { 9 | name = "" 10 | productName = "" 11 | appId = "" 12 | description = "" 13 | author = "" 14 | 15 | changedData = {} 16 | 17 | beforeRouteEnter(to: Route, from: Route, next: (r: Error | ((vm: Vue) => void)) => any) { 18 | // catch before then to not handle error in the then handler 19 | getInfo() 20 | .catch(error => next(error)) 21 | .then(it => next(vm => { 22 | Object.assign(vm, it.metadata) 23 | })) 24 | } 25 | 26 | applyChanges() { 27 | alert("Changed " + JSON.stringify(this.changedData)) 28 | } 29 | } -------------------------------------------------------------------------------- /src/rx-ipc/deep-diff.d.ts: -------------------------------------------------------------------------------- 1 | declare module "deep-diff" { 2 | interface IDiff { 3 | kind: string; 4 | path: string[]; 5 | lhs: any; 6 | rhs: any; 7 | index?: number; 8 | item?: IDiff; 9 | } 10 | 11 | interface IAccumulator { 12 | push(diff: IDiff): void; 13 | 14 | length: number; 15 | } 16 | 17 | interface IPrefilter { 18 | (path: string[], key: string): boolean; 19 | } 20 | 21 | function diff(lhs: Object, rhs: Object, prefilter?: IPrefilter, acc?: IAccumulator): IDiff[]; 22 | 23 | function observableDiff(lhs: Object, rhs: Object, changes: Function, prefilter?: IPrefilter, path?: string[], key?: string, stack?: Object[]): void; 24 | 25 | function applyDiff(target: Object, source: Object, filter: Function): void; 26 | 27 | function applyChange(target: Object, source: Object, change: IDiff): void; 28 | 29 | function revertChange(target: Object, source: Object, change: IDiff): void; 30 | } -------------------------------------------------------------------------------- /src/renderer/iview.d.ts: -------------------------------------------------------------------------------- 1 | declare module "iview/src/components/*" { 2 | import { PluginObject } from "vue" 3 | const component: PluginObject 4 | export default component 5 | } 6 | 7 | declare module "iview" { 8 | export interface LoadingBarI { 9 | start(): void 10 | 11 | finish(): void 12 | } 13 | 14 | export const LoadingBar: LoadingBarI 15 | } 16 | 17 | declare module "iview/src/components/grid" { 18 | export const Row: any 19 | export const Col: any 20 | } 21 | 22 | declare module "iview/src/components/loading-bar" { 23 | interface LoadingBar { 24 | start(): void 25 | 26 | finish(): void 27 | } 28 | 29 | const loadingBar: LoadingBar 30 | 31 | export default loadingBar 32 | } 33 | 34 | declare module "iview/src/locale/lang/en-US" { 35 | const data: any 36 | export default data 37 | } 38 | 39 | declare module "iview/src/locale" { 40 | interface LocaleManager { 41 | use(locale: any): void 42 | } 43 | 44 | const localeManager: LocaleManager 45 | export default localeManager 46 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode8.3 2 | 3 | dist: trusty 4 | sudo: false 5 | 6 | language: node_js 7 | node_js: "8" 8 | 9 | env: 10 | global: 11 | - ELECTRON_CACHE=$HOME/.cache/electron 12 | - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder 13 | 14 | os: 15 | - osx 16 | - linux 17 | 18 | cache: 19 | directories: 20 | - node_modules 21 | - $HOME/.cache/electron 22 | - $HOME/.cache/electron-builder 23 | - $HOME/.npm/_prebuilds 24 | 25 | before_install: 26 | - mkdir -p /tmp/git-lfs && curl -L https://github.com/github/git-lfs/releases/download/v2.2.0/git-lfs-$([ "$TRAVIS_OS_NAME" == "linux" ] && echo "linux" || echo "darwin")-amd64-2.2.0.tar.gz | tar -xz -C /tmp/git-lfs --strip-components 1 && /tmp/git-lfs/git-lfs pull 27 | - curl -L https://yarnpkg.com/latest.tar.gz | tar xvz && mv dist $HOME/.yarn 28 | - export PATH="$HOME/.yarn/bin:$PATH" 29 | 30 | install: 31 | - yarn 32 | 33 | script: 34 | - yarn test 35 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then yarn release; fi 36 | - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then yarn release -- --mac --win; fi 37 | 38 | before_cache: 39 | - rm -rf $HOME/.cache/electron-builder/wine 40 | 41 | branches: 42 | except: 43 | - "/^v\\d+\\.\\d+\\.\\d+$/" -------------------------------------------------------------------------------- /src/renderer/components/project.vue: -------------------------------------------------------------------------------- 1 | 6 | 22 | 51 | -------------------------------------------------------------------------------- /src/renderer/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | import Component from "vue-class-component" 3 | import Router from "vue-router" 4 | 5 | Vue.use(Router) 6 | 7 | // you have to register the hooks before component definition 8 | Component.registerHooks(["beforeRouteEnter", "beforeRouteUpdate", "beforeRouteLeave", "beforeRouteLeave"]) 9 | 10 | export default new Router({ 11 | routes: [ 12 | { 13 | path: "/", 14 | redirect: "/project/prerequisites" 15 | }, 16 | { 17 | path: "/project", 18 | redirect: "/project/prerequisites", 19 | component: () => import(/* webpackMode: "eager" */ "./components/project.vue"), 20 | children: [ 21 | { 22 | path: "prerequisites", 23 | component: () => import(/* webpackChunkName: "project" */ "./components/prerequisites.vue") 24 | }, 25 | { 26 | path: "projectInfo", 27 | component: () => import(/* webpackChunkName: "project" */ "./components/projectInfo.vue") 28 | }, 29 | { 30 | path: "icons", 31 | component: () => import(/* webpackMode: "eager" */ "./components/icons.vue") 32 | }, 33 | ], 34 | }, 35 | { 36 | path: "*", 37 | redirect: "/project/prerequisites" 38 | } 39 | ] 40 | } as any) 41 | -------------------------------------------------------------------------------- /src/renderer/components/prerequisites.vue: -------------------------------------------------------------------------------- 1 | 6 | 39 | -------------------------------------------------------------------------------- /src/renderer/initIView.ts: -------------------------------------------------------------------------------- 1 | import "iview/dist/styles/iview.css" 2 | import Button from "iview/src/components/button" 3 | import Card from "iview/src/components/card" 4 | import Form from "iview/src/components/form" 5 | import * as grid from "iview/src/components/grid" 6 | import Input from "iview/src/components/input" 7 | import LoadingBar from "iview/src/components/loading-bar" 8 | import Menu from "iview/src/components/menu" 9 | import Tag from "iview/src/components/tag" 10 | import Tooltip from "iview/src/components/tooltip" 11 | import localeManager from "iview/src/locale" 12 | import enLocale from "iview/src/locale/lang/en-US" 13 | import Vue from "vue" 14 | 15 | // cannot make it working, include the whole css 16 | // import "iview/src/styles/custom.less" 17 | 18 | // import "iview/src/styles/mixins/common.less" 19 | // import "iview/src/styles/common/base.less" 20 | 21 | // import "iview/src/styles/mixins/layout.less" 22 | // import "iview/src/styles/common/layout.less" 23 | 24 | // import "iview/src/styles/common/article.less" 25 | 26 | // import "iview/src/styles/components/menu.less" 27 | // import "iview/src/styles/components/card.less" 28 | // import "iview/src/styles/components/tag.less" 29 | // import "iview/src/styles/components/loading-bar.less" 30 | 31 | localeManager.use(enLocale) 32 | 33 | const nameToComponent: any = { 34 | Row: grid.Row, 35 | Col: grid.Col, 36 | iCol: grid.Col, 37 | MenuItem: (Menu as any).Item, 38 | Menu, 39 | Card, 40 | Tag, 41 | Form, 42 | iForm: Form, 43 | FormItem: Form.Item, 44 | Input, 45 | Button, 46 | Tooltip, 47 | iButton: Button, 48 | } 49 | 50 | for (const name of Object.keys(nameToComponent)) { 51 | Vue.component(name, nameToComponent[name]) 52 | } 53 | 54 | (Vue.prototype as any).$Loading = LoadingBar -------------------------------------------------------------------------------- /src/renderer/vue-apply-diff.ts: -------------------------------------------------------------------------------- 1 | import { IDiff } from "deep-diff" 2 | import Vue from "vue" 3 | 4 | export function applyDiff(data: any, changes: Array) { 5 | for (const change of changes) { 6 | let it: any = data 7 | let i = -1 8 | const last = change.path ? change.path.length - 1 : 0 9 | while (++i < last) { 10 | if (typeof it[change.path[i]] === "undefined") { 11 | Vue.set(it, change.path[i], (typeof change.path[i] === "number") ? [] : {}) 12 | } 13 | it = it[change.path[i]] 14 | } 15 | switch (change.kind) { 16 | case "A": 17 | applyArrayChange(change.path ? it[change.path[i]] : it, change.index!, change.item!) 18 | break 19 | 20 | case "D": 21 | Vue.delete(it, change.path[i]) 22 | break 23 | 24 | case "E": 25 | case "N": 26 | Vue.set(it, change.path[i], change.rhs) 27 | break 28 | } 29 | } 30 | } 31 | 32 | function applyArrayChange(array: Array, index: number, change: IDiff): void { 33 | if (change.path && change.path.length) { 34 | let it: any = array[index] 35 | let i = 0 36 | const u = change.path.length - 1 37 | for (; i < u; i++) { 38 | it = it[change.path[i]] 39 | } 40 | 41 | switch (change.kind) { 42 | case "A": 43 | applyArrayChange(it[change.path[i]], change.index!, change.item!) 44 | break 45 | 46 | case "D": 47 | Vue.delete(it, change.path[i]) 48 | break 49 | 50 | case "E": 51 | case "N": 52 | Vue.set(it, change.path[i], change.rhs) 53 | break 54 | } 55 | } 56 | else { 57 | switch (change.kind) { 58 | case "A": 59 | applyArrayChange(array[index] as any, change.index!, change.item!) 60 | break 61 | 62 | case "D": 63 | array.splice(index, 1) 64 | break 65 | 66 | case "E": 67 | case "N": 68 | Vue.set(array, index, change.rhs) 69 | break 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/main/store.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron" 2 | import * as os from "os" 3 | import * as path from "path" 4 | 5 | export interface Project { 6 | path: string 7 | } 8 | 9 | function createStore() { 10 | const options: any = {} 11 | if (os.platform() === "darwin") { 12 | options.name = "build.electron.electrify" + (process.env.NODE_ENV === "development" ? "-dev" : "") 13 | options.cwd = path.join(os.homedir(), "Library", "Preferences") 14 | } 15 | else { 16 | options.name = "electrify" 17 | } 18 | 19 | const Store = require("electron-store") 20 | return new Store(options) 21 | } 22 | 23 | function getHotData(): any { 24 | return (module.hot == null ? null : module.hot.data) || {} 25 | } 26 | 27 | export class StoreManager { 28 | private readonly store = createStore() 29 | private isSaveOnWindowClose = true 30 | 31 | private readonly windowToProject = getHotData().windowToProject || new Map() 32 | 33 | constructor() { 34 | app.on("before-quit", () => { 35 | this.save() 36 | this.isSaveOnWindowClose = false 37 | }) 38 | 39 | if (module.hot != null) { 40 | module.hot.dispose((data: any) => { 41 | data.windowToProject = this.windowToProject 42 | }) 43 | } 44 | } 45 | 46 | get isSomeProjectOpened() { 47 | return this.windowToProject.size > 0 48 | } 49 | 50 | getProject(window: Electron.BrowserWindow) { 51 | return this.windowToProject.get(window) 52 | } 53 | 54 | private save() { 55 | this.store.set("projects", Array.from(this.windowToProject.values()).filter(it => it != null)) 56 | } 57 | 58 | addProject(project: Project, window: Electron.BrowserWindow, isSave = true) { 59 | const isAddWindowListener = !this.windowToProject.has(window) 60 | this.windowToProject.set(window, project) 61 | 62 | if (isAddWindowListener) { 63 | window.once("closed", () => { 64 | this.windowToProject.delete(window) 65 | if (this.isSaveOnWindowClose) { 66 | this.save() 67 | } 68 | }) 69 | } 70 | 71 | if (isSave) { 72 | this.save() 73 | } 74 | } 75 | 76 | getProjects(): Array { 77 | const result: Array = this.store.get("projects") 78 | if (result == null) { 79 | return [] 80 | } 81 | else { 82 | return result.filter(it => it != null && it.path != null) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/renderer/components/projectInfo.vue: -------------------------------------------------------------------------------- 1 | 6 | 49 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, shell } from "electron" 2 | import xstream from "xstream" 3 | import rxIpc from "../rx-ipc/main" 4 | import { ProjectInfoProducer } from "./ProjectInfoProducer" 5 | import { Project, StoreManager } from "./store" 6 | 7 | const isDev = process.env.NODE_ENV === "development" 8 | 9 | // to debug packed app as well 10 | require("electron-debug")({enabled: true, showDevTools: isDev}) 11 | 12 | // set `__static` path to static files in production 13 | if (!isDev) { 14 | (global as any).__static = require("path").join(__dirname, "/static").replace(/\\/g, "\\\\") 15 | } 16 | 17 | const winURL = isDev ? `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}` : `file://${__dirname}/index.html` 18 | 19 | app.once("ready", () => { 20 | const storeManager = new StoreManager() 21 | 22 | const projects: Array = storeManager.getProjects() 23 | if (projects.length === 0) { 24 | projects.push(null) 25 | } 26 | 27 | configureIpc(storeManager) 28 | 29 | if (module.hot != null) { 30 | module.hot.accept("./ProjectInfoProducer", () => { 31 | rxIpc.cleanUp() 32 | configureIpc(storeManager) 33 | }) 34 | } 35 | 36 | for (const project of projects) { 37 | createWindow(project, storeManager) 38 | } 39 | 40 | app.on("activate", () => { 41 | if (!storeManager.isSomeProjectOpened) { 42 | createWindow(null, storeManager) 43 | } 44 | }) 45 | }) 46 | 47 | function configureIpc(storeManager: StoreManager) { 48 | rxIpc.registerListener("toolStatus", webContents => xstream.create(new ProjectInfoProducer(webContents, storeManager))) 49 | } 50 | 51 | if (process.platform !== "darwin") { 52 | app.on("window-all-closed", () => { 53 | app.quit() 54 | }) 55 | } 56 | 57 | function createWindow(project: Project | null, storeManager: StoreManager) { 58 | const window = new BrowserWindow({ 59 | height: 563, 60 | useContentSize: true, 61 | width: 1000, 62 | }) 63 | 64 | if (project != null) { 65 | storeManager.addProject(project, window, false) 66 | } 67 | window.loadURL(winURL) 68 | window.setTitle(`Electrify - ${project == null ? "open project" : project.path}`) 69 | 70 | window.webContents.on("new-window", (event, url) => { 71 | event.preventDefault() 72 | shell.openExternal(url) 73 | }) 74 | 75 | window.webContents.on("devtools-opened", () => { 76 | window.focus() 77 | setImmediate(() => { 78 | window.focus() 79 | }) 80 | }) 81 | } -------------------------------------------------------------------------------- /src/renderer/projectInfoManager.ts: -------------------------------------------------------------------------------- 1 | import BluebirdPromise from "bluebird-lst" 2 | import { ProjectInfo } from "common/projectInfo" 3 | import debugFactory from "debug" 4 | import { diff, IDiff } from "deep-diff" 5 | import { ipcRenderer } from "electron" 6 | import LoadingBar from "iview/src/components/loading-bar" 7 | import { Listener } from "xstream" 8 | import { Lazy } from "../main/util" 9 | import { Applicator, RxIpc } from "../rx-ipc/rx-ipc" 10 | import { applyDiff } from "./vue-apply-diff" 11 | 12 | let info: ProjectInfo | null = null 13 | 14 | const debug = debugFactory("electrify") 15 | 16 | class ProjectInfoListener implements Listener, Applicator { 17 | constructor(private resolve: ((data: ProjectInfo) => void) | null, private reject: ((error: Error | any) => void) | null) { 18 | LoadingBar.start() 19 | } 20 | 21 | applyChanges(changes: Array): void { 22 | if (debug.enabled) { 23 | debug("Diff: " + JSON.stringify(changes, null, 2)) 24 | } 25 | applyDiff(info, changes) 26 | } 27 | 28 | next(data: ProjectInfo): void { 29 | if (debug.enabled) { 30 | debug("Initial data: " + JSON.stringify(data, null, 2)) 31 | } 32 | 33 | const resolve = this.resolve 34 | if (resolve == null) { 35 | Object.assign(info, data) 36 | } 37 | else { 38 | if (info == null) { 39 | info = data 40 | } 41 | else { 42 | this.applyChanges(diff(info, data)) 43 | } 44 | 45 | LoadingBar.finish() 46 | this.resolve = null 47 | resolve(info) 48 | } 49 | } 50 | 51 | error(error: any): void { 52 | const reject = this.reject 53 | if (reject == null) { 54 | console.error() 55 | } 56 | else { 57 | LoadingBar.finish() 58 | this.reject = null 59 | console.error(error) 60 | reject(error instanceof Error ? error : new Error(error)) 61 | } 62 | } 63 | 64 | complete(): void { 65 | // HMR 66 | subscription.value = subscribe() 67 | } 68 | } 69 | 70 | const ipc = new RxIpc(ipcRenderer) 71 | 72 | function subscribe() { 73 | return new BluebirdPromise((resolve, reject) => { 74 | const listener = new ProjectInfoListener(resolve, reject) 75 | ipc.runCommand("toolStatus", null, null, listener) 76 | .subscribe(listener) 77 | }) 78 | } 79 | 80 | const subscription = new Lazy(() => subscribe()) 81 | 82 | export async function getInfo(): Promise { 83 | if (info != null) { 84 | return info 85 | } 86 | return await subscription.value 87 | } -------------------------------------------------------------------------------- /src/main/lint/prerequisites.ts: -------------------------------------------------------------------------------- 1 | import BluebirdPromise from "bluebird-lst" 2 | import { execFile } from "child_process" 3 | import { ProjectInfo } from "common/projectInfo" 4 | import { net } from "electron" 5 | import { DataProducer } from "../ProjectInfoProducer" 6 | import { Lazy } from "../util" 7 | 8 | const bashEnv = new Lazy(() => require("shell-env")()) 9 | 10 | const latestElectronBuilderVersion = new Lazy(() => getLatestElectronBuilderVersion() 11 | .catch(error => { 12 | console.log(JSON.stringify(error, null, 2)) 13 | return null 14 | })) 15 | 16 | export async function computePrerequisites(data: ProjectInfo, projectDir: string, metadata: any, dataProducer: DataProducer) { 17 | if (!latestElectronBuilderVersion.hasValue) { 18 | latestElectronBuilderVersion.value 19 | .then(it => { 20 | data.prerequisites.electronBuilder.latest = it 21 | dataProducer.dataChanged() 22 | }) 23 | } 24 | 25 | data.prerequisites.yarn = await getYarnVersion(projectDir) 26 | applyMetadata(data, metadata, latestElectronBuilderVersion.hasValue ? (await latestElectronBuilderVersion.value) : null) 27 | } 28 | 29 | function applyMetadata(data: ProjectInfo, metadata: any, latestElectronBuilderVersion: string | null) { 30 | const deps = metadata.devDependencies 31 | const result = {installed: false, latest: latestElectronBuilderVersion} 32 | if (deps != null) { 33 | const electronBuilderVersion = deps["electron-builder"] 34 | if (electronBuilderVersion != null) { 35 | result.installed = electronBuilderVersion 36 | } 37 | } 38 | Object.assign(data.prerequisites.electronBuilder, result) 39 | } 40 | 41 | export function getYarnVersion(workingDir: string): Promise { 42 | return bashEnv.value 43 | .then(env => new BluebirdPromise((resolve, reject) => { 44 | execFile("yarn", ["--version"], {env, cwd: workingDir}, (error, stdout) => { 45 | if (error == null) { 46 | resolve(stdout) 47 | } 48 | else { 49 | reject(error) 50 | } 51 | }) 52 | })) 53 | .then(it => true) 54 | .catch(error => { 55 | if (error.code === "ENOENT") { 56 | return false 57 | } 58 | else { 59 | throw error 60 | } 61 | }) 62 | } 63 | 64 | export function getLatestElectronBuilderVersion() { 65 | return new BluebirdPromise((resolve, reject) => { 66 | const request = net.request({ 67 | protocol: "https:", 68 | hostname: "github.com", 69 | path: "/electron-userland/electron-builder/releases/latest", 70 | headers: { 71 | Accept: "application/json", 72 | }, 73 | }) 74 | request.on("response", response => { 75 | try { 76 | let data = "" 77 | response.on("error", reject) 78 | response.on("data", chunk => { 79 | data += chunk 80 | }) 81 | response.on("end", () => { 82 | try { 83 | const releaseInfo = JSON.parse(data) 84 | resolve((releaseInfo.tag_name.startsWith("v")) ? releaseInfo.tag_name.substring(1) : releaseInfo.tag_name) 85 | } 86 | catch (e) { 87 | reject(e) 88 | } 89 | }) 90 | } 91 | catch (e) { 92 | reject(e) 93 | } 94 | }) 95 | request.on("error", reject) 96 | request.end() 97 | }) 98 | } -------------------------------------------------------------------------------- /test/out/__snapshots__/prerequisites.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`preprocessor should process a \`.vue\` file 1`] = ` 4 |
7 |
11 | 46 |
49 |
52 |

55 | 60 | Yarn 61 | 62 | offers fast and reliable build, with reduced application size. 63 |

64 |
    67 |
  • 70 | -  npm is unreliable and slow, 71 |
  • 72 |
  • 75 | -  npm doesn't produce ideal dependency tree, 76 |
  • 77 |
  • 80 | -  npm doesn't perform automatic deduplication and cleaning. 81 |
  • 82 |
83 |
84 |
85 |
89 |
92 |
95 | electron-builder 96 |
100 | 101 | 104 | Not installed 105 | 106 | 107 |
108 | Please install 109 | 114 | electron-builder 115 | 116 | locally. 117 |
118 |
119 |
122 |
125 | 128 | yarn add electron-builder --dev 129 | 130 |
131 |
132 |
133 | `; 134 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electrify", 3 | "version": "0.0.0", 4 | "author": "Vladimir Krivosheev ", 5 | "description": "Step-by-step wizard to prepare Electron app for distribution, from packaging to auto-update", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "electron-webpack dev", 9 | "compile": "electron-webpack", 10 | "dist-dir": "yarn compile && electron-builder --dir -c.compression=store -c.mac.identity=null", 11 | "dist": "yarn compile && electron-builder", 12 | "lint": "yarn lint:main && yarn lint:renderer", 13 | "lint:main": "tslint -c ./node_modules/electron-builder-tslint-config/tslint.json -p src/main --exclude '**/*.js'", 14 | "lint:renderer": "tslint -c ./node_modules/electron-builder-tslint-config/tslint.json -p src/renderer --exclude '**/*.js'", 15 | "lint:main-fix": "tslint -c ./node_modules/electron-builder-tslint-config/tslint.json -p src/main --exclude '**/*.js' --fix", 16 | "lint:renderer-fix": "tslint -c ./node_modules/electron-builder-tslint-config/tslint.json -p src/renderer --exclude '**/*.js' --fix", 17 | "lintAndFix": "yarn lint:main-fix && yarn lint:renderer-fix", 18 | "test": "tsc -p test && jest", 19 | "pretest": "webpack --display minimal --config node_modules/electron-webpack/webpack.test.config.js", 20 | "postinstall": "electron-webpack dll" 21 | }, 22 | "repository": "electron-userland/electrify", 23 | "build": { 24 | "productName": "Electrify", 25 | "appId": "build.electron.electrify", 26 | "files": [ 27 | "!node_modules/deep-diff/releases{,/**/*}", 28 | "!node_modules/bluebird/js/browser{,/**/*}", 29 | "!node_modules/source-map-support/**/*", 30 | "node_modules/source-map-support/source-map-support.js" 31 | ] 32 | }, 33 | "electronWebpack": { 34 | "renderer": { 35 | "dll": [ 36 | "vue", 37 | "vue-router", 38 | "iview/dist/styles/iview.css", 39 | "iview/src/components/button", 40 | "iview/src/components/card", 41 | "iview/src/components/form", 42 | "iview/src/components/grid", 43 | "iview/src/components/input", 44 | "iview/src/components/loading-bar", 45 | "iview/src/components/menu", 46 | "iview/src/components/tag", 47 | "iview/src/components/tooltip", 48 | "iview/src/locale", 49 | "iview/src/locale/lang/en-US" 50 | ] 51 | } 52 | }, 53 | "dependencies": { 54 | "bluebird-lst": "^1.0.5", 55 | "clone": "^2.1.2", 56 | "debug": "^3.1.0", 57 | "deep-diff": "^1.0.2", 58 | "electron-debug": "^2.0.0", 59 | "electron-store": "^2.0.0", 60 | "fs-extra-p": "^4.6.1", 61 | "node-watch": "^0.5.8", 62 | "shell-env": "^2.0.1", 63 | "source-map-support": "^0.5.9", 64 | "try-require": "^1.2.1", 65 | "xstream": "^11.7.0" 66 | }, 67 | "devDependencies": { 68 | "@types/jest": "^23.3.1", 69 | "devtron": "^1.4.0", 70 | "electron": "2.0.7", 71 | "electron-builder": "^20.28.1", 72 | "electron-builder-tslint-config": "^1.1.0", 73 | "electron-webpack": "~2.2.1", 74 | "electron-webpack-ts": "^2.1.0", 75 | "electron-webpack-vue": "^2.2.0", 76 | "iview": "^3.0.0", 77 | "jest-cli": "^23.5.0", 78 | "jest-environment-node-debug": "^2.0.0", 79 | "less": "^3.8.1", 80 | "less-loader": "^4.1.0", 81 | "tslint": "^5.11.0", 82 | "typescript": "^3.0.1", 83 | "vue": "^2.5.17", 84 | "vue-router": "^3.0.1", 85 | "webpack": "4.16.5", 86 | "webpack-build-notifier": "^0.1.28" 87 | }, 88 | "jest": { 89 | "roots": [ 90 | "/test" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 87 | 89 | -------------------------------------------------------------------------------- /src/main/ProjectInfoProducer.ts: -------------------------------------------------------------------------------- 1 | import BluebirdPromise from "bluebird-lst" 2 | import { ProjectInfo } from "common/projectInfo" 3 | import debugFactory from "debug" 4 | import { BrowserWindow, dialog } from "electron" 5 | import { FSWatcher } from "fs" 6 | import { readJson, stat } from "fs-extra-p" 7 | import * as path from "path" 8 | import { Listener, Producer } from "xstream" 9 | import { computePrerequisites } from "./lint/prerequisites" 10 | import { StoreManager } from "./store" 11 | 12 | const watch = require("node-watch") 13 | 14 | const debug = debugFactory("electrify") 15 | 16 | export interface DataProducer { 17 | dataChanged(): void 18 | } 19 | 20 | // todo listed system changes (to update status when yarn will be installed) 21 | export class ProjectInfoProducer implements Producer, DataProducer { 22 | private fsWatcher: FSWatcher | null = null 23 | private readonly data: ProjectInfo = { 24 | prerequisites: { 25 | yarn: true, 26 | electronBuilder: { 27 | installed: false, 28 | latest: null, 29 | }, 30 | dependencies: {}, 31 | }, 32 | metadata: {} 33 | } 34 | 35 | private listener: Listener | null = null 36 | 37 | constructor(private readonly webContents: Electron.WebContents, private readonly storeManager: StoreManager) { 38 | } 39 | 40 | private get window() { 41 | return BrowserWindow.fromWebContents(this.webContents) 42 | } 43 | 44 | dataChanged() { 45 | if (this.listener != null) { 46 | this.listener.next(this.data) 47 | } 48 | } 49 | 50 | start(listener: Listener) { 51 | this.listener = listener 52 | this.doStart() 53 | .then(data => listener.next(data)) 54 | .catch(error => { 55 | console.error(error.stack || error.toString()) 56 | listener.error(error) 57 | }) 58 | } 59 | 60 | private async doStart(): Promise { 61 | const project = this.storeManager.getProject(this.window) 62 | let projectDir = project == null ? null : project.path 63 | if (projectDir != null && !(await validateProjectDir(projectDir))) { 64 | projectDir = null 65 | } 66 | 67 | if (projectDir != null) { 68 | await this.computeProjectInfo(projectDir) 69 | this.watchProjectPackageFile(path.join(projectDir, "package.json")) 70 | return this.data 71 | } 72 | 73 | return await new BluebirdPromise((resolve, reject) => this.selectProject(resolve, reject)) 74 | } 75 | 76 | private selectProject(resolve: (result: ProjectInfo) => void, reject: (error: Error) => void) { 77 | dialog.showOpenDialog(this.window, { 78 | title: "Open Project", 79 | properties: ["openDirectory", "noResolveAliases"], 80 | message: "Select project directory" 81 | }, files => { 82 | const projectDir = files[0] 83 | validateProjectDir(projectDir) 84 | .then(result => { 85 | if (!result) { 86 | this.selectProject(resolve, reject) 87 | return 88 | } 89 | 90 | this.storeManager.addProject({ 91 | path: projectDir 92 | }, this.window) 93 | 94 | return this.computeProjectInfo(projectDir) 95 | .then(it => { 96 | this.watchProjectPackageFile(path.join(projectDir, "package.json")) 97 | resolve(it) 98 | }) 99 | }) 100 | .catch(reject) 101 | }) 102 | } 103 | 104 | private async computeProjectInfo(projectDir: string): Promise { 105 | const metadata = await readJson(path.join(projectDir, "package.json")) 106 | await computePrerequisites(this.data, projectDir, metadata, this) 107 | 108 | this.data.metadata.name = metadata.name 109 | this.data.metadata.description = metadata.description 110 | this.data.metadata.author = metadata.author 111 | 112 | const electronBuilderConfig = require("try-require")(path.join(projectDir, "node_modules", "electron-builder", "out/util/config")) 113 | const getBuilderConfig = electronBuilderConfig == null ? null : electronBuilderConfig.getConfig 114 | if (getBuilderConfig != null) { 115 | const config = await getBuilderConfig(projectDir) 116 | this.data.metadata.appId = config.appId 117 | this.data.metadata.productName = config.productName 118 | } 119 | else { 120 | this.data.metadata.appId = "" 121 | this.data.metadata.productName = "" 122 | } 123 | return this.data 124 | } 125 | 126 | private watchProjectPackageFile(packageFile: string) { 127 | if (this.fsWatcher != null) { 128 | return 129 | } 130 | 131 | debug(`Start watching ${packageFile}`) 132 | this.fsWatcher = watch(packageFile, { 133 | persistent: false, 134 | }, (event: "update" | "remove", file: string) => { 135 | if (debug.enabled) { 136 | debug(`File event: ${event} ${file}`) 137 | } 138 | this.computeProjectInfo(path.dirname(packageFile)) 139 | .then(it => { 140 | if (this.listener != null) { 141 | this.listener.next(it) 142 | } 143 | }) 144 | .catch(error => { 145 | console.error(error.stack || error.toString()) 146 | if (this.listener != null) { 147 | return this.listener.error(error) 148 | } 149 | }) 150 | }) 151 | } 152 | 153 | stop() { 154 | this.listener = null 155 | 156 | const fsWatcherHandle = this.fsWatcher 157 | if (fsWatcherHandle != null) { 158 | debug("Stop watching package file") 159 | this.fsWatcher = null 160 | fsWatcherHandle.close() 161 | } 162 | } 163 | } 164 | 165 | async function validateProjectDir(projectDir: string) { 166 | try { 167 | const file = path.join(projectDir, "package.json") 168 | const fileStat = await stat(file) 169 | if (!fileStat.isFile()) { 170 | debug(`${file} is not a package file`) 171 | return false 172 | } 173 | } 174 | catch (e) { 175 | debug(e) 176 | return false 177 | } 178 | 179 | return true 180 | } -------------------------------------------------------------------------------- /src/rx-ipc/rx-ipc.ts: -------------------------------------------------------------------------------- 1 | import BluebirdPromise from "bluebird-lst" 2 | import debugFactory from "debug" 3 | import { diff, IDiff } from "deep-diff" 4 | import xstream, { Listener, Producer, Stream } from "xstream" 5 | 6 | const clone = require("clone") 7 | const debug = debugFactory("rx-ipc") 8 | 9 | export type ObservableFactory = (webContents: Electron.WebContents, args?: Array) => Stream 10 | 11 | class SubChannelSubscription { 12 | constructor(private readonly listener: MyListener, private readonly stream: Stream) { 13 | } 14 | 15 | unsubscribe() { 16 | this.stream.removeListener(this.listener) 17 | } 18 | 19 | completeStream() { 20 | this.stream.shamefullySendComplete() 21 | } 22 | } 23 | 24 | export class RxIpc { 25 | static listenerCount = 0 26 | 27 | private readonly channelToSubscriptions = new Map>() 28 | 29 | constructor(private ipc: Electron.IpcRenderer | Electron.IpcMain) { 30 | // respond to checks if a listener is registered 31 | this.ipc.on("rx-ipc-check-listener", (event: any, channel: string) => { 32 | event.sender.send(`rx-ipc-check-reply:${channel}`, this.channelToSubscriptions.has(channel)) 33 | }) 34 | } 35 | 36 | protected checkRemoteListener(channel: string, target: Electron.IpcRenderer | Electron.WebContents): Promise { 37 | return new BluebirdPromise((resolve, reject) => { 38 | this.ipc.once(`rx-ipc-check-reply:${channel}`, (event: any, result: boolean) => { 39 | if (result) { 40 | resolve(result) 41 | } 42 | else { 43 | reject(false) 44 | } 45 | }) 46 | target.send("rx-ipc-check-listener", channel) 47 | }) 48 | } 49 | 50 | private get eventEmitter() { 51 | return this.ipc as Electron.EventEmitter 52 | } 53 | 54 | // noinspection JSUnusedGlobalSymbols 55 | cleanUp() { 56 | debug("Remove all listeners and unsubscribe from all streams") 57 | this.eventEmitter.removeAllListeners("rx-ipc-check-listener") 58 | for (const [channel, subscriptions] of this.channelToSubscriptions) { 59 | this.eventEmitter.removeAllListeners(channel) 60 | for (const subscription of subscriptions.values()) { 61 | subscription.completeStream() 62 | } 63 | } 64 | this.channelToSubscriptions.clear() 65 | } 66 | 67 | registerListener(channel: string, observableFactory: ObservableFactory): void { 68 | if (this.channelToSubscriptions.has(channel)) { 69 | throw new Error(`Channel ${channel} already registered`) 70 | } 71 | 72 | debug(`Listen ${channel}`) 73 | 74 | const subChannelToSubscription = new Map() 75 | this.channelToSubscriptions.set(channel, subChannelToSubscription) 76 | this.ipc.on(channel, (event: Electron.Event, subChannel: string, ...args: Array) => { 77 | debug(`Subscribe ${subChannel} to ${channel}`) 78 | try { 79 | const stream = observableFactory(event.sender, args) 80 | const listener = new MyListener(event.sender, subChannel) 81 | stream.addListener(listener) 82 | subChannelToSubscription.set(subChannel, new SubChannelSubscription(listener, stream)) 83 | event.sender.on("destroyed", () => { 84 | // this listener must be static and do not use any variable (except subChannel) from outer scope (so, on hot reload, we don't need to remove/add it again) 85 | debug(`Unsubscribe ${subChannel} from ${channel} on web contents destroyed`) 86 | this.removeListener(channel, subChannel) 87 | }) 88 | } 89 | catch (e) { 90 | event.sender.send(subChannel, MessageType.ERROR, `Cannot subscribe: ${e.toString()}`) 91 | } 92 | }) 93 | } 94 | 95 | private removeListener(channel: string, subChannel?: string) { 96 | this.eventEmitter.removeAllListeners(channel) 97 | const subChannelToSubscription = this.channelToSubscriptions.get(channel) 98 | if (subChannelToSubscription == null) { 99 | return 100 | } 101 | 102 | this.channelToSubscriptions.delete(channel) 103 | for (const [key, subscription] of subChannelToSubscription) { 104 | if (subChannel == null || key === subChannel) { 105 | subscription.unsubscribe() 106 | } 107 | } 108 | } 109 | 110 | runCommand(channel: string, receiver: Electron.IpcRenderer | Electron.WebContents | null = null, args?: Array | null, applicator?: Applicator): Stream { 111 | const subChannel = `${channel}:${RxIpc.listenerCount++}` 112 | const target = receiver == null ? this.ipc as Electron.IpcRenderer : receiver 113 | 114 | if (args == null) { 115 | target.send(channel, subChannel) 116 | } 117 | else { 118 | target.send(channel, subChannel, ...args) 119 | } 120 | 121 | const stream = xstream.create(new MyProducer(this.ipc, subChannel, applicator)) 122 | this.checkRemoteListener(channel, target) 123 | .catch(() => { 124 | stream.shamefullySendError(new Error(`Invalid channel: ${channel}`)) 125 | }) 126 | 127 | if (process.env.NODE_ENV === "development") { 128 | return stream.debug(channel) 129 | } 130 | return stream 131 | } 132 | } 133 | 134 | class MyProducer implements Producer { 135 | private ipcListener: any | null = null 136 | 137 | constructor(private ipc: Electron.IpcRenderer | Electron.IpcMain, private channel: string, private applicator: Applicator | null | undefined) { 138 | } 139 | 140 | start(listener: Listener) { 141 | this.ipc.on(this.channel, (event: any, type: MessageType, data: any) => { 142 | switch (type) { 143 | case MessageType.INIT: 144 | listener.next(data) 145 | break 146 | 147 | case MessageType.UPDATE: 148 | const applicator = this.applicator 149 | if (applicator == null) { 150 | throw new Error("Not implemented") 151 | } 152 | else { 153 | applicator.applyChanges(data) 154 | } 155 | break 156 | 157 | case MessageType.ERROR: 158 | listener.error(data) 159 | break 160 | 161 | case MessageType.COMPLETE: 162 | listener.complete() 163 | break 164 | 165 | default: 166 | listener.error(new Error(`Unknown message type: ${type} with payload: ${data}`)) 167 | break 168 | } 169 | }) 170 | } 171 | 172 | stop() { 173 | const listener = this.ipcListener 174 | if (listener != null) { 175 | this.ipc.removeListener(this.channel, listener) 176 | this.ipcListener = null 177 | } 178 | } 179 | } 180 | 181 | class MyListener implements Listener { 182 | private count = 0 183 | private lastData: any | null = null 184 | 185 | constructor(private replyTo: Electron.WebContents, private subChannel: string) { 186 | } 187 | 188 | next(data: any) { 189 | const replyTo = this.replyTo 190 | if (this.count === 0) { 191 | replyTo.send(this.subChannel, MessageType.INIT, data) 192 | } 193 | else { 194 | replyTo.send(this.subChannel, MessageType.UPDATE, diff(this.lastData, data)) 195 | } 196 | 197 | // clone to be sure that it will be not modified because client can pass to `next` the same object reference each time 198 | this.lastData = clone(data) 199 | this.count++ 200 | } 201 | 202 | error(error: any) { 203 | this.replyTo.send(this.subChannel, MessageType.ERROR, error.stack || error.toString()) 204 | } 205 | 206 | complete() { 207 | this.replyTo.send(this.subChannel, MessageType.COMPLETE) 208 | } 209 | } 210 | 211 | enum MessageType { 212 | INIT, UPDATE, COMPLETE, ERROR 213 | } 214 | 215 | export interface Applicator { 216 | applyChanges(changes: Array): void 217 | } --------------------------------------------------------------------------------