From 87cb06be56276f6c0e7968f33b341d7953c67783 Mon Sep 17 00:00:00 2001 From: hiuyt Date: Fri, 23 Oct 2026 21:29:47 +0800 Subject: [PATCH] fix(linux): persist and validate UInput screen dimensions --- src/components/ConnectionPanel.tsx | 57 +++++++++++++++++++----------- src/components/Toolbar.tsx | 1 + src/i18n/locales/en-US.ts | 2 ++ src/i18n/locales/ja-JP.ts | 1 + src/i18n/locales/ko-KR.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/i18n/locales/zh-TW.ts | 1 + src/utils/controller.ts | 8 +++++ src/utils/linuxUinput.ts | 11 ++++++ tests/linuxController.test.ts | 31 ++++++++++++++++ 10 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 src/utils/linuxUinput.ts create mode 100644 tests/linuxController.test.ts diff --git a/src/components/ConnectionPanel.tsx b/src/components/ConnectionPanel.tsx index 499d1d7e..f62b3c48 100644 --- a/src/components/ConnectionPanel.tsx +++ b/src/components/ConnectionPanel.tsx @@ -25,6 +25,7 @@ import type { AdbDevice, Win32Window, GamescopeInstance, ControllerConfig } from import type { ControllerItem, ResourceItem } from '@/types/interface'; import { computeResourcePaths } from '@/utils/resourcePath'; import { buildPlayCoverControllerConfig } from '@/utils/playCoverController'; +import { hasValidUinputDimensions, parseUinputDimension } from '@/utils/linuxUinput'; import { buildDesktopWindowControllerConfig, buildLinuxControllerConfig, @@ -42,14 +43,6 @@ import { autoReconnectAttempted, } from './connection'; -/** 将 uinput 尺寸输入字符串解析为正整数;空串或非法输入返回 undefined */ -function parseUinputDimension(value: string): number | undefined { - const trimmed = value.trim(); - if (!trimmed) return undefined; - const n = Number.parseInt(trimmed, 10); - return Number.isFinite(n) && n > 0 ? n : undefined; -} - export function ConnectionPanel() { const { t } = useTranslation(); const { @@ -1040,6 +1033,14 @@ export function ConnectionPanel() { // Linux:判断给定选择是否满足当前配置所需的全部设备 const isLinuxReadyWith = (instance: GamescopeInstance | null, wlr: string | null) => { if (!linuxNeeds) return false; + if ( + isUinputInput && + !hasValidUinputDimensions( + parseUinputDimension(uinputScreenWidth), + parseUinputDimension(uinputScreenHeight), + ) + ) + return false; if (linuxNeeds.needGamescopeNode || linuxNeeds.needEisSocket) { if (!instance) return false; if (linuxNeeds.needGamescopeNode && instance.pipewire_node_id === 0) return false; @@ -1055,6 +1056,21 @@ export function ConnectionPanel() { setDeviceError(null); try { + const parsedWidth = parseUinputDimension(uinputScreenWidth); + const parsedHeight = parseUinputDimension(uinputScreenHeight); + + const config = buildLinuxControllerConfig( + currentController, + { + wlrSocketPath: wlr ?? undefined, + pwNodeId: instance?.pipewire_node_id, + eisSocketPath: instance?.eis_socket_path || undefined, + uinputScreenWidth: parsedWidth, + uinputScreenHeight: parsedHeight, + }, + t('controller.uinputDimensionsRequired'), + ); + if (isConnected) { await maaService.destroyInstance(instanceId).catch(() => {}); setIsConnected(false); @@ -1068,17 +1084,6 @@ export function ConnectionPanel() { await maaService.createInstance(instanceId).catch(() => {}); - const parsedWidth = parseUinputDimension(uinputScreenWidth); - const parsedHeight = parseUinputDimension(uinputScreenHeight); - - const config = buildLinuxControllerConfig(currentController, { - wlrSocketPath: wlr ?? undefined, - pwNodeId: instance?.pipewire_node_id, - eisSocketPath: instance?.eis_socket_path || undefined, - uinputScreenWidth: parsedWidth, - uinputScreenHeight: parsedHeight, - }); - saveLinuxDeviceInfo({ uinputScreenWidth: parsedWidth, uinputScreenHeight: parsedHeight, @@ -1614,7 +1619,12 @@ export function ConnectionPanel() { type="number" min={1} value={uinputScreenWidth} - onChange={(e) => setUinputScreenWidth(e.target.value)} + onChange={(e) => { + setUinputScreenWidth(e.target.value); + saveLinuxDeviceInfo({ + uinputScreenWidth: parseUinputDimension(e.target.value), + }); + }} placeholder={t('controller.uinputWidth')} disabled={isConnected || isConnecting || isRunning} className={clsx( @@ -1628,7 +1638,12 @@ export function ConnectionPanel() { type="number" min={1} value={uinputScreenHeight} - onChange={(e) => setUinputScreenHeight(e.target.value)} + onChange={(e) => { + setUinputScreenHeight(e.target.value); + saveLinuxDeviceInfo({ + uinputScreenHeight: parseUinputDimension(e.target.value), + }); + }} placeholder={t('controller.uinputHeight')} disabled={isConnected || isConnecting || isRunning} className={clsx( diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 505ea102..820634a9 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1290,6 +1290,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr const cancelled = errMsg === PRE_ACTION_CANCELLED_ERROR; if (!cancelled) { const localizedErrMsg = errMsg + .replace('UINPUT_SCREEN_SIZE_REQUIRED', t('controller.uinputDimensionsRequired')) .replace( ' [[hint:spawn_file_not_found]]', ` ${t('taskList.autoConnect.agentSpawnHintFileNotFound')}`, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index dd052f03..0edb7246 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -412,6 +412,8 @@ export default { portal: 'Portal', uinputWidth: 'Width (px)', uinputHeight: 'Height (px)', + uinputDimensionsRequired: + 'Enter valid UInput screen width and height (positive integers) first.', playcover: 'PlayCover (macOS)', macos: 'macOS Window', macosPermissionsRequired: diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 52e6a68e..dec47542 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -407,6 +407,7 @@ export default { portal: 'Portal', uinputWidth: '幅 (px)', uinputHeight: '高さ (px)', + uinputDimensionsRequired: 'UInput の画面の幅と高さを正の整数で入力してください。', playcover: 'PlayCover (macOS)', macos: 'macOS ウィンドウ', macosPermissionsRequired: diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 04917402..e789eb3b 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -402,6 +402,7 @@ export default { portal: 'Portal', uinputWidth: '너비 (px)', uinputHeight: '높이 (px)', + uinputDimensionsRequired: 'UInput 화면 너비와 높이를 양의 정수로 먼저 입력하세요.', playcover: 'PlayCover (macOS)', macos: 'macOS 창', macosPermissionsRequired: diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cdbca16a..25c0ae99 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -399,6 +399,7 @@ export default { portal: 'Portal', uinputWidth: '宽 (px)', uinputHeight: '高 (px)', + uinputDimensionsRequired: '请先填写有效的 UInput 屏幕宽度和高度(正整数)', playcover: 'PlayCover (macOS)', macos: 'macOS 窗口', macosPermissionsRequired: diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index fa4098b9..5a24218a 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -395,6 +395,7 @@ export default { portal: 'Portal', uinputWidth: '寬 (px)', uinputHeight: '高 (px)', + uinputDimensionsRequired: '請先填寫有效的 UInput 螢幕寬度和高度(正整數)', playcover: 'PlayCover (macOS)', macos: 'macOS 視窗', macosPermissionsRequired: diff --git a/src/utils/controller.ts b/src/utils/controller.ts index 27af5f12..6c5cc168 100644 --- a/src/utils/controller.ts +++ b/src/utils/controller.ts @@ -1,5 +1,6 @@ import type { ControllerItem, ControllerType, SavedDeviceInfo } from '@/types/interface'; import type { AdbDevice, ControllerConfig } from '@/types/maa'; +import { hasValidUinputDimensions } from '@/utils/linuxUinput'; import { LinuxInputMethod, LinuxScreencapMethod, @@ -248,8 +249,15 @@ export function buildLinuxControllerConfig( uinputScreenWidth?: number; uinputScreenHeight?: number; }, + uinputDimensionsError = 'UINPUT_SCREEN_SIZE_REQUIRED', ): ControllerConfig { const linux = controller?.linux; + if ( + parseLinuxInputMethod(linux?.input) === LinuxInputMethod.UInput && + !hasValidUinputDimensions(discovery.uinputScreenWidth, discovery.uinputScreenHeight) + ) { + throw new Error(uinputDimensionsError); + } return { type: 'Linux', diff --git a/src/utils/linuxUinput.ts b/src/utils/linuxUinput.ts new file mode 100644 index 00000000..f29c13d9 --- /dev/null +++ b/src/utils/linuxUinput.ts @@ -0,0 +1,11 @@ +export function hasValidUinputDimensions(width?: number, height?: number): boolean { + return [width, height].every( + (value) => value !== undefined && Number.isInteger(value) && value > 0 && value <= 2147483647, + ); +} + +/** 空值或非法尺寸不能作为 UInput 的绝对坐标范围。 */ +export function parseUinputDimension(value: string): number | undefined { + const n = Number(value.trim()); + return hasValidUinputDimensions(n, n) ? n : undefined; +} diff --git a/tests/linuxController.test.ts b/tests/linuxController.test.ts new file mode 100644 index 00000000..80d0b7b8 --- /dev/null +++ b/tests/linuxController.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseUinputDimension, hasValidUinputDimensions } from '../src/utils/linuxUinput.ts'; + +test('empty or malformed UInput sizes cannot reach the controller', () => { + for (const input of ['', ' ', '0', '-1', '1.5', '1920px', 'Infinity', '2147483648']) { + assert.equal(parseUinputDimension(input), undefined, input); + } +}); + +test('UInput sizes are parsed without truncation', () => { + assert.equal(parseUinputDimension(' 2560 '), 2560); + assert.equal(parseUinputDimension('1440'), 1440); + assert.equal(parseUinputDimension('2147483647'), 2147483647); +}); + +test('both UInput sizes must be positive signed 32-bit integers', () => { + for (const value of [undefined, 0, -1, 1.5, NaN, Infinity, 2147483648]) { + assert.equal(hasValidUinputDimensions(value, 1440), false); + assert.equal(hasValidUinputDimensions(2560, value), false); + } + assert.equal(hasValidUinputDimensions(2560, 1440), true); +}); + +test('clearing one previously valid size invalidates the saved dimensions', () => { + const saved = { width: parseUinputDimension('2560'), height: parseUinputDimension('1440') }; + assert.equal(hasValidUinputDimensions(saved.width, saved.height), true); + saved.width = parseUinputDimension(''); + assert.equal(hasValidUinputDimensions(saved.width, saved.height), false); +});