diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e599dcc5dc..215a5e7b975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Firmware: 18.0.0 [[release](https://github.com/UltimateHackingKeyboard/firmware/ - Accesibility: improve click-only controls behavior. - Make host connection management slot-focused instead of host-focused. - Group macros in the sidebar +- Fix: refuse importing a user configuration newer than Agent supports, and refuse saving one newer than the firmware supports. ## [10.1.0] - 2026-06-23 diff --git a/packages/uhk-common/src/util/helpers.ts b/packages/uhk-common/src/util/helpers.ts index 538ee464367..80219f00567 100644 --- a/packages/uhk-common/src/util/helpers.ts +++ b/packages/uhk-common/src/util/helpers.ts @@ -3,6 +3,7 @@ import { Buffer } from '../buffer.js'; import { HardwareConfiguration, UhkBuffer, UserConfiguration } from '../config-serializer/index.js'; import { UHK_EEPROM_SIZE } from './constants.js'; +import { readUserConfigurationVersionFromBinary } from './read-user-configuration-version.js'; import { shouldUpgradeAgent } from './should-upgrade-agent.js'; export const getHardwareConfigFromDeviceResponse = (json: string): HardwareConfiguration => { @@ -34,12 +35,7 @@ export const getUserConfigFromDeviceResponse = (json: string): ParsedUserConfigu try { const data: number[] = JSON.parse(json); const uhkBuffer = UhkBuffer.fromArray(data) - const userConfigMajorVersion = uhkBuffer.readUInt16(); - const userConfigMinorVersion = uhkBuffer.readUInt16(); - const userConfigPatchVersion = uhkBuffer.readUInt16(); - uhkBuffer.offset = 0; - - const userConfigurationVersion = `${userConfigMajorVersion}.${userConfigMinorVersion}.${userConfigPatchVersion}` + const userConfigurationVersion = readUserConfigurationVersionFromBinary(uhkBuffer); if (shouldUpgradeAgent(userConfigurationVersion, false)) { return { result: 'newer', diff --git a/packages/uhk-common/src/util/index.ts b/packages/uhk-common/src/util/index.ts index ba1b3704faf..be978009ff8 100644 --- a/packages/uhk-common/src/util/index.ts +++ b/packages/uhk-common/src/util/index.ts @@ -20,8 +20,10 @@ export * from './is-device-protocol-support-status-error.js'; export * from './is-equal-array.js'; export * from './is-official-uhk-firmware.js'; export * from './is-same-firmware.js'; +export * from './is-user-config-version-higher-than-firmware.js'; export * from './map-i2c-address-to-module-name.js'; export * from './map-i2c-address-to-slot-id.js'; +export * from './read-user-configuration-version.js'; export * from './should-upgrade-agent.js'; export * from './should-upgrade-firmware.js'; export * from './simulate-invalid-user-config-error.js'; diff --git a/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.test.ts b/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.test.ts new file mode 100644 index 00000000000..c87cec2f9d2 --- /dev/null +++ b/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; + +import { isUserConfigVersionHigherThanFirmware } from './is-user-config-version-higher-than-firmware.js'; + +describe('isUserConfigVersionHigherThanFirmware', () => { + it('should return false when versions are equal', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('12.0.1', '12.0.1'), false); + }); + + it('should return false when user config is older than firmware', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('12.0.0', '12.0.1'), false); + }); + + it('should return true when user config minor is newer than firmware', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('12.1.0', '12.0.1'), true); + }); + + it('should return true when user config patch is newer than firmware', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('12.0.2', '12.0.1'), true); + }); + + it('should return false when user config version is missing', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('', '12.0.1'), false); + }); + + it('should return false when firmware user config version is missing', ({ assert }) => { + assert.equal(isUserConfigVersionHigherThanFirmware('12.1.0', ''), false); + }); +}); diff --git a/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.ts b/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.ts new file mode 100644 index 00000000000..27ce4980bc3 --- /dev/null +++ b/packages/uhk-common/src/util/is-user-config-version-higher-than-firmware.ts @@ -0,0 +1,16 @@ +import { isVersionGt } from './version-helpers.js'; + +/** + * Returns true when the user configuration version is higher than what the firmware supports + * (firmwareBuiltUserconfig / rightHalf.userConfigVersion). + */ +export function isUserConfigVersionHigherThanFirmware( + userConfigVersion: string, + firmwareUserConfigVersion: string | undefined, +): boolean { + return Boolean( + userConfigVersion + && firmwareUserConfigVersion + && isVersionGt(userConfigVersion, firmwareUserConfigVersion), + ); +} diff --git a/packages/uhk-common/src/util/read-user-configuration-version.test.ts b/packages/uhk-common/src/util/read-user-configuration-version.test.ts new file mode 100644 index 00000000000..4b1a2c5abfa --- /dev/null +++ b/packages/uhk-common/src/util/read-user-configuration-version.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test'; + +import { UhkBuffer } from '../config-serializer/uhk-buffer.js'; +import { + readUserConfigurationVersionFromBinary, + readUserConfigurationVersionFromJsonObject, +} from './read-user-configuration-version.js'; + +describe('readUserConfigurationVersionFromBinary', () => { + it('should read the version and restore the buffer offset', ({ assert }) => { + const buffer = new UhkBuffer(); + buffer.writeUInt16(15); + buffer.writeUInt16(1); + buffer.writeUInt16(2); + buffer.writeUInt16(99); + buffer.offset = 0; + + assert.equal(readUserConfigurationVersionFromBinary(buffer), '15.1.2'); + assert.equal(buffer.offset, 0); + assert.equal(buffer.readUInt16(), 15); + }); +}); + +describe('readUserConfigurationVersionFromJsonObject', () => { + it('should read the version from a JSON object', ({ assert }) => { + assert.equal(readUserConfigurationVersionFromJsonObject({ + userConfigMajorVersion: 15, + userConfigMinorVersion: 1, + userConfigPatchVersion: 0, + }), '15.1.0'); + }); + + it('should return undefined when version fields are missing', ({ assert }) => { + assert.equal(readUserConfigurationVersionFromJsonObject({}), undefined); + assert.equal(readUserConfigurationVersionFromJsonObject(null), undefined); + }); +}); diff --git a/packages/uhk-common/src/util/read-user-configuration-version.ts b/packages/uhk-common/src/util/read-user-configuration-version.ts new file mode 100644 index 00000000000..4f8c44bb761 --- /dev/null +++ b/packages/uhk-common/src/util/read-user-configuration-version.ts @@ -0,0 +1,28 @@ +import { UhkBuffer } from '../config-serializer/uhk-buffer.js'; + +/** + * Reads user-config version from a binary buffer without advancing the caller's offset permanently. + */ +export function readUserConfigurationVersionFromBinary(buffer: UhkBuffer): string { + const offset = buffer.offset; + const userConfigMajorVersion = buffer.readUInt16(); + const userConfigMinorVersion = buffer.readUInt16(); + const userConfigPatchVersion = buffer.readUInt16(); + buffer.offset = offset; + + return `${userConfigMajorVersion}.${userConfigMinorVersion}.${userConfigPatchVersion}`; +} + +/** + * Reads user-config version from a JSON object without fully deserializing it. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function readUserConfigurationVersionFromJsonObject(jsonObject: any): string | undefined { + if (jsonObject?.userConfigMajorVersion == null + || jsonObject?.userConfigMinorVersion == null + || jsonObject?.userConfigPatchVersion == null) { + return undefined; + } + + return `${jsonObject.userConfigMajorVersion}.${jsonObject.userConfigMinorVersion}.${jsonObject.userConfigPatchVersion}`; +} diff --git a/packages/uhk-web/src/app/store/effects/device.ts b/packages/uhk-web/src/app/store/effects/device.ts index 70f575372f6..757b0319bbd 100644 --- a/packages/uhk-web/src/app/store/effects/device.ts +++ b/packages/uhk-web/src/app/store/effects/device.ts @@ -12,6 +12,7 @@ import { HardwareConfiguration, HOST_CONNECTION_COUNT_MAX, IpcResponse, + isUserConfigVersionHigherThanFirmware, NotificationType, shouldUpgradeFirmware, UdevRulesInfo, @@ -346,26 +347,38 @@ export class DeviceEffects { .pipe( ofType(ActionTypes.SaveConfiguration), withLatestFrom(this.store, this.store.select(getShowFirmwareUpgradePanel)), - tap(([action, state, shouldUpgradeFirmware]) => { + mergeMap(([action, state, shouldUpgradeFirmware]) => { if (shouldUpgradeFirmware) { this.router.navigate(['/update-firmware']); - return; + return EMPTY; } if (state.userConfiguration.userConfiguration.hostConnections.length > HOST_CONNECTION_COUNT_MAX) { this.router.navigate(['/host-connections']); - return; + return EMPTY; + } + + const userConfig = state.userConfiguration.userConfiguration; + const firmwareUserConfigVersion = state.device.modules.rightModuleInfo?.userConfigVersion; + if (isUserConfigVersionHigherThanFirmware(userConfig.getSemanticVersion(), firmwareUserConfigVersion)) { + return [ + new ShowNotificationAction({ + type: NotificationType.Error, + message: `The user configuration version (${userConfig.getSemanticVersion()}) is too high for this firmware (supports up to ${firmwareUserConfigVersion}). Please update the firmware or use a compatible configuration.` + }), + new SaveToKeyboardSuccessFailed() + ]; } setTimeout(() => this.sendUserConfigToKeyboard( - state.userConfiguration.userConfiguration, + userConfig, state.app.hardwareConfig, action.payload), 100); - }), - switchMap(() => EMPTY) - ), - { dispatch: false } + + return EMPTY; + }) + ) ); saveConfigurationReply$ = createEffect(() => this.actions$ 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 4d8def2666d..3c54aea40db 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -15,9 +15,13 @@ import { getHardwareConfigFromDeviceResponse, getUserConfigFromDeviceResponse, ConfigurationReply, + isUserConfigVersionHigherThanFirmware, LogService, NotificationType, + readUserConfigurationVersionFromBinary, + readUserConfigurationVersionFromJsonObject, RightModuleInfo, + shouldUpgradeAgent, UHK_60_DEVICE, UhkBuffer, UhkDeviceProduct, @@ -72,7 +76,6 @@ import { } from '../actions/device'; import { DeviceRendererService } from '../../services/device-renderer.service'; import { UndoUserConfigData } from '../../models/undo-user-config-data'; -import { LoadUserConfigurationFromFilePayload } from '../../models'; import { RouterState } from '../router-util.js'; @Injectable() @@ -328,19 +331,52 @@ export class UserConfigEffects { loadUserConfigurationFromFile$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.LoadUserConfigurationFromFile), - withLatestFrom(this.store.select(getUserConfiguration)), - map(([action, currentUserConfiguration]): [LoadUserConfigurationFromFilePayload, string] => - [action.payload, currentUserConfiguration.deviceName]), - map(([payload, deviceName]: [LoadUserConfigurationFromFilePayload, string]) => { + withLatestFrom( + this.store.select(getUserConfiguration), + this.store.select(disableUpdateAgentProtection), + this.store.select(getHardwareModules), + ), + map(([action, currentUserConfiguration, disableUpdateAgentProtection, hardwareModules]) => { + const payload = action.payload; + const deviceName = currentUserConfiguration.deviceName; + const firmwareUserConfigVersion = hardwareModules.rightModuleInfo?.userConfigVersion; + const userConfigTooHighForAgentNotification = (importedVersion: string) => new ShowNotificationAction({ + type: NotificationType.Error, + message: `The imported user configuration version (${importedVersion}) is too high for this Agent (supports up to ${VERSIONS.userConfigVersion}). Please update Agent.` + }); + const userConfigTooHighForFirmwareNotification = (importedVersion: string) => new ShowNotificationAction({ + type: NotificationType.Error, + message: `The imported user configuration version (${importedVersion}) is too high for this firmware (supports up to ${firmwareUserConfigVersion}). Please update the firmware or use a compatible configuration.` + }); + + let importedUserConfigVersion: string | undefined; + try { let userConfig = new UserConfiguration(); if (payload.uploadFileData.filename.endsWith('.bin')) { - userConfig.fromBinary(UhkBuffer.fromArray(payload.uploadFileData.data)); + const uhkBuffer = UhkBuffer.fromArray(payload.uploadFileData.data); + importedUserConfigVersion = readUserConfigurationVersionFromBinary(uhkBuffer); + if (shouldUpgradeAgent(importedUserConfigVersion, disableUpdateAgentProtection)) { + return userConfigTooHighForAgentNotification(importedUserConfigVersion); + } + if (isUserConfigVersionHigherThanFirmware(importedUserConfigVersion, firmwareUserConfigVersion)) { + return userConfigTooHighForFirmwareNotification(importedUserConfigVersion); + } + userConfig.fromBinary(uhkBuffer); } else { const buffer = Buffer.from(payload.uploadFileData.data); - const json = buffer.toString(); - userConfig.fromJsonObject(JSON.parse(json)); + const json = JSON.parse(buffer.toString()); + importedUserConfigVersion = readUserConfigurationVersionFromJsonObject(json); + if (importedUserConfigVersion + && shouldUpgradeAgent(importedUserConfigVersion, disableUpdateAgentProtection)) { + return userConfigTooHighForAgentNotification(importedUserConfigVersion); + } + if (importedUserConfigVersion + && isUserConfigVersionHigherThanFirmware(importedUserConfigVersion, firmwareUserConfigVersion)) { + return userConfigTooHighForFirmwareNotification(importedUserConfigVersion); + } + userConfig.fromJsonObject(json); } if (userConfig.userConfigMajorVersion) { @@ -367,6 +403,15 @@ export class UserConfigEffects { message: 'Invalid configuration specified.' }); } catch (err) { + if (importedUserConfigVersion + && shouldUpgradeAgent(importedUserConfigVersion, false)) { + return userConfigTooHighForAgentNotification(importedUserConfigVersion); + } + if (importedUserConfigVersion + && isUserConfigVersionHigherThanFirmware(importedUserConfigVersion, firmwareUserConfigVersion)) { + return userConfigTooHighForFirmwareNotification(importedUserConfigVersion); + } + return new ShowNotificationAction({ type: NotificationType.Error, message: 'Invalid configuration specified.'