diff --git a/.travis.yml b/.travis.yml index 94804bdb..a2bf69b7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,6 +28,9 @@ addons: - libgnome-keyring-dev - libsecret-1-dev - icnsutils + - libudev-dev + - build-essential + - libusb-1.0-0-dev install: - nvm install @@ -45,9 +48,3 @@ script: cache: directories: - node_modules - -addons: - apt: - packages: - - build-essential - - libudev-dev diff --git a/electron/src/app.module.ts b/electron/src/app.module.ts index f80c8d2d..87e8f5ff 100644 --- a/electron/src/app.module.ts +++ b/electron/src/app.module.ts @@ -1,4 +1,4 @@ -import { NgModule } from '@angular/core'; +import { ErrorHandler, NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -75,7 +75,9 @@ import { SafeStylePipe } from './shared/pipes'; import { CaptureService } from './shared/services/capture.service'; import { MapperService } from './shared/services/mapper.service'; import { SvgModuleProviderService } from './shared/services/svg-module-provider.service'; -import { UhkDeviceService } from './services/uhk-device.service'; +import { UhkLibUsbApiService } from './services/uhk-lib-usb-api.service'; +import { UhkHidApiService } from './services/uhk-hid-api.service'; +import { uhkDeviceProvider } from './services/uhk-device-provider'; import { AutoUpdateSettingsEffects, KeymapEffects, MacroEffects, UserConfigEffects } from './shared/store/effects'; import { ApplicationEffect, AppUpdateEffect } from './store/effects'; @@ -90,6 +92,9 @@ import { UhkDeviceUninitializedGuard } from './services/uhk-device-uninitialized import { DATA_STORAGE_REPOSITORY } from './shared/services/datastorage-repository.service'; import { ElectronDataStorageRepositoryService } from './services/electron-datastorage-repository.service'; import { DefaultUserConfigurationService } from './shared/services/default-user-configuration.service'; +import { ElectronLogService } from './services/electron-log.service'; +import { LOG_SERVICE } from '../../shared/src/services/logger.service'; +import { ElectronErrorHandlerService } from './services/electron-error-handler.service'; import { AppUpdateRendererService } from './services/app-update-renderer.service'; import { reducer } from './store'; import { AutoUpdateSettings } from './shared/components/auto-update-settings/auto-update-settings'; @@ -185,10 +190,15 @@ import { AutoUpdateSettings } from './shared/components/auto-update-settings/aut KeymapEditGuard, MacroNotFoundGuard, CaptureService, - UhkDeviceService, { provide: DATA_STORAGE_REPOSITORY, useClass: ElectronDataStorageRepositoryService }, DefaultUserConfigurationService, - AppUpdateRendererService + { provide: LOG_SERVICE, useClass: ElectronLogService }, + { provide: ErrorHandler, useClass: ElectronErrorHandlerService }, + AppUpdateRendererService, + UhkHidApiService, + UhkLibUsbApiService, + uhkDeviceProvider() + ], bootstrap: [AppComponent] }) diff --git a/electron/src/components/missing-device/missing-device.component.ts b/electron/src/components/missing-device/missing-device.component.ts index 0b153e21..d2643bb5 100644 --- a/electron/src/components/missing-device/missing-device.component.ts +++ b/electron/src/components/missing-device/missing-device.component.ts @@ -1,12 +1,11 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; import 'rxjs/add/operator/distinctUntilChanged'; import 'rxjs/add/operator/ignoreElements'; import 'rxjs/add/operator/takeWhile'; -import { UhkDeviceService } from './../../services/uhk-device.service'; +import { UhkDeviceService } from '../../services/uhk-device.service'; @Component({ selector: 'missing-device', diff --git a/electron/src/components/privilege-checker/privilege-checker.component.ts b/electron/src/components/privilege-checker/privilege-checker.component.ts index b16aba51..3ca98aee 100644 --- a/electron/src/components/privilege-checker/privilege-checker.component.ts +++ b/electron/src/components/privilege-checker/privilege-checker.component.ts @@ -1,7 +1,7 @@ /// -import { Component } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { Router } from '@angular/router'; - +import * as isDev from 'electron-is-dev'; import { Observable } from 'rxjs/Observable'; import { ReplaySubject } from 'rxjs/ReplaySubject'; import 'rxjs/add/observable/of'; @@ -15,6 +15,7 @@ import * as path from 'path'; import * as sudo from 'sudo-prompt'; import { UhkDeviceService } from '../../services/uhk-device.service'; +import { ILogService, LOG_SERVICE } from '../../../../shared/src/services/logger.service'; @Component({ selector: 'privilege-checker', @@ -23,7 +24,18 @@ import { UhkDeviceService } from '../../services/uhk-device.service'; }) export class PrivilegeCheckerComponent { - constructor(private router: Router, private uhkDevice: UhkDeviceService) { + private rootDir: string; + + constructor(private router: Router, + private uhkDevice: UhkDeviceService, + @Inject(LOG_SERVICE) private logService: ILogService) { + if (isDev) { + this.rootDir = path.resolve(path.join(remote.process.cwd(), remote.process.argv[1]), '..'); + } else { + this.rootDir = path.dirname(remote.app.getAppPath()); + } + this.logService.info('App root dir: ', this.rootDir); + uhkDevice.isConnected() .distinctUntilChanged() .takeWhile(connected => connected) @@ -50,14 +62,21 @@ export class PrivilegeCheckerComponent { case 'linux': permissionSetter = this.setUpPermissionsOnLinux(); break; + // HID API shouldn't need privilege escalation on Windows + // TODO: If all HID API test success then delete this branch and setUpPermissionsOnWin() method + // case 'win32': + // permissionSetter = this.setUpPermissionsOnWin(); + // break; default: permissionSetter = Observable.throw('Permissions couldn\'t be set. Invalid platform: ' + process.platform); break; } permissionSetter.subscribe({ - error: e => console.error(e), + error: e => { + console.log(e); + }, complete: () => { - console.log('Permissions has been successfully set'); + this.logService.info('Permissions has been successfully set'); this.uhkDevice.initialize(); this.router.navigate(['/']); } @@ -66,12 +85,13 @@ export class PrivilegeCheckerComponent { private setUpPermissionsOnLinux(): Observable { const subject = new ReplaySubject(); - const rootDir = path.resolve(path.join(remote.process.cwd(), remote.process.argv[1]), '..'); - const scriptPath = path.resolve(rootDir, 'rules/setup-rules.sh'); + const scriptPath = path.resolve(this.rootDir, 'rules/setup-rules.sh'); const options = { name: 'Setting UHK access rules' }; - sudo.exec(`sh ${scriptPath}`, options, (error: any) => { + const command = `sh ${scriptPath}`; + console.log(command); + sudo.exec(command, options, (error: any) => { if (error) { subject.error(error); } else { @@ -82,4 +102,34 @@ export class PrivilegeCheckerComponent { return subject.asObservable(); } + // TODO: If all HID API test success then delete this method. + // and remove zadic-${process.arch}.exe files from windows installer resources + // private setUpPermissionsOnWin(): Observable { + // const subject = new ReplaySubject(); + // + // // source code: https://github.com/pbatard/libwdi + // const scriptPath = path.resolve(this.rootDir, `rules/zadic-${process.arch}.exe`); + // const options = { + // name: 'Setting UHK access rules' + // }; + // const params = [ + // `--vid $\{Constants.VENDOR_ID}`, + // `--pid $\{Constants.PRODUCT_ID}`, + // '--iface 0', // interface ID + // '--usealldevices', // if the device has installed USB driver than overwrite it + // '--noprompt' // return at the end of the installation and not waiting for any user command + // ]; + // const paramsString = params.join(' '); + // const command = `"${scriptPath}" ${paramsString}`; + // + // sudo.exec(command, options, (error: any) => { + // if (error) { + // subject.error(error); + // } else { + // subject.complete(); + // } + // }); + // + // return subject.asObservable(); + // } } diff --git a/electron/src/electron-main.ts b/electron/src/electron-main.ts index 2bcc8bf2..16fedf67 100644 --- a/electron/src/electron-main.ts +++ b/electron/src/electron-main.ts @@ -13,7 +13,7 @@ import { IpcEvents } from './shared/util'; import { ElectronDataStorageRepositoryService } from './services/electron-datastorage-repository.service'; // import './dev-extension'; -// require('electron-debug')({ showDevTools: true, enabled: true }); +require('electron-debug')({ showDevTools: false, enabled: true }); // 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. diff --git a/electron/src/index.html b/electron/src/index.html index e9fa1032..512f3c75 100644 --- a/electron/src/index.html +++ b/electron/src/index.html @@ -11,6 +11,7 @@ diff --git a/electron/src/models/sender-message.ts b/electron/src/models/sender-message.ts new file mode 100644 index 00000000..3ff62a06 --- /dev/null +++ b/electron/src/models/sender-message.ts @@ -0,0 +1,6 @@ +import { Observer } from 'rxjs/Observer'; + +export interface SenderMessage { + buffer: Buffer; + observer: Observer; +} diff --git a/electron/src/package.json b/electron/src/package.json index 2df162ad..0befc731 100644 --- a/electron/src/package.json +++ b/electron/src/package.json @@ -14,6 +14,7 @@ "npm": ">=3.10.7 <4.0.0" }, "dependencies": { + "node-hid": "0.5.4", "usb": "git+https://github.com/aktary/node-usb.git" } } diff --git a/electron/src/services/electron-error-handler.service.ts b/electron/src/services/electron-error-handler.service.ts new file mode 100644 index 00000000..4be0533a --- /dev/null +++ b/electron/src/services/electron-error-handler.service.ts @@ -0,0 +1,10 @@ +import { ErrorHandler, Inject } from '@angular/core'; +import { ILogService, LOG_SERVICE } from '../../../shared/src/services/logger.service'; + +export class ElectronErrorHandlerService implements ErrorHandler { + constructor(@Inject(LOG_SERVICE)private logService: ILogService) {} + + handleError(error: any) { + this.logService.error(error); + } +} diff --git a/electron/src/services/electron-log.service.ts b/electron/src/services/electron-log.service.ts new file mode 100644 index 00000000..75a8461f --- /dev/null +++ b/electron/src/services/electron-log.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import * as log from 'electron-log'; +import * as util from 'util'; + +import { ILogService } from '../../../shared/src/services/logger.service'; + +/** + * This service use the electron-log package to write log in file. + * The logger usable in main and renderer process. + * The location of the log files: + * - on Linux: ~/.config//log.log + * - on OS X: ~/Library/Logs//log.log + * - on Windows: %USERPROFILE%\AppData\Roaming\\log.log + * The app name: UHK Agent. The up to date value in the scripts/release.js file. + */ +@Injectable() +export class ElectronLogService implements ILogService { + private static getErrorText(args: any) { + return util.inspect(args); + } + + error(...args: any[]): void { + log.error(ElectronLogService.getErrorText(args)); + } + + info(...args: any[]): void { + log.info(ElectronLogService.getErrorText(args)); + } +} diff --git a/electron/src/services/uhk-device-provider.ts b/electron/src/services/uhk-device-provider.ts new file mode 100644 index 00000000..2e2e3b9b --- /dev/null +++ b/electron/src/services/uhk-device-provider.ts @@ -0,0 +1,18 @@ +import { Provider } from '@angular/core'; + +import { UhkDeviceService } from './uhk-device.service'; +import { UhkHidApiService } from './uhk-hid-api.service'; + +export function uhkDeviceProvider(): Provider { + // HID API officially support MAC, WIN and linux x64 platform + // https://github.com/node-hid/node-hid#platform-support + if (process.platform === 'darwin' || + process.platform === 'win32' || + (process.platform === 'linux' && process.arch === 'x64')) { + return { provide: UhkDeviceService, useClass: UhkHidApiService }; + } + + // On other platform use libUsb, but we try to test on all platform + // return { provide: UhkDeviceService, useClass: UhkLibUsbApiService }; + return { provide: UhkDeviceService, useClass: UhkHidApiService }; +} diff --git a/electron/src/services/uhk-device.service.ts b/electron/src/services/uhk-device.service.ts index 8e9e8d79..9c1d4f95 100644 --- a/electron/src/services/uhk-device.service.ts +++ b/electron/src/services/uhk-device.service.ts @@ -1,195 +1,49 @@ -import { Injectable, OnDestroy, NgZone } from '@angular/core'; - +import { Inject } from '@angular/core'; import { Observable } from 'rxjs/Observable'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { Observer } from 'rxjs/Observer'; -import { ConnectableObservable } from 'rxjs/observable/ConnectableObservable'; import { Subject } from 'rxjs/Subject'; +import { Observer } from 'rxjs/Observer'; import { Subscriber } from 'rxjs/Subscriber'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Subscription } from 'rxjs/Subscription'; -import 'rxjs/add/observable/empty'; -import 'rxjs/add/observable/from'; -import 'rxjs/add/observable/of'; -import 'rxjs/add/observable/timer'; -import 'rxjs/add/operator/catch'; -import 'rxjs/add/operator/concat'; -import 'rxjs/add/operator/combineLatest'; -import 'rxjs/add/operator/concatMap'; -import 'rxjs/add/operator/first'; -import 'rxjs/add/operator/mergeMap'; -import 'rxjs/add/operator/publish'; -import 'rxjs/add/operator/switchMap'; - -import { Device, Interface, InEndpoint, OutEndpoint, findByIds, on } from 'usb'; - -import { Layer } from '../shared/config-serializer/config-items/layer'; -import { UhkBuffer } from '../shared/config-serializer/uhk-buffer'; - -const vendorId = 0x1d50; -const productId = 0x6122; -const MAX_PAYLOAD_SIZE = 64; +import { ILogService, LOG_SERVICE } from '../shared/services/logger.service'; +import { SenderMessage } from '../models/sender-message'; +import { Constants } from '../shared/util/constants'; enum Command { UploadConfig = 8, ApplyConfig = 9 } -interface SenderMessage { - buffer: Buffer; - observer: Observer; -} +export abstract class UhkDeviceService { + protected connected$: BehaviorSubject; + protected initialized$: BehaviorSubject; + protected deviceOpened$: BehaviorSubject; + protected outSubscription: Subscription; -@Injectable() -export class UhkDeviceService implements OnDestroy { + protected messageIn$: Observable; + protected messageOut$: Subject; - private device: Device; - private deviceOpened$: BehaviorSubject; - private connected$: BehaviorSubject; - private initizalized$: BehaviorSubject; - - private messageIn$: Observable; - private messageOut$: Subject; - - private outSubscription: Subscription; - - constructor(zone: NgZone) { + constructor(@Inject(LOG_SERVICE) protected logService: ILogService) { this.messageOut$ = new Subject(); - this.initizalized$ = new BehaviorSubject(false); + this.initialized$ = new BehaviorSubject(false); this.connected$ = new BehaviorSubject(false); this.deviceOpened$ = new BehaviorSubject(false); this.outSubscription = Subscription.EMPTY; - - this.initialize(); - - // The change detection doesn't work properly if the callbacks are called outside Angular Zone - on('attach', (device: Device) => zone.run(() => this.onDeviceAttach(device))); - on('detach', (device: Device) => zone.run(() => this.onDeviceDetach(device))); } ngOnDestroy() { this.disconnect(); - this.initizalized$.unsubscribe(); + this.initialized$.unsubscribe(); this.connected$.unsubscribe(); this.deviceOpened$.unsubscribe(); } - initialize(): void { - if (this.initizalized$.getValue()) { - return; - } - this.device = findByIds(vendorId, productId); - this.connected$.next(!!this.device); - if (!this.device) { - return; - } - try { - this.device.open(); - this.deviceOpened$.next(true); - } catch (error) { - console.log(error); - return; - } - - const usbInterface: Interface = this.device.interface(0); - // https://github.com/tessel/node-usb/issues/147 - // The function 'isKernelDriverActive' is not available on Windows and not even needed. - if (process.platform !== 'win32' && usbInterface.isKernelDriverActive()) { - usbInterface.detachKernelDriver(); - } - - // https://github.com/tessel/node-usb/issues/30 - // Mac is not allow excusive right to use USB - if (process.platform !== 'darwin') { - usbInterface.claim(); - } - - this.messageIn$ = Observable.create((subscriber: Subscriber) => { - const inEndPoint: InEndpoint = usbInterface.endpoints[0]; - console.log('Try to read'); - inEndPoint.transfer(MAX_PAYLOAD_SIZE, (error: string, receivedBuffer: Buffer) => { - if (error) { - console.error('reading error', error); - subscriber.error(error); - } else { - console.log('read data', receivedBuffer); - subscriber.next(receivedBuffer); - subscriber.complete(); - } - }); - }); - - const outEndPoint: OutEndpoint = usbInterface.endpoints[1]; - const outSending = this.messageOut$.concatMap(senderPackage => { - return (>Observable.create((subscriber: Subscriber) => { - console.log('transfering', senderPackage.buffer); - outEndPoint.transfer(senderPackage.buffer, error => { - if (error) { - console.error('transfering errored', error); - subscriber.error(error); - } else { - console.log('transfering finished'); - subscriber.complete(); - } - }); - })).concat(this.messageIn$) - .do(buffer => senderPackage.observer.next(buffer) && senderPackage.observer.complete()) - .catch((error: string) => { - senderPackage.observer.error(error); - return Observable.empty(); - }); - }).publish(); - this.outSubscription = outSending.connect(); - - this.initizalized$.next(true); - } - - disconnect() { - this.outSubscription.unsubscribe(); - this.messageIn$ = undefined; - this.initizalized$.next(false); - this.deviceOpened$.next(false); - this.connected$.next(false); - } - - isInitialized(): Observable { - return this.initizalized$.asObservable(); - } - - isConnected(): Observable { - return this.connected$.asObservable(); - } - - hasPermissions(): Observable { - return this.isConnected() - .combineLatest(this.deviceOpened$) - .map((latest: boolean[]) => { - const connected = latest[0]; - const opened = latest[1]; - if (!connected) { - return false; - } else if (opened) { - return true; - } - try { - this.device.open(); - } catch (error) { - return false; - } - this.device.close(); - return true; - }); - } - - isOpened(): Observable { - return this.deviceOpened$.asObservable(); - } - sendConfig(configBuffer: Buffer): Observable { return Observable.create((subscriber: Subscriber) => { - console.log('Sending...', configBuffer); + this.logService.info('Sending...', configBuffer); const fragments: Buffer[] = []; - const MAX_SENDING_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE - 4; + const MAX_SENDING_PAYLOAD_SIZE = Constants.MAX_PAYLOAD_SIZE - 4; for (let offset = 0; offset < configBuffer.length; offset += MAX_SENDING_PAYLOAD_SIZE) { const length = offset + MAX_SENDING_PAYLOAD_SIZE < configBuffer.length ? MAX_SENDING_PAYLOAD_SIZE @@ -206,7 +60,7 @@ export class UhkDeviceService implements OnDestroy { if (buffers.length === fragments.length) { subscriber.next(Buffer.concat(buffers)); subscriber.complete(); - console.log('Sending finished'); + this.logService.info('Sending finished'); } } }; @@ -219,7 +73,7 @@ export class UhkDeviceService implements OnDestroy { applyConfig(): Observable { return Observable.create((subscriber: Subscriber) => { - console.log('Applying configuration'); + this.logService.info('Applying configuration'); this.messageOut$.next({ buffer: new Buffer([Command.ApplyConfig]), observer: subscriber @@ -227,21 +81,27 @@ export class UhkDeviceService implements OnDestroy { }); } - onDeviceAttach(device: Device) { - if (device.deviceDescriptor.idVendor !== vendorId || device.deviceDescriptor.idProduct !== productId) { - return; - } - // Ugly hack: device is not openable (on Windows) right after the attach - Observable.timer(100) - .first() - .subscribe(() => this.initialize()); + isInitialized(): Observable { + return this.initialized$.asObservable(); } - onDeviceDetach(device: Device) { - if (device.deviceDescriptor.idVendor !== vendorId || device.deviceDescriptor.idProduct !== productId) { - return; - } - this.disconnect(); + isConnected(): Observable { + return this.connected$.asObservable(); } + isOpened(): Observable { + return this.deviceOpened$.asObservable(); + } + + disconnect() { + this.outSubscription.unsubscribe(); + this.messageIn$ = undefined; + this.initialized$.next(false); + this.deviceOpened$.next(false); + this.connected$.next(false); + } + + abstract initialize(): void; + + abstract hasPermissions(): Observable; } diff --git a/electron/src/services/uhk-hid-api.service.ts b/electron/src/services/uhk-hid-api.service.ts new file mode 100644 index 00000000..e3f31668 --- /dev/null +++ b/electron/src/services/uhk-hid-api.service.ts @@ -0,0 +1,164 @@ +import { Inject, Injectable, OnDestroy } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; +import { Subscription } from 'rxjs/Subscription'; +import { Device, devices, HID } from 'node-hid'; + +import 'rxjs/add/observable/empty'; +import 'rxjs/add/observable/timer'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/operator/concat'; +import 'rxjs/add/operator/combineLatest'; +import 'rxjs/add/operator/concatMap'; +import 'rxjs/add/operator/publish'; +import 'rxjs/add/operator/do'; + +import { ILogService, LOG_SERVICE } from '../shared/services/logger.service'; +import { Constants } from '../shared/util'; +import { UhkDeviceService } from './uhk-device.service'; + +@Injectable() +export class UhkHidApiService extends UhkDeviceService implements OnDestroy { + private device: HID; + + private pollTimer$: Subscription; + + constructor(@Inject(LOG_SERVICE) protected logService: ILogService) { + super(logService); + + this.pollUhkDevice(); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.pollTimer$.unsubscribe(); + } + + initialize(): void { + if (this.initialized$.getValue()) { + return; + } + + this.device = this.getDevice(); + if (!this.device) { + return; + } + + this.deviceOpened$.next(true); + + this.messageIn$ = Observable.create((subscriber: Subscriber) => { + this.logService.info('Try to read'); + this.device.read((error: any, data: any = []) => { + if (error) { + this.logService.error('reading error', error); + subscriber.error(error); + } else { + this.logService.info('read data', data); + subscriber.next(data); + subscriber.complete(); + } + }); + }); + + const outSending = this.messageOut$.concatMap(senderPackage => { + return (>Observable.create((subscriber: Subscriber) => { + this.logService.info('transfering', senderPackage.buffer); + const data = Array.prototype.slice.call(senderPackage.buffer, 0); + // if data start with 0 need to add additional leading zero because HID API remove it. + // https://github.com/node-hid/node-hid/issues/187 + if (data.length > 0 && data[0] === 0) { + data.unshift(0); + } + + // From HID API documentation: + // http://www.signal11.us/oss/hidapi/hidapi/doxygen/html/group__API.html#gad14ea48e440cf5066df87cc6488493af + // The first byte of data[] must contain the Report ID. + // For devices which only support a single report, this must be set to 0x0. + data.unshift(0); + + try { + this.device.write(data); + this.logService.info('transfering finished'); + subscriber.complete(); + } + catch (error) { + this.logService.error('transfering errored', error); + subscriber.error(error); + } + })).concat(this.messageIn$) + .do(buffer => senderPackage.observer.next(buffer) && senderPackage.observer.complete()) + .catch((error: string) => { + senderPackage.observer.error(error); + return Observable.empty(); + }); + }).publish(); + this.outSubscription = outSending.connect(); + + this.initialized$.next(true); + } + + hasPermissions(): Observable { + return this.isConnected() + .combineLatest(this.deviceOpened$) + .map((latest: boolean[]) => { + const connected = latest[0]; + const opened = latest[1]; + if (!connected) { + return false; + } else if (opened) { + return true; + } + return true; + }); + } + + /** + * Return the 0 interface of the keyboard. + * @returns {HID} + */ + getDevice(): HID { + try { + const devs = devices(); + this.logService.info('Available devices:', devs); + + const dev = devs.find((x: Device) => + x.vendorId === Constants.VENDOR_ID && + x.productId === Constants.PRODUCT_ID && + ((x.usagePage === 128 && x.usage === 129) || x.interface === 0)); + + const device = new HID(dev.path); + this.logService.info('Used device:', dev); + return device; + } + catch (err) { + this.logService.error('Can not create device:', err); + } + + return null; + } + + /** + * HID API not support device attached and detached event. + * This method check the keyboard is attached to the computer or not. + * Every second check the HID device list. + */ + private pollUhkDevice() { + this.pollTimer$ = Observable.timer(0, 1000) + .map(() => { + return devices().some((dev: Device) => dev.vendorId === Constants.VENDOR_ID && + dev.productId === Constants.PRODUCT_ID); + }) + .distinctUntilChanged() + .do((connected: boolean) => { + this.connected$.next(connected); + if (connected) { + this.initialize(); + } else { + this.disconnect(); + } + + }) + .subscribe(); + } +} diff --git a/electron/src/services/uhk-lib-usb-api.service.ts b/electron/src/services/uhk-lib-usb-api.service.ts new file mode 100644 index 00000000..43408ac6 --- /dev/null +++ b/electron/src/services/uhk-lib-usb-api.service.ts @@ -0,0 +1,142 @@ +import { Inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; + +import 'rxjs/add/observable/empty'; +import 'rxjs/add/observable/timer'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/operator/concat'; +import 'rxjs/add/operator/combineLatest'; +import 'rxjs/add/operator/concatMap'; +import 'rxjs/add/operator/first'; +import 'rxjs/add/operator/publish'; +import 'rxjs/add/operator/do'; + +import { Device, findByIds, InEndpoint, Interface, on, OutEndpoint } from 'usb'; + +import { ILogService, LOG_SERVICE } from '../shared/services/logger.service'; +import { Constants } from '../shared/util'; +import { UhkDeviceService } from './uhk-device.service'; + +@Injectable() +export class UhkLibUsbApiService extends UhkDeviceService implements OnDestroy { + private device: Device; + + static isUhkDevice(device: Device) { + return device.deviceDescriptor.idVendor === Constants.VENDOR_ID && + device.deviceDescriptor.idProduct === Constants.PRODUCT_ID; + } + + constructor(zone: NgZone, + @Inject(LOG_SERVICE) protected logService: ILogService) { + super(logService); + + this.initialize(); + + // The change detection doesn't work properly if the callbacks are called outside Angular Zone + on('attach', (device: Device) => zone.run(() => this.onDeviceAttach(device))); + on('detach', (device: Device) => zone.run(() => this.onDeviceDetach(device))); + } + + initialize(): void { + if (this.initialized$.getValue()) { + return; + } + this.device = findByIds(Constants.VENDOR_ID, Constants.PRODUCT_ID); + this.connected$.next(!!this.device); + if (!this.device) { + return; + } + try { + this.device.open(); + this.deviceOpened$.next(true); + } catch (error) { + this.logService.error(error); + return; + } + + const usbInterface: Interface = this.device.interface(0); + // https://github.com/tessel/node-usb/issues/147 + // The function 'isKernelDriverActive' is not available on Windows and not even needed. + if (usbInterface.isKernelDriverActive()) { + usbInterface.detachKernelDriver(); + } + + this.messageIn$ = Observable.create((subscriber: Subscriber) => { + const inEndPoint: InEndpoint = usbInterface.endpoints[0]; + this.logService.info('Try to read'); + inEndPoint.transfer(Constants.MAX_PAYLOAD_SIZE, (error: string, receivedBuffer: Buffer) => { + if (error) { + this.logService.error('reading error', error); + subscriber.error(error); + } else { + this.logService.info('read data', receivedBuffer); + subscriber.next(receivedBuffer); + subscriber.complete(); + } + }); + }); + + const outEndPoint: OutEndpoint = usbInterface.endpoints[1]; + const outSending = this.messageOut$.concatMap(senderPackage => { + return (>Observable.create((subscriber: Subscriber) => { + this.logService.info('transfering', senderPackage.buffer); + outEndPoint.transfer(senderPackage.buffer, error => { + if (error) { + this.logService.error('transfering errored', error); + subscriber.error(error); + } else { + this.logService.info('transfering finished'); + subscriber.complete(); + } + }); + })).concat(this.messageIn$) + .do(buffer => senderPackage.observer.next(buffer) && senderPackage.observer.complete()) + .catch((error: string) => { + senderPackage.observer.error(error); + return Observable.empty(); + }); + }).publish(); + this.outSubscription = outSending.connect(); + + this.initialized$.next(true); + } + + hasPermissions(): Observable { + return this.isConnected() + .combineLatest(this.deviceOpened$) + .map((latest: boolean[]) => { + const connected = latest[0]; + const opened = latest[1]; + if (!connected) { + return false; + } else if (opened) { + return true; + } + try { + this.device.open(); + } catch (error) { + return false; + } + this.device.close(); + return true; + }); + } + + onDeviceAttach(device: Device) { + if (!UhkLibUsbApiService.isUhkDevice(device)) { + return; + } + // Ugly hack: device is not openable (on Windows) right after the attach + Observable.timer(100) + .first() + .subscribe(() => this.initialize()); + } + + onDeviceDetach(device: Device) { + if (!UhkLibUsbApiService.isUhkDevice(device)) { + return; + } + this.disconnect(); + } +} diff --git a/electron/src/webpack.config.js b/electron/src/webpack.config.js index ed8de2e3..34ab4a2a 100644 --- a/electron/src/webpack.config.js +++ b/electron/src/webpack.config.js @@ -18,7 +18,8 @@ module.exports = { }, target: 'electron-renderer', externals: { - usb: 'usb' + usb: 'usb', + 'node-hid':'nodeHid' }, devtool: 'source-map', resolve: { @@ -40,7 +41,8 @@ module.exports = { use: ['raw-loader', 'sass-loader'] }, { test: /jquery/, loader: 'expose-loader?$!expose-loader?jQuery' }, - { test: require.resolve("usb"), loader: "expose-loader?usb" } + { test: require.resolve("usb"), loader: "expose-loader?usb" }, + { test: require.resolve("node-hid"), loader: "expose-loader?node-hid" } ] }, plugins: [ diff --git a/package.json b/package.json index 3ab78bf1..559015f0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "uhk-agent", + "author": "Ultimate Gadget Laboratories", "main": "electron/dist/electron-main.js", "version": "1.0.0", "description": "Agent is the configuration application of the Ultimate Hacking Keyboard.", @@ -21,6 +22,7 @@ "@types/file-saver": "0.0.1", "@types/jquery": "3.2.1", "@types/node": "^6.0.78", + "@types/node-hid": "^0.5.2", "@types/usb": "^1.1.3", "angular2-template-loader": "0.6.2", "copy-webpack-plugin": "^4.0.1", @@ -37,6 +39,7 @@ "npm-run-all": "4.0.2", "path": "^0.12.7", "raw-loader": "^0.5.1", + "rimraf": "^2.6.1", "sass-loader": "^6.0.3", "standard-version": "^4.0.0", "stylelint": "^7.10.1", @@ -75,6 +78,7 @@ "ng2-dragula": "1.5.0", "ng2-select2": "1.0.0-beta.10", "ngrx-store-freeze": "^0.1.9", + "node-hid": "0.5.4", "reselect": "3.0.1", "rxjs": "^5.4.1", "select2": "^4.0.3", @@ -85,17 +89,17 @@ "zone.js": "0.8.12" }, "scripts": { - "postinstall": "run-p build:usb \"symlink -- -i\" ", + "postinstall": "run-p \"symlink -- -i\" ", "test": "cd ./test-serializer && node ./test-serializer.js", "lint": "run-s -scn lint:ts lint:style", "lint:ts": "tslint \"electron/src/**/*.ts\" \"web/src/**/*.ts\" \"shared/**/*.ts\" \"test-serializer/**/*.ts\"", "lint:style": "stylelint \"electron/**/*.scss\" \"web/**/*.scss\" \"shared/**/*.scss\" --syntax scss", "build": "run-p build:web build:electron", "build:web": "webpack --config \"web/src/webpack.config.js\"", - "build:electron": "run-s -scn build:electron:main build:electron:app", + "build:electron": "run-s -sn build:electron:main build:electron:app install:build-deps", "build:electron:main": "webpack --config \"electron/src/webpack.config.electron-main.js\"", "build:electron:app": "webpack --config \"electron/src/webpack.config.js\"", - "build:usb": "electron-rebuild -w usb -p", + "build:usb": "electron-rebuild -w usb,node-hid -p -m electron/dist", "build:test": "webpack --config \"test-serializer/webpack.config.js\"", "server:web": "webpack-dev-server --config \"web/src/webpack.config.js\" --content-base \"./web/dist\"", "server:electron": "webpack --config \"electron/src/webpack.config.js\" --watch", @@ -103,7 +107,8 @@ "symlink": "node ./tools/symlinker", "standard-version": "standard-version", "pack": "node ./scripts/release.js", - "install:build-deps": "cd electron/dist && npm i", - "release": "npm run install:build-deps && node ./scripts/release.js" + "install:build-deps": "cd electron/dist && npm i && cd .. && npm run build:usb", + "release": "node ./scripts/release.js", + "clean": "rimraf ./node_modules ./electron/dist" } } diff --git a/rules/zadic-ia32.exe b/rules/zadic-ia32.exe new file mode 100644 index 00000000..8c96a9a8 Binary files /dev/null and b/rules/zadic-ia32.exe differ diff --git a/rules/zadic-x64.exe b/rules/zadic-x64.exe new file mode 100644 index 00000000..31178dc2 Binary files /dev/null and b/rules/zadic-x64.exe differ diff --git a/scripts/release.js b/scripts/release.js index 3219ea21..dc48c2cf 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -1,7 +1,11 @@ 'use strict'; const jsonfile = require('jsonfile'); -const TEST_BUILD = false; // set true if you would like to test on your local machince +const TEST_BUILD = process.env.TEST_BUILD; // set true if you would like to test on your local machince + +// electron-builder security override. +// Need if wanna create test release build from PR +process.env.PUBLISH_FOR_PULL_REQUEST = TEST_BUILD; if (!process.env.CI && !TEST_BUILD) { console.error('Create release only on CI server'); @@ -25,7 +29,7 @@ if (process.env.TRAVIS) { repoName = process.env.APPVEYOR_REPO_NAME; } -console.log({branchName, pullRequestNr, gitTag, repoName}); +console.log({ branchName, pullRequestNr, gitTag, repoName }); const isReleaseCommit = TEST_BUILD || branchName === gitTag && repoName === 'UltimateHackingKeyboard/agent'; @@ -51,16 +55,23 @@ if (process.env.TRAVIS) { let target = ''; let artifactName = 'UHK.Agent-${version}-${os}'; +let extraResources = []; if (process.platform === 'darwin') { target = Platform.MAC.createTarget(); artifactName += '.${ext}'; } else if (process.platform === 'win32') { target = Platform.WINDOWS.createTarget(); + // TODO: If all HID API test success then remove zadic extra resources + extraResources.push(`rules/zadic-ia32.exe`); + extraResources.push(`rules/zadic-x64.exe`); artifactName += '-${arch}.${ext}'; + extraResources.push(`rules/zadic-${process.arch}.exe`); } else if (process.platform === 'linux') { target = Platform.LINUX.createTarget(); artifactName += '.${ext}'; + extraResources.push('rules/setup-rules.sh'); + extraResources.push('rules/50-uhk60.rules'); } else { console.error(`I dunno how to publish a release for ${process.platform} :(`); process.exit(1); @@ -78,7 +89,7 @@ if (TEST_BUILD || gitTag) { updateVersionNumberIn2rndPackageJson(jsonVersion); builder.build({ - dir: true, + dir: TEST_BUILD, targets: target, appMetadata: { main: 'electron-main.js', @@ -97,6 +108,12 @@ if (TEST_BUILD || gitTag) { mac: { category: 'public.app-category.utilities' }, + win: { + extraResources + }, + linux: { + extraResources + }, publish: 'github', artifactName, files: [ diff --git a/shared/src/services/logger.service.ts b/shared/src/services/logger.service.ts new file mode 100644 index 00000000..468d6898 --- /dev/null +++ b/shared/src/services/logger.service.ts @@ -0,0 +1,20 @@ +import {Injectable, InjectionToken} from '@angular/core'; + +export interface ILogService { + + error(...args: any[]): void; + info(...args: any[]): void; +} + +export let LOG_SERVICE = new InjectionToken('logger-service'); + +@Injectable() +export class ConsoleLogService implements ILogService { + error(...args: any[]): void { + console.error(args); + } + + info(...args: any[]): void { + console.info(args); + } +} diff --git a/shared/src/util/constants.ts b/shared/src/util/constants.ts new file mode 100644 index 00000000..0b3e32e3 --- /dev/null +++ b/shared/src/util/constants.ts @@ -0,0 +1,5 @@ +export namespace Constants { + export const VENDOR_ID = 0x1D50; + export const PRODUCT_ID = 0x6122; + export const MAX_PAYLOAD_SIZE = 64; +} diff --git a/shared/src/util/index.ts b/shared/src/util/index.ts index b25fbecc..71e8a2a5 100644 --- a/shared/src/util/index.ts +++ b/shared/src/util/index.ts @@ -1,3 +1,7 @@ +import { Constants } from './constants'; + +export { Constants }; + // Source: http://stackoverflow.com/questions/13720256/javascript-regex-camelcase-to-sentence export function camelCaseToSentence(camelCasedText: string): string { return camelCasedText.replace(/^[a-z]|[A-Z]/g, function (v, i) { diff --git a/web/src/app.module.ts b/web/src/app.module.ts index 6db46ee0..bd5b28cd 100644 --- a/web/src/app.module.ts +++ b/web/src/app.module.ts @@ -76,7 +76,8 @@ import { MacroNotFoundGuard } from './shared/components/macro/not-found'; import { DATA_STORAGE_REPOSITORY } from './shared/services/datastorage-repository.service'; import { LocalDataStorageRepositoryService } from './shared/services/local-datastorage-repository.service'; import { DefaultUserConfigurationService } from './shared/services/default-user-configuration.service'; -import { reducer } from './shared/store/reducers/index'; +import { reducer } from '../../shared/src/store/reducers/index'; +import { ConsoleLogService, LOG_SERVICE } from '../../shared/src/services/logger.service'; import { AutoUpdateSettings } from './shared/components/auto-update-settings/auto-update-settings'; @NgModule({ @@ -159,10 +160,11 @@ import { AutoUpdateSettings } from './shared/components/auto-update-settings/aut KeymapEditGuard, MacroNotFoundGuard, CaptureService, - { provide: DATA_STORAGE_REPOSITORY, useClass: LocalDataStorageRepositoryService }, + {provide: DATA_STORAGE_REPOSITORY, useClass: LocalDataStorageRepositoryService}, + DefaultUserConfigurationService, + { provide: LOG_SERVICE, useClass: ConsoleLogService }, DefaultUserConfigurationService ], bootstrap: [MainAppComponent] }) -export class AppModule { -} +export class AppModule { }