diff --git a/packages/uhk-agent/src/electron-main.ts b/packages/uhk-agent/src/electron-main.ts index 357423b4e56..df4a920fdf5 100644 --- a/packages/uhk-agent/src/electron-main.ts +++ b/packages/uhk-agent/src/electron-main.ts @@ -20,6 +20,7 @@ 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 { SharedConfigService } from './services/shared-config.service'; import { SudoService } from './services/sudo.service'; import { SmartMacroDocService } from './services/smart-macro-doc.service'; import { TrayService } from './services/tray.service'; @@ -58,6 +59,7 @@ let uhkOperations: UhkOperations; let appUpdateService: AppUpdateService; let appService: AppService; let fileDialogService: FileDialogService; +let sharedConfigService: SharedConfigService; let sudoService: SudoService; let packagesDir: string; let smartMacroDocService: SmartMacroDocService; @@ -136,6 +138,7 @@ async function createWindow() { appUpdateService = new AppUpdateService(logger, win, options); appService = new AppService(logger, win, deviceService, options, packagesDir); fileDialogService = new FileDialogService(logger, win); + sharedConfigService = new SharedConfigService(logger, win); sudoService = new SudoService(logger, options, deviceService, packagesDir); // and load the index.html of the app. @@ -227,6 +230,8 @@ async function windowClosed() { appUpdateService = null; appService = null; fileDialogService = null; + sharedConfigService?.dispose(); + sharedConfigService = 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 index ab814f68aaf..fdf7a36ada8 100644 --- a/packages/uhk-agent/src/services/file-dialog.service.ts +++ b/packages/uhk-agent/src/services/file-dialog.service.ts @@ -1,4 +1,4 @@ -import { dialog, ipcMain } from 'electron'; +import { app, dialog, ipcMain } from 'electron'; import settings from 'electron-settings'; import { promises as fs } from 'fs'; import { dirname, join } from 'path'; @@ -9,6 +9,8 @@ import { OpenUserConfigDialogResult, SaveUserConfigDialogData, SaveUserConfigDialogResult, + SelectSharedConfigDialogResult, + SHARED_CONFIG_DEFAULT_FILE_NAME, } from 'uhk-common'; import { MainServiceBase } from './main-service-base'; @@ -24,6 +26,10 @@ export class FileDialogService extends MainServiceBase { 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)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.fileDialog.selectSharedConfig, this.selectSharedConfig.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.fileDialog.getDefaultSharedConfig, this.getDefaultSharedConfig.bind(this)); logService.misc('[FileDialogService] init success'); } @@ -86,6 +92,40 @@ export class FileDialogService extends MainServiceBase { }; } + private async selectSharedConfig(_event: Electron.IpcMainInvokeEvent, + data?: { currentPath?: string }): Promise { + const defaultPath = data?.currentPath || this.getDefaultSharedConfigPath(); + this.logService.misc(`[FileDialogService] select shared configuration dialog, defaultPath: ${defaultPath}`); + + const result = await dialog.showSaveDialog(this.win, { + title: 'Select shared configuration file', + defaultPath, + filters: [ + { name: 'User configuration', extensions: ['json'] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + this.logService.misc(`[FileDialogService] selected shared configuration: ${result.filePath}`); + + return { + canceled: false, + filePath: result.filePath, + }; + } + + private getDefaultSharedConfig(): string { + return this.getDefaultSharedConfigPath(); + } + + private getDefaultSharedConfigPath(): string { + return join(app.getPath('userData'), SHARED_CONFIG_DEFAULT_FILE_NAME); + } + private async getUserConfigFolder(): Promise { const folder = await settings.get(USER_CONFIG_FOLDER_SETTING); diff --git a/packages/uhk-agent/src/services/shared-config.service.ts b/packages/uhk-agent/src/services/shared-config.service.ts new file mode 100644 index 00000000000..0c82b60a629 --- /dev/null +++ b/packages/uhk-agent/src/services/shared-config.service.ts @@ -0,0 +1,277 @@ +import { ipcMain } from 'electron'; +import settings from 'electron-settings'; +import { FSWatcher, promises as fs, watch } from 'fs'; +import { createHash } from 'crypto'; +import { basename, dirname } from 'path'; + +import { + IpcEvents, + LogService, + SharedConfigAcknowledgeData, + SharedConfigApplyData, + SharedConfigConfiguration, + SharedConfigWriteData, +} from 'uhk-common'; + +import { MainServiceBase } from './main-service-base'; + +const CHANGE_DEBOUNCE_MS = 500; +const POLL_INTERVAL_MS = 3000; +const ACKNOWLEDGED_CONTENT_HASH_SETTING = 'shared-config-acknowledged-content-hash'; + +export class SharedConfigService extends MainServiceBase { + private filePath?: string; + private detectChanges = false; + private watcher?: FSWatcher; + private pollTimer?: NodeJS.Timeout; + private changeTimer?: NodeJS.Timeout; + /** + * Hash of the content the user last applied or ignored. Persisted so that a change made + * while Agent was not running is still detected on the next startup. + */ + private acknowledgedContentHash?: string; + /** + * Hash of the content currently offered to the user. Used to avoid offering the same + * content repeatedly while it waits for the user's decision. + */ + private pendingContentHash?: string; + private configurationId = 0; + private isChecking = false; + + constructor(protected logService: LogService, + protected win: Electron.BrowserWindow) { + super(logService, win); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.sharedConfig.configure, this.configure.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.sharedConfig.write, this.write.bind(this)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ipcMain.handle(IpcEvents.sharedConfig.acknowledge, this.acknowledge.bind(this)); + + logService.misc('[SharedConfigService] init success'); + } + + dispose(): void { + this.stopWatching(); + } + + private async configure(_event: Electron.IpcMainInvokeEvent, + configuration: SharedConfigConfiguration): Promise { + // Bump the configuration id so an in-flight configure() cannot re-enable watching + // after a newer configuration (for example unchecking the checkbox) took over. + const configurationId = ++this.configurationId; + + this.stopWatching(); + this.filePath = configuration?.filePath; + this.detectChanges = !!configuration?.detectChanges; + this.pendingContentHash = undefined; + + if (!this.filePath) { + this.logService.misc('[SharedConfigService] disabled, no shared configuration file path'); + return; + } + + const content = await this.readContent(this.filePath); + if (configurationId !== this.configurationId) { + this.logService.misc('[SharedConfigService] superseded configuration, ignoring'); + return; + } + + const contentHash = this.hashContent(content); + const acknowledgedContentHash = await this.getAcknowledgedContentHash(); + + if (acknowledgedContentHash === undefined) { + // First time this file is seen: establish a baseline without prompting. From now on + // any difference between the file and this baseline is reported. + if (contentHash !== undefined) { + await this.setAcknowledgedContentHash(contentHash); + } + } else if (this.detectChanges && contentHash !== undefined && contentHash !== acknowledgedContentHash) { + // The file changed while Agent was not running. Offer it on startup. + this.pendingContentHash = contentHash; + this.notifyChange(content); + } + + this.logService.misc( + `[SharedConfigService] configured, filePath: ${this.filePath}, detectChanges: ${this.detectChanges}`); + + if (this.detectChanges) { + this.startWatching(); + } + } + + private async write(_event: Electron.IpcMainInvokeEvent, data: SharedConfigWriteData): Promise { + if (!this.filePath) { + return; + } + + const buffer = Buffer.from(data.content, 'base64'); + await fs.writeFile(this.filePath, buffer); + + // An exported configuration is, by definition, already known to the user. + const contentHash = this.hashContent(data.content); + if (contentHash !== undefined) { + await this.setAcknowledgedContentHash(contentHash); + } + this.pendingContentHash = undefined; + this.logService.misc(`[SharedConfigService] wrote shared configuration: ${this.filePath}`); + } + + private async acknowledge(_event: Electron.IpcMainInvokeEvent, data: SharedConfigAcknowledgeData): Promise { + if (!data?.content) { + return; + } + + const contentHash = this.hashContent(data.content); + if (contentHash === undefined) { + return; + } + + await this.setAcknowledgedContentHash(contentHash); + if (this.pendingContentHash === contentHash) { + this.pendingContentHash = undefined; + } + this.logService.misc('[SharedConfigService] acknowledged shared configuration change'); + } + + private startWatching(): void { + if (!this.filePath) { + return; + } + + const directory = dirname(this.filePath); + const fileName = basename(this.filePath); + + try { + this.watcher = watch(directory, (_eventType, changedFile) => { + if (changedFile && changedFile.toString() !== fileName) { + return; + } + + this.scheduleChangeCheck(); + }); + this.watcher.on('error', error => { + this.logService.error('[SharedConfigService] watcher error', error); + }); + this.logService.misc(`[SharedConfigService] watching: ${this.filePath}`); + } catch (error) { + this.logService.error('[SharedConfigService] failed to watch shared configuration', error); + } + + // Poll as a fallback: not every filesystem, sync client or network share delivers + // reliable change events, so never rely on fs.watch alone for detection. + this.pollTimer = setInterval(() => { + this.handleExternalChange(); + }, POLL_INTERVAL_MS); + } + + private stopWatching(): void { + if (this.changeTimer) { + clearTimeout(this.changeTimer); + this.changeTimer = undefined; + } + + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + + if (this.watcher) { + this.watcher.close(); + this.watcher = undefined; + } + } + + private scheduleChangeCheck(): void { + // Coalesce bursts of events into a single check, but do not reset the timer on every + // event: a file that is written continuously would otherwise keep pushing the check + // into the future and no change would ever be reported. + if (this.changeTimer) { + return; + } + + this.changeTimer = setTimeout(() => { + this.changeTimer = undefined; + this.handleExternalChange(); + }, CHANGE_DEBOUNCE_MS); + } + + private async handleExternalChange(): Promise { + if (this.isChecking || !this.filePath || !this.detectChanges) { + return; + } + + this.isChecking = true; + try { + const content = await this.readContent(this.filePath); + if (content === undefined) { + return; + } + + const contentHash = this.hashContent(content); + if (contentHash === undefined) { + return; + } + + const acknowledgedContentHash = await this.getAcknowledgedContentHash(); + if (contentHash === acknowledgedContentHash || contentHash === this.pendingContentHash) { + return; + } + + this.pendingContentHash = contentHash; + this.notifyChange(content); + } catch (error) { + this.logService.error('[SharedConfigService] failed to handle shared configuration change', error); + } finally { + this.isChecking = false; + } + } + + private notifyChange(content: string): void { + if (!this.filePath) { + return; + } + + this.logService.misc(`[SharedConfigService] external change detected: ${this.filePath}`); + + const applyData: SharedConfigApplyData = { + filePath: this.filePath, + fileName: basename(this.filePath), + content, + }; + this.sendIpcToWindow(IpcEvents.sharedConfig.changeDetected, applyData); + } + + private async readContent(filePath: string): Promise { + try { + const buffer = await fs.readFile(filePath); + + return buffer.toString('base64'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this.logService.error(`[SharedConfigService] failed to read ${filePath}`, error); + } + + return undefined; + } + } + + private hashContent(content?: string): string | undefined { + if (content === undefined) { + return undefined; + } + + return createHash('sha256').update(content).digest('hex'); + } + + private async getAcknowledgedContentHash(): Promise { + const value = await settings.get(ACKNOWLEDGED_CONTENT_HASH_SETTING); + + return typeof value === 'string' && value.length > 0 ? value : undefined; + } + + private async setAcknowledgedContentHash(contentHash: string): Promise { + await settings.set(ACKNOWLEDGED_CONTENT_HASH_SETTING, contentHash); + } +} diff --git a/packages/uhk-common/src/models/application-settings.ts b/packages/uhk-common/src/models/application-settings.ts index aec6cf747fa..b3a95ee194e 100644 --- a/packages/uhk-common/src/models/application-settings.ts +++ b/packages/uhk-common/src/models/application-settings.ts @@ -44,4 +44,15 @@ export interface ApplicationSettings { * Sidebar macro grouping preferences. */ macroGrouping?: Partial; + /** + * Absolute path of a configuration file in a folder that is synced between computers + * (for example Dropbox, Google Drive, or a network share). When set, Agent exports the + * current configuration there and can offer to apply changes made on the other computer. + */ + sharedConfigurationFilePath?: string; + /** + * If true, Agent watches the shared configuration file and prompts to apply it when it + * changes on the other computer. Requires sharedConfigurationFilePath to be set. + */ + detectSharedConfigurationChanges?: boolean; } diff --git a/packages/uhk-common/src/models/file-dialog.ts b/packages/uhk-common/src/models/file-dialog.ts index 9fb2eddb0ef..5a9b1f3d1f5 100644 --- a/packages/uhk-common/src/models/file-dialog.ts +++ b/packages/uhk-common/src/models/file-dialog.ts @@ -16,3 +16,8 @@ export interface SaveUserConfigDialogResult { canceled: boolean; filePath?: string; } + +export interface SelectSharedConfigDialogResult { + canceled: boolean; + filePath?: string; +} diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index 9413606f90e..730c8190566 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -18,6 +18,7 @@ export * from './firmware-version.js'; export * from './firmware-version-info.js'; export * from './notification.js'; export * from './protocol-versions.js'; +export * from './shared-config.js'; export * from './init-backlighting-color-palette.js'; export * from './ipc-response.js'; export * from './key-language.js'; diff --git a/packages/uhk-common/src/models/shared-config.ts b/packages/uhk-common/src/models/shared-config.ts new file mode 100644 index 00000000000..d6b2ee11c7d --- /dev/null +++ b/packages/uhk-common/src/models/shared-config.ts @@ -0,0 +1,36 @@ +export const SHARED_CONFIG_DEFAULT_FILE_NAME = 'SharedUserConfiguration.json'; + +export interface SharedConfigConfiguration { + /** + * Absolute path of the shared configuration file. Undefined disables the feature. + */ + filePath?: string; + /** + * Whether Agent should watch the file and prompt to apply external changes. + */ + detectChanges: boolean; +} + +export interface SharedConfigWriteData { + /** + * Base64 encoded configuration file content. + */ + content: string; +} + +export interface SharedConfigAcknowledgeData { + /** + * Base64 encoded configuration file content that the user has acknowledged + * (by applying or ignoring it). + */ + content: string; +} + +export interface SharedConfigApplyData { + filePath: string; + fileName: string; + /** + * Base64 encoded configuration file content. + */ + content: string; +} diff --git a/packages/uhk-common/src/util/ipcEvents.ts b/packages/uhk-common/src/util/ipcEvents.ts index 6f9e0e7b249..fcd6f971535 100644 --- a/packages/uhk-common/src/util/ipcEvents.ts +++ b/packages/uhk-common/src/util/ipcEvents.ts @@ -100,6 +100,15 @@ export class SmartMacroDoc { export class FileDialog { public static readonly openUserConfig = 'file-dialog-open-user-config'; public static readonly saveUserConfig = 'file-dialog-save-user-config'; + public static readonly selectSharedConfig = 'file-dialog-select-shared-config'; + public static readonly getDefaultSharedConfig = 'file-dialog-get-default-shared-config'; +} + +export class SharedConfig { + public static readonly configure = 'shared-config-configure'; + public static readonly write = 'shared-config-write'; + public static readonly acknowledge = 'shared-config-acknowledge'; + public static readonly changeDetected = 'shared-config-change-detected'; } export class IpcEvents { @@ -107,5 +116,6 @@ export class IpcEvents { public static readonly autoUpdater = AutoUpdate; public static readonly device = Device; public static readonly fileDialog = FileDialog; + public static readonly sharedConfig = SharedConfig; public static readonly smartMacroDoc = SmartMacroDoc; } diff --git a/packages/uhk-web/src/app/app.component.html b/packages/uhk-web/src/app/app.component.html index 87ec5d9e09f..5e7fd7c7f92 100644 --- a/packages/uhk-web/src/app/app.component.html +++ b/packages/uhk-web/src/app/app.component.html @@ -21,6 +21,13 @@ > + + + diff --git a/packages/uhk-web/src/app/app.component.scss b/packages/uhk-web/src/app/app.component.scss index 6c153663b81..8649aba2919 100644 --- a/packages/uhk-web/src/app/app.component.scss +++ b/packages/uhk-web/src/app/app.component.scss @@ -54,7 +54,8 @@ ble-pairing-panel, dongle-pairing-panel, firmware-upgrade-panel, -app-update-available { +app-update-available, +shared-config-change-panel { height: $main-content-top-margin-on-update; position: fixed; top: 0; diff --git a/packages/uhk-web/src/app/app.component.ts b/packages/uhk-web/src/app/app.component.ts index 6ddd0eecc3e..d632976963c 100644 --- a/packages/uhk-web/src/app/app.component.ts +++ b/packages/uhk-web/src/app/app.component.ts @@ -7,9 +7,10 @@ import { SplitGutterInteractionEvent } from 'angular-split'; import { NotifierService } from 'gramli-angular-notifier'; import { Observable, Subscription } from 'rxjs'; import { Action, Store } from '@ngrx/store'; -import { ERR_UPDATER_INVALID_SIGNATURE } from 'uhk-common'; +import { ERR_UPDATER_INVALID_SIGNATURE, SharedConfigApplyData } from 'uhk-common'; import { KeyboardSvgExportService } from './services/keyboard-svg-export.service'; +import { SharedConfigService } from './services/shared-config.service'; import { ActionTypes as AppUpdateActionTypes } from './store/actions/app-update.action'; import { DoNotUpdateAppAction, UpdateAppAction } from './store/actions/app-update.action'; import { EnableUsbStackTestAction, UpdateFirmwareAction } from './store/actions/device'; @@ -28,7 +29,8 @@ import { firstAttemptOfSaveToKeyboard, isStatusBufferErrorHidden, getOutOfSpaceWaringData, - getShowFirmwareUpgradePanel + getShowFirmwareUpgradePanel, + getSharedConfigChange } from './store'; import { StartDonglePairingAction } from './store/actions/dongle-pairing.action'; import { AddNewPairedDevicesToHostConnectionsAction } from './store/actions/user-config'; @@ -40,6 +42,8 @@ import { ErrorPanelSizeChangedAction, KeyUpAction, KeyDownAction, + ApplySharedConfigChangeAction, + DismissSharedConfigChangeAction, } from './store/actions/app'; import { BleAddingState, DonglePairingState, OutOfSpaceWarningData } from './models'; import { filter } from 'rxjs/operators'; @@ -120,6 +124,7 @@ export class MainAppComponent implements OnDestroy { firstAttemptOfSaveToKeyboard$: Observable; isStatusBufferErrorHidden$: Observable; outOfSpaceWarning: OutOfSpaceWarningData; + sharedConfigChange: SharedConfigApplyData; secondSideMenuVisible = false; splitSizes = { top: 100, @@ -133,11 +138,13 @@ export class MainAppComponent implements OnDestroy { private newPairedDevicesStateSubscription: Subscription; private errorPanelHeightSubscription: Subscription; private readonly keyboardSvgExportService = inject(KeyboardSvgExportService); + private readonly sharedConfigService = inject(SharedConfigService); private keypressCapturing: boolean; private saveToKeyboardStateSubscription: Subscription; private keypressCapturingSubscription: Subscription; private readonly notificationService = inject(NotifierService); private showFirmwareUpgradePanelSubscription: Subscription; + private sharedConfigChangeSubscription: Subscription; private showUpdateAvailableSubscription: Subscription; private outOfSpaceWarningSubscription: Subscription; private routeDataSubscription: Subscription; @@ -183,6 +190,11 @@ export class MainAppComponent implements OnDestroy { this.showFirmwareUpgradePanel = data; this.cdRef.markForCheck(); }); + this.sharedConfigChangeSubscription = this.store.select(getSharedConfigChange) + .subscribe(data => { + this.sharedConfigChange = data; + this.cdRef.markForCheck(); + }); this.showUpdateAvailableSubscription = this.store.select(getShowAppUpdateAvailable) .subscribe(data => { this.showUpdateAvailable = data; @@ -241,6 +253,7 @@ export class MainAppComponent implements OnDestroy { this.saveToKeyboardStateSubscription.unsubscribe(); this.keypressCapturingSubscription.unsubscribe(); this.showFirmwareUpgradePanelSubscription.unsubscribe(); + this.sharedConfigChangeSubscription.unsubscribe(); this.showUpdateAvailableSubscription.unsubscribe(); this.outOfSpaceWarningSubscription.unsubscribe(); this.routeDataSubscription.unsubscribe(); @@ -325,7 +338,18 @@ export class MainAppComponent implements OnDestroy { return this.showFirmwareUpgradePanel || this.showUpdateAvailable || this.donglePairingState?.showDonglePairingPanel - || this.newPairedDevicesState?.showNewPairedDevicesPanel; + || this.newPairedDevicesState?.showNewPairedDevicesPanel + || !!this.sharedConfigChange; + } + + applySharedConfigChange(): void { + this.sharedConfigService.acknowledge(this.sharedConfigChange?.content); + this.store.dispatch(new ApplySharedConfigChangeAction()); + } + + dismissSharedConfigChange(): void { + this.sharedConfigService.acknowledge(this.sharedConfigChange?.content); + this.store.dispatch(new DismissSharedConfigChangeAction()); } updateFirmware(): void { diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.html b/packages/uhk-web/src/app/components/agent/settings/settings.component.html index e20014c9e46..6b35b25b63b 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.html +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.html @@ -116,7 +116,41 @@

- + +

Configuration file

+ +
+ Path: + {{ sharedConfigurationFile.filePath }} + {{ sharedConfigurationFile.isDefault ? '(default path)' : '(custom path)' }} + + + +
+
+
+ +
diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts index 317cbe06917..ad4b016c207 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts @@ -2,8 +2,8 @@ import { Component, inject } from '@angular/core'; import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import { faCog, faDesktop, faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Observable, combineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; import { AppTheme, KEY_LANGUAGE_OPTIONS, KeyLanguage, MacroGroupingSettings } from 'uhk-common'; import { @@ -12,11 +12,13 @@ import { getAlwaysEnableAdvancedMode, getAnimationEnabled, getAppTheme, + getDetectSharedConfigurationChanges, getIsAdvancedSettingsMenuVisible, getKeyLanguage, getMacroGroupingSettings, getMinimizeToTray, getOperatingSystem, + getSharedConfigurationFilePath, keyboardHalvesAlwaysJoined, } from '../../../store'; import { MACRO_GROUPING_MAX_DEPTH } from '../../../util/group-macros-by-name'; @@ -28,14 +30,17 @@ import { import { OpenConfigFolderAction, SetAppThemeAction, + SetDetectSharedConfigurationChangesAction, SetKeyLanguageAction, SetMacroGroupingSettingsAction, + SetSharedConfigurationFilePathAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction, ToggleMinimizeToTrayAction, } from '../../../store/actions/app'; import { ToggleAlwaysEnableAdvancedModeAction } from '../../../store/actions/advance-settings.action'; import { OperatingSystem } from '../../../models/operating-system'; +import { FileDialogService } from '../../../services/file-dialog.service'; type ThemeOption = { id: AppTheme; @@ -64,6 +69,10 @@ export class SettingsComponent { alwaysEnableAdvancedMode$: Observable; alwaysEnableAdvancedModeSettingVisible$: Observable; macroGroupingSettings$: Observable; + sharedConfigurationFilePath$: Observable; + hasSharedConfigurationFilePath$: Observable; + sharedConfigurationFile$: Observable<{ filePath: string; isDefault: boolean }>; + detectSharedConfigurationChanges$: Observable; macroGroupingMaxDepth = MACRO_GROUPING_MAX_DEPTH; keyLanguages = KEY_LANGUAGE_OPTIONS; themes: ThemeOption[] = [ @@ -73,6 +82,7 @@ export class SettingsComponent { ]; private readonly store = inject>(Store); + private readonly fileDialogService = inject(FileDialogService); constructor() { this.updateSettingsState$ = this.store.select(appUpdateSettingsState); @@ -85,6 +95,18 @@ export class SettingsComponent { this.alwaysEnableAdvancedMode$ = this.store.select(getAlwaysEnableAdvancedMode); this.alwaysEnableAdvancedModeSettingVisible$ = this.store.select(getIsAdvancedSettingsMenuVisible); this.macroGroupingSettings$ = this.store.select(getMacroGroupingSettings); + this.sharedConfigurationFilePath$ = this.store.select(getSharedConfigurationFilePath); + this.hasSharedConfigurationFilePath$ = this.sharedConfigurationFilePath$.pipe(map(filePath => !!filePath)); + this.sharedConfigurationFile$ = combineLatest([ + this.sharedConfigurationFilePath$, + this.fileDialogService.getDefaultSharedConfigurationFilePath(), + ]).pipe( + map(([filePath, defaultFilePath]) => ({ + filePath: filePath || defaultFilePath, + isDefault: !filePath, + })) + ); + this.detectSharedConfigurationChanges$ = this.store.select(getDetectSharedConfigurationChanges); } openConfigFolder(): void { @@ -127,4 +149,24 @@ export class SettingsComponent { this.store.dispatch(new SetMacroGroupingSettingsAction(settings)); } + chooseSharedConfigurationFile(): void { + this.sharedConfigurationFilePath$.pipe(take(1)) + .subscribe(currentPath => { + this.fileDialogService.selectSharedConfigurationFile(currentPath) + .subscribe(filePath => { + if (filePath) { + this.store.dispatch(new SetSharedConfigurationFilePathAction(filePath)); + } + }); + }); + } + + useDefaultSharedConfigurationFilePath(): void { + this.store.dispatch(new SetSharedConfigurationFilePathAction(undefined)); + } + + toggleDetectSharedConfigurationChanges(enabled: boolean): void { + this.store.dispatch(new SetDetectSharedConfigurationChangesAction(enabled)); + } + } diff --git a/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.html b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.html new file mode 100644 index 00000000000..17cabe9941c --- /dev/null +++ b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.html @@ -0,0 +1,7 @@ + diff --git a/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.scss b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.scss new file mode 100644 index 00000000000..11ff3e46e83 --- /dev/null +++ b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.scss @@ -0,0 +1,22 @@ +@import '../../../styles/variables'; + +.shared-config-change-panel-wrapper { + display: flex; + justify-content: center; + align-items: center; + height: $main-content-top-margin-on-update; + margin: 0; + padding-top: 5px; + padding-bottom: 5px; + + background-color: var(--color-firmware-upgrade-panel-bg); + color: var(--color-firmware-upgrade-panel-text); + border-radius: 0; + border-width: 0; + + a { + color: var(--color-firmware-upgrade-panel-text); + text-decoration: underline; + cursor: pointer; + } +} diff --git a/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.ts b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.ts new file mode 100644 index 00000000000..4344b39a7fd --- /dev/null +++ b/packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +import { SharedConfigApplyData } from 'uhk-common'; + +@Component({ + selector: 'shared-config-change-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, + templateUrl: './shared-config-change-panel.component.html', + styleUrls: ['./shared-config-change-panel.component.scss'] +}) +export class SharedConfigChangePanelComponent { + @Input() change: SharedConfigApplyData; + + @Output() apply = new EventEmitter(); + + @Output() dismiss = new EventEmitter(); +} diff --git a/packages/uhk-web/src/app/services/file-dialog.service.ts b/packages/uhk-web/src/app/services/file-dialog.service.ts index f76612a5e4c..d276bdc1d54 100644 --- a/packages/uhk-web/src/app/services/file-dialog.service.ts +++ b/packages/uhk-web/src/app/services/file-dialog.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; -import { Observable, from } from 'rxjs'; +import { Observable, from, of } from 'rxjs'; import { saveAs } from 'file-saver'; -import { UploadFileData } from 'uhk-common'; +import { SHARED_CONFIG_DEFAULT_FILE_NAME, UploadFileData } from 'uhk-common'; export interface SaveFileResult { canceled: boolean; @@ -64,4 +64,19 @@ export class FileDialogService { return from(Promise.resolve({ canceled: false })); } + + /** + * Lets the user choose a shared configuration file in a folder synced between computers. + * Returns null when the platform has no access to a filesystem path (for example the browser). + */ + selectSharedConfigurationFile(_currentPath?: string): Observable { + return of(null); + } + + /** + * The path Agent uses for the shared configuration file when the user has not chosen one. + */ + getDefaultSharedConfigurationFilePath(): Observable { + return of(SHARED_CONFIG_DEFAULT_FILE_NAME); + } } diff --git a/packages/uhk-web/src/app/services/shared-config.service.ts b/packages/uhk-web/src/app/services/shared-config.service.ts new file mode 100644 index 00000000000..9d732fbe307 --- /dev/null +++ b/packages/uhk-web/src/app/services/shared-config.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { NEVER, Observable } from 'rxjs'; + +import { SharedConfigApplyData } from 'uhk-common'; + +/** + * Platform specific shared configuration file synchronization. + * + * The default implementation is a no-op. The Electron implementation + * (see ElectronSharedConfigService) writes to the shared file, watches it for + * external changes, and emits them on changeDetected$. + */ +@Injectable() +export class SharedConfigService { + readonly changeDetected$: Observable = NEVER; + + configure(_filePath: string | undefined, _detectChanges: boolean): Promise { + return Promise.resolve(); + } + + write(_data: Uint8Array): Promise { + return Promise.resolve(); + } + + acknowledge(_content?: string): Promise { + return Promise.resolve(); + } +} diff --git a/packages/uhk-web/src/app/shared.module.ts b/packages/uhk-web/src/app/shared.module.ts index 265c1e71e8c..5028892aff3 100644 --- a/packages/uhk-web/src/app/shared.module.ts +++ b/packages/uhk-web/src/app/shared.module.ts @@ -24,6 +24,7 @@ import CircleTooltipComponent from './components/circle-tooltip/circle-tooltip.c import { BlePairingPanelComponent } from './components/device/ble-pairing-panel/ble-pairing-panel.component'; import { FadeTimeoutSliderComponent } from './components/device/led-settings/fade-timeout-slider.component'; import { DonglePairingPanelComponent } from './components/device/dongle-pairing-panel/dongle-pairing-panel.component'; +import { SharedConfigChangePanelComponent } from './components/shared-config-change-panel/shared-config-change-panel.component'; import { KeyboardSliderComponent } from './components/keyboard/slider'; import { AdvancedSettingsPageComponent, @@ -121,6 +122,7 @@ 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 { SharedConfigService } from './services/shared-config.service'; import { LogService } from 'uhk-common'; import { AutoUpdateSettings } from './components/auto-update-settings/auto-update-settings'; import { angularNotifierConfig } from './models/angular-notifier-config'; @@ -203,6 +205,7 @@ import appInitFactory from './services/app-init-factory'; KeymapHeaderComponent, NotificationComponent, DonglePairingPanelComponent, + SharedConfigChangePanelComponent, SvgIconTextKeyComponent, SvgKeyboardKeyComponent, SvgKeystrokeKeyComponent, @@ -339,6 +342,7 @@ import appInitFactory from './services/app-init-factory'; DataStorageRepositoryService, DefaultUserConfigurationService, FileDialogService, + SharedConfigService, LogService, AppUpdateRendererService, AppRendererService, diff --git a/packages/uhk-web/src/app/store/actions/app.ts b/packages/uhk-web/src/app/store/actions/app.ts index db70132318c..8818805a996 100644 --- a/packages/uhk-web/src/app/store/actions/app.ts +++ b/packages/uhk-web/src/app/store/actions/app.ts @@ -1,6 +1,6 @@ import { Action } from '@ngrx/store'; -import { ApplicationSettings, AppStartInfo, AppTheme, HardwareConfiguration, KeyLanguage, MacroGroupingSettings, Notification } from 'uhk-common'; +import { ApplicationSettings, AppStartInfo, AppTheme, HardwareConfiguration, KeyLanguage, MacroGroupingSettings, Notification, SharedConfigApplyData } from 'uhk-common'; import { ElectronLogEntry } from '../../models/xterm-log'; import { NavigationPayload } from '../../models'; @@ -33,6 +33,11 @@ export enum ActionTypes { ToggleMinimizeToTray = '[app] Toggle minimize to tray', SetAppTheme = '[app] Set application theme', SetKeyLanguage = '[app] Set key language', + SetSharedConfigurationFilePath = '[app] Set shared configuration file path', + SetDetectSharedConfigurationChanges = '[app] Set detect shared configuration changes', + SharedConfigChangeDetected = '[app] shared configuration change detected', + ApplySharedConfigChange = '[app] apply shared configuration change', + DismissSharedConfigChange = '[app] dismiss shared configuration change', LoadAppStartInfo = '[app] Load app start info', StartKeypressCapturing = '[app] Start keypress capturing', StopKeypressCapturing = '[app] Stop keypress capturing', @@ -204,6 +209,35 @@ export class SetKeyLanguageAction implements Action { } } +export class SetSharedConfigurationFilePathAction implements Action { + type = ActionTypes.SetSharedConfigurationFilePath; + + constructor(public payload?: string) { + } +} + +export class SetDetectSharedConfigurationChangesAction implements Action { + type = ActionTypes.SetDetectSharedConfigurationChanges; + + constructor(public payload: boolean) { + } +} + +export class SharedConfigChangeDetectedAction implements Action { + type = ActionTypes.SharedConfigChangeDetected; + + constructor(public payload: SharedConfigApplyData) { + } +} + +export class ApplySharedConfigChangeAction implements Action { + type = ActionTypes.ApplySharedConfigChange; +} + +export class DismissSharedConfigChangeAction implements Action { + type = ActionTypes.DismissSharedConfigChange; +} + export class LoadAppStartInfoAction implements Action { type = ActionTypes.LoadAppStartInfo; } @@ -263,6 +297,11 @@ export type Actions | ToggleMinimizeToTrayAction | SetAppThemeAction | SetKeyLanguageAction + | SetSharedConfigurationFilePathAction + | SetDetectSharedConfigurationChangesAction + | SharedConfigChangeDetectedAction + | ApplySharedConfigChangeAction + | DismissSharedConfigChangeAction | LoadAppStartInfoAction | StartKeypressCapturingAction | StopKeypressCapturingAction diff --git a/packages/uhk-web/src/app/store/effects/app.ts b/packages/uhk-web/src/app/store/effects/app.ts index 0fc6103dab9..31abef8b483 100644 --- a/packages/uhk-web/src/app/store/effects/app.ts +++ b/packages/uhk-web/src/app/store/effects/app.ts @@ -155,6 +155,8 @@ export class ApplicationEffects { ActionTypes.ErrorPanelSizeChanged, ActionTypes.SetAppTheme, ActionTypes.SetKeyLanguage, + ActionTypes.SetSharedConfigurationFilePath, + ActionTypes.SetDetectSharedConfigurationChanges, ActionTypes.SetMacroGroupingSettings, ActionTypes.ToggleAnimationEnabled, ActionTypes.ToggleKeyboardHalvesAlwaysJoined, diff --git a/packages/uhk-web/src/app/store/effects/index.ts b/packages/uhk-web/src/app/store/effects/index.ts index d7ca60da894..9f82574e034 100644 --- a/packages/uhk-web/src/app/store/effects/index.ts +++ b/packages/uhk-web/src/app/store/effects/index.ts @@ -11,6 +11,7 @@ import { AppUpdateEffect } from './app-update'; import { ContributorsEffect } from './contributors.effect'; import { UserConfigurationHistoryEffects } from './user-configuration-history.effects'; import { SmartMacroDocEffect } from './smart-macro-doc.effect'; +import { SharedConfigEffects } from './shared-config.effect'; export * from './keymap'; export * from './macro'; @@ -31,5 +32,6 @@ export const effects = [ DeviceEffects, ContributorsEffect, UserConfigurationHistoryEffects, - SmartMacroDocEffect + SmartMacroDocEffect, + SharedConfigEffects ]; diff --git a/packages/uhk-web/src/app/store/effects/shared-config.effect.ts b/packages/uhk-web/src/app/store/effects/shared-config.effect.ts new file mode 100644 index 00000000000..9f20171494a --- /dev/null +++ b/packages/uhk-web/src/app/store/effects/shared-config.effect.ts @@ -0,0 +1,129 @@ +import { Injectable, inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { EMPTY, from } from 'rxjs'; +import { catchError, map, mergeMap, switchMap, withLatestFrom } from 'rxjs/operators'; + +import { LogService } from 'uhk-common'; + +import { SharedConfigService } from '../../services/shared-config.service'; +import { + AppState, + getDetectSharedConfigurationChanges, + getHardwareModules, + getSharedConfigChange, + getSharedConfigurationFilePath, + getUserConfiguration, +} from '../index'; +import { + ActionTypes, + ApplySharedConfigChangeAction, + DismissSharedConfigChangeAction, + SharedConfigChangeDetectedAction, +} from '../actions/app'; +import { ActionTypes as DeviceActionTypes } from '../actions/device'; +import { LoadUserConfigurationFromFileAction } from '../actions/user-config'; +import { updateUserConfigurationWithLastSaveInfo } from './user-config'; + +@Injectable() +export class SharedConfigEffects { + private readonly actions$ = inject(Actions); + private readonly logService = inject(LogService); + private readonly sharedConfigService = inject(SharedConfigService); + private readonly store = inject>(Store); + + configureSharedConfig$ = createEffect(() => this.actions$ + .pipe( + ofType( + ActionTypes.LoadApplicationSettingsSuccess, + ActionTypes.SetSharedConfigurationFilePath, + ActionTypes.SetDetectSharedConfigurationChanges + ), + withLatestFrom( + this.store.select(getSharedConfigurationFilePath), + this.store.select(getDetectSharedConfigurationChanges) + ), + switchMap(([, filePath, detectChanges]) => from( + this.sharedConfigService.configure(filePath, detectChanges) + ).pipe( + catchError(error => { + this.logService.error('[SharedConfigEffects] Failed to configure the shared configuration', error); + return EMPTY; + }) + )) + ), + { dispatch: false } + ); + + exportSharedConfig$ = createEffect(() => this.actions$ + .pipe( + ofType(DeviceActionTypes.SaveToKeyboardSuccess), + withLatestFrom( + this.store.select(getSharedConfigurationFilePath), + this.store.select(getUserConfiguration), + this.store.select(getHardwareModules) + ), + switchMap(([, filePath, userConfiguration, hardwareModules]) => { + if (!filePath) { + return EMPTY; + } + + const newUserConfiguration = updateUserConfigurationWithLastSaveInfo( + userConfiguration, + hardwareModules.rightModuleInfo + ); + const asString = JSON.stringify(newUserConfiguration.toJsonObject(), null, 2); + const data = new TextEncoder().encode(asString); + + return from(this.sharedConfigService.write(data)).pipe( + catchError(error => { + this.logService.error('[SharedConfigEffects] Failed to export the shared configuration', error); + return EMPTY; + }) + ); + }) + ), + { dispatch: false } + ); + + detectSharedConfigChange$ = createEffect(() => this.sharedConfigService.changeDetected$ + .pipe( + map(data => new SharedConfigChangeDetectedAction(data)) + ) + ); + + applySharedConfigChange$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ApplySharedConfigChange), + withLatestFrom(this.store.select(getSharedConfigChange)), + mergeMap(([, change]) => { + if (!change) { + return []; + } + + return [ + new LoadUserConfigurationFromFileAction({ + uploadFileData: { + filename: change.fileName, + data: Array.from(base64ToUint8Array(change.content)), + saveInHistory: true, + }, + autoSave: false, + }), + new DismissSharedConfigChangeAction(), + ]; + }) + ) + ); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} 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 57876f2f818..03088627ab0 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -547,7 +547,7 @@ function isNavigateToMacroSaveKey(action: Action): boolean { return keyAction.navigateToMacro && !keyAction.assignNewMacro; } -function updateUserConfigurationWithLastSaveInfo(userConfiguration: UserConfiguration, rightModuleInfo: RightModuleInfo) { +export function updateUserConfigurationWithLastSaveInfo(userConfiguration: UserConfiguration, rightModuleInfo: RightModuleInfo) { const newUserConfiguration = userConfiguration.clone() newUserConfiguration.lastSaveAgentTag = `${VERSIONS.agentRepo}/${VERSIONS.agentTag}`; newUserConfiguration.lastSaveFirmwareTag = `${rightModuleInfo.firmwareGitRepo}/${rightModuleInfo.firmwareGitTag}` diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index 6dbed682bf5..48181426b50 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -209,6 +209,9 @@ export const getMacroGroupingSettings = createSelector(appState, fromApp.getMacr export const getMinimizeToTray = createSelector(appState, fromApp.getMinimizeToTray); export const getAppTheme = createSelector(appState, fromApp.getAppTheme); export const getKeyLanguage = createSelector(appState, fromApp.getKeyLanguage); +export const getSharedConfigurationFilePath = createSelector(appState, fromApp.getSharedConfigurationFilePath); +export const getDetectSharedConfigurationChanges = createSelector(appState, fromApp.getDetectSharedConfigurationChanges); +export const getSharedConfigChange = createSelector(appState, fromApp.getSharedConfigChange); export const getUhkThemeColors = createSelector(getAppTheme, (theme): UhkThemeColors => { return defaultUhkThemeColors(theme); }); @@ -893,6 +896,8 @@ export const getApplicationSettings = createSelector( keyboardHalvesAlwaysJoined, getAlwaysEnableAdvancedMode, getMacroGroupingSettings, + getSharedConfigurationFilePath, + getDetectSharedConfigurationChanges, (updateSettingsState, app, smartMacroPanelWidth, @@ -900,6 +905,8 @@ export const getApplicationSettings = createSelector( keyboardHalvesAlwaysJoined, alwaysEnableAdvancedMode, macroGrouping, + sharedConfigurationFilePath, + detectSharedConfigurationChanges, ): ApplicationSettings => { return { errorPanelHeight: app.errorPanelHeight, @@ -913,6 +920,8 @@ export const getApplicationSettings = createSelector( minimizeToTray: app.minimizeToTray, alwaysEnableAdvancedMode, macroGrouping, - smartMacroPanelWidth + smartMacroPanelWidth, + sharedConfigurationFilePath, + detectSharedConfigurationChanges }; }); diff --git a/packages/uhk-web/src/app/store/reducers/app.reducer.ts b/packages/uhk-web/src/app/store/reducers/app.reducer.ts index 223a2a18e5f..9572e2b5c4f 100644 --- a/packages/uhk-web/src/app/store/reducers/app.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/app.reducer.ts @@ -12,6 +12,7 @@ import { Notification, NotificationType, runInElectron, + SharedConfigApplyData, UserConfiguration, } from 'uhk-common'; @@ -47,6 +48,9 @@ export interface State { everAttemptedSavingToKeyboard: boolean; macroGrouping: MacroGroupingSettings; udevFileContent: string; + sharedConfigurationFilePath?: string; + detectSharedConfigurationChanges: boolean; + sharedConfigChange?: SharedConfigApplyData; } export const initialState: State = { @@ -66,7 +70,8 @@ export const initialState: State = { keypressCapturing: false, everAttemptedSavingToKeyboard: false, macroGrouping: DEFAULT_MACRO_GROUPING_SETTINGS, - udevFileContent: '' + udevFileContent: '', + detectSharedConfigurationChanges: false }; export function reducer( @@ -223,6 +228,9 @@ export function reducer( keyLanguage: settings.keyLanguage || DEFAULT_KEY_LANGUAGE, macroGrouping: normalizeMacroGroupingSettings(settings.macroGrouping), minimizeToTray: settings.minimizeToTray ?? false, + sharedConfigurationFilePath: settings.sharedConfigurationFilePath, + detectSharedConfigurationChanges: !!settings.sharedConfigurationFilePath + && !!settings.detectSharedConfigurationChanges, }; } @@ -265,6 +273,35 @@ export function reducer( keyLanguage: (action as App.SetKeyLanguageAction).payload }; + case App.ActionTypes.SetSharedConfigurationFilePath: { + const filePath = (action as App.SetSharedConfigurationFilePathAction).payload; + + return { + ...state, + sharedConfigurationFilePath: filePath, + detectSharedConfigurationChanges: filePath ? state.detectSharedConfigurationChanges : false + }; + } + + case App.ActionTypes.SetDetectSharedConfigurationChanges: + return { + ...state, + detectSharedConfigurationChanges: !!state.sharedConfigurationFilePath + && (action as App.SetDetectSharedConfigurationChangesAction).payload + }; + + case App.ActionTypes.SharedConfigChangeDetected: + return { + ...state, + sharedConfigChange: (action as App.SharedConfigChangeDetectedAction).payload + }; + + case App.ActionTypes.DismissSharedConfigChange: + return { + ...state, + sharedConfigChange: undefined + }; + default: return state; } @@ -306,6 +343,9 @@ export const getMacroGroupingSettings = (state: State): MacroGroupingSettings => export const getMinimizeToTray = (state: State): boolean => state.minimizeToTray; export const getAppTheme = (state: State): AppTheme => state.appTheme; export const getKeyLanguage = (state: State): KeyLanguage => state.keyLanguage; +export const getSharedConfigurationFilePath = (state: State): string | undefined => state.sharedConfigurationFilePath; +export const getDetectSharedConfigurationChanges = (state: State): boolean => state.detectSharedConfigurationChanges; +export const getSharedConfigChange = (state: State): SharedConfigApplyData | undefined => state.sharedConfigChange; export const getHardwareConfiguration = (state: State): HardwareConfiguration => state.hardwareConfig; export const getPlatform = (state: State): string => state.platform; export const isColorPickerEyeDropperEnabled = (state: State): boolean => !state.isRunningOnWayland; 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 index 70bed688828..5a5663c7ffb 100644 --- a/packages/uhk-web/src/renderer/services/electron-file-dialog.service.ts +++ b/packages/uhk-web/src/renderer/services/electron-file-dialog.service.ts @@ -6,6 +6,7 @@ import { IpcEvents, OpenUserConfigDialogResult, SaveUserConfigDialogResult, + SelectSharedConfigDialogResult, UploadFileData, } from 'uhk-common'; @@ -38,6 +39,23 @@ export class ElectronFileDialogService extends FileDialogService { }) as Promise ); } + + selectSharedConfigurationFile(currentPath?: string): Observable { + return from( + (window as any).electron.ipcRenderer.invoke( + IpcEvents.fileDialog.selectSharedConfig, + { currentPath } + ) as Promise + ).pipe( + map(result => result && !result.canceled && result.filePath ? result.filePath : null) + ); + } + + getDefaultSharedConfigurationFilePath(): Observable { + return from( + (window as any).electron.ipcRenderer.invoke(IpcEvents.fileDialog.getDefaultSharedConfig) as Promise + ); + } } function toBase64(data: Uint8Array): string { diff --git a/packages/uhk-web/src/renderer/services/electron-shared-config.service.ts b/packages/uhk-web/src/renderer/services/electron-shared-config.service.ts new file mode 100644 index 00000000000..befa0cea3be --- /dev/null +++ b/packages/uhk-web/src/renderer/services/electron-shared-config.service.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Subject } from 'rxjs'; + +import { IpcEvents, SharedConfigApplyData } from 'uhk-common'; + +import { SharedConfigService } from '../../app/services/shared-config.service'; + +export class ElectronSharedConfigService extends SharedConfigService { + private readonly changeDetectedSubject = new Subject(); + + readonly changeDetected$ = this.changeDetectedSubject.asObservable(); + + constructor() { + super(); + + (window as any).electron.ipcRenderer.on( + IpcEvents.sharedConfig.changeDetected, + (_event: unknown, data: SharedConfigApplyData) => this.changeDetectedSubject.next(data) + ); + } + + configure(filePath: string | undefined, detectChanges: boolean): Promise { + return (window as any).electron.ipcRenderer.invoke(IpcEvents.sharedConfig.configure, { + filePath, + detectChanges, + }) as Promise; + } + + write(data: Uint8Array): Promise { + return (window as any).electron.ipcRenderer.invoke(IpcEvents.sharedConfig.write, { + content: toBase64(data), + }) as Promise; + } + + acknowledge(content?: string): Promise { + if (!content) { + return Promise.resolve(); + } + + return (window as any).electron.ipcRenderer.invoke(IpcEvents.sharedConfig.acknowledge, { + content, + }) 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 eed169e0599..87723f4bd9e 100644 --- a/packages/uhk-web/src/renderer/uhk-renderer.module.ts +++ b/packages/uhk-web/src/renderer/uhk-renderer.module.ts @@ -3,6 +3,7 @@ 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 { ElectronSharedConfigService } from './services/electron-shared-config.service'; import { ElectronLogService } from './services/electron-log.service'; import { ElectronErrorHandlerService } from './services/electron-error-handler.service'; import { MainAppComponent, routing } from '../../'; @@ -10,6 +11,7 @@ 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 { SharedConfigService } from '../app/services/shared-config.service'; import { SharedModule } from '../app/shared.module'; import { reducers } from '../app/store'; import { EffectsModule } from '@ngrx/effects'; @@ -36,6 +38,7 @@ import { CustomRouterStateSerializer } from '../app/store/router-util'; providers: [ {provide: DataStorageRepositoryService, useClass: ElectronDataStorageRepositoryService}, {provide: FileDialogService, useClass: ElectronFileDialogService}, + {provide: SharedConfigService, useClass: ElectronSharedConfigService}, {provide: IpcCommonRenderer, useClass: IpcUhkRenderer}, {provide: LogService, useClass: ElectronLogService}, {provide: ErrorHandler, useClass: ElectronErrorHandlerService}