diff --git a/packages/uhk-agent/src/electron-main.ts b/packages/uhk-agent/src/electron-main.ts index 349e104616d..357423b4e56 100644 --- a/packages/uhk-agent/src/electron-main.ts +++ b/packages/uhk-agent/src/electron-main.ts @@ -19,6 +19,7 @@ import { DeviceService } from './services/device.service'; import { ElectronLogService } from './services/logger.service'; import { AppUpdateService } from './services/app-update.service'; import { AppService } from './services/app.service'; +import { FileDialogService } from './services/file-dialog.service'; import { SudoService } from './services/sudo.service'; import { SmartMacroDocService } from './services/smart-macro-doc.service'; import { TrayService } from './services/tray.service'; @@ -56,6 +57,7 @@ let uhkHidDeviceService: UhkHidDevice; let uhkOperations: UhkOperations; let appUpdateService: AppUpdateService; let appService: AppService; +let fileDialogService: FileDialogService; let sudoService: SudoService; let packagesDir: string; let smartMacroDocService: SmartMacroDocService; @@ -133,6 +135,7 @@ async function createWindow() { deviceService = new DeviceService(logger, win, uhkHidDeviceService, uhkOperations, options, packagesDir); appUpdateService = new AppUpdateService(logger, win, options); appService = new AppService(logger, win, deviceService, options, packagesDir); + fileDialogService = new FileDialogService(logger, win); sudoService = new SudoService(logger, options, deviceService, packagesDir); // and load the index.html of the app. @@ -223,6 +226,7 @@ async function windowClosed() { deviceService = null; appUpdateService = null; appService = null; + fileDialogService = null; await uhkHidDeviceService.close(); uhkHidDeviceService = null; sudoService = null; diff --git a/packages/uhk-agent/src/services/file-dialog.service.ts b/packages/uhk-agent/src/services/file-dialog.service.ts new file mode 100644 index 00000000000..ab814f68aaf --- /dev/null +++ b/packages/uhk-agent/src/services/file-dialog.service.ts @@ -0,0 +1,98 @@ +import { dialog, ipcMain } from 'electron'; +import settings from 'electron-settings'; +import { promises as fs } from 'fs'; +import { dirname, join } from 'path'; + +import { + IpcEvents, + LogService, + OpenUserConfigDialogResult, + SaveUserConfigDialogData, + SaveUserConfigDialogResult, +} from 'uhk-common'; + +import { MainServiceBase } from './main-service-base'; + +const USER_CONFIG_FOLDER_SETTING = 'user-config-folder'; + +export class FileDialogService extends MainServiceBase { + constructor(protected logService: LogService, + protected win: Electron.BrowserWindow) { + super(logService, win); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.fileDialog.openUserConfig, this.openUserConfig.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.fileDialog.saveUserConfig, this.saveUserConfig.bind(this)); + + logService.misc('[FileDialogService] init success'); + } + + private async openUserConfig(): Promise { + const defaultPath = await this.getUserConfigFolder(); + this.logService.misc(`[FileDialogService] open user configuration dialog, defaultPath: ${defaultPath}`); + + const result = await dialog.showOpenDialog(this.win, { + defaultPath, + properties: ['openFile'], + filters: [ + { name: 'User configuration', extensions: ['json', 'bin'] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true }; + } + + const filePath = result.filePaths[0]; + await this.saveUserConfigFolder(filePath); + + const buffer = await fs.readFile(filePath); + this.logService.misc(`[FileDialogService] opened user configuration: ${filePath}`); + + return { + canceled: false, + filePath, + data: Array.from(buffer), + }; + } + + private async saveUserConfig(_event: Electron.IpcMainInvokeEvent, + data: SaveUserConfigDialogData): Promise { + const folder = await this.getUserConfigFolder(); + const defaultPath = folder ? join(folder, data.fileName) : data.fileName; + this.logService.misc(`[FileDialogService] save user configuration dialog, defaultPath: ${defaultPath}`); + + const result = await dialog.showSaveDialog(this.win, { + defaultPath, + filters: [ + { name: 'User configuration', extensions: [data.fileName.split('.').pop()] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + await fs.writeFile(result.filePath, Buffer.from(data.content, 'base64')); + await this.saveUserConfigFolder(result.filePath); + this.logService.misc(`[FileDialogService] saved user configuration: ${result.filePath}`); + + return { + canceled: false, + filePath: result.filePath, + }; + } + + private async getUserConfigFolder(): Promise { + const folder = await settings.get(USER_CONFIG_FOLDER_SETTING); + + return typeof folder === 'string' && folder.length > 0 ? folder : undefined; + } + + private async saveUserConfigFolder(filePath: string): Promise { + await settings.set(USER_CONFIG_FOLDER_SETTING, dirname(filePath)); + } +} diff --git a/packages/uhk-common/src/models/file-dialog.ts b/packages/uhk-common/src/models/file-dialog.ts new file mode 100644 index 00000000000..9fb2eddb0ef --- /dev/null +++ b/packages/uhk-common/src/models/file-dialog.ts @@ -0,0 +1,18 @@ +export interface OpenUserConfigDialogResult { + canceled: boolean; + filePath?: string; + data?: Array; +} + +export interface SaveUserConfigDialogData { + fileName: string; + /** + * Base64 encoded file content. + */ + content: string; +} + +export interface SaveUserConfigDialogResult { + canceled: boolean; + filePath?: string; +} diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index 53a89f2e95a..9413606f90e 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -9,6 +9,7 @@ export * from './currently-updating-module-info.js'; export * from './device-module.js'; export * from './device-version-information.js'; export * from './dongle.js'; +export * from './file-dialog.js'; export * from './firmware-json.js'; export * from './firmware-repo-info.js'; export * from './firmware-upgrade-connect-prompt.js'; diff --git a/packages/uhk-common/src/util/ipcEvents.ts b/packages/uhk-common/src/util/ipcEvents.ts index 47d4539897b..6f9e0e7b249 100644 --- a/packages/uhk-common/src/util/ipcEvents.ts +++ b/packages/uhk-common/src/util/ipcEvents.ts @@ -97,9 +97,15 @@ export class SmartMacroDoc { public static readonly serviceListening = 'smart-macro-doc-service-listening'; } +export class FileDialog { + public static readonly openUserConfig = 'file-dialog-open-user-config'; + public static readonly saveUserConfig = 'file-dialog-save-user-config'; +} + export class IpcEvents { public static readonly app = App; public static readonly autoUpdater = AutoUpdate; public static readonly device = Device; + public static readonly fileDialog = FileDialog; public static readonly smartMacroDoc = SmartMacroDoc; } diff --git a/packages/uhk-web/src/app/components/device/configuration/device-configuration.component.html b/packages/uhk-web/src/app/components/device/configuration/device-configuration.component.html index 4a6aa9a06f7..2fa1cadb448 100644 --- a/packages/uhk-web/src/app/components/device/configuration/device-configuration.component.html +++ b/packages/uhk-web/src/app/components/device/configuration/device-configuration.component.html @@ -20,10 +20,10 @@

  • - - +
  • >(Store); + private readonly fileDialogService = inject(FileDialogService); private subscription = new Subscription(); constructor() { @@ -87,14 +88,21 @@ export class DeviceConfigurationComponent implements OnInit, OnDestroy { this.store.dispatch(new ChangeUserConfigurationHistoryTabAction(index)); } - changeFile(data: UploadFileData): void { - this.store.dispatch(new LoadUserConfigurationFromFileAction({ - uploadFileData: { - ...data, - saveInHistory: true - }, - autoSave: false - })); + importUserConfiguration(): void { + this.fileDialogService.openUserConfigurationFile() + .subscribe(data => { + if (!data) { + return; + } + + this.store.dispatch(new LoadUserConfigurationFromFileAction({ + uploadFileData: { + ...data, + saveInHistory: true + }, + autoSave: false + })); + }); } deleteUserConfigHistory(deviceUniqueId: number): void { diff --git a/packages/uhk-web/src/app/services/file-dialog.service.ts b/packages/uhk-web/src/app/services/file-dialog.service.ts new file mode 100644 index 00000000000..f76612a5e4c --- /dev/null +++ b/packages/uhk-web/src/app/services/file-dialog.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { Observable, from } from 'rxjs'; +import { saveAs } from 'file-saver'; + +import { UploadFileData } from 'uhk-common'; + +export interface SaveFileResult { + canceled: boolean; + filePath?: string; +} + +/** + * Platform specific user configuration import/export file picker. + * + * The default implementation uses the browser file input and file-saver. + * The Electron implementation (see ElectronFileDialogService) uses the native + * dialogs and remembers the last used folder. + */ +@Injectable() +export class FileDialogService { + openUserConfigurationFile(): Observable { + return new Observable(subscriber => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.bin'; + input.style.display = 'none'; + document.body.appendChild(input); + + const cleanup = () => input.remove(); + input.onchange = () => { + const files = input.files; + if (!files || files.length === 0) { + cleanup(); + subscriber.next(null); + subscriber.complete(); + return; + } + + const file = files[0]; + const fileReader = new FileReader(); + fileReader.onloadend = () => { + cleanup(); + subscriber.next({ + filename: file.name, + data: Array.from(new Uint8Array(fileReader.result as ArrayBuffer)), + saveInHistory: false, + }); + subscriber.complete(); + }; + fileReader.onerror = () => { + cleanup(); + subscriber.error(fileReader.error); + }; + fileReader.readAsArrayBuffer(file); + }; + input.click(); + }); + } + + saveUserConfigurationFile(fileName: string, data: Uint8Array, mimeType: string): Observable { + const arrayBuffer = new ArrayBuffer(data.byteLength); + new Uint8Array(arrayBuffer).set(data); + saveAs(new Blob([arrayBuffer], { type: mimeType }), fileName); + + return from(Promise.resolve({ canceled: false })); + } +} diff --git a/packages/uhk-web/src/app/shared.module.ts b/packages/uhk-web/src/app/shared.module.ts index 3dbfabc9255..265c1e71e8c 100644 --- a/packages/uhk-web/src/app/shared.module.ts +++ b/packages/uhk-web/src/app/shared.module.ts @@ -120,6 +120,7 @@ import { KeymapEditGuard } from './components/keymap/edit'; import { MacroNotFoundGuard } from './components/macro/not-found'; import { DataStorageRepositoryService } from './services/datastorage-repository.service'; import { DefaultUserConfigurationService } from './services/default-user-configuration.service'; +import { FileDialogService } from './services/file-dialog.service'; import { LogService } from 'uhk-common'; import { AutoUpdateSettings } from './components/auto-update-settings/auto-update-settings'; import { angularNotifierConfig } from './models/angular-notifier-config'; @@ -337,6 +338,7 @@ import appInitFactory from './services/app-init-factory'; CaptureService, DataStorageRepositoryService, DefaultUserConfigurationService, + FileDialogService, LogService, AppUpdateRendererService, AppRendererService, diff --git a/packages/uhk-web/src/app/store/effects/user-config.ts b/packages/uhk-web/src/app/store/effects/user-config.ts index 3c54aea40db..57876f2f818 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -3,10 +3,9 @@ import { Router } from '@angular/router'; import { Actions, createEffect, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { routerNavigatedAction, RouterNavigatedAction } from '@ngrx/router-store'; import { Action } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap, withLatestFrom, } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, map, mergeMap, switchMap, tap, withLatestFrom, } from 'rxjs/operators'; import { Store } from '@ngrx/store'; -import { saveAs } from 'file-saver'; import { BackupUserConfigurationInfo, @@ -46,6 +45,7 @@ import { import { DataStorageRepositoryService } from '../../services/datastorage-repository.service'; import { DefaultUserConfigurationService } from '../../services/default-user-configuration.service'; +import { FileDialogService } from '../../services/file-dialog.service'; import { Uhk80MigratorService } from '../../services/uhk80-migrator.service'; import { AppState, @@ -84,6 +84,7 @@ export class UserConfigEffects { private readonly dataStorageRepository = inject(DataStorageRepositoryService); private readonly defaultUserConfigurationService = inject(DefaultUserConfigurationService); private readonly deviceRendererService = inject(DeviceRendererService); + private readonly fileDialogService = inject(FileDialogService); private readonly logService = inject(LogService); private readonly router = inject(Router); private readonly store = inject>(Store); @@ -303,29 +304,37 @@ export class UserConfigEffects { .pipe( ofType(ActionTypes.SaveUserConfigInJsonFile), withLatestFrom(this.store.select(getUserConfiguration), this.store.select(getHardwareModules)), - tap(([action, userConfiguration, hardwareModules]) => { + mergeMap(([action, userConfiguration, hardwareModules]) => { const newUserConfiguration= updateUserConfigurationWithLastSaveInfo(userConfiguration, hardwareModules.rightModuleInfo); const asString = JSON.stringify(newUserConfiguration.toJsonObject(), null, 2); - const asBlob = new Blob([asString], { type: 'text/plain' }); - saveAs(asBlob, 'UserConfiguration.json'); + const data = new TextEncoder().encode(asString); + + return this.fileDialogService.saveUserConfigurationFile('UserConfiguration.json', data, 'text/plain') + .pipe( + map(() => new EmptyAction()), + catchError(error => this.showFileSaveErrorNotification(error)) + ); }) - ), - { dispatch: false } + ) ); saveUserConfigInBinFile$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.SaveUserConfigInBinFile), withLatestFrom(this.store.select(getUserConfiguration), this.store.select(getHardwareModules)), - tap(([action, userConfiguration, hardwareModules]) => { + mergeMap(([action, userConfiguration, hardwareModules]) => { const newUserConfiguration= updateUserConfigurationWithLastSaveInfo(userConfiguration, hardwareModules.rightModuleInfo); const uhkBuffer = new UhkBuffer(); newUserConfiguration.toBinary(uhkBuffer); - const blob = new Blob([uhkBuffer.getBufferContent()]); - saveAs(blob, 'UserConfiguration.bin'); + const data = uhkBuffer.getBufferContent(); + + return this.fileDialogService.saveUserConfigurationFile('UserConfiguration.bin', data, 'application/octet-stream') + .pipe( + map(() => new EmptyAction()), + catchError(error => this.showFileSaveErrorNotification(error)) + ); }) - ), - { dispatch: false } + ) ); loadUserConfigurationFromFile$ = createEffect(() => this.actions$ @@ -497,6 +506,15 @@ export class UserConfigEffects { { dispatch: false } ); + private showFileSaveErrorNotification(error: unknown): Observable { + this.logService.error('[UserConfigEffects] Failed to save the user configuration file', error); + + return of(new ShowNotificationAction({ + type: NotificationType.Error, + message: 'Failed to save the user configuration file.' + })); + } + private getUserConfiguration(uhkDeviceProduct: UhkDeviceProduct): Observable { return this.dataStorageRepository.getConfig(uhkDeviceProduct) .pipe( diff --git a/packages/uhk-web/src/renderer/services/electron-file-dialog.service.ts b/packages/uhk-web/src/renderer/services/electron-file-dialog.service.ts new file mode 100644 index 00000000000..70bed688828 --- /dev/null +++ b/packages/uhk-web/src/renderer/services/electron-file-dialog.service.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Observable, from } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + IpcEvents, + OpenUserConfigDialogResult, + SaveUserConfigDialogResult, + UploadFileData, +} from 'uhk-common'; + +import { FileDialogService, SaveFileResult } from '../../app/services/file-dialog.service'; + +export class ElectronFileDialogService extends FileDialogService { + openUserConfigurationFile(): Observable { + return from( + (window as any).electron.ipcRenderer.invoke(IpcEvents.fileDialog.openUserConfig) as Promise + ).pipe( + map(result => { + if (!result || result.canceled || !result.data) { + return null; + } + + return { + filename: result.filePath, + data: result.data, + saveInHistory: false, + }; + }) + ); + } + + saveUserConfigurationFile(fileName: string, data: Uint8Array, _mimeType: string): Observable { + return from( + (window as any).electron.ipcRenderer.invoke(IpcEvents.fileDialog.saveUserConfig, { + fileName, + content: toBase64(data), + }) as Promise + ); + } +} + +function toBase64(data: Uint8Array): string { + let binary = ''; + const chunkSize = 0x8000; + + for (let i = 0; i < data.length; i += chunkSize) { + binary += String.fromCharCode(...data.subarray(i, i + chunkSize)); + } + + return btoa(binary); +} diff --git a/packages/uhk-web/src/renderer/uhk-renderer.module.ts b/packages/uhk-web/src/renderer/uhk-renderer.module.ts index 87186d871ab..eed169e0599 100644 --- a/packages/uhk-web/src/renderer/uhk-renderer.module.ts +++ b/packages/uhk-web/src/renderer/uhk-renderer.module.ts @@ -2,12 +2,14 @@ import { ErrorHandler, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { LogService } from 'uhk-common'; import { ElectronDataStorageRepositoryService } from './services/electron-datastorage-repository.service'; +import { ElectronFileDialogService } from './services/electron-file-dialog.service'; import { ElectronLogService } from './services/electron-log.service'; import { ElectronErrorHandlerService } from './services/electron-error-handler.service'; import { MainAppComponent, routing } from '../../'; import { IpcUhkRenderer } from './services/ipc-uhk-renderer'; import { IpcCommonRenderer } from '../app/services/ipc-common-renderer'; import { DataStorageRepositoryService } from '../app/services/datastorage-repository.service'; +import { FileDialogService } from '../app/services/file-dialog.service'; import { SharedModule } from '../app/shared.module'; import { reducers } from '../app/store'; import { EffectsModule } from '@ngrx/effects'; @@ -33,6 +35,7 @@ import { CustomRouterStateSerializer } from '../app/store/router-util'; ], providers: [ {provide: DataStorageRepositoryService, useClass: ElectronDataStorageRepositoryService}, + {provide: FileDialogService, useClass: ElectronFileDialogService}, {provide: IpcCommonRenderer, useClass: IpcUhkRenderer}, {provide: LogService, useClass: ElectronLogService}, {provide: ErrorHandler, useClass: ElectronErrorHandlerService}