From 66acae0fbaa1d8d7423a0e72678ee7dbb89df893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Mon, 5 Oct 2026 23:01:37 +0200 Subject: [PATCH] feat: keep user configurations in sync via a shared configuration file (#3058) Add a shared configuration file to the Settings page. Agent exports the configuration there when it is saved to the keyboard, watches the file for changes, and offers to load changes made on another computer. - configurable shared configuration file path via a native file picker - change detection checkbox that is only enabled once a custom path is set - top notification panel (matching the other top panels) showing the full path instead of a native popup - persisted acknowledged content hash so a change made while Agent was not running is offered on startup, and applied/ignored changes are not offered again until the file changes - fs.watch plus a polling fallback for sync folders and network shares --- packages/uhk-agent/src/electron-main.ts | 5 + .../src/services/file-dialog.service.ts | 42 ++- .../src/services/shared-config.service.ts | 277 ++++++++++++++++++ .../src/models/application-settings.ts | 11 + packages/uhk-common/src/models/file-dialog.ts | 5 + packages/uhk-common/src/models/index.ts | 1 + .../uhk-common/src/models/shared-config.ts | 36 +++ packages/uhk-common/src/util/ipcEvents.ts | 10 + packages/uhk-web/src/app/app.component.html | 7 + packages/uhk-web/src/app/app.component.scss | 3 +- packages/uhk-web/src/app/app.component.ts | 30 +- .../agent/settings/settings.component.html | 36 ++- .../agent/settings/settings.component.ts | 46 ++- .../shared-config-change-panel.component.html | 7 + .../shared-config-change-panel.component.scss | 22 ++ .../shared-config-change-panel.component.ts | 18 ++ .../src/app/services/file-dialog.service.ts | 19 +- .../src/app/services/shared-config.service.ts | 28 ++ packages/uhk-web/src/app/shared.module.ts | 4 + packages/uhk-web/src/app/store/actions/app.ts | 41 ++- packages/uhk-web/src/app/store/effects/app.ts | 2 + .../uhk-web/src/app/store/effects/index.ts | 4 +- .../app/store/effects/shared-config.effect.ts | 129 ++++++++ .../src/app/store/effects/user-config.ts | 2 +- packages/uhk-web/src/app/store/index.ts | 11 +- .../src/app/store/reducers/app.reducer.ts | 42 ++- .../services/electron-file-dialog.service.ts | 18 ++ .../electron-shared-config.service.ts | 55 ++++ .../src/renderer/uhk-renderer.module.ts | 3 + 29 files changed, 899 insertions(+), 15 deletions(-) create mode 100644 packages/uhk-agent/src/services/shared-config.service.ts create mode 100644 packages/uhk-common/src/models/shared-config.ts create mode 100644 packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.html create mode 100644 packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.scss create mode 100644 packages/uhk-web/src/app/components/shared-config-change-panel/shared-config-change-panel.component.ts create mode 100644 packages/uhk-web/src/app/services/shared-config.service.ts create mode 100644 packages/uhk-web/src/app/store/effects/shared-config.effect.ts create mode 100644 packages/uhk-web/src/renderer/services/electron-shared-config.service.ts 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}