From f513cecf7b574124f5b049ee3ddb1a5526cae8aa Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 01:05:36 +0700 Subject: [PATCH 01/10] Relocate style guide from design/ to docs/ --- {design/mockups/honor-control-overview => docs}/STYLEGUIDE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {design/mockups/honor-control-overview => docs}/STYLEGUIDE.md (100%) diff --git a/design/mockups/honor-control-overview/STYLEGUIDE.md b/docs/STYLEGUIDE.md similarity index 100% rename from design/mockups/honor-control-overview/STYLEGUIDE.md rename to docs/STYLEGUIDE.md From 89ffd1b609684c620b40ce9f7896c6641b491ead Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 01:08:08 +0700 Subject: [PATCH 02/10] Relocate protocol sources to docs/; archive research material Move the compact protocol source of truth to docs/protocol/ and docs/hardware-validation/, and the optional WMI module to experimental/touchpad-wmi/ (space-free path). Research-only files (Frida capture scripts, RE landing page, Windows plan) are preserved on branch archive/research-2026-07 and removed from main. Fix all doc and source links; simplify the Reverse-engineering ignore rule. --- .gitignore | 18 +- Reverse engineering/README.md | 15 - .../capture_honor_subscribers.js | 277 ------ Reverse engineering/capture_honor_touchpad.js | 242 ----- WINDOWS_SUPPORT_PLAN.md | 939 ------------------ docs/gesture-linux-remaining-work.md | 4 +- .../hardware-validation/touchpad-linux.md | 6 +- .../protocol/touchpad-porting-spec.md | 6 +- .../protocol/touchpad.json | 0 .../touchpad-wmi}/Makefile | 0 .../touchpad-wmi}/README.md | 0 .../touchpad-wmi}/dkms.conf | 0 .../touchpad-wmi}/honor_touchpad_wmi.c | 0 honor_control/core/gestures.py | 2 +- 14 files changed, 13 insertions(+), 1496 deletions(-) delete mode 100644 Reverse engineering/README.md delete mode 100755 Reverse engineering/capture_honor_subscribers.js delete mode 100755 Reverse engineering/capture_honor_touchpad.js delete mode 100644 WINDOWS_SUPPORT_PLAN.md rename Reverse engineering/finish-trackpad-re/linux/README.md => docs/hardware-validation/touchpad-linux.md (93%) rename Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md => docs/protocol/touchpad-porting-spec.md (97%) rename Reverse engineering/finish-trackpad-re/protocol.json => docs/protocol/touchpad.json (100%) rename {Reverse engineering/finish-trackpad-re/linux/wmi => experimental/touchpad-wmi}/Makefile (100%) rename {Reverse engineering/finish-trackpad-re/linux/wmi => experimental/touchpad-wmi}/README.md (100%) rename {Reverse engineering/finish-trackpad-re/linux/wmi => experimental/touchpad-wmi}/dkms.conf (100%) rename {Reverse engineering/finish-trackpad-re/linux/wmi => experimental/touchpad-wmi}/honor_touchpad_wmi.c (100%) diff --git a/.gitignore b/.gitignore index d4c150a..8d52924 100644 --- a/.gitignore +++ b/.gitignore @@ -30,20 +30,10 @@ htmlcov/ .DS_Store Thumbs.db -# Reverse-engineering evidence stays local. Keep only the compact source of -# truth and the optional narrow WMI driver in Git; tracked historical files -# remain tracked even though this pattern covers their directory. -Reverse engineering/* -!Reverse engineering/README.md -!Reverse engineering/finish-trackpad-re/ -Reverse engineering/finish-trackpad-re/* -!Reverse engineering/finish-trackpad-re/protocol.json -!Reverse engineering/finish-trackpad-re/linux/ -Reverse engineering/finish-trackpad-re/linux/* -!Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md -!Reverse engineering/finish-trackpad-re/linux/README.md -!Reverse engineering/finish-trackpad-re/linux/wmi/ -!Reverse engineering/finish-trackpad-re/linux/wmi/* +# Reverse-engineering evidence stays local and is not tracked. The compact +# protocol source of truth lives under docs/protocol/ and +# docs/hardware-validation/; the optional WMI module under experimental/. +Reverse engineering/ # Generated repo map (local reference, not tracked) REPO_MAP.md diff --git a/Reverse engineering/README.md b/Reverse engineering/README.md deleted file mode 100644 index c38f943..0000000 --- a/Reverse engineering/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Reverse-engineering status - -The authoritative current touchpad result is documented in -`../docs/gesture-linux-remaining-work.md`, with the machine-readable wire -contract in `finish-trackpad-re/protocol.json` and implementation rules in -`finish-trackpad-re/linux/PORTING_SPEC.md`. - -The discarded WMI/EC theory and the ungated standalone replay program are not -kept in the source tree. The updated Honor plugin shows that ordinary settings -are nine-byte HID Output reports sent with `WriteFile`; only the global master -switch uses OEM WMI. - -Do not run the old `wmi_ioctl_test.js` or infer a firmware command from the -61-byte UI IPC packet. Use the typed Linux tooling and first-boot checklist in -`finish-trackpad-re/linux/README.md`. diff --git a/Reverse engineering/capture_honor_subscribers.js b/Reverse engineering/capture_honor_subscribers.js deleted file mode 100755 index 1458a3f..0000000 --- a/Reverse engineering/capture_honor_subscribers.js +++ /dev/null @@ -1,277 +0,0 @@ -// Frida JS payload — runs inside each Honor subscriber process. -// Tag is set via Python by string-substituting '' before loading. -'use strict'; - -function ts() { - var d = new Date(); - function pad(n){ return ('0'+n).slice(-2); } - return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + - ('00'+d.getMilliseconds()).slice(-3); -} -function hex(buf, len, max) { - if (!buf) return '(null)'; - try { - if (len <= 0) return '(empty)'; - if (len > 2048) len = 2048; // safety cap - var ba = new Uint8Array(buf.readByteArray(len)); - var real = ba.length; - if (max && real > max) real = max; - var s = ''; - for (var i = 0; i < real; i++) { var b = ba[i]; s += (b < 16 ? '0' : '') + b.toString(16) + ' '; } - if (ba.length > real) s += '...(' + ba.length + 'B)'; - return s.trim(); - } catch (e) { return 'hex_err:' + e; } -} -function ascii(buf, len, max) { - if (!buf) return ''; - try { - if (len <= 0) return ''; - if (len > 2048) len = 2048; - var ba = new Uint8Array(buf.readByteArray(len)); - var real = ba.length; - if (max && real > max) real = max; - var s = ''; - for (var i = 0; i < real; i++) { var b = ba[i]; if (b>=32 && b<127) s += String.fromCharCode(b); } - return s; - } catch (e) { return ''; } -} -function send_log(msg) { send('[' + ts() + '] ' + TAG + ' | ' + msg); } - -// Frida 17 helper - the old Module.findExportByName(modName, exportName) no longer exists. -function findExport(name) { - try { var fn = Module.findGlobalExportByName(name); if (fn) return fn; } catch (e) {} - try { - var mods = Process.enumerateModules(); - for (var i = 0; i < mods.length; i++) { - try { var exp = mods[i].findExportByName(name); if (exp) return exp; } catch (e) {} - } - } catch (e) {} - return null; -} - -// ----- Dump loaded modules ----- -(function () { - var mods = Process.enumerateModules(); - var keyRex = /hid|hnsdk|trifinger|magic|touch|vhid|wmi|setupapi|cfgmgr|acpi|rpcrt|honor|plugin/i; - send_log('=== MODULES (total ' + mods.length + ') ==='); - for (var i = 0; i < mods.length && i < 80; i++) { - send_log('MOD ' + mods[i].name + ' @ ' + mods[i].base + ' size=' + mods[i].size); - } - send_log('=== KEY MODULES ==='); - for (var i = 0; i < mods.length; i++) { - if (keyRex.test(mods[i].name)) send_log('KEY ' + mods[i].name + ' @ ' + mods[i].base); - } -})(); - -// ----- CreateFileW: see what device / pipe / file handles get opened ----- -(function () { - var p = findExport('CreateFileW'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (args) { - var path = args[0].readUtf16String(); - if (!path) return; - var lp = path.toLowerCase(); - if (lp.indexOf('hid') >= 0 || lp.indexOf('acpi') >= 0 || lp.indexOf('wmi') >= 0 || - lp.indexOf('\\\\.\\') === 0 || lp.indexOf('\\\\?\\') === 0 || lp.indexOf('pipe') >= 0) { - send_log('CreateFileW ' + path); - this.log = true; - } - }, - onLeave: function (ret) { if (this.log) send_log(' -> ret=0x' + ret.toString(16)); }, - }); - send_log('HOOK CreateFileW'); -})(); - -// ----- LoadLibrary: detect newly-loaded HID/WMI libs ----- -['LoadLibraryW', 'LoadLibraryExW'].forEach(function (fn) { - var p = findExport(fn); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var n = a[0].readUtf16String(); - if (n) { - var l = n.toLowerCase(); - if (l.indexOf('hid') >= 0 || l.indexOf('magic') >= 0 || l.indexOf('touch') >= 0 || - l.indexOf('trifinger') >= 0 || l.indexOf('vhid') >= 0 || l.indexOf('wmi') >= 0 || - l.indexOf('hnsdk') >= 0) { - send_log(fn + ' ' + n); - this.log = true; - } - } - }, - onLeave: function (r) { if (this.log) send_log(' -> ret=0x' + r.toString(16)); }, - }); - send_log('HOOK ' + fn); -}); - -// ----- NtDeviceIoControlFile: catch all IOCTLs to HID (0xB0xxx), ACPI, WMI ----- -(function () { - var p = findExport('NtDeviceIoControlFile'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var ioctl = a[5].toInt32 ? a[5].toInt32() : parseInt(a[5]); - var inBuf = a[6]; var inLen = a[7].toInt32 ? a[7].toInt32() : parseInt(a[7]); - var outBuf = a[8]; var outLen = a[9].toInt32 ? a[9].toInt32() : parseInt(a[9]); - if (inLen < 0) inLen += 0x100000000; - if (outLen < 0) outLen += 0x100000000; - var code = ioctl; if (code < 0) code += 0x100000000; - var hexCode = '0x' + code.toString(16); - this.tag = null; - var inHex = ''; - if (inBuf && !inBuf.isNull() && inLen > 0 && inLen < 512) { - inHex = hex(inBuf, inLen > 128 ? 128 : inLen); - } - var wmi_sniff = inHex.indexOf('c0 b0 ea f9 d4 26 d0 11 bb bf 00 aa 00 6c 34') >= 0; - // 0xB0xxx HID, 0x222xxx generic device method, small buffer (HID feature id) - if ((code >= 0xb0000 && code < 0xb1000) || wmi_sniff || - (code >= 0x222000 && code < 0x223000) || code === 0x12047 || code === 0x120bf || - (inLen >= 1 && inLen <= 16 && inBuf && !inBuf.isNull())) { - this.tag = hexCode + ' h=' + h + ' inLen=' + inLen + ' outLen=' + outLen + ' IN=' + inHex; - send_log('NtIoctl ' + this.tag); - this.outBuf = outBuf; this.outLen = outLen; this.outCode = code; - } - }, - onLeave: function (r) { - if (this.tag && this.outLen > 0 && this.outBuf && !this.outBuf.isNull()) { - try { - send_log('NtIoctl OUT ' + hex(this.outBuf, this.outLen, 128) + - ' ret=0x' + r.toString(16)); - } catch (e) {} - } - }, - }); - send_log('HOOK NtDeviceIoControlFile'); -})(); - -// ----- DeviceIoControl (kernel32 user-mode wrapper, may not be inlined) ----- -(function () { - var p = findExport('DeviceIoControl'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var ioctl = a[1].toInt32 ? a[1].toInt32() : parseInt(a[1]); - var inBuf = a[2]; var inLen = a[3].toInt32 ? a[3].toInt32() : parseInt(a[3]); - var outBuf = a[4]; var outLen = a[5].toInt32 ? a[5].toInt32() : parseInt(a[5]); - if (inLen < 0) inLen += 0x100000000; - if (outLen < 0) outLen += 0x100000000; - if (ioctl < 0) ioctl += 0x100000000; - var hexCode = '0x' + ioctl.toString(16); - var inHex = ''; - if (inBuf && !inBuf.isNull() && inLen > 0 && inLen < 256) { - inHex = hex(inBuf, inLen, 128); - } - // Filter to interesting IOCTLs: HID (0xB0xxx), generic device methods (0x222xxx), small buffers (likely feature reports) - if ((ioctl >= 0xb0000 && ioctl < 0xb1000) || - (ioctl >= 0x222000 && ioctl < 0x223000) || - inLen <= 32) { - send_log('DevIoCtl h=' + h + ' IOCTL=' + hexCode + ' inLen=' + inLen + ' outLen=' + outLen + ' IN=' + inHex); - this.outBuf = outBuf; this.outLen = outLen; this.log = true; - } - }, - onLeave: function (r) { - if (this.log && this.outLen > 0 && this.outBuf && !this.outBuf.isNull()) { - try { send_log('DevIoCtl OUT ' + hex(this.outBuf, this.outLen, 128) + ' ret=' + r); } catch (e) {} - } - }, - }); - send_log('HOOK DeviceIoControl'); -})(); - -// ----- NtFsControlFile: alternative device I/O ----- -(function () { - var p = findExport('NtFsControlFile'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var fsctl = a[5].toInt32 ? a[5].toInt32() : parseInt(a[5]); - var inBuf = a[6]; var inLen = a[7].toInt32 ? a[7].toInt32() : parseInt(a[7]); - if (inLen < 0) inLen += 0x100000000; - if (fsctl < 0) fsctl += 0x100000000; - var hexCode = '0x' + fsctl.toString(16); - if (inLen > 0 && inLen < 256) { - send_log('NtFsCtl h=' + h + ' FsCtl=' + hexCode + ' inLen=' + inLen + ' IN=' + hex(inBuf, inLen, 128)); - } - }, - }); - send_log('HOOK NtFsControlFile'); -})(); - -// ----- NtWriteFile: any write to HID device handle or pipe ----- -(function () { - var p = findExport('NtWriteFile'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var buf = a[5]; var len = a[6].toInt32 ? a[6].toInt32() : parseInt(a[6]); - if (len < 0) len += 0x100000000; - if (len > 0 && len < 2048) { - var hx = hex(buf, len, 256); - var ao = ascii(buf, len, 96); - send_log('NtWrite h=' + h + ' len=' + len + ' hex=' + hx + ' ascii=[' + ao + ']'); - } else if (len >= 2048) { - send_log('NtWrite h=' + h + ' len=' + len + ' big'); - } - }, - }); - send_log('HOOK NtWriteFile'); -})(); - -// ----- NtReadFile: just log small reads (HID input reports) ----- -(function () { - var p = findExport('NtReadFile'); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var buf = a[5]; var len = a[6].toInt32 ? a[6].toInt32() : parseInt(a[6]); - if (len < 0) len += 0x100000000; - if (len > 0 && len < 1024) { - send_log('NtRead h=' + h + ' len=' + len); - } - }, - }); - send_log('HOOK NtReadFile'); -})(); - -// ----- Registry writes ----- -['RegSetValueExW', 'RegSetValueExA', 'RegCreateKeyExW', 'RegCreateKeyExA'].forEach(function (fn) { - var p = findExport(fn); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - try { - var s = null; - for (var i = 0; i < 6; i++) { - if (a[i] && !a[i].isNull()) { - var v = a[i].readUtf16String && a[i].readUtf16String(); - if (v && v.length > 3) { s = v; break; } - } - } - send_log(fn + ' ' + (s || '')); - this.log = true; - } catch (e) {} - }, - onLeave: function (r) { if (this.log) send_log(' -> ret=0x' + r.toString(16)); }, - }); - send_log('HOOK ' + fn); -}); - -// ----- Direct HID APIs from hid.dll ----- -['HidD_SetFeature', 'HidD_SetOutputReport', 'HidD_GetFeature', 'HidD_GetInputReport', - 'HidD_GetAttributes', 'HidD_GetManufacturerString', 'HidD_GetProductString', - 'HidD_GetSerialNumberString'].forEach(function (fn) { - var p = findExport(fn); - if (!p) return; - Interceptor.attach(p, { - onEnter: function (a) { - var h = a[0]; var buf = a[1]; var len = parseInt(a[2]); - var hx = (buf && !buf.isNull()) ? hex(buf, len, 32) : '(null)'; - send_log(fn + ' h=' + h + ' len=' + len + ' data=' + hx); - }, - onLeave: function (r) { send_log(' -> ret=0x' + r.toString(16)); }, - }); - send_log('HOOK ' + fn); -}); - -send_log('READY'); diff --git a/Reverse engineering/capture_honor_touchpad.js b/Reverse engineering/capture_honor_touchpad.js deleted file mode 100755 index 6990654..0000000 --- a/Reverse engineering/capture_honor_touchpad.js +++ /dev/null @@ -1,242 +0,0 @@ -// Frida 17 capture - PCManagerTray.exe -// Hooks IPC PostIPCMessage + WriteFile with proper closure + buffer reading - -function readBytes(addr, len) { - try { - if (len <= 0 || len > 4096) return ''; - var bytes = addr.readByteArray(len); - var arr = new Uint8Array(bytes); - var s = []; - for (var i = 0; i < arr.length; i++) s.push(('0' + arr[i].toString(16)).slice(-2)); - return s.join(' '); - } catch (e) { - return ''; - } -} - -function readAnsi(addr, maxLen) { - try { return addr.readAnsiString(maxLen || 256); } catch (e) { return null; } -} - -function readUtf16(addr, maxLen) { - try { return addr.readUtf16String(maxLen || 256); } catch (e) { return null; } -} - -function findExport(name) { - try { var fn = Module.findGlobalExportByName(name); if (fn) return fn; } catch (e) {} - try { - var mods = Process.enumerateModules(); - for (var i = 0; i < mods.length; i++) { - try { var exp = mods[i].findExportByName(name); if (exp) return exp; } catch (e) {} - } - } catch (e) {} - return null; -} - -function logCapture(tag, data) { - var line = '[' + new Date().toISOString().substr(11, 12) + '] ' + tag + ' ' + data; - console.log(line); - send({ type: 'capture', tag: tag, data: data, timestamp: new Date().toISOString() }); -} - -// List relevant modules -var mods = Process.enumerateModules(); -for (var i = 0; i < mods.length; i++) { - var n = mods[i].name.toLowerCase(); - if (n.indexOf('ipc') !== -1 || n.indexOf('magic') !== -1 || n.indexOf('hid') !== -1 || n.indexOf('touch') !== -1) { - logCapture('MODULE', mods[i].name + ' @ ' + mods[i].base + ' size=' + mods[i].size); - } -} - -// ── Hook IPC PostIPCMessage / SendIPCMessage etc ── -// Fix closure: capture name per-hook -var ipcMod = null; -for (var i = 0; i < mods.length; i++) { - if (mods[i].name.toLowerCase() === 'ipcmessage.dll') { ipcMod = mods[i]; break; } -} -if (ipcMod) { - var ipcExports = ipcMod.enumerateExports(); - ipcExports.forEach(function (exp) { - var name = exp.name; - if (name.indexOf('PostIPCMessage') !== -1 || name.indexOf('SendIPCData') !== -1 || - name.indexOf('SendIPCMessage') !== -1 || name.indexOf('PublishIPC') !== -1) { - try { - Interceptor.attach(exp.address, (function (fname, faddr) { - return { - onEnter: function (args) { - // PostIPCMessage(this, TagIPCMessageItem&) - args[0]=this(ptr), args[1]=msgItem ref - // SendIPCData(this, TagIPCMessageItem&, TagIPCMessageItem&, uint) - args - // Try reading TagIPCMessageItem - it's passed by reference (AEBU = const ref, AEAU = ref) - this.fname = fname; - this.args = []; - for (var i = 0; i < 4; i++) { - try { this.args.push(args[i]); } catch (e) { this.args.push(ptr(0)); } - } - // Try to read the IPC message item as a struct - // TagIPCMessageItem likely has: int moduleId, int msgId, string data, int dataLen, etc. - // Read raw bytes to see the structure - // Try both arg[0] and arg[1] as potential message pointers - for (var ai = 0; ai < 3; ai++) { - try { - var raw = readBytes(this.args[ai], 128); - if (raw && raw.indexOf(' 4096) { this.skip = true; return; } - this.h = args[0]; - // Read the buffer NOW in onEnter while it's definitely valid - this.data = readBytes(args[1], this.len); - // Try to extract ASCII strings from the buffer - try { - var buf = args[1].readByteArray(this.len); - var arr = new Uint8Array(buf); - var strs = []; - var cur = ''; - for (var i = 0; i < arr.length; i++) { - if (arr[i] >= 0x20 && arr[i] < 0x7f) { cur += String.fromCharCode(arr[i]); } - else { if (cur.length >= 4) strs.push(cur); cur = ''; } - } - if (cur.length >= 4) strs.push(cur); - this.strings = strs.join(' | '); - } catch (e) { this.strings = ''; } - }, - onLeave: function (retval) { - if (this.skip) return; - logCapture('WriteFile', 'h=' + this.h + ' len=' + this.len + ' ret=' + retval + - ' hex=' + this.data + - (this.strings ? ' strings=[' + this.strings + ']' : '')); - } - }); - logCapture('HOOK', 'WriteFile'); -} - -// ── Hook ReadFile ── -var readFn = findExport('ReadFile'); -if (readFn) { - Interceptor.attach(readFn, { - onEnter: function (args) { - this.len = args[2].toInt32(); - if (this.len > 4096) { this.skip = true; return; } - this.h = args[0]; - this.buf = args[1]; - }, - onLeave: function (retval) { - if (this.skip) return; - var data = readBytes(this.buf, this.len); - logCapture('ReadFile', 'h=' + this.h + ' len=' + this.len + ' ret=' + retval + ' hex=' + data); - } - }); - logCapture('HOOK', 'ReadFile'); -} - -// ── Hook CreateFileA/W for HID device opens ── -function hookCreateFile(name) { - var fn = findExport(name); - if (!fn) return; - Interceptor.attach(fn, (function (fname) { - return { - onEnter: function (args) { - try { - if (fname === 'CreateFileW') this.path = readUtf16(args[0]); - else this.path = readAnsi(args[0]); - } catch (e) { this.path = ''; } - }, - onLeave: function (retval) { - if (!this.path) return; - var p = this.path.toLowerCase(); - if (p.indexOf('hid#') !== -1 || p.indexOf('tops') !== -1 || p.indexOf('pipe') !== -1 && p.indexOf('pcmanager') !== -1) { - logCapture('CreateFile', 'OPEN ' + (this.path) + ' h=' + retval); - } - } - }; - })(name)); - logCapture('HOOK', name); -} -hookCreateFile('CreateFileA'); -hookCreateFile('CreateFileW'); - -// ── Hook GetProcAddress for dynamic HidD_* resolution ── -var gpa = findExport('GetProcAddress'); -if (gpa) { - Interceptor.attach(gpa, { - onEnter: function (args) { - try { this.name = readAnsi(args[1]); } catch (e) { this.name = ''; } - }, - onLeave: function (retval) { - if (!this.name || retval.isNull()) return; - if (this.name.indexOf('HidD_') === 0 || this.name.indexOf('HidP_') === 0) { - logCapture('GetProcAddress', this.name + ' -> ' + retval); - } - } - }); - logCapture('HOOK', 'GetProcAddress'); -} - -// Also try to hook MagicTouchPadHelper if it is or gets loaded -function hookHelperExports() { - var helperMod = null; - var allMods = Process.enumerateModules(); - for (var i = 0; i < allMods.length; i++) { - if (allMods[i].name.toLowerCase() === 'magictouchpadhelper.dll') { helperMod = allMods[i]; break; } - } - if (helperMod) { - logCapture('INFO', 'MagicTouchPadHelper.dll found at ' + helperMod.base); - var exps = helperMod.enumerateExports(); - exps.forEach(function (exp) { - var name = exp.name; - if (name.indexOf('Change') === 0 || name.indexOf('Register') === 0) { - try { - Interceptor.attach(exp.address, (function (fname) { - return { - onEnter: function (args) { - logCapture('HELPER', fname + ' called arg0=' + args[0] + ' arg1=' + args[1] + ' arg2=' + args[2]); - } - }; - })(name)); - logCapture('HOOK', 'Helper: ' + name); - } catch (e) {} - } - }); - } -} -hookHelperExports(); - -// Re-check for Helper DLL periodically (it might load lazily) -var checkCount = 0; -var helperInterval = setInterval(function () { - checkCount++; - if (checkCount > 60) { clearInterval(helperInterval); return; } - var allMods = Process.enumerateModules(); - for (var i = 0; i < allMods.length; i++) { - if (allMods[i].name.toLowerCase() === 'magictouchpadhelper.dll') { - logCapture('INFO', 'MagicTouchPadHelper.dll loaded at ' + allMods[i].base + '!'); - hookHelperExports(); - clearInterval(helperInterval); - return; - } - } -}, 2000); - -logCapture('READY', 'Tray capture running. Toggle trackpad settings now.'); \ No newline at end of file diff --git a/WINDOWS_SUPPORT_PLAN.md b/WINDOWS_SUPPORT_PLAN.md deleted file mode 100644 index 6cef604..0000000 --- a/WINDOWS_SUPPORT_PLAN.md +++ /dev/null @@ -1,939 +0,0 @@ -# Honor Control Windows Support Architecture and Implementation Plan - -## 1. Status, scope, and evidence rules - -**Status:** consolidated implementation plan. No Windows product implementation is claimed by this document. - -This plan was produced by checking both earlier drafts against the current repository, the authoritative touchpad reverse-engineering artifacts, and current Microsoft platform documentation. Where the evidence is incomplete, the plan creates an experiment or release gate instead of choosing an implementation by assumption. - -### Evidence vocabulary - -- **Repository-confirmed:** proven by current source, tests, captures, or static analysis in this repository. -- **Platform-documented:** specified by Microsoft for the Windows API, but not necessarily tested on the target Honor laptop. -- **Target-validated:** executed successfully on an allowlisted laptop under the exact intended service identity and supported Windows build. -- **Experimental:** a plausible mechanism exists but the final call, permissions, semantics, readback, or recovery is incomplete. -- **Unknown:** no sufficient access path is known. - -“Confirmed” must always include its scope. In particular, the nine-byte touchpad HID protocol is repository-confirmed from the updated OEM implementation, but Honor Control has not yet replayed it from its own Windows service on target hardware. It is therefore not production-ready until the target-validation gates pass. - -### Authoritative repository evidence - -- Current architecture and behavior: `README.md`, `pyproject.toml`, - `docs/architecture.md`, `docs/dbus-api.md`, `docs/safety.md`, - `docs/hardware-support.md`, `docs/development.md`, and - `docs/gesture-linux-remaining-work.md`. -- Source: `honor_control/core`, `honor_control/backend`, `honor_control/client`, `honor_control/cli`, and `honor_control/frontend`. -- Touchpad source of truth: `Reverse engineering/README.md`, - `Reverse engineering/finish-trackpad-re/protocol.json`, and - `Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md`. -- Historical local captures informed those compact tracked sources, but are not - required to understand or implement the plan. Their discarded “ordinary - settings use the EC/WMI driver” conclusion is superseded by the newer HID - analysis. - -### External platform references - -- [LocalService account](https://learn.microsoft.com/en-us/windows/win32/services/localservice-account) -- [Service isolation, required privileges, and service SIDs](https://learn.microsoft.com/en-us/windows/win32/services/service-changes-for-windows-vista) -- [Named-pipe security and access rights](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights) -- [CreateNamedPipe](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createnamedpipew) -- [Impersonating a named-pipe client](https://learn.microsoft.com/en-us/windows/win32/ipc/impersonating-a-named-pipe-client) -- [RegisterServiceCtrlHandlerEx](https://learn.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-registerservicectrlhandlerexw) -- [Registering for power events](https://learn.microsoft.com/en-us/windows/win32/power/registering-for-power-events) -- [Sending HID reports from user mode](https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/sending-hid-reports) -- [Obtaining HID reports](https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/obtaining-hid-reports) -- [Power-scheme management](https://learn.microsoft.com/en-us/windows/win32/power/managing-power-schemes) -- [Windows services and user interaction](https://learn.microsoft.com/en-us/windows/win32/services/interactive-services) -- [Windows 11 release information](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information) - -## 2. Executive summary - -Honor Control should retain one shared domain/application layer and add narrow Windows implementations around it. Linux keeps systemd, D-Bus, polkit, sysfs, `hidraw`, `uinput`, `acpi_call`, and honor-tools. Windows adds an SCM service, secure local named-pipe transport, Windows discovery/HID/WMI helpers, Windows persistence/logging, and a platform client factory. - -The Windows process model is: - -- `HonorControlService.exe`: delayed-start SCM service, initially targeted at `NT AUTHORITY\LocalService` with an enabled per-service SID. It is the only machine-hardware owner. -- `HonorControl.exe`: unelevated per-user Qt GUI. -- `HonorControlTray.exe`: unelevated per-user tray process, preserving the current separate GUI/tray entry points while sharing controller code. -- `honorctl.exe`: unelevated CLI with stable human and JSON output. -- `HonorControlElevate.exe`: signed, one-shot UAC helper that can redeem only a service-created, short-lived operation nonce. -- `HonorControlSession.exe`: deferred user-session gesture agent, added only if custom Windows gesture actions are validated and cannot reliably live in the tray process. - -The shared code remains responsible for validation, desired/applied/observed state, snapshot publication, sequencing, serialized hardware calls, reconciliation, CLI semantics, and most GUI behavior. Platform code owns transport, authorization, service lifecycle, paths/ACLs, logging, device discovery, native I/O, action dispatch, and OS-specific feature models. - -The first Windows deliverable is a **read-only beta** with service/IPC/security, exact detection, diagnostics, shared clients, packaging, and capability reporting. A **stable Windows release** additionally requires at least one reversible control that passes the complete target-hardware, permission, lifecycle, coexistence, and recovery matrix. The likely control candidates are: - -1. a documented Windows power operation, only if its per-user versus machine scope is correct for the architecture and it has reliable readback/restore; or -2. the exact-gated touchpad HID setting writer, shipped first as an administrator-approved, default-off Preview because the OEM protocol has no value readback. - -Battery protection writes, Honor performance modes, CPU package limits, fan control, GPU control, the OEM touchpad master switch, and custom gestures are deferred until their own evidence gates pass. A feature missing its gate is absent or reported unavailable; implementation milestones never broaden the hardware allowlist automatically. - -## 3. Current architecture assessment - -### 3.1 Runtime flow today - -The GUI and tray use `GuiController`; the CLI and controller construct `SdbusClient`. Calls reach `org.honorlinux.Control1`, where the D-Bus layer decodes arguments, captures the caller, performs polkit authorization, and delegates to `ApplicationService`. The application coordinates `ConfigStore`, `SnapshotStore`, `RuntimeSupervisor`, and a one-worker `HardwareCommandQueue`. `HonorToolsAdapter` and the Linux touchpad/gesture transports perform hardware I/O. State-change events contain a sequence and changed domains; clients refetch the authoritative snapshot. - -Two existing abstractions are valuable: - -- `ControlClient` is the frontend-facing async contract. -- `HardwarePort` is the application-facing synchronous hardware contract whose calls run through `HardwareCommandQueue`. - -They are necessary but not sufficient portability seams. Today, importing or constructing several supposedly shared modules still pulls in Linux-only code. - -### 3.2 What is already reusable - -| Area | Disposition | Required change | -|---|---|---| -| `backend/command_queue.py` | Shared | Keep single-worker serialization and poison-after-timeout semantics; add hardware-generation metadata only when Windows lifecycle tests need it. | -| `backend/snapshot_store.py` | Shared | No architectural change. | -| `backend/supervisor.py` | Shared | Inject normalized lifecycle signals and capability changes. | -| `core/touchpad.py` | Shared | Keep the typed nine-byte report encoder, support query, and setting validation. | -| `core/errors.py` | Shared | Keep stable domain codes; move D-Bus name mapping out of the domain module and add any genuinely cross-platform codes such as `outcome_unknown`. | -| `core/models.py` | Shared after cleanup | Preserve immutable DTOs, but stop forcing Linux PPD/governor/EPP/RAPL fields to represent Windows state. Add capability parameter metadata and nullable observed fields. | -| `core/validation.py` | Shared plus platform validators | Keep neutral numeric/profile validation; move Linux key-code and Linux power-schema checks beside their platform models. | -| `backend/config_store.py` | Shared logical schema | Extract path selection, ACL verification, atomic replacement, and ownership policy. Add explicit schema migration functions; older-version default loading is not a complete migration system. | -| `backend/application.py` | Shared after dependency injection | Inject hardware, gesture lifecycle, log reader, diagnostics/probe provider, transition runner, and platform capability schema. | -| `client/protocol.py` | Shared | Keep as the only frontend client dependency; add negotiated capabilities/version information without exposing pipe or D-Bus details. | -| GUI pages/state/widgets | Mostly shared | Render controls from capabilities and parameter schemas; keep Linux-only advanced power and hook panels gated. | -| CLI handlers/output | Mostly shared | Inject the client factory; separate platform-neutral commands/output from Linux `--bus` and systemd help. | - -### 3.3 Concrete portability blockers - -1. `backend/hardware.py` defines `HardwarePort` but imports Linux `gesture_runtime` and `touchpad_firmware` at module import time. Move the protocol and neutral result types to `backend/ports.py`; keep `HonorToolsAdapter` in a Linux module behind a compatibility import. -2. `backend/application.py` imports `GestureRuntime`, `TouchpadFirmwareError`, and Linux helpers directly. Replace them with protocols/neutral exceptions before attempting a Windows import. -3. `client/sdbus_client.py` imports `client/proxy.py`, which imports `sdbus` at module import time. A Windows factory must never import this module unless Linux D-Bus is selected. -4. `frontend/gui/controller.py` and `cli/honorctl.py` construct `SdbusClient` directly and use Linux-specific timeout/help names. -5. `contract.py` mixes neutral version numbers with D-Bus names, paths, interface names, and errors. -6. `backend/dbus/codec.py` interleaves useful DTO/dict serialization with D-Bus variant wrapping. -7. `core/gestures.py` combines neutral gesture report decoding with Linux input-event key codes; `core/validation.py` imports that Linux table. -8. `ApplicationService.get_recent_logs()` shells out to `journalctl`. -9. Transition hooks use POSIX ownership/mode checks, `shlex` POSIX parsing, process groups, signals, and `start_new_session`. -10. `ConfigStore` hard-codes `/var/lib`, `fchmod(0640)`, and directory `fsync` behavior. -11. Tray and GUI contain freedesktop icon, desktop-file, `systemctl`, PATH launch, and POSIX process flags. -12. `pyproject.toml` declares `sdbus` unconditionally and describes/classifies the package as Linux-only. - -### 3.4 Migration principle - -Do not move the entire tree for symmetry. First add neutral modules and compatibility re-exports, prove Linux behavior unchanged, then add Windows siblings. A file moves only when a second implementation or import-safety boundary makes the move useful. - -## 4. Requirements and compatibility scope - -### 4.1 Functional requirements - -- Install, start, stop, repair, upgrade, and uninstall without a separately installed Python runtime. -- Keep GUI, tray, and CLI on the shared `ControlClient` contract. -- Detect exact model and device interfaces without writes; return a reason for every unavailable capability. -- Support multiple local users and clients against one machine-owned hardware state. -- Reconnect after service restart; fetch a full snapshot after reconnect or a sequence gap; never replay mutations automatically. -- Persist validated desired policy and reconcile only explicitly enabled, target-validated settings after start/resume. -- Provide stable human and JSON CLI output and a redacted diagnostics bundle. -- Preserve Linux behavior and packaging throughout the migration. - -### 4.2 Safety requirements - -- One service-owned serialized hardware writer. -- Feature-specific conjunctive allowlists; no fallback platform or “close enough” match. -- No arbitrary HID report, WMI method, IOCTL, EC offset, device path, executable, or scan-code API. -- Validate a complete operation before opening/writing where practical. -- Distinguish `desired`, `last_accepted`, `observed`, `transport_accepted`, `outcome_unknown`, and `stale` where the hardware permits different evidence levels. -- A timeout does not prove a native operation did not reach hardware. Poison or invalidate the queue/handle generation until late completion or rediscovery resolves the state. -- No automatic retry after an ambiguous mutation. -- Experimental features are disabled by default and cannot be enabled by a generic production bypass. - -### 4.3 Security requirements - -- GUI, tray, CLI, and session helpers remain unelevated. -- Explicit ACLs for service data, binaries, logs, named pipes, and helper state. -- Per-request caller authentication from the kernel token, never JSON-supplied identity. -- First release: all machine policy/hardware mutations require an elevated administrator token; later relaxation is per-method and requires a new threat review. -- The elevation helper is a fixed client, not an arbitrary command or parameter forwarder. -- Service and helpers use absolute installed paths; no shell lookup, current-directory DLL lookup, or user-writable executable/config roots. -- No dependence on network access or network identity. - -### 4.4 Compatibility target - -- Initial support: x64 Windows 11 releases still within Microsoft servicing support and explicitly present in the release test matrix. -- Do not promise Windows 10 Home/Pro: general support ended on 2025-10-14. A separately tested ESU/LTSC build can be considered later without weakening the Windows 11 baseline. -- Initial hardware scope: exact `HONOR` / `MRA-XXX` rows validated per feature. Marketing name alone is never an allowlist. -- Python, PySide6, pywin32, freezer, WiX, and Windows SDK versions are pinned only after the frozen-service/GUI proof; CI then owns the upgrade cadence. - -## 5. Proposed cross-platform architecture - -### 5.1 Package layout - -```text -honor_control/ - core/ - errors.py - models.py - validation.py - gestures.py # semantic events only - touchpad.py # confirmed typed wire encoder - contract/ - api.py # neutral versions, method/event definitions - codec.py # DTO <-> plain Python values - backend/ - application.py - ports.py # HardwarePort and platform service protocols - command_queue.py - config_store.py - snapshot_store.py - supervisor.py - client/ - protocol.py - factory.py - errors.py - platform/ - linux/ - composition.py - dbus/ - hardware.py - gestures.py - paths.py - logs.py - transitions.py - windows/ - composition.py - service.py - scm.py - ipc_server.py - ipc_client.py - authorization.py - elevation.py - discovery.py - paths.py - logs.py - lifecycle.py - hardware/ - adapter.py - hid.py - wmi.py - power.py - touchpad.py - fake.py - session/ - agent.py # deferred until custom gestures pass gates - actions.py -``` - -During migration, old Linux module paths re-export the moved implementations so existing imports and entry points remain stable. - -### 5.2 Shared ports - -Keep `HardwarePort` high-level and synchronous. Add only the OS seams already proven necessary: - -```python -class GestureRuntimePort(Protocol): - async def start(self) -> None: ... - async def stop(self) -> None: ... - def status(self) -> GestureRuntimeStatus: ... - -class PlatformDiagnosticsPort(Protocol): - def collect(self) -> dict[str, object]: ... - -class LogReader(Protocol): - async def recent(self, lines: int) -> list[str]: ... - -class TransitionRunner(Protocol): - async def run(self, transition: str, operation_id: str) -> TransitionResult: ... - -class PersistencePlatform(Protocol): - def state_path(self) -> Path: ... - def verify_root(self, path: Path) -> None: ... - def atomic_replace(self, path: Path, data: bytes) -> None: ... -``` - -Do not create a generic native-call port. HID, WMI, and power helpers expose fixed typed operations only inside the Windows adapter. - -### 5.3 Process and data flow - -```mermaid -flowchart TD - GUI["HonorControl.exe
GUI + tray, user session"] --> CC["ControlClient"] - CLI["honorctl.exe"] --> CC - SES["HonorControlSession.exe
deferred gesture agent"] --> CC - CC --> PIPE["Local named pipe
framed JSON + token auth"] - ELEV["HonorControlElevate.exe
one-shot UAC"] --> PIPE - PIPE --> SVC["HonorControlService.exe
LocalService + service SID"] - SVC --> APP["Shared ApplicationService"] - APP --> STORE["ConfigStore + SnapshotStore + Supervisor"] - APP --> QUEUE["Serialized HardwareCommandQueue"] - QUEUE --> HW["WindowsHardwareAdapter"] - HW --> NATIVE["SetupAPI / HID / typed OEM WMI / documented power APIs"] -``` - -The service owns one machine-global desired state and monotonic snapshot sequence. Per-user preferences remain outside the service. A future gesture agent owns only active-session action delivery; it does not become a second hardware writer. - -## 6. Windows service design - -### 6.1 Identity and privilege - -Target `NT AUTHORITY\LocalService`, configured with `SERVICE_SID_TYPE_UNRESTRICTED` initially so `NT SERVICE\HonorControl` can be used in ACLs. Configure `SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO` after measuring the exact needs; retain `SeImpersonatePrivilege` only because named-pipe token capture requires it. LocalService has minimal local privilege and anonymous network credentials, which matches this service. - -Milestone 0 must install a real probe service and test discovery, safe reads, HID open modes, query-only WMI, and one approved reversible write under the final token. Do not infer permissions from an OEM process running as SYSTEM. - -If LocalService cannot access a nonessential feature, omit that feature. If a production-required feature genuinely requires SYSTEM and its device/WMI ACL cannot be narrowed, design a separate `HonorControlHardwareBroker.exe` with: - -- a service-SID-only private pipe; -- a tiny fixed operation allowlist; -- no parsing of public JSON; -- no state/config/UI code; -- a separate ADR, threat model, and tests. - -Do not switch the entire main service to LocalSystem as a convenience fallback. - -### 6.2 Composition and SCM host - -`HonorControlService.exe` contains no Qt and does not depend on the interactive user profile. It constructs Windows paths, persistence, logging, diagnostics, `WindowsHardwareAdapter`, the shared stores/queue/application/supervisor, IPC server, and SCM bridge. - -Use pywin32 for SCM/security/Event Log primitives after a frozen-build proof. Use small reviewed `ctypes` declarations only for missing structures or flags. `ServiceMain` registers `RegisterServiceCtrlHandlerEx` immediately; the handler posts bounded normalized events to the application loop and returns promptly. - -### 6.3 Lifecycle - -- Install as an own-process, delayed automatic service. -- Report `SERVICE_RUNNING` only after state validation, IPC readiness, read-only discovery, and initial snapshot publication. Missing optional hardware does not fail the service. -- Configure bounded restart-on-crash recovery with escalating delays and a reset period. Never configure an arbitrary recovery executable. -- Stop/preshutdown: reject new mutations, unsubscribe clients, stop session leases, cancel timers, request only proven safe restoration, close/cancel native handles, flush state/logs, and exit within a fixed budget. A stuck OEM call cannot block shutdown forever. -- Device notifications require explicit registration (`RegisterDeviceNotification` or a tested Configuration Manager notification path); accepting `SERVICE_CONTROL_DEVICEEVENT` alone is not enough. -- Power-setting notifications require explicit `RegisterPowerSettingNotification` registrations. Treat Modern Standby notifications as best-effort; safety must not depend on receiving a pre-sleep callback. -- On device removal, invalidate the hardware generation, cancel overlapped I/O, close handles, mark capabilities unavailable/stale, and reject queued work from an older generation. -- On arrival/resume, debounce, increment the generation, rediscover read-only, refresh observed state, then reconcile only opt-in target-validated settings. -- Handle both service start and resume paths; do not assume Fast Startup always presents as only one of them. -- Session logon/logoff/lock/unlock changes subscribers and a future gesture lease, never machine hardware ownership. - -### 6.4 Multi-user behavior - -Machine policy is global. Concurrent writes are serialized, with last accepted request winning; logs record request ID, initiating SID/session, approving SID when different, hardware generation, and outcome. The service never launches UI into a user session. Each session may run a client; at most one active-session helper may own custom gesture actions. - -## 7. IPC and authorization - -### 7.1 Transport - -Use a local duplex byte-mode named pipe such as `\\.\pipe\HonorControl-v1` with: - -- four-byte little-endian frame length followed by strict UTF-8 JSON; -- overlapped I/O, `PIPE_WAIT`, `PIPE_REJECT_REMOTE_CLIENTS`, and `FILE_FLAG_FIRST_PIPE_INSTANCE` on the first instance; -- explicit DACL, not the permissive default descriptor; -- maximum 1 MiB frames, bounded JSON depth/item/string counts, at most 16 connections, eight outstanding requests per connection, and bounded event queues; -- duplicate-key and non-finite-number rejection; -- `CancelIoEx`/handle closure during shutdown or generation invalidation. - -Use a small closed request/response/event schema rather than full JSON-RPC 2.0. Batch calls, arbitrary notifications, and extension fields are unnecessary attack surface. - -### 7.2 Wire contract - -Illustrative version-1 messages: - -```json -{"type":"hello","id":1,"transport":1,"api":{"major":1,"minor":0},"schema":{"min":4,"max":4},"client":"gui"} -{"type":"request","id":2,"method":"snapshot.get","params":{},"deadline_ms":5000} -{"type":"response","id":2,"result":{"sequence":42,"snapshot":{}}} -{"type":"response","id":3,"error":{"code":"not_authorized","message":"Administrator approval required","retryable":false}} -{"type":"event","event":"state_changed","sequence":43,"domains":["power","capabilities"]} -``` - -- Keep transport version, application API version, and snapshot schema version distinct. The current application API is 1 and snapshot schema is 4; adding Windows transport does not itself require an API major bump. -- Major mismatch fails. Minor additions and schema ranges are negotiated; clients must not require exact equality forever. -- Request IDs are connection-local unsigned 64-bit values. Methods and parameter shapes are fixed allowlists. -- Deadlines are capped and advisory. Disconnect/cancel stops waiting, but does not pretend a non-cancellable native call was stopped. -- Events carry sequence/domains only. A slow client gets a resync marker, then refetches a full snapshot. -- After restart, clients reconnect with bounded exponential backoff and jitter, renegotiate, and refetch. Mutations are never replayed automatically. -- Stable wire errors use the exact `DomainError` vocabulary; platform details remain redacted diagnostics. - -### 7.3 Pipe security and server verification - -The pipe descriptor grants the service SID/SYSTEM/Administrators the necessary server rights and local authenticated clients only the individual client-end rights required to connect/read/write. Do **not** grant client `FILE_GENERIC_WRITE`: Microsoft documents that its `FILE_APPEND_DATA` bit is also `FILE_CREATE_PIPE_INSTANCE`, which can permit a client to create a pipe instance. Integration tests must prove a standard user can connect but cannot create an instance. - -Also deny network/anonymous access and set `PIPE_REJECT_REMOTE_CLIENTS`. If product policy later limits access to specific sessions, use logon SIDs; do not confuse `Authenticated Users` with “interactive local user.” - -After opening the pipe, clients verify the server PID with `GetNamedPipeServerProcessId`, compare it with the SCM-reported service process, resolve the expected Program Files image, and verify the installed signed file identity before sending credentials or requests. Any mismatch closes the handle. - -### 7.4 Per-request authentication and authorization - -Each connection worker reads a request and authenticates it on that same thread: - -1. Call `ImpersonateNamedPipeClient` after reading the request; check the return value. -2. Open the thread token and copy only bounded facts: user SID, group SIDs, session ID, elevation type/state, integrity level, token origin/logon type as available. -3. Always call `RevertToSelf` in a guaranteed cleanup path. Authentication/reversion failure closes the connection without dispatch. -4. Apply the immutable method policy per request. Identity fields in JSON are ignored. - -Initial tiers: - -| Tier | Rule | Examples | -|---|---|---| -| Public local health | Valid local pipe connection | Protocol hello and service version only | -| Interactive read | Authenticated local interactive token | Snapshot, capabilities, redacted diagnostics/logs, configuration view | -| Administrative mutation | Elevated token and Administrators membership | Every hardware write, machine policy change, experimental enablement, config repair/migration | -| Internal | Separate descriptor/pipe and explicit service/session identity | Future broker or session-agent lease only | - -Unknown methods fail closed before application dispatch. A filtered administrator token is not elevated. Reconsidering standard-user mutations is a later per-method ADR, not a blanket “active user” tier copied from polkit. - -### 7.5 UAC helper - -1. Unelevated client sends `elevation.prepare` with a typed method/parameter object. -2. Service validates syntax/capability, hashes the canonical operation, and stores a 256-bit random, one-use nonce bound to initiating SID/session, request, hardware generation, and a 60-second expiry. -3. Client launches the absolute signed `HonorControlElevate.exe` using the `runas` verb, passing only the nonce and transport version. -4. Helper connects with its elevated token and redeems the nonce. Service verifies elevation/admin membership, same initiating session, expiry, one-time use, operation hash, and current capability/generation. -5. For a split-token administrator, initiating and approving SIDs normally match. For a standard user entering different administrator credentials, the approving SID may differ; allow this only as an explicit over-the-shoulder approval, log both SIDs, and keep the nonce bound to the initiating session and exact operation. -6. Result reaches the original client through its pending request or authoritative snapshot/event. No approval is cached. - -The helper never accepts method names, JSON parameters, executable paths, device paths, or native command values on its command line. JSON CLI mode never opens UAC unexpectedly; elevation requires an explicit flag or returns a stable approval-required error. - -## 8. Windows hardware-access design - -### 8.1 Adapter layers - -`WindowsHardwareAdapter` implements the shared synchronous `HardwarePort` behind `HardwareCommandQueue` and uses fixed helpers: - -- `discovery.py`: SMBIOS identity, Windows build, SetupAPI/Configuration Manager device tree, HID attributes/caps, and Honor software/driver inventory. -- `hid.py`: exact collection enumeration, minimal handles, overlapped read/write, cancellation, and generation invalidation. -- `wmi.py`: typed allowlisted query/method wrappers with strict shape checking; never a generic WMI executor. -- `power.py`: documented Windows telemetry and separately scoped power-scheme/mode experiments. -- `touchpad.py`: typed HID reports and separately gated OEM master-switch calls. -- `fake.py`: deterministic success/failure/timeout/removal/resume/conflict backend. - -Paths are discovered internally and bound to identity records. No IPC/config parameter can select a native device. - -### 8.2 Feature classification - -| Feature | Candidate mechanism | Evidence now | Release treatment | -|---|---|---|---| -| Model/device detection | SMBIOS/WMI plus SetupAPI/CM/HID caps | Platform-documented; target matching unvalidated | Required read-only | -| AC/battery telemetry | `GetSystemPowerStatus` plus validated battery APIs | Platform-documented; target accuracy unvalidated | Required only for fields that pass comparison | -| Windows power scheme/mode | `PowerGetActiveScheme`/`PowerSetActiveScheme` or Windows 11 user-configured power-mode APIs | Platform-documented; identity/scope behavior unvalidated | Candidate reversible control, not assumed machine-global | -| Linux PPD/governor/EPP/RAPL/PL1/PL2 | No direct semantic equivalent | Unknown | Unsupported/deferred | -| Battery charge protection | Historical PC Manager setting correlation; final native boundary/readback missing | Experimental | Writes deferred | -| Fan mode/curve/manual | No validated call, sensor, auto restore, or model matrix | Unknown/high risk | Unsupported first release | -| Thermal sensors | Documented reading only if target accuracy passes | Unknown | Optional read-only | -| GPU control | No Windows path; Linux path intentionally non-writable | Unknown/not planned | Unsupported | -| Honor performance modes | OEM components observed; no stable typed API proven | Experimental | Deferred | -| Touchpad firmware settings | Exact nine-byte HID Output reports to vendor collection | Repository-confirmed offline; target replay missing; no value readback | Preview only after M0/M4 | -| Touchpad master switch | Exact OEM WMI contract | Repository-confirmed contract; Windows permission/firmware/recovery unvalidated | Deferred separate gate | -| Gesture input | Nine-byte input format is repository-confirmed | Reader access/coexistence/session behavior unvalidated | Research/optional | -| Gesture actions | User-session Windows APIs or OEM handler | Mechanism-specific behavior unvalidated | Deferred | - -### 8.3 Discovery and allowlists - -A writable capability requires all predicates relevant to that feature: - -- exact manufacturer/product row; -- exact PnP ancestry and collection usage; -- VID/PID and report descriptor/caps; -- input/output lengths and report IDs; -- required WMI namespace/class/instance/method/input/output shapes; -- service-token access; -- compatible coexistence mode; -- current hardware generation; -- explicit experimental opt-in where applicable. - -The observed touchpad release `0x0101`, BIOS `3.05`, and EC `3.5` are evidence to record, **not selectors by themselves**. The authoritative porting specification says the descriptor selects the collection. A future release/BIOS/EC bound becomes a gate only after target-matrix evidence proves it is necessary and sufficient. - -Discovery caches only within a hardware generation. It reports every failed predicate without leaking arbitrary paths to unprivileged clients. - -### 8.4 Native-call behavior - -- Use minimal access/share flags proven by M0; do not assume `GENERIC_READ|GENERIC_WRITE` is always required for every operation. -- Use overlapped HID I/O and `CancelIoEx` where supported, but treat cancellation as unproven until completion status is observed. -- A timeout produces `outcome_unknown` for a write without readback and prevents blind retry. -- After removal/resume/driver restart, close old handles and reject stale-generation work. -- Never expose the historical HNOs2EC IOCTL set. Its caller allowlist and discovered dispatch table do not prove safe public buffer semantics. -- Do not spoof an OEM process name or redistribute Honor binaries/drivers without explicit license and API evidence. -- A custom kernel driver is outside the planned stable scope. It requires a new feature-specific ADR, signing/Secure Boot/HVCI plan, fixed IOCTL design, fuzzing/Verifier/HLK, recovery, and a long-term maintainer. - -### 8.5 Power scope correction - -`PowerSetActiveScheme` is documented as setting the active scheme for the **current user**. Windows 11 user-configured power-mode APIs are also explicitly user-scoped votes that other system signals can override. A LocalService call must therefore not be presented as interactive-user or machine-global state without an experiment proving the exact behavior. - -M0 must compare calls under LocalService, a normal interactive user, and an impersonated/explicit session component. If the setting is per-user, implement it through an authenticated user-session component with per-user desired state, or defer it. Do not store it in the machine-global `HardwarePort` snapshot merely because the API returns a GUID. - -## 9. Touchpad and gesture design - -### 9.1 Resolve the two reverse-engineering generations - -The historical investigation associated ordinary settings with OEM IPC, the HNOs2EC driver, WMI, and EC behavior. The newer `finish-trackpad-re` work examined the updated `MagicTouchPadPlugin.dll` and identifies the final boundary: - -```text -MagicTouchPadSettingUI/helper IPC - -> PCManagerMainService / MagicTouchPadPlugin - -> typed setting method - -> HIDClient::WriteToDev - -> overlapped WriteFile to vendor collection COL05 -``` - -Ordinary settings are HID Output reports. Only the global master switch uses `ROOT\WMI:OemWMIMethod.OemWMIfun`. Old IOCTL/WMI guesses must not be revived as alternate setting commands. - -### 9.2 Exact protocol - -Target evidence: - -| Property | Value | -|---|---| -| DMI scope | `HONOR` / `MRA-XXX` | -| ACPI ancestry | `TOPS0102` | -| HID | I2C `35cc:0104`; observed release `0101` | -| Collection | Windows `COL05`, Application usage page `ff00`, usage `0001` | -| Reports | ID `0x0e`, 9-byte Input and 9-byte Output including report ID | - -Setting output is `0e CC VV 00 00 00 00 00 00`: - -| Order | Setting | Command | Values | -|---:|---|---:|---| -| 1 | sensitivity | `01` | low `0`, high `1` | -| 2 | vibration intensity | `02` | low `0`, medium `1`, high `2` | -| 3 | press text | `03` | off/on | -| 4 | press picture | `04` | off/on | -| 5 | three-finger drag | `05`, then `11` | same off/on value in both | -| 6 | mouse-like mode | `06` | off/on | -| 7 | edge brightness | `07` | off/on | -| 8 | edge volume | `08` | off/on | -| 9 | edge control center | `09` | off/on | -| 10 | edge close/minimize | `0a` | off/on | -| 11 | knuckle screenshot | `0b` | off/on | -| 12 | knuckle screen recording | `0c` | off/on | - -Lifecycle/support reports: - -- clock: `0e 00 TT TT TT TT TT 00 00`, low 40 bits of Unix time, little-endian; -- support query: `0e f0 00 00 00 00 00 00 00`; -- response: `0e f0` plus seven bitmap bytes, LSB-first; -- internal reset `0e 0e ...` and legacy aliases are never public operations. - -The firmware exposes no setting-value readback. A complete nine-byte `WriteFile` result proves transport acceptance only. Desired state, accepted report count, and observed value (`None`) remain distinct; partial transactions cannot claim rollback. - -### 9.3 Windows writer - -1. Enumerate top-level HID collections with SetupAPI/CM. -2. Verify DMI, ancestry, VID/PID, usage page/usage, input/output lengths, and report ID. Zero or ambiguous matches disable writes. -3. Log the observed release/BIOS/EC but do not use them as unsupported shortcut selectors. -4. Validate the entire profile before opening. -5. Open with the minimum access/share flags proven by M0 and overlapped I/O. -6. Build bytes only with `core/touchpad.py`; require exactly nine bytes accepted. -7. Send clock if M0 proves the Windows sequence requires it; apply settings in canonical order; always pair `05` then `11`. -8. Serialize support-query reading against all other readers. -9. On partial failure, return exact accepted/failed settings, mark outcome truthfully, and do not retry automatically. -10. On resume/reconnect, rediscover and reapply only when persistence is enabled and coexistence/generation gates pass. - -Prefer direct Windows SetupAPI/HID calls through reviewed pywin32/`ctypes` wrappers because exact caps and collection selection are safety gates. Adopt the binary `hid`/hidapi package only if M0 proves it exposes every required identity/capability and packages cleanly; do not choose it merely for convenience. - -### 9.4 Master switch - -The separate OEM contract is: - -- namespace `ROOT\WMI`; -- class `OemWMIMethod`, method `OemWMIfun`; -- instance `ACPI\PNP0C14\HWMI_0`; -- method ID `1`; -- 64-byte input: little-endian `u64` command plus 56 zero bytes; -- 256-byte output: byte 0 status, byte 1 enabled; -- query `0x00000f02`, disable `0x00001002`, enable `0x00011002`. - -This is an undocumented OEM WMI interface, not a generally supported public Windows API. First test query-only under LocalService. A write requires an external pointing device, query/change/query/restore, firmware matrix, resume/recovery, and provider-without-PC-Manager tests. Until all pass, the master switch is unavailable in production. - -### 9.5 Honor coexistence - -Inventory relevant Honor services/processes/drivers and test actual open/share behavior. Never stop, disable, rename, patch, or impersonate Honor software. - -- Coexist if both sides can open the collection and repeated tests show no report loss or oscillation. -- If ownership is exclusive or conflict is detected, mark the feature `busy/conflict`, disable persistence/automation, and explain that the user must choose one owner. -- Support query has one reader. Stop or lease the custom reader around it; PC Manager reader races remain an explicit M0 test. -- “Honor-managed gestures” may leave action handling to OEM software while Honor Control manages only target-validated settings. - -### 9.6 Gesture runtime - -Windows services run in session 0 and must not inject user input. Use this sequence: - -1. Determine whether Honor software already consumes the reports and performs acceptable actions. If so, prefer OEM handling and do not add a second reader. -2. If custom remapping is valuable, test Raw Input in an unelevated session process. -3. If Raw Input is insufficient, test one-owner overlapped `ReadFile`. If only the service can read, it may forward decoded semantic events to a leased active-session agent; the service never calls `SendInput`. -4. Output uses a fixed Windows semantic-action allowlist. Volume/media keys and fixed window shortcuts may use documented APIs/`SendInput`; brightness, recording, and shell actions remain unavailable until target-validated. -5. On lock/logoff/session switch/suspend, release the lease and discard queued actions. - -Move Linux `KEY_CODES` to the Linux platform. Keep report decoding and semantic gesture IDs shared. Never accept arbitrary scan codes, key sequences, shell commands, or executable mappings from IPC/config. - -## 10. Configuration, state, and logging - -### 10.1 Storage - -Resolve known folders through Windows Known Folder APIs rather than trusting environment variables. - -| Data | Location | Access | -|---|---|---| -| Binaries/resources | `%ProgramFiles%\Honor Control\` | Installer/Administrators write; Users read/execute; immutable at runtime | -| Machine desired state | `%ProgramData%\Honor Control\state.toml` | service SID/SYSTEM write; Administrators read as policy permits; Users no direct access | -| Last-known-good backup | same directory, `state.toml.bak` | same ACL | -| Service logs | `%ProgramData%\Honor Control\Logs\` | service SID/SYSTEM write; redacted access through IPC | -| Crash dumps | `%ProgramData%\Honor Control\CrashDumps\` | opt-in, restricted, bounded retention | -| GUI/tray preferences | QSettings NativeFormat under HKCU or `%LocalAppData%` | owning user only | -| GUI/tray logs | `%LocalAppData%\Honor Control\Logs\` | owning user only | -| Temporary state writes | random create-new file below the state root | same volume/ACL; no shared `%TEMP%` privileged replacement | - -### 10.2 Persistence rules - -- Keep the logical TOML schema and validation shared; add explicit N-to-N+1 migrations with fixtures. -- Verify the root and every path component against reparse/junction attacks before privileged replacement. -- Create temp files with restrictive ACLs, flush file content, replace atomically on the same volume, and preserve a validated backup. -- A future schema or invalid file starts in safe read-only/recovery mode; it never silently resets into hardware writes. -- Per-user state contains no hardware authority. -- Persist only desired policy and evidence-safe metadata, never runtime snapshots as observed truth. - -### 10.3 Logging - -Use a registered Event Log source for lifecycle, discovery summary, authorization denials (rate-limited), safety transitions, and crashes. Use bounded structured rolling files for detailed troubleshooting. Every record includes timestamp, component, request/correlation ID, feature, result class, and hardware generation where relevant. - -Redact tokens/nonces, raw pipe payloads, environment variables, user documents, arbitrary device paths, raw WMI/HID buffers except explicitly safe protocol diagnostics, and secrets. `GetRecentLogs` reads through `LogReader`; it never shells out. - -## 11. GUI, tray, and CLI portability - -### 11.1 Client construction - -Add `client/factory.py`: - -- Linux imports and creates `SdbusClient` only on Linux. -- Windows imports and creates `WindowsPipeClient` only on Windows. -- Tests inject `FakeClient`/factories. -- Unsupported platforms return an explicit error without importing native modules. - -Retype controller/CLI fields to `ControlClient`. Keep all I/O on the existing GUI worker thread. - -### 11.2 Capability-driven UI - -- Feature presence, writability, choices, ranges, evidence level, and reason codes come from capabilities. -- Do not display Linux PPD/governor/EPP/RAPL controls with fabricated Windows values. -- Distinguish unavailable, experimental, conflict, stale, desired, accepted, observed, and outcome-unknown states. -- Hardware writes show UAC intent before launching the helper and refresh from the service after approval. -- Windows service help replaces `systemctl`; icons/resources are bundled; DPI/dark-mode/multi-monitor behavior is tested. - -### 11.3 Tray and startup - -Use an absolute installed GUI path and Windows process creation flags. Enforce one tray instance per user session with a user/session-scoped mutex. Make tray autostart an explicit per-user installer/settings choice. Recover after Explorer restart. Do not use a global service mutex as a substitute for SCM ownership. - -### 11.4 CLI - -Preserve existing exit codes where semantics match. Add explicit `--approve` for interactive UAC; `--json` never prompts. Report unsupported features as structured capability errors. Keep the low-level Linux `honor-touchpadctl` Linux-only; a Windows diagnostic may enumerate/validate through the service but never exposes raw reports. - -## 12. Packaging, installation, and signing - -### 12.1 Python metadata - -- Mark `sdbus==0.14.2` with `sys_platform == "linux"`. -- Add pinned Windows-only dependencies only after M0/M2 freezer proofs; likely pywin32, with raw HID wrappers kept minimal. -- Keep PySide6 optional/platform-appropriate. -- Add Windows service/elevation entry points without removing Linux entry points. -- Add Windows classifiers and neutralize the package description while retaining Linux classifiers. -- Add import-isolation tests so Windows never imports `sdbus`, `fcntl`, `signal`-only service code, hidraw, or uinput modules. - -### 12.2 Build/install stack - -Use pinned PyInstaller **onedir** builds plus a WiX MSI. WiX/MSI is selected for machine-wide service installation, repair, major upgrade, rollback, component ownership, and enterprise-compatible uninstall semantics. Keep installer choice isolated so concrete build failures can justify a later switch. - -Installed artifacts: - -- signed service, GUI/tray, CLI, and elevation helper; -- bundled Python/Qt/native dependencies and resources; -- service/Event Log registration; -- explicit Program Files/ProgramData/pipe ACL setup; -- Start Menu shortcuts and optional per-user tray startup; -- product/version/support manifest and uninstall metadata. - -### 12.3 Service installation - -The installer: - -1. performs no hardware writes; -2. installs into a non-user-writable Program Files directory; -3. creates and ACLs ProgramData without following reparse points; -4. registers Event Log source; -5. installs the quoted own-process service under LocalService; -6. enables the service SID and required-privilege list; -7. configures delayed start, recovery, description, and preshutdown only when needed; -8. starts the service and verifies read-only health; -9. rolls back cleanly if health fails. - -### 12.4 Signing and release artifacts - -Sign every executable, DLL, and MSI with Authenticode and a timestamp. CI builds unsigned artifacts; an isolated release stage signs after tests. Publish hashes, SBOM, dependency/license inventory, support manifest, limitations, and recovery instructions. Audit DLL search paths and loaded modules in a clean VM. No custom driver or OEM binary is bundled. - -### 12.5 Upgrade/uninstall - -- Stop service, preserve validated ProgramData, replace versioned components transactionally, run schema migration, start, and verify health before commit. -- Block unsupported downgrade; test rollback from failed upgrade. -- Uninstall removes owned service/Event Log/program/startup components but preserves state by default. -- Purge is explicit, limited to the verified product data tree, and never touches Honor software or shared drivers. - -## 13. Security threat model - -| Threat | Control | Verification | -|---|---|---| -| Malicious local client invokes privileged writes | Per-request impersonation/token policy; all mutations elevated initially | Standard/filtered/elevated/admin-credential integration matrix | -| Same-named pipe squatting | First-instance flag, client ACE without create-instance, client SCM PID/image/signature verification | Pre-service squatting and reconnect race tests | -| Remote/anonymous pipe access | Explicit DACL, network deny, `PIPE_REJECT_REMOTE_CLIENTS` | Local/remote/anonymous tests and DACL inspection | -| Parser/resource exhaustion | Length prefix, strict schema, depth/count limits, connection/request/event quotas | Fuzz/property/load tests | -| Impersonation failure falls back to service identity | Check every call; close on failure; guaranteed `RevertToSelf` | Fault injection and token audit | -| UAC helper becomes confused deputy | One-use short nonce bound to typed operation/session/generation; helper receives no params | Replay, expiry, SID/session, different-admin approval tests | -| ProgramData junction/symlink attack | Known Folder root, reparse rejection, handle-based checks, same-dir replace | Adversarial filesystem tests | -| DLL/executable hijack | Immutable Program Files, absolute paths, safe DLL search, signing | ACL/load audit in clean VM | -| Raw native-call escape hatch | Fixed typed methods and internal device discovery | API/schema/AST review | -| Privileged transition hook abuse | Hooks disabled/deferred on Windows until separate design | No Windows hook implementation in initial release | -| Cross-session gesture injection | Active-session lease, unelevated agent, lock/logoff discard | Multi-session/UIPI tests | -| Sensitive diagnostics leak | Redaction, bounded fields, service-mediated export | Snapshot/log fuzz and privacy review | - -## 14. Hardware safety model - -Every write follows this pipeline: - -```text -authenticated request - -> authorization policy - -> typed decode and shared validation - -> capability/evidence/allowlist/conflict check - -> hardware-generation check - -> serialized command queue - -> adapter boundary validation - -> native call with deadline/cancellation semantics - -> readback where available - -> desired / accepted / observed result update - -> snapshot + audit event -``` - -Mandatory rules: - -- Validation occurs at both application and adapter boundaries. -- No write is enabled solely by vendor/product strings. -- Device removal/resume invalidates handles and queued generations. -- A late native completion is audited; it cannot overlap a later mutation unnoticed. -- Readback failure after transport success is partial/unknown, not success. -- No-readback touchpad settings can report reports accepted, never firmware truth or transactional rollback. -- Automatic reconciliation is opt-in per feature and disabled on conflict or repeated failure. -- First hardware experiments use an external power/input recovery path and record before/after evidence. -- Fan, battery, CPU/GPU, and unknown WMI/IOCTL experiments are never automated in CI. - -## 15. Feature parity and release scope - -| Feature | Linux | Windows read-only beta | First stable Windows | Later | -|---|---|---|---|---| -| Service/state/snapshots | Supported | Required | Required | — | -| GUI/tray/CLI | Supported | Required shared clients | Required | — | -| Exact platform/device detection | Supported | Required | Required | Extend only by evidence | -| Diagnostics/debug bundle/logs | Supported | Required, redacted | Required | — | -| Battery/AC telemetry | Supported | Only validated fields | Same | Improve accuracy matrix | -| Battery threshold/mode writes | Supported | Unavailable | Deferred | Final native call/readback required | -| Windows power scheme/mode | N/A | Read/research | Candidate required control if scope passes | Per-user component if necessary | -| Linux advanced power profiles | Supported on gate | Unavailable | Unsupported | Separate Windows model, never name mapping | -| Fan stock/curve/manual | Supported on narrow gate | Unavailable | Unsupported | Full access/sensor/restore evidence required | -| Touchpad firmware settings | Linux replay still gated | Probe only | Optional default-off Preview after full validation | Expand matrix cautiously | -| Touchpad support bitmap | Implemented | Probe after reader coordination | With Preview if reliable | — | -| Touchpad master switch | Linux ABI gate remains | Query research | Deferred | Query/change/query/restore matrix | -| Gesture decoding | Supported | Report research | OEM handling only if verified | Session agent after validation | -| Custom gesture actions | Linux uinput | Unavailable | Deferred | Fixed Windows action allowlist | -| GPU mitigation | Intentionally unavailable | Unavailable | Unsupported | New evidence/restore path required | -| Transition hooks | Supported Linux | Unavailable | Deferred | Separate Windows security design | - -## 16. Research gates - -### 16.1 Milestone 0 experiments - -| ID | Experiment | Safe scope | Resolves | -|---|---|---|---| -| R0-01 | Capture exact SMBIOS, Windows build, driver, service, HID collection/caps, BIOS/EC inventory | Read-only | Initial support manifest and near-match fixtures | -| R0-02 | Install LocalService probe with service SID; enumerate/open/query each candidate under its real token | Read-only | Service identity and required privilege list | -| R0-03 | Enumerate COL05 through SetupAPI/HID; confirm usage/length/report ID and minimal open/share flags | Read-only | Windows HID implementation choice | -| R0-04 | Test support query with and without PC Manager/custom reader | Read/query only | Reader ownership and coexistence | -| R0-05 | Replay one benign reversible HID setting, restore immediately, repeat across service restart/resume | Explicit approval; external mouse and OEM UI available | Touchpad Preview gate | -| R0-06 | Query OEM master state under LocalService, with/without PC Manager/provider | Query only | Provider/permission/shape gate | -| R0-07 | Compare power scheme/mode calls under LocalService and interactive user, including readback/restore and session switch | Reversible documented API | Whether power belongs to service or user session | -| R0-08 | Compare OS battery/AC/thermal readings with OEM/Windows UI across states | Read-only | Telemetry accuracy | -| R0-09 | Freeze pywin32 service, Qt GUI, pipe client/helper; test clean VM startup, ACLs, cancellation, upgrade | No hardware writes | Packaging/IPC dependency freeze | -| R0-10 | Run PC Manager coexistence matrix for open/share/write/read behavior and oscillation | Reversible setting only after R0-05 | Conflict policy | - -Each experiment records exact versions, identity/token, input, output, timings, errors, pre/post state, restore result, and raw evidence location. Failure disables the feature; it does not authorize a broader API. - -### 16.2 Unsafe experiments requiring a separate proposal - -- Brute-force WMI methods, IOCTLs, HID commands, EC ports, ACPI methods, or firmware values. -- Fan/manual/curve writes without validated temperature, stock-auto restoration, and external monitoring. -- Battery commands without final boundary, independent readback, and rollback. -- CPU/GPU register or package-limit writes. -- OEM caller-name spoofing, service patching, driver replacement, or disabling PC Manager automatically. -- Kernel driver installation or Secure Boot changes. - -## 17. Testing and CI - -### 17.1 Shared tests on Linux and Windows - -- Domain validation, models, touchpad byte vectors, config migrations, application use cases, snapshots, supervisor, and queue behavior. -- Neutral codec golden vectors shared by D-Bus and pipe transports. -- `ControlClient` conformance suite for fake, D-Bus, and Windows pipe clients. -- Fake hardware scenarios: unavailable, unsupported, conflict, partial, stale, timeout/late completion, removal, resume, generation mismatch, and restoration failure. -- Frontend capability rendering and stable CLI JSON/exit codes. -- Forbidden-import tests for each platform. - -### 17.2 Windows unit/integration tests - -- Fragmented/coalesced/oversized/malformed frames, duplicate keys, depth/count limits, request collisions, slow readers, and backpressure. -- Real DACL and pipe flags; standard user cannot create an instance. -- Token cases: normal user, filtered admin, elevated admin, over-the-shoulder admin, service/batch/remote/anonymous denial, impersonation/reversion failure. -- UAC nonce replay, expiry, operation substitution, cross-session redemption, service restart, and generation change. -- SCM start/stop/preshutdown/recovery, Event Log registration, power/device notification registration, and bounded stuck-call shutdown. -- ProgramData ACL/reparse/atomic replace/disk-full/corrupt/future-schema tests. -- Fake device arrival/removal/resume and stale-generation rejection. -- PyInstaller/WiX clean install, repair, major upgrade, failed-upgrade rollback, downgrade block, uninstall preserve, and explicit purge. - -### 17.3 Manual target-hardware matrix - -For each supported row record laptop SKU, BIOS/EC, Windows build, HID/driver/Honor versions, PC Manager absent/present, service identity, AC/battery, cold boot, restart/crash, sleep/hibernate/Modern Standby as available, Fast Startup, device/driver restart, two-user sessions, concurrent clients, disconnect/timeout, and restore outcome. - -No hardware-write test runs automatically on a developer laptop or public CI. Hardware jobs require a dedicated tagged runner, explicit feature allowlist, external recovery equipment, and human approval. - -### 17.4 CI jobs - -1. Linux Python matrix: current supported Python floor and release version; full suite/ruff/type/import checks. -2. Windows x64 Python matrix: shared plus Windows unit/fake/integration tests. -3. D-Bus contract job on Linux. -4. Frozen Windows build and smoke test. -5. MSI VM install/repair/upgrade/uninstall job. -6. Fuzz/property corpus for codec/config. -7. Dependency/license/SBOM/signature/load-path scanning. -8. Manual signed hardware validation before a stable release. - -## 18. Migration and implementation roadmap - -### Milestone 0 — Evidence and frozen-runtime proofs - -Complete R0-01 through R0-10. Produce the per-feature evidence matrix, LocalService permission result, first control decision, native dependency decision, and packaging proof. No production hardware write is enabled. - -### Milestone 1 — Platform separation with Linux unchanged - -- M1-01: add golden API/schema/codec/client/error fixtures. -- M1-02: move `HardwarePort` and neutral touchpad results/errors to import-safe modules; compatibility re-export old paths. -- M1-03: extract neutral contract and DTO codec; keep D-Bus adapters byte-compatible. -- M1-04: extract gesture semantics from Linux key codes and validators. -- M1-05: inject gesture runtime, diagnostics, log reader, transition runner, and persistence platform into `ApplicationService`. -- M1-06: add client factory; remove direct `SdbusClient` construction/import from GUI/CLI. -- M1-07: add OS dependency markers and forbidden-import tests. - -**Gate:** all existing Linux tests and a live fake session-bus smoke test remain green; Windows can import shared application/client/frontend modules without Linux dependencies. - -### Milestone 2 — Secure Windows service and IPC with fake hardware - -- M2-01: implement strict codec/framing, limits, error/version negotiation, reconnect/resync, and client conformance. -- M2-02: implement explicit pipe DACL, first-instance/local-only flags, server verification, per-request impersonation, and method policy. -- M2-03: implement nonce-bound elevation helper including different-admin credential approval. -- M2-04: implement SCM bridge, LocalService/service SID/required privileges, lifecycle queue, notification registration, Event Log, paths, and ACLs. -- M2-05: compose service with `WindowsFakeHardware`; run multi-client/multi-session/timeout/removal tests. - -**Gate:** no real hardware adapter is required; all authorization, service, lifecycle, config, and IPC tests pass on a clean Windows VM. - -### Milestone 3 — Read-only Windows beta - -- M3-01: implement SMBIOS/SetupAPI/CM/HID inventory and capability reasons. -- M3-02: implement only validated telemetry fields and Honor conflict inventory. -- M3-03: implement redacted diagnostics/log access and debug bundle. -- M3-04: finish capability-driven GUI/tray/CLI and Windows service UX. -- M3-05: ship signed beta installer/support manifest after VM and target read-only tests. - -**Gate:** unsupported/near-match hardware performs no writes; snapshot facts match independent tools; install/upgrade/uninstall and Linux regression gates pass. - -### Milestone 4 — One reversible stable control and touchpad Preview - -- M4-01: implement the winning R0-07 documented power control in the correct machine/user component, if it passed. -- M4-02: implement exact-gated HID writer, accepted/unknown result semantics, reader coordination, generation handling, and coexistence policy, if R0-05 passed. -- M4-03: keep touchpad persistence separate, default off, and labelled Preview. -- M4-04: implement master query only if R0-06 passed; master write remains its own later approval gate. -- M4-05: complete the manual lifecycle/restore matrix and independent security review. - -**Gate:** stable release has at least one target-validated reversible control; otherwise the product remains a read-only beta. - -### Milestone 5 — Product/release hardening - -- Windows DPI/theme/multi-monitor/Explorer restart/single-instance checks. -- MSI repair/major-upgrade/rollback/downgrade/uninstall/purge matrix. -- Signing, SBOM, hashes, privacy/log documentation, recovery instructions, AV reputation process. -- Prior bundled-client compatibility and schema migration tests. - -### Milestone 6 — Separately gated features - -Order by evidence and risk, not by Linux parity: OEM-managed gesture integration, custom session gesture agent, master switch, battery protection, Honor performance modes, thermal reads, fan research. Each needs its own task set, ADR update, threat/safety review, capability gate, and manual matrix. GPU and any custom driver remain unsupported unless a new proposal demonstrates compelling value. - -## 19. Architecture decisions - -| ADR | Decision | Key reason | -|---|---|---| -| W01 service model | SCM service is sole machine hardware owner; UI clients unelevated | Preserves serialization, lifecycle, multi-user, and recovery invariants | -| W02 service identity | LocalService + service SID first; optional tiny SYSTEM broker only after proof | Least privilege; OEM SYSTEM use is not permission evidence | -| W03 IPC | Bounded framed JSON over local named pipe | Native token/ACL support without network/firewall or heavy RPC stack | -| W04 authorization | Per-request impersonation; all initial mutations elevated | Pipe possession is insufficient; conservative first-release policy | -| W05 elevation | One-use exact-operation nonce helper | Keeps GUI unelevated and avoids arbitrary privileged forwarding | -| W06 package structure | Shared core/backend/client plus platform implementations and compatibility imports | Minimal Linux churn with real import isolation | -| W07 hardware boundary | Keep high-level `HardwarePort`; add only proven OS-service ports | Avoid generic native APIs and premature interface forests | -| W08 touchpad | Exact-gated direct HID settings; OEM WMI master separate; no old EC IOCTL route | Matches authoritative Gen-B evidence | -| W09 gestures | Prefer OEM handler; custom actions only in leased user-session agent | Session 0 cannot safely interact with users; avoids multiple readers | -| W10 packaging | PyInstaller onedir + WiX MSI | Frozen Python/Qt practicality plus service/repair/upgrade semantics | -| W11 storage/logging | Service-owned ProgramData, user-owned HKCU/LocalAppData, Event Log + bounded files | Correct privilege/scope separation | -| W12 custom driver | No driver in planned stable scope | User-mode path exists for touchpad; kernel cost/risk is unjustified | - -## 20. Risk register - -| Risk | Probability | Impact | Mitigation/contingency | -|---|---|---|---| -| LocalService cannot access a candidate interface | High | High | Prove in M0; omit feature or design separately reviewed tiny broker | -| False model/interface match | Medium | Critical | Conjunctive feature allowlists, near-match tests, signed support manifest | -| OEM update changes behavior | High | High | Version/evidence logging, capability disappears on mismatch, no wildcard | -| PC Manager conflicts with HID reader/writer | High | High | Coexistence matrix, lease/one-reader rule, disable automation on conflict | -| HID write accepted but not applied | Medium | Medium | Accepted-versus-observed model, Preview label, no rollback claim | -| Timed-out call completes later | Medium | High | Queue poison, generation invalidation, no automatic retry, audit late result | -| Resume/Fast Startup replays stale state | Medium | High | Rediscovery/generation/opt-in reconciliation; disable persistence on failure | -| Pipe ACL/impersonation flaw | Low-medium | Critical | Real descriptor/token tests, server verification, independent security review | -| Elevation nonce replay/confused deputy | Low-medium | Critical | Short one-use exact-operation/session/generation binding and dual-SID audit | -| ProgramData reparse/ACL attack | Medium | Critical | Known root, reparse rejection, restrictive same-dir replacement | -| Frozen DLL/import hijack or AV block | Medium | High | Immutable signed root, safe DLL search, clean VM/load/AV audits | -| MSI upgrade damages state/service | Medium | High | Transactional upgrade, validated backup, health gate, rollback/preserve | -| Telemetry inaccurate on target | Medium | Medium | Independent comparison; omit fields rather than guess | -| Gesture action targets wrong session | Medium | High | Active-session lease, lock/logoff discard, OEM fallback | -| Shared refactor regresses Linux | Medium | High | Golden contracts, compatibility imports, small milestones, full Linux gates | -| Signing/supply-chain compromise | Low | Critical | Isolated signing, pinned hashes, SBOM, protected key, revocation plan | - -## 21. Definition of Windows releases - -### 21.1 Read-only beta - -Required: - -- signed Windows 11 x64 installer and frozen applications; -- LocalService service with service SID, correct ACLs, lifecycle/recovery/logging; -- secure named-pipe transport, per-request token authentication, reconnect/resync, and limits; -- exact detection/capability reasons, redacted diagnostics, and validated read-only facts; -- shared GUI/tray/CLI with no Linux imports or fake Windows fields; -- clean VM install/repair/upgrade/uninstall and Linux regression tests; -- no production hardware writes. - -### 21.2 First stable Windows release - -Adds: - -- at least one reversible control that passed the complete M0/M4/manual matrix; -- UAC helper and authorization/security audit for that operation; -- cold boot, service restart/crash, sleep/resume/Modern Standby as applicable, Fast Startup, device/driver restart, two-user, concurrent-client, PC Manager, timeout/disconnect, and restore evidence; -- published exact per-feature hardware/Windows/driver support rows and recovery procedure. - -Touchpad settings may be included as a default-off Preview only after target replay and coexistence/lifecycle tests. The UI must say that value readback is unavailable. The Preview cannot be the sole basis for claiming broad Windows feature parity. - -### 21.3 Explicitly deferred or unsupported - -- Deferred: touchpad master write, custom gesture agent/actions, battery protection, Honor performance modes, Windows transition hooks, advanced power control, thermal sensors. -- Unsupported first stable release: fan control, GPU control, arbitrary native commands, old HNOs2EC IOCTL path, unallowlisted models/firmware, bundled Honor binaries, custom kernel driver. - -## 22. Open questions - -1. Can LocalService open the exact touchpad collection for the minimum required read/write/share flags and invoke the query-only OEM WMI method? -2. Does the required Windows touchpad write sequence include clock/support-query steps on cold boot and resume, and how does it coexist with PC Manager? -3. Does the OEM WMI provider remain available without PC Manager, and does the master query/change/query/restore contract remain stable across the target matrix? -4. Is the Windows active scheme or user-configured power mode safely controllable from this architecture, and should it be modeled per-user instead of machine-global? -5. Which Windows battery/AC/thermal facts are accurate on the target laptop? -6. What final native call and independent readback implement Honor battery protection? -7. Can an unelevated session process receive the vendor gesture reports without stealing the support-query response, and which Windows actions are reliable? -8. Which exact laptop/SKU, BIOS/EC, Windows, HID, driver, and Honor-software combinations are supportable per feature? -9. Do the pinned frozen service/Qt/native dependencies pass startup, DLL, signing, antivirus, and clean-upgrade tests? -10. What publisher identity, certificate ownership, update channel, privacy policy, and servicing lifetime will the project adopt? - -None of these questions authorizes an implementer to guess. Each is tied to an experiment or release decision above. diff --git a/docs/gesture-linux-remaining-work.md b/docs/gesture-linux-remaining-work.md index 4bc3ecc..b9b7c85 100644 --- a/docs/gesture-linux-remaining-work.md +++ b/docs/gesture-linux-remaining-work.md @@ -23,11 +23,11 @@ The Linux implementation consists of: typed hidraw transport, timeout handling, batching, and support query; - `honor_control/cli/touchpadctl.py`: `probe`, `encode`, `set`, `apply`, `support`, and `master` operations; -- `Reverse engineering/finish-trackpad-re/linux/wmi/`: optional narrow kernel +- `experimental/touchpad-wmi/`: optional narrow kernel driver for the master switch; - `packaging/systemd/honor-touchpad-restore.service`: boot restore plus a system-sleep hook for resume; -- `Reverse engineering/finish-trackpad-re/protocol.json`: machine-readable +- `docs/protocol/touchpad.json`: machine-readable protocol contract. ## Proven route diff --git a/Reverse engineering/finish-trackpad-re/linux/README.md b/docs/hardware-validation/touchpad-linux.md similarity index 93% rename from Reverse engineering/finish-trackpad-re/linux/README.md rename to docs/hardware-validation/touchpad-linux.md index 4c2c31a..9f73207 100644 --- a/Reverse engineering/finish-trackpad-re/linux/README.md +++ b/docs/hardware-validation/touchpad-linux.md @@ -3,8 +3,8 @@ The protocol is recovered; this runbook performs the physical validation that cannot run while the laptop is booted into Windows. -For a clean-room implementation in another language, read `PORTING_SPEC.md` -together with `../protocol.json`. It defines discovery, ordering, failure +For a clean-room implementation in another language, read `../protocol/touchpad-porting-spec.md` +together with `../protocol/touchpad.json`. It defines discovery, ordering, failure semantics, the input-event boundary, the master WMI ABI, and completion tests. ## 1. Read-only preflight @@ -63,7 +63,7 @@ Restart the service afterward if it had been enabled. ## 5. Optional master switch -Connect an external mouse. Follow `wmi/README.md` to build and load the module, +Connect an external mouse. Follow `../../experimental/touchpad-wmi/README.md` to build and load the module, then query before writing: ```bash diff --git a/Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md b/docs/protocol/touchpad-porting-spec.md similarity index 97% rename from Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md rename to docs/protocol/touchpad-porting-spec.md index 37fef47..fb04f30 100644 --- a/Reverse engineering/finish-trackpad-re/linux/PORTING_SPEC.md +++ b/docs/protocol/touchpad-porting-spec.md @@ -2,7 +2,7 @@ This is the complete contract needed to reimplement every recovered Honor touchpad configuration operation without the Windows Honor application. -`../protocol.json` is the machine-readable source of truth; this document adds +`touchpad.json` is the machine-readable source of truth; this document adds the ordering, discovery, failure behavior, and implementation boundaries that are not obvious from a command table. @@ -77,7 +77,7 @@ already accepted by firmware cannot be read back transactionally. Write `0e f0 00 00 00 00 00 00 00`, then read the same vendor node until the 9-byte response `0e f0 ` arrives or a timeout expires. Bitmap numbering starts at byte 2, least-significant bit first. Known bits are -fully listed in `../protocol.json`. +fully listed in `touchpad.json`. Only one process should read the vendor node during this query. A gesture daemon and diagnostic query can otherwise race for the response. Normal input reports @@ -156,7 +156,7 @@ hardware write. A port is complete when: 1. exact DMI and vendor collection are discovered without a hardcoded node; -2. every report matches `../protocol.json` byte-for-byte; +2. every report matches `touchpad.json` byte-for-byte; 3. three-finger drag always writes both reports in order; 4. complete validation occurs before the first write; 5. short writes, timeouts, disconnects, and partial profiles fail visibly; diff --git a/Reverse engineering/finish-trackpad-re/protocol.json b/docs/protocol/touchpad.json similarity index 100% rename from Reverse engineering/finish-trackpad-re/protocol.json rename to docs/protocol/touchpad.json diff --git a/Reverse engineering/finish-trackpad-re/linux/wmi/Makefile b/experimental/touchpad-wmi/Makefile similarity index 100% rename from Reverse engineering/finish-trackpad-re/linux/wmi/Makefile rename to experimental/touchpad-wmi/Makefile diff --git a/Reverse engineering/finish-trackpad-re/linux/wmi/README.md b/experimental/touchpad-wmi/README.md similarity index 100% rename from Reverse engineering/finish-trackpad-re/linux/wmi/README.md rename to experimental/touchpad-wmi/README.md diff --git a/Reverse engineering/finish-trackpad-re/linux/wmi/dkms.conf b/experimental/touchpad-wmi/dkms.conf similarity index 100% rename from Reverse engineering/finish-trackpad-re/linux/wmi/dkms.conf rename to experimental/touchpad-wmi/dkms.conf diff --git a/Reverse engineering/finish-trackpad-re/linux/wmi/honor_touchpad_wmi.c b/experimental/touchpad-wmi/honor_touchpad_wmi.c similarity index 100% rename from Reverse engineering/finish-trackpad-re/linux/wmi/honor_touchpad_wmi.c rename to experimental/touchpad-wmi/honor_touchpad_wmi.c diff --git a/honor_control/core/gestures.py b/honor_control/core/gestures.py index 45f10ef..793aa94 100644 --- a/honor_control/core/gestures.py +++ b/honor_control/core/gestures.py @@ -18,7 +18,7 @@ "4:2": "volumedown", "10:3": "leftmeta,v", "6": "print", - # Firmware input type 7 is "screen recording" (see protocol.json); Linux + # Firmware input type 7 is "screen recording" (see docs/protocol/touchpad.json); # has no universal screen-recording key, so the default chord emulates a # selective screenshot (Shift+Print). "7": "leftshift,print", From 4e7b95d797fc837e3d0b906aa1bac71c3bdf3dc6 Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 02:32:30 +0700 Subject: [PATCH 03/10] Release hardening: reproducible install, CI gates, doc honesty HC-007: offline hash-verified wheelhouse install, supported rollback command, and a release-bundle script (SHA256SUMS + MANIFEST + minimal SBOM). HC-019: split CI into lint/test(72% branch-coverage floor)/security/shell/packaging jobs with SHA-pinned actions. HC-021: drop the unhardened D-Bus Exec= activation fallback. HC-022: mark the WMI module experimental and note the space-free build path. HC-023: replace the overbroad 'personally tested all' claim with a per-feature evidence table, correct repo URLs, and document the supported hardware matrix. The touchpad restore unit is now installed/enabled only when firmware writes are explicitly qualified (default off). --- .github/workflows/ci.yml | 130 +++++++++++++++++- CHANGELOG.md | 29 ++++ README.md | 56 ++++++-- experimental/touchpad-wmi/README.md | 17 +++ .../dbus/org.honorlinux.Control1.service | 14 +- .../polkit/org.honorlinux.control.policy | 4 +- packaging/systemd/honor-control.service | 2 +- scripts/install-local.sh | 63 ++++++++- scripts/make-release-bundle.sh | 119 ++++++++++++++++ scripts/rollback.sh | 58 ++++++++ scripts/smoke-test.sh | 2 +- 11 files changed, 464 insertions(+), 30 deletions(-) create mode 100755 scripts/make-release-bundle.sh create mode 100755 scripts/rollback.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b7e211..26ba8bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,31 @@ on: push: pull_request: +# Least privilege: hosted CI only needs to read the repository. permissions: contents: read +# NOTE: hosted CI runs the offline / fake-hardware gates ONLY. +# Disposable-VM installer lifecycle tests (install / rollback / uninstall in a +# throwaway VM) and real-hardware qualification are SEPARATE self-hosted / +# manual gates and are intentionally NOT run here. + jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install ruff + - name: Ruff lint + run: ruff check honor_control tests + - name: Byte-compile sanity check + run: python -m compileall -q honor_control tests + test: runs-on: ubuntu-latest strategy: @@ -15,15 +36,114 @@ jobs: matrix: python: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: ${{ matrix.python }} cache: pip - name: Install Qt runtime libraries run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 - run: python -m pip install --upgrade pip + # pytest-cov is installed here (not in pyproject) so the coverage floor + # tooling stays a CI concern. + - run: python -m pip install ".[gui,dev]" pytest-cov + # Branch coverage with a hard 72% floor (measured project baseline). + # `-m "not hardware"` selects the whole suite today because no test + # carries a `hardware` mark yet; it becomes meaningful once + # hardware-marked tests exist for the separate self-hosted hardware gate. + - name: Pytest with branch coverage (72% floor) + run: pytest -q -m "not hardware" --cov=honor_control --cov-branch --cov-fail-under=72 + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install ".[gui,dev]" pip-audit bandit + # Best effort: report dependency vulnerabilities but do not fail the gate + # (findings are often in transitive deps outside this repo's control). + - name: pip-audit (best effort) + run: pip-audit || echo "::warning::pip-audit reported findings or could not run" + # Best effort: surface medium/high bandit findings without failing the job. + - name: Bandit (medium/high, best effort) + run: bandit -r honor_control -ll || echo "::warning::bandit reported medium/high findings" + # Secret scanning is a HARD gate: gitleaks if present, otherwise a + # grep-based token/key pattern check that fails on any hit. + - name: Secret scan (hard gate) + run: | + if command -v gitleaks >/dev/null 2>&1; then + gitleaks detect --no-banner --verbose + else + echo "gitleaks unavailable; running grep-based secret scan" + if grep -rInE -e 'AKIA[0-9A-Z]{16}' -e '-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----' -e 'ghp_[0-9A-Za-z]{36}' -e 'xox[baprs]-[0-9A-Za-z-]{10,}' --exclude-dir=.git --exclude-dir=.github --exclude-dir=.venv --exclude-dir=build --exclude-dir=dist --exclude-dir='Reverse engineering' . ; then + echo "::error::secret scan matched a token/key pattern" + exit 1 + fi + echo "grep secret scan: no matches" + fi + + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: bash -n syntax check + run: | + set -e + for f in scripts/*.sh packaging/systemd/honor-touchpad-system-sleep; do + echo "bash -n $f" + bash -n "$f" + done + # shellcheck is a HARD gate: any finding fails the job. + - name: shellcheck (hard gate) + run: shellcheck scripts/*.sh packaging/systemd/honor-touchpad-system-sleep + + packaging: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + cache: pip + - name: Install validators + run: sudo apt-get update && sudo apt-get install -y libxml2-utils desktop-file-utils + # XML well-formedness is a HARD gate. + - name: xmllint polkit + dbus XML (hard gate) + run: | + xmllint --noout packaging/polkit/*.policy + xmllint --noout packaging/dbus/*.conf + # Best effort / allow-unavailable: warn but do not fail when the tool is + # missing or the sandboxed unit references paths absent in CI. + - name: desktop-file-validate (allow unavailable) + run: | + if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate packaging/desktop/*.desktop || echo "::warning::desktop-file-validate reported issues" + else + echo "desktop-file-validate unavailable; skipping" + fi + - name: systemd-analyze verify (allow unavailable) + run: | + if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze verify packaging/systemd/honor-control.service packaging/systemd/honor-touchpad-restore.service || echo "::warning::systemd-analyze verify reported issues" + else + echo "systemd-analyze unavailable; skipping" + fi + - run: python -m pip install --upgrade pip - run: python -m pip install ".[gui,dev]" - - run: ruff check honor_control tests - - run: pytest -q -m "not hardware" - - run: python -m build + - name: Build wheel + sdist + run: python -m build + - name: Verify artifacts contain the package (hard gate) + run: | + set -e + ls -l dist/ + test "$(ls dist/*.whl | wc -l)" -ge 1 || { echo "no wheel built"; exit 1; } + test "$(ls dist/*.tar.gz | wc -l)" -ge 1 || { echo "no sdist built"; exit 1; } + python -m zipfile -l "$(ls dist/*.whl | head -n1)" | grep -q 'honor_control/' || { echo "wheel missing honor_control package"; exit 1; } + tar -tzf "$(ls dist/*.tar.gz | head -n1)" | grep -q 'honor_control/' || { echo "sdist missing honor_control package"; exit 1; } + echo "wheel + sdist both contain honor_control/" diff --git a/CHANGELOG.md b/CHANGELOG.md index e66cf74..aa8f87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## [Unreleased] - 0.2.0 +### Security / Release hardening + +- Touchpad firmware setting writes are now disabled by default behind the + `touchpad_firmware_writes_qualified` capability gate (default `false`); + probe, encode, and gesture input remain available. +- The automatic `honor-touchpad-restore.service` unit is no longer installed + or enabled unless firmware writes are qualified (installer flag + `--enable-touchpad-firmware` / env `HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1`). +- The installer now supports a fully offline, hash-verified install: + `--wheelhouse DIR` requires and verifies a `SHA256SUMS` file and installs + with `--no-index --find-links --no-build-isolation`. +- Added a supported rollback path (`scripts/install-local.sh --rollback` or + `scripts/rollback.sh`) that atomically repoints `/opt/honor-control/current` + to the retained predecessor release and restarts the service. +- Added `scripts/make-release-bundle.sh` to assemble a versioned, checksummed + offline release bundle (wheel + sdist, packaging tree, installer scripts, + `SHA256SUMS`, `MANIFEST`, and a minimal `sbom.json` of pinned deps). +- CI split into `lint`, `test`, `security`, `shell`, and `packaging` jobs: + branch-coverage floor (`--cov-branch --cov-fail-under=72`), shellcheck, + `xmllint` packaging validation, `pip-audit` / `bandit` / secret scanning, + and all actions pinned to full commit SHAs. +- Removed the unhardened direct-`Exec` / `User=root` D-Bus activation + fallback; activation now requires systemd + (`SystemdService=honor-control.service`). +- Corrected repository URLs (`HonorLinux/honor-control` to + `ZachAR3/HonorControl`) in the systemd unit and polkit policy, and replaced + the README "personally tested all" claim with a per-feature verification + status table. + ### Fixed - Power-profile application now coordinates with PPD without masking host diff --git a/README.md b/README.md index 91934b8..1f22668 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,21 @@ D-Bus service and Qt6 GUI for managing Honor MagicBook laptops on Linux. verified against fake hardware in CI; real hardware testing is an explicit, manual pre-release gate. -**Compatibility:** hardware writes are enabled only for the verified Honor -MagicBook Art 14 (`MRA-XXX`) with a supported Intel Core Ultra/Meteor Lake -CPU. Other Intel and AMD models are not allowlisted and receive no power, -fan, or GPU writes. Power profiles use PPD plus standard Intel sysfs controls; -the service does not write raw CPU MSRs or disable host power-management -services. +**Supported hardware matrix.** Hardware writes are enabled only on an exact +positive platform match; there is no fallback to a default platform. + +| Requirement | Supported value | +|---|---| +| DMI vendor / product | `HONOR` / `MRA-XXX` (Honor MagicBook Art 14) | +| CPU (required for fan writes) | Intel Core Ultra 5 125H (Meteor Lake) — exact | +| Kernel modules | `acpi_call` (fan/EC) and `uinput` (gesture output) | + +AMD and all other Intel models are not allowlisted and receive **no** power, +fan, or GPU writes. Power profiles coordinate through PPD plus standard Intel +RAPL/`intel_pstate` sysfs controls; the service never writes raw CPU MSRs or +disables host power-management services. No specific minimum BIOS or kernel +version is pinned — the tested platform is a current distribution kernel that +provides the modules above on that exact Meteor Lake SKU. - **Battery charge control:** verified (sysfs charge thresholds) - **Power profiles:** enabled only on the verified MRA-XXX platform (requires @@ -86,8 +95,10 @@ GUI / Tray CLI 6. **Structured results:** every mutation returns an `OperationResult` with `changed`, `persisted`, and `applied` booleans. Config persistence is never reported as hardware success. -7. **Qt main thread is I/O-free:** all D-Bus, filesystem, subprocess, - and hardware I/O happens on a dedicated worker thread. +7. **Qt main thread is I/O-free in practice:** all D-Bus, filesystem, + subprocess, and hardware I/O happens on a dedicated worker thread. The + documented exception is tiny `QSettings` reads/writes (window geometry and + the close-to-tray preference), which touch a small per-user config file. ## Installation @@ -225,8 +236,29 @@ Linux community. In particular: ## Disclaimer -This software is **AI-generated**. While I have personally tested all of -it on my own hardware, it is provided "AS IS", without warranty of any -kind, express or implied. I am **not responsible** for any issues, data -loss, or hardware damage that may occur from using this product. You use +This software is **AI-generated**. It is provided "AS IS", without warranty +of any kind, express or implied. I am **not responsible** for any issues, +data loss, or hardware damage that may occur from using this product. You use it entirely at your own risk. + +The features below do not all carry the same level of evidence. Some are +exercised only against fake hardware in CI, some are reconstructed statically +from Windows protocol captures, some have been run on the author's own +machine, and some are disabled outright until they can be made safe. Judge the +risk for your use case accordingly. + +### Feature verification status + +| Feature | Status | Evidence / notes | +|---|---|---| +| Battery charge control | `physically-verified` | sysfs charge thresholds on the author's machine | +| Power profiles | `physically-verified` | MRA-XXX | +| Fan control | `physically-verified` | MRA-XXX + Intel Core Ultra 5 125H | +| Gesture input (decode + uinput) | `physically-verified` | HID report `0x0e` decode to uinput dispatch | +| Touchpad firmware setting writes | `disabled` | pending physical Linux replay gate | +| GPU IRQ mitigation | `disabled` | no safe restore of original IRQ/C-state values | + +Status labels: `physically-verified` = run on real hardware; `simulated` = +exercised against fake hardware in CI only; `static` = statically +reconstructed from protocol captures, not yet replayed on Linux hardware; +`disabled` = gated off in production builds. diff --git a/experimental/touchpad-wmi/README.md b/experimental/touchpad-wmi/README.md index 8995170..c44a920 100644 --- a/experimental/touchpad-wmi/README.md +++ b/experimental/touchpad-wmi/README.md @@ -1,5 +1,14 @@ # Honor touchpad WMI bridge +> **EXPERIMENTAL — DO NOT SHIP.** This module is an early research prototype. +> It is **not** built or installed by the main installer +> (`scripts/install-local.sh`), and it is **not qualified**: no load, query, +> toggle, or restore testing has been done on real hardware. Build it only +> **out-of-tree**, against a **supported kernel** with the matching toolchain +> and kernel headers, and only on the exact platform it binds to +> (`HONOR/MRA-XXX`). Treat any write through it as potentially irreversible +> until the first on-hardware Linux call has been captured. + This optional, deliberately narrow module implements only the global touchpad-enable method used by the updated Windows plugin. All sensitivity, haptic, pressure, edge, drag, mouse-like, screenshot, and recording settings @@ -13,6 +22,14 @@ The driver binds only when both conditions match: It exposes one boolean `touchpad_enabled` attribute. There is no generic WMI, ACPI, EC, or raw-command interface. +## Why this directory has no spaces + +The module was moved out of a path that contained a space. The in-tree build +hands the kernel build system the current directory via `M=$(CURDIR)`; a space +in `$(CURDIR)` broke that `make` invocation. This space-free location +(`experimental/touchpad-wmi/`) fixes it — do not move the module back under a +path containing spaces. + ## One-kernel build Install the distribution's compiler and matching kernel headers, then: diff --git a/packaging/dbus/org.honorlinux.Control1.service b/packaging/dbus/org.honorlinux.Control1.service index 8164d4b..4e9f43d 100644 --- a/packaging/dbus/org.honorlinux.Control1.service +++ b/packaging/dbus/org.honorlinux.Control1.service @@ -1,12 +1,16 @@ # D-Bus system-bus service activation file. # When a client calls a method on org.honorlinux.Control1 and nothing owns -# the name yet, dbus-daemon starts this service via systemd (SystemdService) +# the name yet, dbus-daemon activates it through systemd (SystemdService) # rather than spawning a new process — so there is exactly one instance. +# +# Activation REQUIRES systemd. There is deliberately no direct Exec=/User= +# fallback: an unhardened direct-exec activation path would bypass the +# hardened systemd unit (ProtectSystem=strict, ProtectHome=true, +# NoNewPrivileges=true, ...). On a host without systemd, start the service +# from its unit file manually — dbus-daemon will not auto-spawn it. [D-BUS Service] Name=org.honorlinux.Control1 -# systemd owns the lifecycle; no direct Exec here to avoid duplicate forks. +# systemd owns the lifecycle; no direct Exec here to avoid duplicate forks +# and to keep activation inside the hardened unit. SystemdService=honor-control.service -# Fallback direct exec (used when systemd activation is unavailable): -Exec=/usr/bin/honor-control-service -User=root diff --git a/packaging/polkit/org.honorlinux.control.policy b/packaging/polkit/org.honorlinux.control.policy index 3c12769..bdd1347 100644 --- a/packaging/polkit/org.honorlinux.control.policy +++ b/packaging/polkit/org.honorlinux.control.policy @@ -14,8 +14,8 @@ - HonorLinux - https://github.com/HonorLinux/honor-control + ZachAR3 + https://github.com/ZachAR3/HonorControl diff --git a/packaging/systemd/honor-control.service b/packaging/systemd/honor-control.service index 345f82d..d6d018a 100644 --- a/packaging/systemd/honor-control.service +++ b/packaging/systemd/honor-control.service @@ -1,6 +1,6 @@ [Unit] Description=Honor Control D-Bus backend service -Documentation=https://github.com/HonorLinux/honor-control +Documentation=https://github.com/ZachAR3/HonorControl After=dbus.service Wants=polkit.service StartLimitIntervalSec=60 diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 7257a78..29693c6 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -9,14 +9,32 @@ # Does NOT touch the system Python environment (PEP 668 safe). # # Usage: -# sudo bash scripts/install-local.sh [--wheelhouse DIR] +# sudo bash scripts/install-local.sh [--wheelhouse DIR] [--enable-touchpad-firmware] +# sudo bash scripts/install-local.sh --rollback +# +# Offline installs: with --wheelhouse DIR the installer runs fully offline +# (--no-index --find-links --no-build-isolation) and REQUIRES a SHA256SUMS +# file in DIR; every listed artifact is verified with sha256sum -c before +# anything is installed. +# +# Touchpad firmware WRITES are disabled by default (unqualified). The +# automatic honor-touchpad-restore.service unit is neither installed nor +# enabled unless --enable-touchpad-firmware (or +# HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1) is set. set -euo pipefail DEV_MODE=false +ROLLBACK=false +TOUCHPAD_FIRMWARE=false +if [[ "${HONOR_TOUCHPAD_FIRMWARE_QUALIFIED:-0}" == "1" ]]; then + TOUCHPAD_FIRMWARE=true +fi WHEELHOUSE="" while [[ $# -gt 0 ]]; do case "$1" in --dev) DEV_MODE=true ;; + --rollback) ROLLBACK=true ;; + --enable-touchpad-firmware) TOUCHPAD_FIRMWARE=true ;; --wheelhouse) [[ $# -ge 2 ]] || { echo "error: --wheelhouse requires a path" >&2; exit 2; } WHEELHOUSE="$2" @@ -35,6 +53,13 @@ fi HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$HERE/.." && pwd)" +# --rollback hands off to the standalone rollback script before any staging +# or system mutation below. It atomically repoints /opt/honor-control/current +# to the retained predecessor release and restarts the service. +if $ROLLBACK; then + exec bash "$HERE/rollback.sh" +fi + INSTALL_ROOT=/opt/honor-control RELEASE_ID="$(date +%Y%m%d%H%M%S)-$$" RELEASE_DIR="$INSTALL_ROOT/releases/$RELEASE_ID" @@ -176,6 +201,21 @@ if [[ -n "$WHEELHOUSE" && ! -d "$WHEELHOUSE" ]]; then echo "error: wheelhouse directory does not exist: $WHEELHOUSE" >&2 exit 2 fi +if [[ -n "$WHEELHOUSE" ]]; then + # Fully offline, hash-verified install: every artifact must be listed in + # SHA256SUMS and match before we touch the system. + if [[ ! -f "$WHEELHOUSE/SHA256SUMS" ]]; then + echo "error: --wheelhouse requires a SHA256SUMS file in $WHEELHOUSE" >&2 + echo " Generate one with:" >&2 + echo " (cd $WHEELHOUSE && sha256sum ./*.whl ./*.tar.gz > SHA256SUMS)" >&2 + exit 2 + fi + echo "==> Verifying wheelhouse checksums (offline install)" + if ! (cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS); then + echo "error: wheelhouse checksum verification failed; refusing to install" >&2 + exit 2 + fi +fi HONOR_TOOLS_ROOT="$(cd "$ROOT/../honor-tools" 2>/dev/null && pwd || true)" if [[ ( -z "$HONOR_TOOLS_ROOT" || \ ! -f "$HONOR_TOOLS_ROOT/pyproject.toml" ) && -z "$WHEELHOUSE" ]]; then @@ -204,7 +244,9 @@ assert_replaceable "$ROOT/packaging/polkit/org.honorlinux.control.policy" /usr/s assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.desktop" /usr/share/applications/org.honorlinux.Control.desktop assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /usr/share/applications/org.honorlinux.Control.Tray.desktop assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop -assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service +if $TOUCHPAD_FIRMWARE; then + assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service +fi assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore assert_replaceable "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml @@ -223,7 +265,10 @@ PY="$VENV_DIR/bin/python" echo "==> [2/7] Installing Python dependencies" PIP_ARGS=(--quiet) if [[ -n "$WHEELHOUSE" ]]; then - PIP_ARGS+=(--no-index --find-links "$WHEELHOUSE") + # --no-build-isolation stops pip from fetching build backends over the + # network; the wheelhouse must therefore also contain any prebuilt wheels + # (setuptools/wheel and the honor packages) needed for an offline install. + PIP_ARGS+=(--no-index --find-links "$WHEELHOUSE" --no-build-isolation) fi if [[ -n "$HONOR_TOOLS_ROOT" && -f "$HONOR_TOOLS_ROOT/pyproject.toml" ]]; then mkdir -p "$STAGE/honor-tools" @@ -270,7 +315,9 @@ install_managed "$ROOT/packaging/polkit/org.honorlinux.control.policy" /usr/shar install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.desktop" /usr/share/applications/org.honorlinux.Control.desktop 0644 install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /usr/share/applications/org.honorlinux.Control.Tray.desktop 0644 install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop 0644 -install_managed "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service 0644 +if $TOUCHPAD_FIRMWARE; then + install_managed "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service 0644 +fi install_managed "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore 0755 install_managed "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml 0644 install -d -m755 /usr/lib/honor-touchpad/honor_control/{core,backend,cli} @@ -299,6 +346,14 @@ systemctl daemon-reload systemctl reload dbus 2>/dev/null || true command -v update-desktop-database >/dev/null 2>&1 && \ update-desktop-database /usr/share/applications 2>/dev/null || true +if $TOUCHPAD_FIRMWARE; then + echo "==> Touchpad firmware writes QUALIFIED: enabling honor-touchpad-restore.service" + systemctl enable honor-touchpad-restore.service 2>/dev/null || true +else + # Firmware writes are unqualified by default: never leave the automatic + # restore unit enabled (also covers upgrades from a qualified install). + systemctl disable --now honor-touchpad-restore.service 2>/dev/null || true +fi for module in acpi_call uinput; do if ! modprobe "$module" 2>/dev/null; then echo "warning: kernel module $module is unavailable; related features will be disabled" >&2 diff --git a/scripts/make-release-bundle.sh b/scripts/make-release-bundle.sh new file mode 100755 index 0000000..783aca1 --- /dev/null +++ b/scripts/make-release-bundle.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Assemble a versioned, hash-verified, offline release bundle. +# +# The bundle contains everything needed for a fully offline install on a +# supported host: the honor_control wheel + sdist (built with python -m build), +# the packaging tree, the install/uninstall/rollback scripts, a SHA256SUMS over +# every artifact, a MANIFEST (version / git commit / date), and a minimal +# sbom.json of the pinned dependencies. A drop-in directory is provided for the +# separately-reviewed honor-tools wheel. +# +# EXTERNAL STEPS (not performed here): obtaining the reviewed honor-tools 0.1.0 +# wheel, and signing/publishing the bundle. This script only assembles and +# checksums the artifacts it can build locally. +# +# Usage: +# bash scripts/make-release-bundle.sh [OUTPUT_DIR] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +cd "$ROOT" + +VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' pyproject.toml | head -n1)" +if [[ -z "$VERSION" ]]; then + echo "error: could not read version from pyproject.toml" >&2 + exit 2 +fi +GIT_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +BUNDLE="${1:-dist/release/honor-control-$VERSION}" + +echo "==> Assembling release bundle for honor-control $VERSION" +echo " commit: $GIT_COMMIT" +echo " date: $BUILD_DATE" +echo " output: $BUNDLE" + +rm -rf "$BUNDLE" +install -d -m 0755 "$BUNDLE/wheelhouse" "$BUNDLE/honor-tools" "$BUNDLE/scripts" + +echo "==> [1/6] Building honor_control wheel + sdist" +if ! python3 -c "import build" 2>/dev/null; then + echo "error: python -m build requires the build package (pip install build)" >&2 + exit 2 +fi +python3 -m build --outdir "$BUNDLE/wheelhouse" >/dev/null + +echo "==> [2/6] Copying packaging tree and installer scripts" +cp -a packaging "$BUNDLE/packaging" +install -m 0755 scripts/install-local.sh scripts/uninstall-local.sh scripts/rollback.sh "$BUNDLE/scripts/" + +echo "==> [3/6] Creating honor-tools drop-in inbox" +cat > "$BUNDLE/honor-tools/README.txt" <<'NOTE' +Drop the REVIEWED honor-tools 0.1.0 wheel into the sibling wheelhouse/ +directory, then regenerate the checksums before distributing: + + cd /wheelhouse && sha256sum ./*.whl ./*.tar.gz > SHA256SUMS + cd && find . -type f ! -path ./SHA256SUMS -printf "%P\n" \ + | LC_ALL=C sort | while read -r f; do sha256sum "$f"; done > SHA256SUMS + +Obtaining and reviewing the honor-tools wheel is an EXTERNAL step; this +bundle ships without it. +NOTE + +echo "==> [4/6] Writing sbom.json (pinned dependencies)" +cat > "$BUNDLE/sbom.json" <<'JSON' +{ + "bomFormat": "honor-control-minimal-sbom", + "specVersion": "1.0", + "metadata": { + "component": { "name": "honor-control", "type": "application" } + }, + "components": [ + { "type": "library", "name": "sdbus", "version": "0.14.2", "purl": "pkg:pypi/sdbus@0.14.2", "scope": "runtime" }, + { "type": "library", "name": "PySide6", "version": "6.11.1", "purl": "pkg:pypi/pyside6@6.11.1", "scope": "runtime" }, + { "type": "library", "name": "setuptools", "version": "83.0.0", "purl": "pkg:pypi/setuptools@83.0.0", "scope": "build" }, + { "type": "library", "name": "wheel", "version": "0.47.0", "purl": "pkg:pypi/wheel@0.47.0", "scope": "build" } + ] +} +JSON + +echo "==> [5/6] Writing MANIFEST and wheelhouse checksums" +# Checksums for the offline wheelhouse (consumed by install-local.sh --wheelhouse). +( + cd "$BUNDLE/wheelhouse" + sha256sum ./*.whl ./*.tar.gz > SHA256SUMS +) +# MANIFEST records provenance plus the artifact list (paths relative to bundle). +( + cd "$BUNDLE" + { + echo "package=honor-control" + echo "version=$VERSION" + echo "git_commit=$GIT_COMMIT" + echo "date=$BUILD_DATE" + echo "python=$(python3 --version 2>&1)" + echo "" + echo "# artifacts" + find . -type f ! -path ./SHA256SUMS ! -path ./MANIFEST -printf "%P\n" | LC_ALL=C sort + } > MANIFEST +) + +echo "==> [6/6] Writing top-level SHA256SUMS over all artifacts" +( + cd "$BUNDLE" + find . -type f ! -path ./SHA256SUMS -printf "%P\n" | LC_ALL=C sort \ + | while read -r f; do sha256sum "$f"; done > SHA256SUMS +) + +echo "" +echo "==> Release bundle assembled at: $BUNDLE" +echo "" +echo "EXTERNAL STEPS (not performed by this script):" +echo " 1. Obtain the REVIEWED honor-tools 0.1.0 wheel, drop it into" +echo " $BUNDLE/wheelhouse/ , and regenerate the checksums (see" +echo " $BUNDLE/honor-tools/README.txt)." +echo " 2. Sign and publish the bundle (for example, GPG-sign SHA256SUMS)." +echo "" +echo "Offline install on a supported host:" +echo " sudo bash scripts/install-local.sh --wheelhouse $BUNDLE/wheelhouse" diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..f2c8fb7 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Roll back honor-control to the retained predecessor release. +# +# The system installer keeps exactly two releases under +# /opt/honor-control/releases: the active one (the target of the +# /opt/honor-control/current symlink) and one known-good predecessor. +# This script atomically repoints the current symlink to that predecessor +# and restarts the service. It refuses if no predecessor is retained. +# +# Usage: +# sudo bash scripts/rollback.sh +# sudo bash scripts/install-local.sh --rollback # equivalent +set -euo pipefail + +INSTALL_ROOT=/opt/honor-control + +if [[ $EUID -ne 0 ]]; then + echo "error: run this rollback with sudo" >&2 + exit 1 +fi +if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then + echo "error: rollback requires a running systemd instance" >&2 + exit 2 +fi + +CURRENT="$(readlink -f "$INSTALL_ROOT/current" 2>/dev/null || true)" +if [[ -z "$CURRENT" || ! -d "$CURRENT" ]]; then + echo "error: no active release at $INSTALL_ROOT/current" >&2 + exit 1 +fi + +# The predecessor is the most recent retained release that is not active. +PREDECESSOR="$( + for candidate in "$INSTALL_ROOT"/releases/*; do + [[ -d "$candidate" ]] || continue + [[ "$candidate" == "$CURRENT" ]] && continue + echo "$candidate" + done | sort | tail -n 1 +)" +if [[ -z "$PREDECESSOR" || ! -d "$PREDECESSOR/venv" ]]; then + echo "error: no retained predecessor release to roll back to" >&2 + echo " (the installer retains only the active release and one predecessor)" >&2 + exit 1 +fi + +echo "==> Rolling back: $(basename "$CURRENT") -> $(basename "$PREDECESSOR")" +# Atomic switch: create a temporary symlink, then rename() it over current. +TMP_LINK="$INSTALL_ROOT/.current.rollback.$$" +ln -sfn "$PREDECESSOR" "$TMP_LINK" +mv -T "$TMP_LINK" "$INSTALL_ROOT/current" + +systemctl daemon-reload 2>/dev/null || true +if systemctl restart honor-control.service; then + echo "==> Rolled back to $(basename "$PREDECESSOR"); honor-control.service restarted." +else + echo "error: honor-control.service failed to restart after rollback" >&2 + exit 1 +fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1cf2abb..f4981c7 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -20,7 +20,7 @@ PYTEST="$ROOT/.venv/bin/pytest" [[ -x "$PYTEST" ]] || PYTEST="$(command -v pytest || true)" if [[ -z "$RUFF" || -z "$PYTEST" ]]; then echo "error: development tools missing; install the test extra" >&2 - echo " python3 -m pip install -e '$ROOT[dev]'" >&2 + echo " python3 -m pip install -e '${ROOT}[dev]'" >&2 exit 1 fi From e51d3f9b3d8e60d8f3ed74c383554b892390686e Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 02:32:51 +0700 Subject: [PATCH 04/10] Backend safety: queue wedge escalation, AC tri-state, durable recovery faults HC-002: command queue now exposes StuckCommandInfo/poisoned_duration; health reports hardware_queue_stuck and escalates to a nonzero restart after a bounded wedge so a hung EC call cannot leave a healthy-looking read-only service. HC-003: strict AC online decoder (0/1/None) plus debounced auto-switch that never transitions on an unknown AC read. HC-005: overall health aggregates fan fail-safe/pending-restore and controller fault states (failed/stop_timeout/cleanup_failed). HC-006: a failed apply+rollback latches power_recovery_required, blocks auto-switch/hooks, and is cleared only by a converging reconcile_power(). HC-008/009: supervisor reports STOP_TIMEOUT/CLEANUP_FAILED instead of a false STOPPED, refuses duplicate starts, and supports bounded restart policies. HC-012/013: ordered schema migrations with pre-migration backup, unknown-key reporting, and permission-proof backup recovery. HC-014: GPU adapter fails closed internally; any error (incl. restored) is a failure with structured partial on persist failure. HC-018: gesture runtime isolates per-report dispatch errors and backs off reconnects. --- honor_control/backend/application.py | 222 ++++++++++++-- honor_control/backend/command_queue.py | 50 +++ honor_control/backend/config_store.py | 371 +++++++++++++++++++++-- honor_control/backend/dbus/codec.py | 6 + honor_control/backend/gesture_runtime.py | 72 ++++- honor_control/backend/hardware.py | 45 ++- honor_control/backend/supervisor.py | 173 +++++++++-- honor_control/core/errors.py | 11 +- honor_control/core/models.py | 10 + tests/test_application.py | 10 +- tests/test_backend.py | 160 ++++++++++ tests/test_config_store.py | 202 ++++++++++++ tests/test_gesture_runtime.py | 162 ++++++++++ 13 files changed, 1396 insertions(+), 98 deletions(-) diff --git a/honor_control/backend/application.py b/honor_control/backend/application.py index 38b80b1..bcd54dc 100644 --- a/honor_control/backend/application.py +++ b/honor_control/backend/application.py @@ -54,7 +54,11 @@ from honor_control.backend.snapshot_store import SnapshotStore from honor_control.backend.supervisor import RuntimeSupervisor from honor_control.backend.touchpad_firmware import TouchpadFirmwareError -from honor_control.core.errors import DomainError, DomainException +from honor_control.core.errors import ( + ControllerState, + DomainError, + DomainException, +) from honor_control.core.gestures import DEFAULT_GESTURE_MAPPINGS, GESTURE_NAMES from honor_control.core.models import ( POWER_PROFILE_NAMES, @@ -94,6 +98,15 @@ FAN_CURVE_POLL_SECONDS = 2.0 AUTO_SWITCH_POLL_SECONDS = 2.0 AUTO_SWITCH_MAX_RETRY_SECONDS = 60.0 +# AC/battery transitions (which apply a power profile and may run a root hook) +# require this many consecutive consistent AC observations, so one glitchy +# sysfs read can never switch profiles. "Unknown" AC never triggers a change. +AUTO_SWITCH_CONFIRM_READS = 2 +AUTO_SWITCH_UNKNOWN_LOG_SECONDS = 60.0 +# A hardware command that stays poisoned (wedged) beyond this deadline triggers +# a service restart, because a non-cancellable call that never returns would +# otherwise leave the service permanently read-only (BUSY) with no self-heal. +QUEUE_STUCK_ESCALATION_SECONDS = 60.0 def _serialized_mutation(method): @@ -165,12 +178,26 @@ def __init__( self._fan_restore_pending = False self._fan_restore_task: asyncio.Task[bool] | None = None self._last_auto_script_status = "Not run" + self._power_recovery_required = False + self._power_recovery_detail: dict[str, Any] = {} + self._stuck_escalated = False + self._on_stuck_escalation: Callable[[], None] | None = None self._supervisor.register( "manual_fan_ttl", self._manual_fan_expiry, self._restore_fan_auto ) if self._gesture_runtime is not None: self._supervisor.register("gesture_daemon", self._gesture_runtime.run) + def set_stuck_escalation(self, callback: Callable[[], None]) -> None: + """Register the service-level escalation callback. + + Invoked once when the hardware command queue has stayed wedged past + ``QUEUE_STUCK_ESCALATION_SECONDS``. The service uses it to drop the + bus name and exit nonzero so systemd restarts the unit (running the + emergency fan-auto restore on the way out). + """ + self._on_stuck_escalation = callback + @property def snapshots(self) -> SnapshotStore: """Return the snapshot store.""" @@ -501,6 +528,7 @@ async def _apply_power_profile( ) if result.get("error"): await self._refresh_power() + self._record_power_rollback_failure(name, result) return OperationResult.failed( code="power_apply_failed", message=str(result["error"]), @@ -568,6 +596,10 @@ async def _apply_power_profile( "final_observed": observed, }, ) + # A fully converged, verified apply resolves any prior recovery + # fault and re-enables automatic switching/hooks. + self._power_recovery_required = False + self._power_recovery_detail = {} return OperationResult.success( message=f"Profile '{name}' applied", changed=True, @@ -578,6 +610,7 @@ async def _apply_power_profile( ) await self._refresh_power() + self._record_power_rollback_failure(name, result) return OperationResult.partial( code="power_partial_apply", message="Partial apply: " @@ -590,6 +623,64 @@ async def _apply_power_profile( details=result, ) + def _record_power_rollback_failure(self, name: str, result: dict[str, Any]) -> None: + """Latch a durable fault when both apply and rollback fail. + + A multi-resource power transaction (PPD/RAPL/EPP/governor/turbo) whose + rollback also fails can leave the machine in an unrecognized mixed + state. That must not disappear when the initiating response goes away: + it is retained here, surfaced in service health, and blocks automatic + switching until an explicit reconcile converges. + """ + rollback = result.get("rollback") + if ( + isinstance(rollback, dict) + and rollback.get("attempted") + and not rollback.get("ok") + ): + self._power_recovery_required = True + self._power_recovery_detail = { + "profile": name, + "rollback": rollback, + "observed": result.get("observed", {}), + } + log.error( + "power apply and rollback both failed for '%s'; recovery required", + name, + ) + + @_serialized_mutation + async def reconcile_power(self) -> OperationResult: + """Re-apply a known profile to clear an unresolved power-recovery fault. + + Re-reads and re-applies the desired (or built-in ``balanced``) profile + with full verification and clears ``power_recovery_required`` only on + complete convergence. Suppressed auto-switching and hooks resume once + the fault clears. + """ + profiles = self._config.state.power.profiles + target = self._config.state.power.profile + if target not in profiles: + target = "balanced" + result = await self._apply_power_profile(target, persist_desired=False) + if result.applied and not self._power_recovery_required: + return OperationResult.success( + message=f"Power reconciled to '{target}'", + changed=True, + persisted=False, + applied=True, + sequence=self._snapshots.sequence, + details=result.details, + ) + return OperationResult.partial( + code="power_recovery_unresolved", + message="Power state could not be reconciled; recovery still required", + persisted=False, + applied=result.applied, + sequence=self._snapshots.sequence, + details=result.details, + ) + @_serialized_mutation async def set_auto_switch(self, enabled: bool) -> OperationResult: """Enable or disable AC/battery auto-profile switching.""" @@ -1380,19 +1471,35 @@ async def set_gpu_mitigation_enabled(self, enabled: bool) -> OperationResult: result = await self._queue.run( "gpu_restore", self._hw.restore_gpu_mitigation ) - if result.get("error") and not result.get("restored"): + if result.get("error"): + # Any error means the mitigation was not positively applied — + # including the rolled-back case (``restored=True``), which must + # never be reported as success. return OperationResult.failed( code="gpu_mitigation_failed", message=str(result["error"]), sequence=self._snapshots.sequence, details=result, ) - await self._config.update( - lambda s: replace( - s, - gpu=GpuState(mitigation_enabled=enabled), + try: + await self._config.update( + lambda s: replace( + s, + gpu=GpuState(mitigation_enabled=enabled), + ) + ) + except Exception as exc: # noqa: BLE001 + # Applied on hardware but persistence failed: report the partial + # state honestly instead of raising an unstructured internal error. + await self._refresh_gpu() + return OperationResult.partial( + code="gpu_applied_not_persisted", + message=f"GPU mitigation applied but not persisted: {exc}", + applied=True, + persisted=False, + sequence=self._snapshots.sequence, + details=result, ) - ) await self._refresh_gpu() return OperationResult.success( message=f"GPU mitigation {'enabled' if enabled else 'restored'}", @@ -1820,29 +1927,56 @@ async def _refresh_gpu(self) -> None: async def _refresh_service_health(self) -> None: dependency_ok = self._hw.check_dependency() controller_health = self._supervisor.health + # A controller in any fault state (failed, or a stop that timed out / a + # cleanup that raised) must degrade the service — never report a clean + # "stopped" while an unresolved fault is outstanding. + fault_states = { + ControllerState.FAILED.value, + ControllerState.STOP_TIMEOUT.value, + ControllerState.CLEANUP_FAILED.value, + } controller_failed = any( - health.state.value == "failed" for health in controller_health.values() + health.state.value in fault_states for health in controller_health.values() ) gesture_not_ready = bool( self._config.state.gestures.daemon_enabled and self._gesture_runtime is not None and not self._gesture_runtime.status.running ) + stuck = self._queue.stuck_info() + hardware_queue_stuck = stuck is not None + stuck_command = stuck.name if stuck is not None else "" + fan_recovery_required = bool( + self._fan_fail_safe_error or self._fan_restore_pending + ) + power_recovery_required = self._power_recovery_required degraded = ( not self._config.valid or not dependency_ok or bool(self._snapshots.snapshot.stale_domains) or controller_failed or gesture_not_ready + or hardware_queue_stuck + or fan_recovery_required + or power_recovery_required ) - controller_fault = next( - ( - health.last_fault - for health in controller_health.values() - if health.last_fault - ), - "", + fault_parts: list[str] = [] + if self._config.last_error: + fault_parts.append(self._config.last_error) + fault_parts.extend( + health.last_fault + for health in controller_health.values() + if health.last_fault ) + if self._fan_fail_safe_error: + fault_parts.append(f"fan: {self._fan_fail_safe_error}") + if power_recovery_required: + fault_parts.append("power: rollback failed, recovery required") + if hardware_queue_stuck: + fault_parts.append( + f"hardware queue stuck on '{stuck_command}' " + f"for {self._queue.poisoned_duration():.0f}s" + ) health = ServiceHealth( version=__version__, uptime=int(time.time() - self._start_time), @@ -1851,9 +1985,32 @@ async def _refresh_service_health(self) -> None: dependency_ok=dependency_ok, config_valid=self._config.valid, stale_domains=self._snapshots.snapshot.stale_domains, - last_fault=self._config.last_error or controller_fault, + last_fault="; ".join(fault_parts), + hardware_queue_stuck=hardware_queue_stuck, + stuck_command=stuck_command, + fan_recovery_required=fan_recovery_required, + power_recovery_required=power_recovery_required, + controller_restart_counts=dict(self._supervisor.restart_counts()), ) await self._snapshots.update("service", health) + # HC-002 escalation: a permanently wedged hardware call must not leave a + # healthy-looking, indefinitely read-only service. After a bounded + # deadline, ask the service layer to drop the bus name and exit nonzero + # so systemd restarts the unit (and runs the emergency fan-auto + # restore), because the still-running call cannot be cancelled. + if ( + hardware_queue_stuck + and not self._stuck_escalated + and self._queue.poisoned_duration() >= QUEUE_STUCK_ESCALATION_SECONDS + and self._on_stuck_escalation is not None + ): + self._stuck_escalated = True + log.critical( + "hardware queue wedged on '%s' for %.0fs; escalating for restart", + stuck_command, + self._queue.poisoned_duration(), + ) + self._on_stuck_escalation() async def _refresh_loop(self) -> None: while True: @@ -1866,6 +2023,9 @@ async def _auto_switch_loop(self) -> None: failed_key: tuple[bool, str, str, str, str] | None = None failure_count = 0 retry_at = 0.0 + pending_ac: bool | None = None + pending_count = 0 + unknown_ac_logged_at = 0.0 while True: await asyncio.sleep(AUTO_SWITCH_POLL_SECONDS) state = self._config.state.power.auto_switch @@ -1874,6 +2034,12 @@ async def _auto_switch_loop(self) -> None: last_policy = None failed_key = None failure_count = 0 + pending_ac = None + pending_count = 0 + continue + if self._power_recovery_required: + # Do not auto-switch (or run hooks) while a failed power + # rollback has left an unresolved mixed state. continue await self._refresh_power() ac = self._snapshots.snapshot.power.ac_online @@ -1883,7 +2049,29 @@ async def _auto_switch_loop(self) -> None: state.on_ac_script, state.on_battery_script, ) - if ac is None or (ac == last_ac and policy == last_policy): + if ac is None: + # AC state unknown (read failure or disappearance): never treat + # this as "on battery". Skip the transition; keep last state. + pending_ac = None + pending_count = 0 + now = time.monotonic() + if now - unknown_ac_logged_at >= AUTO_SWITCH_UNKNOWN_LOG_SECONDS: + unknown_ac_logged_at = now + log.warning( + "auto-switch: AC state unreadable; skipping transition" + ) + continue + unknown_ac_logged_at = 0.0 + if ac != pending_ac: + # First sighting of a new AC state: require confirmation before + # acting so a single glitchy read cannot switch profiles/hooks. + pending_ac = ac + pending_count = 1 + continue + pending_count += 1 + if pending_count < AUTO_SWITCH_CONFIRM_READS: + continue + if ac == last_ac and policy == last_policy: continue key = (ac, *policy) if key == failed_key and time.monotonic() < retry_at: diff --git a/honor_control/backend/command_queue.py b/honor_control/backend/command_queue.py index a4378e7..dcee4da 100644 --- a/honor_control/backend/command_queue.py +++ b/honor_control/backend/command_queue.py @@ -43,6 +43,21 @@ def __init__( self.recovery_future = recovery_future +@dataclass(frozen=True) +class StuckCommandInfo: + """Observability record for a hardware command that outlived its deadline. + + A non-cancellable call that timed out (or was cancelled) keeps running on + the worker thread and poisons the queue until it actually returns. This + record exposes *what* is wedged and *for how long*, without changing the + poison-until-complete semantics. + """ + + name: str + started_at: float + timed_out_at: float + + @dataclass(frozen=True) class _WorkItem(Generic[T]): loop: asyncio.AbstractEventLoop @@ -64,6 +79,7 @@ def __init__(self, max_workers: int = 1) -> None: self._requests: queue.Queue[_WorkItem[Any] | object] = queue.Queue() self._running: dict[str, float] = {} self._timed_out_future: asyncio.Future[Any] | None = None + self._stuck: StuckCommandInfo | None = None self._closed = False self._thread = threading.Thread( target=self._worker, @@ -77,6 +93,28 @@ def pending_timeout_completion(self) -> asyncio.Future[Any] | None: """Return the command whose late completion currently poisons the queue.""" return self._timed_out_future + def stuck_info(self) -> StuckCommandInfo | None: + """Return the stuck command record while the queue is poisoned. + + The record is returned only while a timed-out/cancelled call is still + incomplete on the worker thread. Once the poisoned future completes + (or the queue is shut down) this returns ``None``. + """ + info = self._stuck + if info is None: + return None + poisoned = self._timed_out_future + if poisoned is None or poisoned.done(): + return None + return info + + def poisoned_duration(self) -> float: + """Seconds elapsed since the current poison started, or 0.0 if not stuck.""" + info = self.stuck_info() + if info is None: + return 0.0 + return max(0.0, time.monotonic() - info.timed_out_at) + async def run( self, name: str, @@ -107,6 +145,7 @@ async def run( "A timed-out hardware command is still running", ) self._timed_out_future = None + self._stuck = None correlation_id = str(uuid.uuid4())[:8] start = time.monotonic() @@ -132,6 +171,11 @@ async def run( future.add_done_callback(self._consume_abandoned_completion) recovery_future = self._queue_timeout_recovery(loop, timeout_recovery) self._timed_out_future = recovery_future or future + self._stuck = StuckCommandInfo( + name=name, + started_at=start, + timed_out_at=time.monotonic(), + ) raise CommandTimeoutError( name, timeout, @@ -143,6 +187,11 @@ async def run( self._timed_out_future = ( self._queue_timeout_recovery(loop, timeout_recovery) or future ) + self._stuck = StuckCommandInfo( + name=name, + started_at=start, + timed_out_at=time.monotonic(), + ) raise except Exception: log.exception("hw-queue: %s failed", name) @@ -231,6 +280,7 @@ def shutdown(self, wait: bool = True, timeout: float = 1.0) -> None: if self._closed: return self._closed = True + self._stuck = None self._requests.put_nowait(_STOP) if wait: self._thread.join(timeout=max(0.0, timeout)) diff --git a/honor_control/backend/config_store.py b/honor_control/backend/config_store.py index 8f7d2ee..b62615e 100644 --- a/honor_control/backend/config_store.py +++ b/honor_control/backend/config_store.py @@ -11,7 +11,10 @@ the active snapshot. A malformed file leaves the last-known-good state active and marks service health degraded. Saves are atomic: write a same-directory temp file, ``flush`` + ``fsync``, mode ``0640``, -``os.replace``, then fsync the directory. +``os.replace``, then fsync the directory. Older ``schema_version`` files +are upgraded by an ordered migration pipeline (after snapshotting the +original to ``.premigration``); unknown keys are reported to +diagnostics but never fail a load. """ from __future__ import annotations @@ -27,6 +30,7 @@ import tempfile import threading import tomllib +from collections.abc import Callable from dataclasses import asdict, dataclass, field from typing import Any @@ -52,6 +56,9 @@ #: Current state schema version. STATE_SCHEMA_VERSION = 3 +#: Suffix for the pre-migration snapshot of an older state file. +PREMIGRATION_SUFFIX = ".premigration" + #: Default state file path. DEFAULT_STATE_PATH = "/var/lib/honor-control/state.toml" @@ -182,11 +189,179 @@ def _state_to_dict(state: ServiceState) -> dict[str, Any]: return d -def _state_from_dict(data: dict[str, Any]) -> ServiceState: +# --------------------------------------------------------------------------- +# Ordered schema migrations (HC-012) +# --------------------------------------------------------------------------- + + +def _migrate_1_to_2(data: dict[str, Any]) -> dict[str, Any]: + """Schema 1 -> 2: introduce the GPU mitigation table. + + v1 documents predate ``[gpu]``; add it (disabled) so later steps and + the current-schema validator see a complete document. + """ + migrated = dict(data) + gpu = migrated.get("gpu") + gpu = dict(gpu) if isinstance(gpu, dict) else {} + gpu.setdefault("mitigation_enabled", False) + migrated["gpu"] = gpu + migrated["schema_version"] = 2 + return migrated + + +def _migrate_2_to_3(data: dict[str, Any]) -> dict[str, Any]: + """Schema 2 -> 3: introduce the touchpad firmware desired-state table. + + v2 documents predate ``[touchpad]``; add an empty settings table so the + firmware desired state starts from a known-empty baseline rather than + implicitly acquiring current defaults during parsing. + """ + migrated = dict(data) + touchpad = migrated.get("touchpad") + touchpad = dict(touchpad) if isinstance(touchpad, dict) else {} + touchpad.setdefault("settings", {}) + migrated["touchpad"] = touchpad + migrated["schema_version"] = 3 + return migrated + + +#: Ordered registry: key ``N`` holds the step upgrading schema N to N+1. +_SCHEMA_MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = { + 1: _migrate_1_to_2, + 2: _migrate_2_to_3, +} + + +def _apply_migrations(data: dict[str, Any], source_version: int) -> dict[str, Any]: + """Run the ordered migration chain from ``source_version`` to current. + + Each step takes a raw dict and returns the transformed dict with + ``schema_version`` bumped by one; the result is structurally validated + after every step. Raises :class:`DomainException` naming the source + version when the chain is incomplete or a step produces an invalid + document — callers must not fall back to current defaults in that case. + """ + version = source_version + while version < STATE_SCHEMA_VERSION: + step = _SCHEMA_MIGRATIONS.get(version) + if step is None: + raise DomainException( + DomainError.UNSUPPORTED, + f"No migration path from schema {source_version} to " + f"{STATE_SCHEMA_VERSION} (no step for schema {version})", + ) + migrated = step(data) + if not isinstance(migrated, dict): + raise DomainException( + DomainError.INTERNAL, + f"Migration step for schema {version} returned a non-table " + f"document ({type(migrated).__name__})", + ) + expected = version + 1 + got = migrated.get("schema_version") + if isinstance(got, bool) or not isinstance(got, int) or got != expected: + raise DomainException( + DomainError.INTERNAL, + f"Migration step for schema {version} produced " + f"schema_version {got!r}, expected {expected}", + ) + data = migrated + version = expected + return data + + +# --------------------------------------------------------------------------- +# Unknown-key reporting (HC-012) +# --------------------------------------------------------------------------- + +#: Allowed keys of the document root and of every fixed-schema table. +_KNOWN_TABLE_KEYS: dict[str, frozenset[str]] = { + "": frozenset( + {"schema_version", "battery", "power", "fan", "gestures", "touchpad", "gpu"} + ), + "battery": frozenset({"end_threshold", "start_threshold", "mode"}), + "power": frozenset({"profile", "auto_switch", "profiles"}), + "power.auto_switch": frozenset( + {"enabled", "on_ac", "on_battery", "on_ac_script", "on_battery_script"} + ), + "fan": frozenset({"mode", "curves"}), + "gestures": frozenset({"mappings", "daemon_enabled"}), + "touchpad": frozenset({"settings"}), + "gpu": frozenset({"mitigation_enabled"}), +} + +#: Allowed keys inside entries of dynamically-keyed tables. (``fan.curves`` +#: and ``touchpad.settings`` hold scalars, so their keys are not checked.) +_KNOWN_ENTRY_KEYS: dict[str, frozenset[str]] = { + "power.profiles": frozenset( + { + "label", + "description", + "pl1_uw", + "pl2_uw", + "governor", + "epp", + "ppd_profile", + "turbo_enabled", + "max_perf_pct", + } + ), + "gestures.mappings": frozenset({"enabled", "mapping"}), +} + + +def _find_unknown_keys(data: dict[str, Any]) -> list[str]: + """Return exact dotted paths of keys absent from the current schema. + + Unknown keys never fail parsing (callers warn instead), but they are + surfaced so typos in persisted hardware state stay diagnosable. + """ + unknown: list[str] = [] + + def walk(table: Any, path: str) -> None: + if not isinstance(table, dict): + return + allowed = _KNOWN_TABLE_KEYS.get(path) + if allowed is not None: + for key in table: + if isinstance(key, str) and key not in allowed: + unknown.append(f"{path}.{key}" if path else key) + for key, value in table.items(): + if not isinstance(key, str): + continue + child = f"{path}.{key}" if path else key + if child in _KNOWN_TABLE_KEYS: + walk(value, child) + elif child in _KNOWN_ENTRY_KEYS: + walk_entries(value, child) + + def walk_entries(table: Any, path: str) -> None: + if not isinstance(table, dict): + return + allowed = _KNOWN_ENTRY_KEYS[path] + for name, entry in table.items(): + if not isinstance(entry, dict): + continue + for key in entry: + if isinstance(key, str) and key not in allowed: + unknown.append(f"{path}.{name}.{key}") + + walk(data, "") + return unknown + + +def _state_from_dict( + data: dict[str, Any], + unknown_keys: list[str] | None = None, +) -> ServiceState: """Parse a dict into a validated ServiceState. - Unknown keys are ignored within the current schema. Invalid values raise - :class:`DomainException`. + Older schema versions are upgraded by the ordered migration pipeline + before parsing; a failed migration raises :class:`DomainException` + naming the source schema version instead of silently adopting current + defaults. Unknown keys never fail a parse: when ``unknown_keys`` is + supplied they are collected there as exact dotted paths. Invalid + values raise :class:`DomainException`. """ if not isinstance(data, dict): raise DomainException( @@ -194,6 +369,48 @@ def _state_from_dict(data: dict[str, Any]) -> ServiceState: "State document must be a TOML table", ) + schema_version = data.get("schema_version", 1) + if ( + isinstance(schema_version, bool) + or not isinstance(schema_version, int) + or schema_version < 1 + ): + raise DomainException( + DomainError.INVALID_ARGUMENT, + f"Invalid schema_version: {schema_version!r}", + ) + if schema_version > STATE_SCHEMA_VERSION: + raise DomainException( + DomainError.UNSUPPORTED, + f"State schema {schema_version} is newer than supported schema " + f"{STATE_SCHEMA_VERSION}", + ) + if schema_version < STATE_SCHEMA_VERSION: + try: + migrated = _apply_migrations(data, schema_version) + return _parse_current_schema(migrated, unknown_keys) + except DomainException as exc: + raise DomainException( + exc.code, + f"State migration from schema {schema_version} to " + f"{STATE_SCHEMA_VERSION} failed: {exc.message}", + detail=exc.detail, + ) from exc + return _parse_current_schema(data, unknown_keys) + + +def _parse_current_schema( + data: dict[str, Any], + unknown_keys: list[str] | None = None, +) -> ServiceState: + """Parse a current-schema document into a validated ServiceState. + + Unknown keys are collected into ``unknown_keys`` (when supplied) as + exact dotted paths; invalid values raise :class:`DomainException`. + """ + if unknown_keys is not None: + unknown_keys.extend(_find_unknown_keys(data)) + def table(parent: dict[str, Any], key: str) -> dict[str, Any]: value = parent.get(key, {}) if not isinstance(value, dict): @@ -230,24 +447,6 @@ def string(parent: dict[str, Any], key: str, default: str) -> str: ) return value - schema_version = data.get("schema_version", 1) - if ( - isinstance(schema_version, bool) - or not isinstance(schema_version, int) - or schema_version < 1 - ): - raise DomainException( - DomainError.INVALID_ARGUMENT, - f"Invalid schema_version: {schema_version!r}", - ) - if schema_version > STATE_SCHEMA_VERSION: - raise DomainException( - DomainError.UNSUPPORTED, - f"State schema {schema_version} is newer than supported schema " - f"{STATE_SCHEMA_VERSION}", - ) - # Future: apply ordered migration functions for old schema versions. - # Battery bat_data = table(data, "battery") end = integer(bat_data, "end_threshold", 90) @@ -383,6 +582,7 @@ def __init__( self._state: ServiceState = default_state() self._valid = True self._last_error = "" + self._unknown_keys: list[str] = [] self._has_loaded = False @property @@ -400,6 +600,11 @@ def last_error(self) -> str: """Return the last load/save error (empty when valid).""" return self._last_error + @property + def unknown_keys(self) -> list[str]: + """Dotted paths of unknown keys seen during the last successful load.""" + return list(self._unknown_keys) + @property def state_path(self) -> pathlib.Path: """Return the state file path.""" @@ -425,37 +630,132 @@ def reload(self) -> ServiceState: return self._load(allow_backup=False) def _load(self, *, allow_backup: bool) -> ServiceState: - """Load one candidate, optionally using the startup backup.""" + """Load one candidate, optionally using the startup backup. + + Never raises: every inspection step (primary existence/read/parse, + and each backup step independently) degrades to an invalid store + with a clear ``last_error`` so service composition can report + health instead of aborting on an inaccessible state directory. + """ try: if not self._state_path.exists(): log.info("state file %s missing; using defaults", self._state_path) self._state = default_state() self._valid = True self._last_error = "" + self._unknown_keys = [] return self._state data = self._read_toml(self._state_path) - self._state = _state_from_dict(data) + self._maybe_backup_premigration(data) + unknown: list[str] = [] + self._state = _state_from_dict(data, unknown) self._valid = True self._last_error = "" + self._unknown_keys = unknown + if unknown: + log.warning( + "state file %s contains unknown keys: %s", + self._state_path, + ", ".join(unknown), + ) log.info("loaded state from %s", self._state_path) except Exception as exc: # noqa: BLE001 log.error("state load failed: %s", exc) self._valid = False self._last_error = str(exc) + self._unknown_keys = [] # A fresh process has no in-memory last-known-good state. Recover # the bounded backup only during initial load, while retaining # degraded health so the primary still requires attention. - backup = self._state_path.with_suffix(".toml.bak") - if allow_backup and backup.exists(): - try: - self._state = _state_from_dict(self._read_toml(backup)) - log.warning("recovered state from %s", backup) - except Exception as backup_exc: # noqa: BLE001 - log.error("state backup load failed: %s", backup_exc) + if allow_backup: + self._recover_backup() finally: self._has_loaded = True return self._state + def _recover_backup(self) -> None: + """Recover the startup ``.bak``; never raises. + + Each backup inspection step (existence check, read, parse) is + guarded separately so an inaccessible backup degrades to the + already-recorded primary error instead of raising a second + exception out of service composition. + """ + backup = self._state_path.with_suffix(".toml.bak") + try: + backup_exists = backup.exists() + except Exception as exc: # noqa: BLE001 + log.error("state backup check failed for %s: %s", backup, exc) + return + if not backup_exists: + return + try: + backup_data = self._read_toml(backup) + except Exception as exc: # noqa: BLE001 + log.error("state backup read failed for %s: %s", backup, exc) + return + unknown: list[str] = [] + try: + self._state = _state_from_dict(backup_data, unknown) + except Exception as backup_exc: # noqa: BLE001 + log.error("state backup load failed: %s", backup_exc) + return + self._unknown_keys = unknown + if unknown: + log.warning( + "state backup %s contains unknown keys: %s", + backup, + ", ".join(unknown), + ) + log.warning("recovered state from %s", backup) + + def _maybe_backup_premigration(self, data: dict[str, Any]) -> None: + """Snapshot the original file before upgrading an older schema. + + Best-effort: a failed snapshot is logged but does not fail the load; + the migration itself still validates and degrades the store when it + cannot produce a current-schema document. + """ + version = data.get("schema_version", 1) + if isinstance(version, bool) or not isinstance(version, int): + return + if not 1 <= version < STATE_SCHEMA_VERSION: + return + dest = self._state_path.with_name(self._state_path.name + PREMIGRATION_SUFFIX) + try: + self._safe_copy(self._state_path, dest) + except Exception as exc: # noqa: BLE001 + log.warning("pre-migration backup of %s failed: %s", dest, exc) + else: + log.info("wrote pre-migration backup %s (schema %s)", dest, version) + + def _safe_copy(self, source: pathlib.Path, dest: pathlib.Path) -> None: + """Copy ``source`` to ``dest`` with the atomic-save discipline.""" + payload = source.read_bytes() + fd, tmp_name = tempfile.mkstemp( + prefix=f".{dest.name}.", + suffix=".tmp", + dir=dest.parent, + ) + tmp = pathlib.Path(tmp_name) + try: + os.fchmod(fd, 0o640) + with os.fdopen(fd, "wb") as fh: + fd = -1 # ownership transferred to the file object + fh.write(payload) + fh.flush() + os.fsync(fh.fileno()) + os.replace(str(tmp), str(dest)) + self._fsync_dir(dest.parent) + except Exception: + if fd >= 0: + os.close(fd) + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise + async def update(self, mutator) -> ServiceState: """Apply ``mutator(current_state) -> new_state`` atomically. @@ -555,7 +855,8 @@ def export_state_dict(self) -> dict[str, Any]: def import_state_dict(self, data: dict[str, Any]) -> ServiceState: """Validate and adopt a state dict (used by the import command).""" with self._io_lock: - state = _state_from_dict(data) + unknown: list[str] = [] + state = _state_from_dict(data, unknown) _validate_state(state) try: self._save_atomic(state) @@ -568,6 +869,12 @@ def import_state_dict(self, data: dict[str, Any]) -> ServiceState: detail=str(exc), ) from exc self._state = state + self._unknown_keys = unknown + if unknown: + log.warning( + "imported state contains unknown keys: %s", + ", ".join(unknown), + ) return state diff --git a/honor_control/backend/dbus/codec.py b/honor_control/backend/dbus/codec.py index dae813c..fd6bb71 100644 --- a/honor_control/backend/dbus/codec.py +++ b/honor_control/backend/dbus/codec.py @@ -108,6 +108,11 @@ def _service_to_dict(snap: SystemSnapshot) -> dict[str, Any]: "config_valid": snap.service.config_valid, "stale_domains": list(snap.service.stale_domains), "last_fault": snap.service.last_fault, + "hardware_queue_stuck": snap.service.hardware_queue_stuck, + "stuck_command": snap.service.stuck_command, + "fan_recovery_required": snap.service.fan_recovery_required, + "power_recovery_required": snap.service.power_recovery_required, + "controller_restart_counts": dict(snap.service.controller_restart_counts), } @@ -225,6 +230,7 @@ def _gestures_to_dict(snap: SystemSnapshot) -> dict[str, Any]: ], "wmi_transport_present": ges.wmi_transport_present, "firmware_settings_supported": ges.firmware_settings_supported, + "firmware_writes_qualified": ges.firmware_writes_qualified, "firmware_settings": dict(ges.firmware_settings), "last_error": ges.last_error, } diff --git a/honor_control/backend/gesture_runtime.py b/honor_control/backend/gesture_runtime.py index 5dc505b..92193de 100644 --- a/honor_control/backend/gesture_runtime.py +++ b/honor_control/backend/gesture_runtime.py @@ -20,6 +20,7 @@ from typing import Any from honor_control.backend.touchpad_firmware import discover_touchpad_input +from honor_control.core.errors import DomainException from honor_control.core.gestures import ( DEFAULT_GESTURE_MAPPINGS, KEY_CODES, @@ -70,6 +71,8 @@ class GestureRuntimeStatus: reports_seen: int = 0 gestures_emitted: int = 0 last_error: str = "" + #: Reports dropped because of a malformed mapping/emit (session survived). + skipped: int = 0 def discover_touchpad( @@ -195,17 +198,22 @@ def __init__( sysfs_root: pathlib.Path = pathlib.Path("/sys/class/hidraw"), dev_root: pathlib.Path = pathlib.Path("/dev"), retry_seconds: float = 2.0, + max_retry_seconds: float = 30.0, + stable_session_seconds: float = 5.0, ) -> None: self._mappings_provider = mappings_provider self._sysfs_root = sysfs_root self._dev_root = dev_root self._retry_seconds = retry_seconds + self._max_retry_seconds = max(max_retry_seconds, retry_seconds) + self._stable_session_seconds = stable_session_seconds self._status = GestureRuntimeStatus() self._hid_fd: int | None = None self._uinput_fd: int | None = None self._session_done: asyncio.Future[None] | None = None self._last_report: bytes | None = None self._last_report_at = 0.0 + self._retry_backoff = retry_seconds @property def status(self) -> GestureRuntimeStatus: @@ -218,9 +226,17 @@ def probe(self) -> GestureProbe: ) async def run(self) -> None: - """Run until cancelled, reconnecting after removal or transient errors.""" + """Run until cancelled, reconnecting after removal or I/O failures. + + Consecutive sessions that fail quickly back off exponentially from + ``retry_seconds`` up to ``max_retry_seconds``; a session that ran for + at least ``stable_session_seconds`` (and a clean stop) resets the + backoff so an ordinary unplug/replug stays responsive. + """ + self._retry_backoff = self._retry_seconds try: while True: + session_started = time.monotonic() try: await self._run_session() except asyncio.CancelledError: @@ -238,15 +254,33 @@ async def run(self) -> None: running=False, last_error=str(exc), ) - await asyncio.sleep(self._retry_seconds) + delay = self._next_retry_delay(time.monotonic() - session_started) + await asyncio.sleep(delay) finally: self._close_session() + self._retry_backoff = self._retry_seconds self._status = replace( self._status, running=False, uinput_ready=False, ) + def _next_retry_delay(self, session_seconds: float) -> float: + """Compute the reconnect delay after a session ended. + + A session that survived at least ``stable_session_seconds`` counts as + healthy and resets the delay to ``retry_seconds``. Otherwise the delay + doubles on every consecutive quick failure, capped at + ``max_retry_seconds``, so a persistent open/I/O fault cannot churn the + session at a fixed high rate. + """ + if session_seconds >= self._stable_session_seconds: + self._retry_backoff = self._retry_seconds + return self._retry_seconds + delay = self._retry_backoff + self._retry_backoff = min(self._retry_backoff * 2.0, self._max_retry_seconds) + return delay + async def _run_session(self) -> None: device = discover_touchpad( sysfs_root=self._sysfs_root, @@ -328,17 +362,27 @@ def _read_ready(self) -> None: continue try: tokens = validate_key_combo(combo) + codes = [KEY_CODES[token] for token in tokens] + except (DomainException, KeyError, TypeError, ValueError) as exc: + # Tampered/malformed mapping: drop this report only. The + # session and the uinput device must stay alive so every + # other gesture keeps working. + self._skip_report(combo, exc) + continue + try: if self._uinput_fd is None: raise OSError(errno.ENODEV, "uinput device is closed") - emit_key_combo(self._uinput_fd, [KEY_CODES[token] for token in tokens]) - self._status = replace( - self._status, - gestures_emitted=self._status.gestures_emitted + 1, - last_error="", - ) - except Exception as exc: # noqa: BLE001 + emit_key_combo(self._uinput_fd, codes) + except OSError as exc: + # Genuine I/O failure (uinput write/ioctl): end the session + # so run() reconnects with a fresh device. self._fail_session(exc) return + self._status = replace( + self._status, + gestures_emitted=self._status.gestures_emitted + 1, + last_error="", + ) def _mapping_for(self, keys: tuple[str, str]) -> str: configured = self._mappings_provider() @@ -354,7 +398,17 @@ def _mapping_for(self, keys: tuple[str, str]) -> str: return default return "" + def _skip_report(self, combo: str, exc: Exception) -> None: + """Record a per-report mapping/emit failure without ending the session.""" + log.warning("skipping gesture report, unusable mapping %r: %s", combo, exc) + self._status = replace( + self._status, + skipped=self._status.skipped + 1, + last_error=str(exc), + ) + def _fail_session(self, exc: BaseException) -> None: + """End the session for genuine I/O failures (read/write/EOF/create).""" if self._session_done is not None and not self._session_done.done(): self._session_done.set_exception(exc) diff --git a/honor_control/backend/hardware.py b/honor_control/backend/hardware.py index 595c92f..a5573c6 100644 --- a/honor_control/backend/hardware.py +++ b/honor_control/backend/hardware.py @@ -1144,9 +1144,7 @@ def read_battery(self) -> BatterySnapshot: available=True, capacity_percent=self._read_int(self._battery_path / "capacity"), status=battery_status, - ac_online=self._read_int(self._ac_path / "online") == 1 - if self._ac_path - else None, + ac_online=self._read_ac_online(self._ac_path), observed_end=end, observed_start=start, desired_end=end, @@ -1256,9 +1254,7 @@ def read_power(self) -> PowerSnapshot: and status["no_turbo"] is not None and status["max_perf_pct"] is not None ) - ac_online = None - if self._ac_path is not None: - ac_online = self._read_int(self._ac_path / "online") == 1 + ac_online = self._read_ac_online(self._ac_path) return PowerSnapshot( available=complete, observed_summary=status, @@ -1634,16 +1630,14 @@ def read_gpu(self) -> GpuSnapshot: return GpuSnapshot(available=False, last_error=str(exc)) def apply_gpu_mitigation(self) -> dict[str, Any]: - self._require_honor() - plat = self._require_platform() - try: - from honor.config import Config - from honor.gpu import apply_irq_fix - - cfg = Config() - return apply_irq_fix(cfg, plat) - except Exception as exc: # noqa: BLE001 - return {"error": str(exc)} + # Fail closed at the adapter. The GPU IRQ mitigation is an irreversible + # write and honor-tools provides no verified restore (see + # restore_gpu_mitigation). Refusing here means safety does not depend + # on every caller remembering to check capability writability first. + return { + "error": "GPU mitigation writes are disabled: no verified restore exists", + "restored": False, + } def restore_gpu_mitigation(self) -> dict[str, Any]: # The honor-tools package does not provide a restore function. @@ -1659,6 +1653,25 @@ def _read_int(path: pathlib.Path) -> int | None: except (OSError, ValueError): return None + @staticmethod + def _read_ac_online(ac_path: pathlib.Path | None) -> bool | None: + """Decode a power-supply ``online`` attribute as a strict tri-state. + + Return ``True`` only for an exact ``1`` and ``False`` only for an exact + ``0``. A missing AC source, a read/parse failure, or any other value + returns ``None`` ("unknown") so callers never mistake a transient sysfs + failure for a genuine "on battery" observation and trigger a profile + transition or root hook by accident. + """ + if ac_path is None: + return None + raw = HonorToolsAdapter._read_int(ac_path / "online") + if raw == 1: + return True + if raw == 0: + return False + return None + @staticmethod def _write_int(path: pathlib.Path, value: int) -> bool: try: diff --git a/honor_control/backend/supervisor.py b/honor_control/backend/supervisor.py index b8e6bb6..b642177 100644 --- a/honor_control/backend/supervisor.py +++ b/honor_control/backend/supervisor.py @@ -7,6 +7,13 @@ Task exceptions are captured into controller health. Explicit ``stop()`` requests run registered safety cleanup with a bounded timeout; controllers whose abnormal exit requires immediate restoration own that exception path. + +Fault states: a stop whose task ignores cancellation ends in +``stop_timeout`` (never a clean ``stopped``), and the still-alive task stays +tracked so ``start()`` refuses to spawn a duplicate until it completes. A +registered cleanup that raises ends in ``cleanup_failed``. Controllers may +opt into a bounded exponential-backoff restart policy for unexpected +crashes (see :meth:`RuntimeSupervisor.register`). """ from __future__ import annotations @@ -21,6 +28,9 @@ log = logging.getLogger("honor_control.backend.supervisor") +#: Upper bound for a single restart backoff delay. +MAX_RESTART_DELAY_SECONDS = 30.0 + @dataclass class ControllerHealth: @@ -44,23 +54,46 @@ class RuntimeSupervisor: * ``start()``/``stop()`` are idempotent state transitions. * Task exceptions are captured into controller health. * Explicit stop runs registered safety cleanup with a bounded timeout. + * A stop timeout or cleanup failure is reported as a fault state, + never as a clean ``stopped``. + * Controllers with a restart policy are restarted with bounded + exponential backoff after unexpected crashes. """ def __init__(self) -> None: self._controllers: dict[str, _ControllerEntry] = {} self._health: dict[str, ControllerHealth] = {} + self._restart_counts: dict[str, int] = {} + self._pending_restarts: set[asyncio.Task[None]] = set() def register( self, name: str, start_func: Callable[[], Coroutine[Any, Any, None]], stop_func: Callable[[], Awaitable[None]] | None = None, + *, + restart: bool = False, + max_restarts: int = 3, + backoff_seconds: float = 1.0, ) -> None: - """Register a controller by name.""" + """Register a controller by name. + + ``restart`` opts the controller into a bounded restart policy: + unexpected task crashes (not cancellation, not an explicit stop) + are restarted up to ``max_restarts`` times with exponential backoff + starting at ``backoff_seconds`` (each delay capped at + ``MAX_RESTART_DELAY_SECONDS``). When the budget is exhausted the + controller stays ``failed``. Without the policy, a crash ends in + ``failed`` immediately (the historical behavior). + """ if name in self._controllers: raise ValueError(f"controller '{name}' is already registered") self._controllers[name] = _ControllerEntry( - start_func=start_func, stop_func=stop_func + start_func=start_func, + stop_func=stop_func, + restart=restart, + max_restarts=max_restarts, + backoff_seconds=backoff_seconds, ) self._health[name] = ControllerHealth(name=name) @@ -73,8 +106,18 @@ def get_health(self, name: str) -> ControllerHealth: """Return the health record for a controller.""" return self._health.get(name, ControllerHealth(name=name)) + def restart_counts(self) -> dict[str, int]: + """Return restarts attempted per registered controller.""" + return {name: self._restart_counts.get(name, 0) for name in self._controllers} + async def start(self, name: str) -> bool: - """Start a controller. Idempotent: returns True if now running.""" + """Start a controller. Idempotent: returns True if now running. + + Refuses (returns False) when a previous task for the controller is + known to still be alive — e.g. it ignored cancellation during a stop + timeout — rather than spawning a duplicate. A fresh start is only + allowed once the prior task has completed (or was never started). + """ entry = self._controllers.get(name) if entry is None: log.warning("supervisor: unknown controller '%s'", name) @@ -82,27 +125,51 @@ async def start(self, name: str) -> bool: health = self._health[name] if health.running or health.state == ControllerState.STARTING: return True + prior = entry.task + if prior is not None and not prior.done(): + log.warning( + "supervisor: '%s' previous task is still alive; not starting", + name, + ) + return False + return await self._spawn(name) + + async def _spawn(self, name: str) -> bool: + """Create the controller task and mark it running.""" + entry = self._controllers[name] + health = self._health[name] health.state = ControllerState.STARTING try: task = asyncio.create_task(entry.start_func(), name=f"ctrl-{name}") - entry.task = task - task.add_done_callback(lambda done, n=name: self._task_done(n, done)) - health.state = ControllerState.RUNNING - health.last_fault = "" - log.info("supervisor: started '%s'", name) - return True except Exception as exc: # noqa: BLE001 health.state = ControllerState.FAILED health.last_fault = str(exc) log.error("supervisor: failed to start '%s': %s", name, exc) return False + entry.task = task + task.add_done_callback(lambda done, n=name: self._task_done(n, done)) + health.state = ControllerState.RUNNING + health.last_fault = "" + log.info("supervisor: started '%s'", name) + return True def _task_done(self, name: str, task: asyncio.Task) -> None: + entry = self._controllers.get(name) + if entry is not None and entry.task is task: + # The tracked task finished: a later start() may proceed again. + entry.task = None health = self._health.get(name) if ( health is None or task.cancelled() - or health.state == ControllerState.STOPPING + or health.state + in ( + ControllerState.STOPPING, + # Fault states stay visible; a late completion of a stuck or + # failed-cleanup controller must not masquerade as healthy. + ControllerState.STOP_TIMEOUT, + ControllerState.CLEANUP_FAILED, + ) ): return try: @@ -112,12 +179,60 @@ def _task_done(self, name: str, task: asyncio.Task) -> None: if fault is not None: health.state = ControllerState.FAILED health.last_fault = str(fault) + if entry is not None and entry.restart: + count = self._restart_counts.get(name, 0) + if count < entry.max_restarts: + self._restart_counts[name] = count + 1 + log.warning( + "supervisor: '%s' crashed (%s); restart %d/%d", + name, + fault, + count + 1, + entry.max_restarts, + ) + self._schedule_restart(name, count + 1) + return log.error("supervisor: '%s' stopped: %s", name, health.last_fault) elif health.state == ControllerState.RUNNING: health.state = ControllerState.STOPPED + def _schedule_restart(self, name: str, attempt: int) -> None: + """Schedule a crash restart with bounded exponential backoff.""" + entry = self._controllers.get(name) + if entry is None: + return + delay = min( + entry.backoff_seconds * (2 ** (attempt - 1)), + MAX_RESTART_DELAY_SECONDS, + ) + loop = asyncio.get_running_loop() + task = loop.create_task( + self._restart_after(name, delay), + name=f"ctrl-{name}-restart", + ) + self._pending_restarts.add(task) + task.add_done_callback(self._pending_restarts.discard) + + async def _restart_after(self, name: str, delay: float) -> None: + await asyncio.sleep(delay) + health = self._health.get(name) + if health is None or name not in self._controllers: + return + if health.state != ControllerState.FAILED: + # Stopped or restarted by other means while the backoff was pending. + return + log.info("supervisor: restarting '%s'", name) + await self._spawn(name) + async def stop(self, name: str, timeout: float = 5.0) -> None: - """Stop a controller and run safety cleanup. Idempotent.""" + """Stop a controller and run safety cleanup. Idempotent. + + A task that does not finish within ``timeout`` leaves the controller + in ``STOP_TIMEOUT`` (not ``STOPPED``) and stays tracked, so a later + ``start()`` refuses to duplicate it until the task completes. A + cleanup/stop function that raises leaves the controller in + ``CLEANUP_FAILED``. Only a fully clean stop reports ``STOPPED``. + """ entry = self._controllers.get(name) if entry is None: return @@ -127,18 +242,25 @@ async def stop(self, name: str, timeout: float = 5.0) -> None: health.state = ControllerState.STOPPING task = entry.task entry.task = None + stop_timed_out = False if task is not None and not task.done(): task.cancel() - try: - await asyncio.wait_for(task, timeout=timeout) - except asyncio.CancelledError: - pass - except TimeoutError: + _, pending = await asyncio.wait({task}, timeout=timeout) + if pending: + # The task ignored cancellation and is still alive. Keep + # tracking it (its done-callback clears the reference) so + # start() knows a duplicate must not be spawned. + entry.task = task + stop_timed_out = True + health.last_fault = f"did not stop within {timeout:.1f}s" log.warning("supervisor: '%s' did not stop in %.1fs", name, timeout) - except Exception as exc: # noqa: BLE001 - log.error("supervisor: '%s' raised: %s", name, exc) - health.last_fault = str(exc) + elif not task.cancelled(): + fault = task.exception() + if fault is not None: + log.error("supervisor: '%s' raised: %s", name, fault) + health.last_fault = str(fault) # Run safety cleanup. + cleanup_failed = False if entry.stop_func is not None: try: await asyncio.wait_for(entry.stop_func(), timeout=timeout) @@ -149,8 +271,14 @@ async def stop(self, name: str, timeout: float = 5.0) -> None: except Exception as exc: # noqa: BLE001 log.error("supervisor: '%s' cleanup failed: %s", name, exc) health.last_fault = str(exc) - health.state = ControllerState.STOPPED - log.info("supervisor: stopped '%s'", name) + cleanup_failed = True + if stop_timed_out: + health.state = ControllerState.STOP_TIMEOUT + elif cleanup_failed: + health.state = ControllerState.CLEANUP_FAILED + else: + health.state = ControllerState.STOPPED + log.info("supervisor: stopped '%s'", name) async def stop_all(self, timeout: float = 5.0) -> None: """Stop all controllers in reverse registration order.""" @@ -170,3 +298,6 @@ class _ControllerEntry: start_func: Callable[[], Coroutine[Any, Any, None]] stop_func: Callable[[], Awaitable[None]] | None = None task: asyncio.Task | None = None + restart: bool = False + max_restarts: int = 3 + backoff_seconds: float = 1.0 diff --git a/honor_control/core/errors.py b/honor_control/core/errors.py index bb0332f..762ed3e 100644 --- a/honor_control/core/errors.py +++ b/honor_control/core/errors.py @@ -60,13 +60,22 @@ class CapabilityStatus(StrEnum): class ControllerState(StrEnum): - """Lifecycle state of a background controller.""" + """Lifecycle state of a background controller. + + ``STOP_TIMEOUT`` and ``CLEANUP_FAILED`` are *fault* states: a controller + that ignored cancellation or whose cleanup raised must never be reported + as a clean ``STOPPED``. Health aggregation treats them as degraded so an + unresolved stop/cleanup failure stays visible (and is not silently + overwritten by a later transition). + """ STOPPED = "stopped" STARTING = "starting" RUNNING = "running" STOPPING = "stopping" FAILED = "failed" + STOP_TIMEOUT = "stop_timeout" + CLEANUP_FAILED = "cleanup_failed" class TransportError(StrEnum): diff --git a/honor_control/core/models.py b/honor_control/core/models.py index e937e36..1a67d98 100644 --- a/honor_control/core/models.py +++ b/honor_control/core/models.py @@ -383,6 +383,7 @@ class GesturesSnapshot: mappings: tuple[GestureEntry, ...] = field(default_factory=tuple) wmi_transport_present: bool = False firmware_settings_supported: bool = False + firmware_writes_qualified: bool = False firmware_settings: dict[str, int] = field(default_factory=dict) last_error: str = "" @@ -422,6 +423,15 @@ class ServiceHealth: config_valid: bool = True stale_domains: tuple[str, ...] = field(default_factory=tuple) last_fault: str = "" + # Durable safety-recovery signals: these keep the overall status degraded + # until the underlying incident is resolved, so a failed fan stock-auto + # restoration, a failed power rollback, or a wedged hardware queue can + # never coexist with an "healthy" service report. + hardware_queue_stuck: bool = False + stuck_command: str = "" + fan_recovery_required: bool = False + power_recovery_required: bool = False + controller_restart_counts: dict[str, int] = field(default_factory=dict) @dataclass(frozen=True) diff --git a/tests/test_application.py b/tests/test_application.py index 26e913d..036b8e8 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -446,6 +446,9 @@ def test_auto_switch_applies_selected_profile_and_runs_hook_once( self, tmp_path, monkeypatch ) -> None: _allow_test_hook(monkeypatch) + monkeypatch.setattr( + "honor_control.backend.application.AUTO_SWITCH_POLL_SECONDS", 0.05 + ) svc = _make_service(tmp_path) async def scenario() -> None: @@ -454,7 +457,9 @@ async def scenario() -> None: await svc.configure_auto_switch( True, "performance", "silent", "/usr/bin/true", "" ) - await asyncio.sleep(2.1) + # Several polls: the transition requires AUTO_SWITCH_CONFIRM_READS + # consistent AC observations before it applies (HC-003 debounce). + await asyncio.sleep(0.3) snapshot = await svc.get_snapshot() assert snapshot.power.applied_profile == "performance" assert ( @@ -464,7 +469,8 @@ async def scenario() -> None: name == "apply_power_profile" for name, _args, _kwargs in svc._hw.call_log # noqa: SLF001 ) - await asyncio.sleep(2.1) + assert calls >= 1 + await asyncio.sleep(0.3) assert ( sum( name == "apply_power_profile" diff --git a/tests/test_backend.py b/tests/test_backend.py index 8e55d1a..98bbefa 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import threading import pytest @@ -184,6 +185,48 @@ def test_shutdown_rejects_new_work(self) -> None: asyncio.run(q.run("after-shutdown", lambda: None)) assert unavailable.value.code == DomainError.UNAVAILABLE + def test_stuck_command_is_observable_until_release(self) -> None: + q = HardwareCommandQueue() + release = threading.Event() + + def blocked() -> str: + release.wait() + return "late" + + async def scenario() -> None: + with pytest.raises(CommandTimeoutError): + await q.run("stuck-call", blocked, timeout=0.05) + await asyncio.sleep(0.005) + info = q.stuck_info() + assert info is not None + assert info.name == "stuck-call" + assert info.timed_out_at >= info.started_at + assert q.poisoned_duration() > 0 + with pytest.raises(DomainException) as busy: + await q.run("while-stuck", lambda: 1) + assert busy.value.code == DomainError.BUSY + # Release the wedged call; the poison must clear on completion. + release.set() + loop = asyncio.get_running_loop() + deadline = loop.time() + 2 + while q.stuck_info() is not None: + assert loop.time() < deadline, "poison never cleared" + await asyncio.sleep(0.01) + assert q.poisoned_duration() == 0.0 + assert await q.run("after-stuck", lambda: 7) == 7 + + asyncio.run(scenario()) + q.shutdown() + + def test_successful_command_leaves_no_stuck_record(self) -> None: + q = HardwareCommandQueue() + assert q.stuck_info() is None + assert q.poisoned_duration() == 0.0 + assert asyncio.run(q.run("ok", lambda: 1)) == 1 + assert q.stuck_info() is None + assert q.poisoned_duration() == 0.0 + q.shutdown() + class TestSupervisor: """Verify controller lifecycle and health.""" @@ -275,3 +318,120 @@ async def start_func() -> None: sup.register("test", start_func) health = sup.check_health() assert health["test"] == "stopped" + + def test_stop_timeout_is_a_fault_and_blocks_duplicate_start(self) -> None: + sup = RuntimeSupervisor() + give_up = False + cancellations = 0 + + async def stubborn() -> None: + nonlocal cancellations + while not give_up: + try: + await asyncio.sleep(0.02) + except asyncio.CancelledError: + cancellations += 1 + + async def scenario() -> None: + nonlocal give_up + sup.register("stubborn", stubborn) + assert await sup.start("stubborn") is True + await asyncio.sleep(0) + await sup.stop("stubborn", timeout=0.05) + assert sup.get_health("stubborn").state == ControllerState.STOP_TIMEOUT + assert cancellations >= 1 + # A duplicate start must be refused while the task is alive. + assert await sup.start("stubborn") is False + assert sup.get_health("stubborn").state == ControllerState.STOP_TIMEOUT + # Let the wedged task exit; then a fresh start is allowed again + # and a clean stop reports STOPPED. + give_up = True + loop = asyncio.get_running_loop() + deadline = loop.time() + 2 + while not await sup.start("stubborn"): + assert loop.time() < deadline, "stuck task never released" + await asyncio.sleep(0.01) + await sup.stop("stubborn", timeout=1) + assert sup.get_health("stubborn").state == ControllerState.STOPPED + + asyncio.run(scenario()) + + def test_cleanup_failure_is_not_a_clean_stop(self) -> None: + sup = RuntimeSupervisor() + + async def start_func() -> None: + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + pass + + async def bad_cleanup() -> None: + raise RuntimeError("cleanup exploded") + + async def scenario() -> None: + sup.register("bad", start_func, bad_cleanup) + await sup.start("bad") + await asyncio.sleep(0) + await sup.stop("bad", timeout=1) + assert sup.get_health("bad").state == ControllerState.CLEANUP_FAILED + assert "cleanup exploded" in sup.get_health("bad").last_fault + + asyncio.run(scenario()) + + def test_restart_policy_is_bounded(self) -> None: + sup = RuntimeSupervisor() + attempts = 0 + + async def always_crashes() -> None: + nonlocal attempts + attempts += 1 + raise RuntimeError("boom") + + async def scenario() -> None: + sup.register( + "flaky", + always_crashes, + restart=True, + max_restarts=2, + backoff_seconds=0.01, + ) + assert await sup.start("flaky") is True + loop = asyncio.get_running_loop() + deadline = loop.time() + 2 + while attempts < 3 or ( + sup.get_health("flaky").state != ControllerState.FAILED + ): + assert loop.time() < deadline, ( + f"restart budget not exhausted (attempts={attempts}, " + f"state={sup.get_health('flaky').state})" + ) + await asyncio.sleep(0.01) + + asyncio.run(scenario()) + assert attempts == 3 # initial run + 2 restarts + assert sup.restart_counts()["flaky"] == 2 + assert sup.get_health("flaky").state == ControllerState.FAILED + + def test_no_restart_policy_stays_failed(self) -> None: + sup = RuntimeSupervisor() + attempts = 0 + + async def crashes_once() -> None: + nonlocal attempts + attempts += 1 + raise RuntimeError("dead") + + async def scenario() -> None: + sup.register("plain", crashes_once) + assert await sup.start("plain") is True + loop = asyncio.get_running_loop() + deadline = loop.time() + 2 + while sup.get_health("plain").state != ControllerState.FAILED: + assert loop.time() < deadline, "crash not recorded" + await asyncio.sleep(0.01) + await asyncio.sleep(0.05) # grace period: no restart may happen + + asyncio.run(scenario()) + assert attempts == 1 + assert sup.restart_counts()["plain"] == 0 + assert sup.get_health("plain").state == ControllerState.FAILED diff --git a/tests/test_config_store.py b/tests/test_config_store.py index fa2599d..8ecf185 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio +import builtins import pathlib import pytest +from honor_control.backend import config_store as config_store_module from honor_control.backend.config_store import ( STATE_SCHEMA_VERSION, BatteryState, @@ -298,3 +300,203 @@ def test_state_path_is_not_in_home(self, tmp_state_path: pathlib.Path) -> None: asyncio.run(store.update(lambda s: _set_battery(s, 80, 75, "travel"))) home = pathlib.Path.home() assert not str(tmp_state_path).startswith(str(home)) + + +class TestConfigStoreMigration: + """HC-012: ordered schema migrations and strict old-document handling.""" + + @staticmethod + def _v1_document() -> dict: + """Golden schema-version-1 document (predates gpu and touchpad).""" + return { + "schema_version": 1, + "battery": {"end_threshold": 80, "start_threshold": 75, "mode": "travel"}, + "power": { + "profile": "balanced", + "auto_switch": { + "enabled": False, + "on_ac": "balanced", + "on_battery": "silent", + "on_ac_script": "", + "on_battery_script": "", + }, + "profiles": {}, + }, + "fan": {"mode": "stock", "curves": {}}, + "gestures": {"mappings": {}, "daemon_enabled": False}, + } + + def test_v1_file_migrates_to_current(self, tmp_state_path: pathlib.Path) -> None: + tmp_state_path.write_text(_toml_dump(self._v1_document()), encoding="utf-8") + + store = ConfigStore(state_path=tmp_state_path) + state = store.load() + + assert store.valid is True + assert store.last_error == "" + assert state.schema_version == STATE_SCHEMA_VERSION + assert state.battery.end_threshold == 80 + assert state.battery.start_threshold == 75 + assert state.battery.mode == "travel" + # The migration steps supplied the tables introduced after v1. + assert state.gpu.mitigation_enabled is False + assert state.touchpad.settings == {} + # The original older file was snapshotted before migrating. + premigration = tmp_state_path.with_name( + tmp_state_path.name + config_store_module.PREMIGRATION_SUFFIX + ) + assert premigration.exists() + assert "schema_version = 1" in premigration.read_text(encoding="utf-8") + + def test_v2_document_migrates_in_memory(self) -> None: + data = self._v1_document() + data["schema_version"] = 2 + data["gpu"] = {"mitigation_enabled": True} + + state = _state_from_dict(data) + + assert state.schema_version == STATE_SCHEMA_VERSION + assert state.gpu.mitigation_enabled is True + assert state.touchpad.settings == {} + + def test_current_version_file_is_not_snapshotted( + self, tmp_state_path: pathlib.Path + ) -> None: + store = ConfigStore(state_path=tmp_state_path) + store.load() + asyncio.run(store.update(lambda s: _set_battery(s, 80, 75, "home"))) + + reloaded = ConfigStore(state_path=tmp_state_path) + state = reloaded.load() + + premigration = tmp_state_path.with_name( + tmp_state_path.name + config_store_module.PREMIGRATION_SUFFIX + ) + assert not premigration.exists() + assert reloaded.valid is True + assert state.battery.end_threshold == 80 + + def test_failed_migration_keeps_store_invalid( + self, tmp_state_path: pathlib.Path + ) -> None: + data = self._v1_document() + # Structurally migratable, but the migrated document can never + # satisfy current validation: must not silently adopt defaults. + data["battery"] = {"end_threshold": 10, "start_threshold": 90, "mode": "home"} + tmp_state_path.write_text(_toml_dump(data), encoding="utf-8") + + store = ConfigStore(state_path=tmp_state_path) + store.load() + + assert store.valid is False + assert "schema 1" in store.last_error + # The pre-migration snapshot of the original file still exists. + premigration = tmp_state_path.with_name( + tmp_state_path.name + config_store_module.PREMIGRATION_SUFFIX + ) + assert premigration.exists() + + def test_missing_migration_step_is_rejected(self, monkeypatch) -> None: + monkeypatch.delitem(config_store_module._SCHEMA_MIGRATIONS, 2) + + with pytest.raises(DomainException) as excinfo: + _state_from_dict(self._v1_document()) + + message = str(excinfo.value) + assert "schema 1" in message + assert "migration" in message.lower() + + +class TestConfigStoreUnknownKeys: + """HC-012: unknown keys warn and surface, but never fail a valid load.""" + + def test_unknown_keys_are_surfaced_not_fatal( + self, tmp_state_path: pathlib.Path + ) -> None: + data = _state_to_dict(default_state()) + data["power"]["unknown_key"] = "foobar" + data["bogus_section"] = {"nested": 1} + tmp_state_path.write_text(_toml_dump(data), encoding="utf-8") + + store = ConfigStore(state_path=tmp_state_path) + state = store.load() + + assert store.valid is True + assert state == default_state() + assert "power.unknown_key" in store.unknown_keys + assert "bogus_section" in store.unknown_keys + + def test_unknown_keys_inside_dynamic_entries( + self, tmp_state_path: pathlib.Path + ) -> None: + data = _state_to_dict(default_state()) + data["power"]["profiles"]["balanced"]["typo_field"] = 5 + tmp_state_path.write_text(_toml_dump(data), encoding="utf-8") + + store = ConfigStore(state_path=tmp_state_path) + store.load() + + assert store.valid is True + assert "power.profiles.balanced.typo_field" in store.unknown_keys + + def test_clean_file_reports_no_unknown_keys( + self, tmp_state_path: pathlib.Path + ) -> None: + store = ConfigStore(state_path=tmp_state_path) + store.load() + asyncio.run(store.update(lambda s: _set_battery(s, 80, 75, "home"))) + + reloaded = ConfigStore(state_path=tmp_state_path) + reloaded.load() + + assert reloaded.valid is True + assert reloaded.unknown_keys == [] + + +class TestConfigStoreBackupRecovery: + """HC-013: backup inspection failures must degrade, not raise.""" + + def test_backup_exists_check_permission_error( + self, tmp_state_path: pathlib.Path, monkeypatch + ) -> None: + tmp_state_path.write_text("not valid toml {{{{", encoding="utf-8") + + real_exists = pathlib.Path.exists + + def guarded_exists(self, *args, **kwargs): + if self.name.endswith(".toml.bak"): + raise PermissionError(13, "Permission denied", str(self)) + return real_exists(self, *args, **kwargs) + + monkeypatch.setattr(pathlib.Path, "exists", guarded_exists) + + store = ConfigStore(state_path=tmp_state_path) + state = store.load() # must not raise a second exception + + assert store.valid is False + assert store.last_error + assert state == default_state() + + def test_backup_read_permission_error( + self, tmp_state_path: pathlib.Path, monkeypatch + ) -> None: + tmp_state_path.write_text("not valid toml {{{{", encoding="utf-8") + tmp_state_path.with_suffix(".toml.bak").write_text( + _toml_dump(_state_to_dict(default_state())), encoding="utf-8" + ) + + real_open = builtins.open + + def guarded_open(file, *args, **kwargs): + if str(file).endswith(".toml.bak"): + raise PermissionError(13, "Permission denied", str(file)) + return real_open(file, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", guarded_open) + + store = ConfigStore(state_path=tmp_state_path) + state = store.load() # must not raise a second exception + + assert store.valid is False + assert store.last_error + assert state == default_state() diff --git a/tests/test_gesture_runtime.py b/tests/test_gesture_runtime.py index cfc8102..4333b4e 100644 --- a/tests/test_gesture_runtime.py +++ b/tests/test_gesture_runtime.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import errno +import fcntl import os import pathlib import struct @@ -11,6 +13,7 @@ from honor_control.backend.gesture_runtime import ( EV_KEY, UI_DEV_CREATE, + UI_DEV_DESTROY, UI_DEV_SETUP, GestureRuntime, create_uinput_device, @@ -148,3 +151,162 @@ async def exercise() -> None: os.close(fd) asyncio.run(exercise()) + + +def test_runtime_skips_bad_mapping_without_tearing_down_session(monkeypatch) -> None: + destroyed: list[int] = [] + real_ioctl = fcntl.ioctl + + def spy_ioctl(fd: int, request: int, *args: object) -> int: + if request == UI_DEV_DESTROY: + destroyed.append(fd) + return real_ioctl(fd, request, *args) + + monkeypatch.setattr("fcntl.ioctl", spy_ioctl) + + async def exercise() -> None: + runtime = GestureRuntime( + lambda: { + "3:1": GestureMappingState( + enabled=True, mapping="definitely_not_a_key" + ), + "3:2": GestureMappingState(enabled=True, mapping="leftmeta,x"), + } + ) + hid_read, hid_write = os.pipe2(os.O_NONBLOCK) + event_read, event_write = os.pipe2(os.O_NONBLOCK) + runtime._hid_fd = hid_read # noqa: SLF001 + runtime._uinput_fd = event_write # noqa: SLF001 + session_done = asyncio.get_running_loop().create_future() + runtime._session_done = session_done # noqa: SLF001 + try: + # Report 3:1 carries a permanently invalid chord: it must be + # skipped per-report without ending the session. + os.write(hid_write, b"\x0e\x03\x01\x00\x00\x00\x00\x00\x00") + runtime._read_ready() # noqa: SLF001 + assert not session_done.done() + assert runtime.status.skipped == 1 + assert runtime.status.gestures_emitted == 0 + assert "definitely_not_a_key" in runtime.status.last_error + assert runtime._uinput_fd == event_write # noqa: SLF001 + assert destroyed == [] + + # A subsequent valid report still emits: the session survived and + # no uinput destroy/recreate happened for the bad report. + os.write(hid_write, b"\x0e\x03\x02\x00\x00\x00\x00\x00\x00") + runtime._read_ready() # noqa: SLF001 + assert not session_done.done() + assert os.read(event_read, 4096) + assert runtime.status.reports_seen == 2 + assert runtime.status.gestures_emitted == 1 + assert runtime.status.skipped == 1 + assert destroyed == [] + finally: + runtime._hid_fd = None # noqa: SLF001 + runtime._uinput_fd = None # noqa: SLF001 + for fd in (hid_read, hid_write, event_read, event_write): + os.close(fd) + + asyncio.run(exercise()) + + +def test_runtime_eof_still_fails_session() -> None: + async def exercise() -> None: + runtime = GestureRuntime(lambda: {}) + hid_read, hid_write = os.pipe2(os.O_NONBLOCK) + runtime._hid_fd = hid_read # noqa: SLF001 + runtime._session_done = asyncio.get_running_loop().create_future() # noqa: SLF001 + os.close(hid_write) # EOF on the next read: simulates device unplug + try: + runtime._read_ready() # noqa: SLF001 + assert runtime._session_done.done() # noqa: SLF001 + exc = runtime._session_done.exception() # noqa: SLF001 + assert isinstance(exc, OSError) + assert exc.errno == errno.ENODEV + finally: + runtime._hid_fd = None # noqa: SLF001 + os.close(hid_read) + + asyncio.run(exercise()) + + +def test_runtime_uinput_write_error_still_fails_session() -> None: + async def exercise() -> None: + runtime = GestureRuntime( + lambda: {"3:1": GestureMappingState(enabled=True, mapping="leftmeta,x")} + ) + hid_read, hid_write = os.pipe2(os.O_NONBLOCK) + event_read, event_write = os.pipe2(os.O_NONBLOCK) + os.close(event_read) # uinput writes now fail with EPIPE + runtime._hid_fd = hid_read # noqa: SLF001 + runtime._uinput_fd = event_write # noqa: SLF001 + runtime._session_done = asyncio.get_running_loop().create_future() # noqa: SLF001 + try: + os.write(hid_write, b"\x0e\x03\x01\x00\x00\x00\x00\x00\x00") + runtime._read_ready() # noqa: SLF001 + assert runtime._session_done.done() # noqa: SLF001 + exc = runtime._session_done.exception() # noqa: SLF001 + assert isinstance(exc, OSError) + finally: + runtime._hid_fd = None # noqa: SLF001 + runtime._uinput_fd = None # noqa: SLF001 + for fd in (hid_read, hid_write, event_write): + os.close(fd) + + asyncio.run(exercise()) + + +def test_reconnect_backoff_grows_and_is_capped() -> None: + runtime = GestureRuntime( + lambda: {}, + retry_seconds=2.0, + max_retry_seconds=30.0, + stable_session_seconds=5.0, + ) + delays = [runtime._next_retry_delay(0.1) for _ in range(6)] # noqa: SLF001 + assert delays == [2.0, 4.0, 8.0, 16.0, 30.0, 30.0] + # A session that ran long enough resets the backoff... + assert runtime._next_retry_delay(10.0) == 2.0 # noqa: SLF001 + assert runtime._next_retry_delay(0.1) == 2.0 # noqa: SLF001 + # ...while a short-lived session keeps growing it. + assert runtime._next_retry_delay(4.0) == 4.0 # noqa: SLF001 + + +def test_run_retries_failed_sessions_with_growing_backoff(monkeypatch) -> None: + runtime = GestureRuntime( + lambda: {}, + retry_seconds=2.0, + max_retry_seconds=30.0, + ) + attempts = 0 + + async def failing_session() -> None: + nonlocal attempts + attempts += 1 + raise OSError(errno.EIO, "injected I/O failure") + + monkeypatch.setattr(runtime, "_run_session", failing_session) + sleeps: list[float] = [] + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + if len(sleeps) >= 3: + raise asyncio.CancelledError + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + async def exercise() -> None: + try: + await runtime.run() + except asyncio.CancelledError: + pass + else: + raise AssertionError("run() should propagate cancellation") + + asyncio.run(exercise()) + assert attempts == 3 + assert sleeps == [2.0, 4.0, 8.0] + assert runtime.status.running is False + assert "injected I/O failure" in runtime.status.last_error + # A clean stop resets the backoff for the next run(). + assert runtime._retry_backoff == 2.0 # noqa: SLF001 From 88cb11c89d0456a522420c4bc142cb81483ed541 Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 02:41:35 +0700 Subject: [PATCH 05/10] HC-001: gate unqualified firmware writes; HC-002: service escalation Touchpad firmware setting writes are now disabled by default behind touchpad_firmware_writes_qualified() (HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1, matching the installer flag). ApplyTouchpadSettings/SetTouchpadSetting return an unavailable result while unqualified; probe/encode/gesture-input remain available and the snapshot/probe expose firmware_writes_qualified so frontends can disable controls. The service registers a wedge-escalation callback: a hardware queue stuck past the deadline drops the bus name and exits nonzero so systemd restarts the unit (ExecStopPost restores fan auto; StartLimit bounds the loop), and the emergency fan-restore helper is SIGALRM-bounded under the unit's TimeoutStopSec. --- honor_control/backend/application.py | 13 +++ honor_control/backend/service.py | 50 +++++++++--- honor_control/core/touchpad.py | 18 +++++ tests/test_application.py | 117 ++++++++++++++++++++++++++- tests/test_hardware.py | 64 +++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) diff --git a/honor_control/backend/application.py b/honor_control/backend/application.py index bcd54dc..16fe681 100644 --- a/honor_control/backend/application.py +++ b/honor_control/backend/application.py @@ -78,6 +78,7 @@ SUPPORTED_GESTURE_BITS, parse_touchpad_setting, parse_touchpad_value, + touchpad_firmware_writes_qualified, ) from honor_control.core.validation import ( thresholds_for_mode, @@ -1296,6 +1297,7 @@ async def probe_touchpad_firmware(self) -> dict[str, Any]: "report_id": probe.report_id if probe.report_id is not None else -1, "input_report_bytes": probe.input_report_bytes, "output_report_bytes": probe.output_report_bytes, + "firmware_writes_qualified": touchpad_firmware_writes_qualified(), "error": probe.error, } @@ -1304,6 +1306,15 @@ async def apply_touchpad_settings( self, settings: dict[str, int] ) -> OperationResult: """Validate and apply a profile, persisting only accepted writes.""" + if not touchpad_firmware_writes_qualified(): + return OperationResult.unavailable( + code="touchpad_firmware_unqualified", + message=( + "Touchpad firmware writes are not qualified on this build; " + "probe/encode remain available" + ), + sequence=self._snapshots.sequence, + ) if not isinstance(settings, dict) or not settings: raise DomainException( DomainError.INVALID_ARGUMENT, @@ -1829,6 +1840,7 @@ async def _refresh_gestures(self) -> None: mappings=self._gesture_entries(), wmi_transport_present=wmi_transport_present(), firmware_settings_supported=firmware_probe.available, + firmware_writes_qualified=touchpad_firmware_writes_qualified(), firmware_settings=dict(self._config.state.touchpad.settings), last_error=status.last_error or probe.error, ) @@ -1841,6 +1853,7 @@ async def _refresh_gestures(self) -> None: ges, daemon_enabled=self._config.state.gestures.daemon_enabled, mappings=self._gesture_entries(entries), + firmware_writes_qualified=touchpad_firmware_writes_qualified(), firmware_settings=dict(self._config.state.touchpad.settings), ) await self._snapshots.update("gestures", ges) diff --git a/honor_control/backend/service.py b/honor_control/backend/service.py index df2d5c6..166f549 100644 --- a/honor_control/backend/service.py +++ b/honor_control/backend/service.py @@ -21,6 +21,7 @@ import asyncio import logging import signal +from typing import Any from honor_control import __version__ from honor_control.backend.application import ApplicationService @@ -40,8 +41,12 @@ log = logging.getLogger("honor_control.backend.service") +# Bound the emergency fan-auto restore (run as ExecStopPost and on escalation) +# so a hung EC call cannot wedge shutdown; kept under the unit's TimeoutStopSec. +EMERGENCY_FAN_RESTORE_TIMEOUT_SECONDS = 8 -async def _serve(use_session_bus: bool = False, state_path: str | None = None) -> None: + +async def _serve(use_session_bus: bool = False, state_path: str | None = None) -> int: """Export the D-Bus interface, publish the bus name, and run forever.""" import sdbus from sdbus import request_default_bus_name_async @@ -60,6 +65,8 @@ async def _serve(use_session_bus: bool = False, state_path: str | None = None) - sdbus.set_default_bus(bus) loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + escalated = False # Compose the application. config = ConfigStore(state_path=state_path) if state_path else ConfigStore() @@ -80,6 +87,19 @@ async def _serve(use_session_bus: bool = False, state_path: str | None = None) - gesture_runtime=gesture_runtime, ) + def _on_stuck_escalation() -> None: + # HC-002: a permanently wedged hardware call cannot be cancelled. Stop + # accepting work and exit nonzero so systemd's Restart=on-failure brings + # up a fresh process (ExecStopPost restores stock fan auto on the way + # out) instead of leaving a healthy-looking, indefinitely read-only + # service. + nonlocal escalated + escalated = True + log.critical("hardware queue wedged; escalating for service restart") + stop_event.set() + + app.set_stuck_escalation(_on_stuck_escalation) + def unsubscribe() -> None: """No-op until the snapshot signal subscription is installed.""" @@ -103,8 +123,6 @@ def _emit_state_changed(snapshot, domains) -> None: log.info("acquired bus name %s", BUS_NAME) log.info("honor-control-service v%s ready", __version__) - stop_event = asyncio.Event() - def _signal_handler() -> None: log.info("received stop signal") stop_event.set() @@ -125,6 +143,7 @@ def _signal_handler() -> None: # any in-flight serialized mutation to finish. bus.close() await app.shutdown() + return 1 if escalated else 0 def _parse_args(argv: list[str] | None) -> argparse.Namespace: @@ -161,17 +180,28 @@ def main(argv: list[str] | None = None) -> int: format="%(asctime)s %(name)s %(levelname)s %(message)s", ) if args.restore_fan_auto: - hardware = HonorToolsAdapter() - platform = hardware.detect_platform() - if platform.matched and not hardware.set_fan_auto(): - log.error("emergency fan-auto restore failed") + # Bound the emergency restore so a hung EC call cannot wedge shutdown. + def _restore_alarm(signum: int, frame: Any) -> None: + raise TimeoutError("emergency fan-auto restore timed out") + + signal.signal(signal.SIGALRM, _restore_alarm) + signal.alarm(EMERGENCY_FAN_RESTORE_TIMEOUT_SECONDS) + try: + hardware = HonorToolsAdapter() + platform = hardware.detect_platform() + if platform.matched and not hardware.set_fan_auto(): + log.error("emergency fan-auto restore failed") + return 1 + return 0 + except TimeoutError: + log.error("emergency fan-auto restore timed out") return 1 - return 0 + finally: + signal.alarm(0) try: - asyncio.run(_serve(args.session_bus, args.state_path)) + return asyncio.run(_serve(args.session_bus, args.state_path)) except KeyboardInterrupt: return 0 - return 0 if __name__ == "__main__": diff --git a/honor_control/core/touchpad.py b/honor_control/core/touchpad.py index cf77abe..619c9cd 100644 --- a/honor_control/core/touchpad.py +++ b/honor_control/core/touchpad.py @@ -13,6 +13,7 @@ from __future__ import annotations +import os import time from dataclasses import dataclass from enum import StrEnum @@ -27,6 +28,23 @@ TOUCHPAD_REPORT_ID: Final = 0x0E TOUCHPAD_REPORT_BYTES: Final = 9 +#: Environment flag that qualifies touchpad firmware setting writes for +#: production. Such writes have no readback or rollback and have not passed +#: the documented physical Linux replay gate, so they stay disabled unless an +#: explicit, reviewed hardware qualification record exists. Probe, descriptor +#: inspection, dry-run encoding, and gesture input are unaffected. +_FIRMWARE_QUALIFIED_ENV: Final = "HONOR_TOUCHPAD_FIRMWARE_QUALIFIED" + + +def touchpad_firmware_writes_qualified() -> bool: + """Return True only if firmware writes were explicitly qualified. + + Reads ``HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1`` (matching the installer's + ``--enable-touchpad-firmware`` flag). Defaults to False so that no + production path can write unqualified reports. + """ + return os.environ.get(_FIRMWARE_QUALIFIED_ENV, "").strip() == "1" + class TouchpadSetting(StrEnum): """Public settings exposed by the updated Honor touchpad UI.""" diff --git a/tests/test_application.py b/tests/test_application.py index 036b8e8..50c505a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -203,7 +203,10 @@ async def fail_update(_mutator): class TestTouchpadFirmwareMutation: """Verify typed touchpad writes and desired-state persistence.""" - def test_apply_profile_persists_only_after_acceptance(self, tmp_path) -> None: + def test_apply_profile_persists_only_after_acceptance( + self, tmp_path, monkeypatch + ) -> None: + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") svc = _make_service(tmp_path) asyncio.run(svc.initialize()) @@ -220,7 +223,8 @@ def test_apply_profile_persists_only_after_acceptance(self, tmp_path) -> None: "three_finger_drag": 1, } - def test_invalid_profile_never_reaches_hardware(self, tmp_path) -> None: + def test_invalid_profile_never_reaches_hardware(self, tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") svc = _make_service(tmp_path) asyncio.run(svc.initialize()) before = len( @@ -255,6 +259,7 @@ def test_support_query_returns_named_bitmap(self, tmp_path) -> None: def test_partial_profile_persists_only_completed_settings( self, tmp_path, monkeypatch ) -> None: + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") svc = _make_service(tmp_path) asyncio.run(svc.initialize()) completed = TouchpadApplyResult( @@ -284,6 +289,38 @@ def fail_apply(_settings): assert result.persisted is True assert svc.config_store.state.touchpad.settings == {"sensitivity": 1} + def test_firmware_writes_gated_unqualified_by_default( + self, tmp_path, monkeypatch + ) -> None: + """HC-001: unqualified builds refuse writes but keep probe available.""" + monkeypatch.delenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", raising=False) + svc = _make_service(tmp_path) + asyncio.run(svc.initialize()) + + result = asyncio.run(svc.apply_touchpad_settings({"sensitivity": 1})) + assert result.status == OperationStatus.UNAVAILABLE + assert result.code == "touchpad_firmware_unqualified" + assert not [ + entry + for entry in svc._hw.call_log # noqa: SLF001 + if entry[0] == "apply_touchpad_settings" + ] + probe = asyncio.run(svc.probe_touchpad_firmware()) + assert probe["firmware_writes_qualified"] is False + + def test_firmware_writes_enabled_by_qualification_flag( + self, tmp_path, monkeypatch + ) -> None: + """HC-001: the qualification flag re-enables the (gated) write path.""" + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") + svc = _make_service(tmp_path) + asyncio.run(svc.initialize()) + + probe = asyncio.run(svc.probe_touchpad_firmware()) + assert probe["firmware_writes_qualified"] is True + result = asyncio.run(svc.apply_touchpad_settings({"sensitivity": 1})) + assert result.status == OperationStatus.SUCCESS + class TestPowerMutation: """Verify power profile mutations.""" @@ -1338,3 +1375,79 @@ def blocked_speed(speed: int) -> bool: assert str(closing.value.code) == "unavailable" asyncio.run(scenario()) + + +class TestSafetyRecoveryHealth: + """HC-002/005/006: durable recovery faults and wedge escalation.""" + + def test_wedged_queue_escalates_and_reports_stuck( + self, tmp_path, monkeypatch + ) -> None: + import threading + + from honor_control.backend.command_queue import CommandTimeoutError + + monkeypatch.setattr( + "honor_control.backend.application.QUEUE_STUCK_ESCALATION_SECONDS", 0.0 + ) + svc = _make_service(tmp_path) + asyncio.run(svc.initialize()) + escalated: list[bool] = [] + svc.set_stuck_escalation(lambda: escalated.append(True)) + release = threading.Event() + + def block_forever() -> None: + release.wait() + + async def scenario() -> None: + with pytest.raises(CommandTimeoutError): + await svc.queue.run("wedge", block_forever, timeout=0.05) + assert svc.queue.stuck_info() is not None + await svc._refresh_service_health() # noqa: SLF001 + snap = await svc.get_snapshot() + assert snap.service.hardware_queue_stuck is True + assert snap.service.stuck_command == "wedge" + assert snap.service.overall == "degraded" + assert escalated == [True] + + try: + asyncio.run(scenario()) + finally: + release.set() + + def test_fan_fail_safe_degrades_overall_health(self, tmp_path) -> None: + svc = _make_service(tmp_path) + asyncio.run(svc.initialize()) + + async def scenario() -> None: + svc._fan_fail_safe_error = "sensor lost" # noqa: SLF001 + await svc._refresh_service_health() # noqa: SLF001 + snap = await svc.get_snapshot() + assert snap.service.overall == "degraded" + assert snap.service.fan_recovery_required is True + assert "fan: sensor lost" in snap.service.last_fault + + asyncio.run(scenario()) + + def test_power_rollback_failure_latches_then_reconcile_clears( + self, tmp_path + ) -> None: + svc = _make_service(tmp_path) + asyncio.run(svc.initialize()) + + async def scenario() -> None: + svc._record_power_rollback_failure( # noqa: SLF001 + "balanced", + {"rollback": {"attempted": True, "ok": False}, "observed": {}}, + ) + assert svc._power_recovery_required is True # noqa: SLF001 + await svc._refresh_service_health() # noqa: SLF001 + snap = await svc.get_snapshot() + assert snap.service.power_recovery_required is True + assert snap.service.overall == "degraded" + + result = await svc.reconcile_power() + assert result.status == OperationStatus.SUCCESS + assert svc._power_recovery_required is False # noqa: SLF001 + + asyncio.run(scenario()) diff --git a/tests/test_hardware.py b/tests/test_hardware.py index f9a3b73..1fb9dfc 100644 --- a/tests/test_hardware.py +++ b/tests/test_hardware.py @@ -241,6 +241,70 @@ def test_restore_is_not_implemented(self, tmp_path) -> None: assert result["restored"] is False assert "not implemented" in result["error"] + def test_apply_is_fail_closed_without_restore(self, tmp_path, monkeypatch) -> None: + """HC-014: the adapter itself refuses the irreversible GPU write. + + Even with honor-tools importable, apply must fail closed at the adapter + until a verified restore exists — safety cannot depend on every caller + remembering to check capability writability first. + """ + + def boom_apply(*args, **kwargs): + raise AssertionError("apply_irq_fix must never run") + + honor_module = types.ModuleType("honor") + honor_module.__path__ = [] # type: ignore[attr-defined] + gpu_module = types.ModuleType("honor.gpu") + gpu_module.apply_irq_fix = boom_apply # type: ignore[attr-defined] + config_module = types.ModuleType("honor.config") + config_module.Config = object # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "honor", honor_module) + monkeypatch.setitem(sys.modules, "honor.gpu", gpu_module) + monkeypatch.setitem(sys.modules, "honor.config", config_module) + + result = HonorToolsAdapter(root_path=tmp_path).apply_gpu_mitigation() + assert result["restored"] is False + assert "disabled" in result["error"] + + +class TestAcOnlineDecoding: + """HC-003: AC 'online' must decode as a strict tri-state (0/1/None).""" + + def test_read_ac_online_is_strict_tri_state(self, tmp_path) -> None: + adapter = HonorToolsAdapter(root_path=tmp_path) + supply = tmp_path / "supply" + supply.mkdir() + (supply / "online").write_text("1", encoding="utf-8") + assert adapter._read_ac_online(supply) is True # noqa: SLF001 + (supply / "online").write_text("0", encoding="utf-8") + assert adapter._read_ac_online(supply) is False # noqa: SLF001 + for bad in ("2", "garbage", ""): + (supply / "online").write_text(bad, encoding="utf-8") + assert adapter._read_ac_online(supply) is None # noqa: SLF001 + assert adapter._read_ac_online(None) is None # noqa: SLF001 + (supply / "online").unlink() + assert adapter._read_ac_online(supply) is None # noqa: SLF001 + + def test_battery_ac_read_failure_is_unknown_not_battery(self, tmp_path) -> None: + supplies = tmp_path / "sys/class/power_supply" + battery = supplies / "BAT1" + mains = supplies / "AC" + battery.mkdir(parents=True) + mains.mkdir(parents=True) + for name, value in { + "type": "Battery", + "status": "Charging", + "charge_control_end_threshold": "80", + "charge_control_start_threshold": "75", + }.items(): + (battery / name).write_text(value, encoding="utf-8") + (mains / "type").write_text("Mains", encoding="utf-8") + # Deliberately no 'online' file: the read fails, which must surface as + # "unknown" (None), never as a false "on battery" (False). + snapshot = HonorToolsAdapter(root_path=tmp_path).read_battery() + assert snapshot.available is True + assert snapshot.ac_online is None + class TestHonorToolsAdapterFilesystem: def test_discovers_rpm_sensor_outside_temperature_hwmon(self, tmp_path) -> None: From e8dc025db12e269c0dcca96dfaf665d7d389ce3d Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 03:08:06 +0700 Subject: [PATCH 06/10] HC-004/015/016/017: interactive auth deadlines, structured errors, single frontend HC-004: polkit interactive deadline raised from 5s to a human-scale 60s with distinct denial/timeout/no-agent messages; client (90s), GUI (90s) and CLI (90s) deadlines now sit above the polkit deadline plus operation time. HC-015: worker/controller/state carry a structured UiError (code/message/retryable/fatal) instead of a bare string; the GUI renders polkit denials and fatal errors persistently with guidance, transient busy/timeout briefly, and stops reconnecting on a fatal API mismatch. HC-016: honorctl touchpad set/apply validation and profile-load errors now exit 2 (usage) instead of 70. HC-017: the GUI and tray share one per-user frontend lock with a GUI-priority handoff flag, so they no longer run concurrently; the standalone tray cleans up on aboutToQuit. --- honor_control/backend/dbus/authorizer.py | 30 ++++++++-- honor_control/cli/honorctl.py | 27 +++++++-- honor_control/client/sdbus_client.py | 6 +- honor_control/frontend/gui/app.py | 25 ++++++++- honor_control/frontend/gui/controller.py | 68 ++++++++++++++++++----- honor_control/frontend/gui/main_window.py | 26 ++++++++- honor_control/frontend/gui/state.py | 8 +-- honor_control/frontend/single_instance.py | 44 ++++++++++++++- honor_control/frontend/tray/tray.py | 32 +++++++++-- tests/test_gui.py | 8 ++- 10 files changed, 229 insertions(+), 45 deletions(-) diff --git a/honor_control/backend/dbus/authorizer.py b/honor_control/backend/dbus/authorizer.py index e260848..49c3ccc 100644 --- a/honor_control/backend/dbus/authorizer.py +++ b/honor_control/backend/dbus/authorizer.py @@ -120,7 +120,11 @@ class PolkitAuthorizer: resolved, the call is denied. No fallback to "active local user". """ - def __init__(self, timeout: float = 5.0) -> None: + def __init__(self, timeout: float = 60.0) -> None: + # Interactive deadline: admin-tier actions may present a polkit password + # prompt, so this must be human-scale. The previous 5s denied users who + # simply took longer to type their password. Non-interactive reads never + # reach here (UNPRIVILEGED_METHODS return before any polkit call). self._timeout = timeout async def check(self, method: str, caller: CallerSubject | None) -> None: @@ -167,19 +171,33 @@ async def _ask_polkit(self, action_id: str, caller: CallerSubject) -> None: timeout=self._timeout, ) if not is_authorized: - reason = "authentication required" if is_challenge else "denied" + # is_challenge means polkit wanted interactive authentication + # but it did not complete (user cancelled, or no agent answered); + # otherwise the subject was affirmatively denied. + reason = ( + "authentication was not completed (cancelled or no agent)" + if is_challenge + else "denied" + ) raise DomainException( DomainError.NOT_AUTHORIZED, f"Authorization {reason} for '{action_id}'", ) except DomainException: raise - except Exception as exc: # noqa: BLE001 - log.error("polkit check failed: %s", exc) + except TimeoutError: raise DomainException( DomainError.NOT_AUTHORIZED, - "Authorization service is unavailable", - ) from exc + "Authorization timed out waiting for authentication", + ) from None + except Exception as exc: # noqa: BLE001 + log.error("polkit check failed: %s", exc) + message = ( + "No authentication agent is available" + if "agent" in str(exc).lower() + else "Authorization service is unavailable" + ) + raise DomainException(DomainError.NOT_AUTHORIZED, message) from exc class FakeAuthorizer: diff --git a/honor_control/cli/honorctl.py b/honor_control/cli/honorctl.py index f953fd6..6f21653 100644 --- a/honor_control/cli/honorctl.py +++ b/honor_control/cli/honorctl.py @@ -349,8 +349,15 @@ async def cmd_touchpad_list(args: argparse.Namespace, client) -> int: async def cmd_touchpad_set(args: argparse.Namespace, client) -> int: - setting = parse_touchpad_setting(args.setting) - value = parse_touchpad_value(setting, args.value) + try: + setting = parse_touchpad_setting(args.setting) + value = parse_touchpad_value(setting, args.value) + except (ValueError, KeyError, TypeError) as exc: + # Usage/validation errors must exit 2, not fall through to the generic + # handler's exit 70 (reserved for wire/API/internal contract failures). + raise DomainException( + code=DomainError.INVALID_ARGUMENT, message=str(exc) + ) from exc result = await client.set_touchpad_setting(setting.value, value) _emit(args, result.to_dict(), f"Touchpad setting: {result.message}") return EXIT_OK if result.applied else EXIT_OPERATION_FAILED @@ -359,7 +366,17 @@ async def cmd_touchpad_set(args: argparse.Namespace, client) -> int: async def cmd_touchpad_apply(args: argparse.Namespace, client) -> int: from honor_control.cli.touchpadctl import _load_profile - settings, master = _load_profile(args.profile) + try: + settings, master = _load_profile(args.profile) + except DomainException: + raise + except (OSError, ValueError) as exc: # incl. tomllib.TOMLDecodeError (a ValueError) + # A missing/unreadable/malformed profile is a usage error (exit 2), not + # a protocol/internal fault (exit 70). + raise DomainException( + code=DomainError.INVALID_ARGUMENT, + message=f"could not load touchpad profile: {exc}", + ) from exc if master is not None: raise DomainException( code=DomainError.INVALID_ARGUMENT, @@ -464,8 +481,8 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument( "--timeout", type=float, - default=10.0, - help="per-call timeout in seconds (default: 10)", + default=90.0, + help="per-call timeout in seconds (default: 90; above the interactive polkit deadline)", ) sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND") diff --git a/honor_control/client/sdbus_client.py b/honor_control/client/sdbus_client.py index ed6f84f..5026a18 100644 --- a/honor_control/client/sdbus_client.py +++ b/honor_control/client/sdbus_client.py @@ -45,8 +45,10 @@ log = logging.getLogger("honor_control.client.sdbus_client") -#: Default per-call timeout (seconds). -DEFAULT_TIMEOUT = 10.0 +#: Default per-call timeout (seconds). Admin mutations may trigger an +#: interactive polkit prompt (the authorizer allows ~60s for a human), so the +#: client deadline must sit above that plus normal hardware operation time. +DEFAULT_TIMEOUT = 90.0 MAX_COALESCED_REFRESHES = 4 diff --git a/honor_control/frontend/gui/app.py b/honor_control/frontend/gui/app.py index c3ba40d..810833c 100644 --- a/honor_control/frontend/gui/app.py +++ b/honor_control/frontend/gui/app.py @@ -8,6 +8,7 @@ import argparse import sys +import time from PySide6.QtCore import Qt from PySide6.QtGui import QIcon @@ -15,7 +16,12 @@ from honor_control import __version__ from honor_control.frontend.gui.design import apply_design_system -from honor_control.frontend.single_instance import acquire_single_instance +from honor_control.frontend.single_instance import ( + FRONTEND_LOCK_NAME, + acquire_single_instance, + clear_frontend_handoff, + request_frontend_handoff, +) def _parse_args(argv: list[str] | None) -> argparse.Namespace: @@ -57,9 +63,22 @@ def main(argv: list[str] | None = None) -> int: _apply_metadata(app) apply_design_system(app) app.setQuitOnLastWindowClosed(False) - instance_lock = acquire_single_instance("gui") + # HC-017: one per-user frontend session shared with the tray. If the tray + # currently owns it, ask it to step aside and wait briefly for the lock so + # the GUI takes over instead of running a second icon/client. + request_frontend_handoff() + instance_lock = None + deadline = time.monotonic() + 3.0 + while instance_lock is None and time.monotonic() < deadline: + instance_lock = acquire_single_instance(FRONTEND_LOCK_NAME) + if instance_lock is None: + time.sleep(0.1) + clear_frontend_handoff() if instance_lock is None: - print("honor-control-gui: another instance is already running", file=sys.stderr) + print( + "honor-control-gui: another frontend is running and did not step aside", + file=sys.stderr, + ) return 0 app._honor_instance_lock = instance_lock # type: ignore[attr-defined] diff --git a/honor_control/frontend/gui/controller.py b/honor_control/frontend/gui/controller.py index 7b777ac..d9901eb 100644 --- a/honor_control/frontend/gui/controller.py +++ b/honor_control/frontend/gui/controller.py @@ -19,6 +19,7 @@ import asyncio import logging import threading +from dataclasses import dataclass from typing import Any from PySide6.QtCore import QObject, QThread, Signal @@ -31,9 +32,44 @@ log = logging.getLogger("honor_control.frontend.gui.controller") # Profile saves/applies include PPD settling, sysfs readback, and serialized -# hardware work. Keep the frontend deadline above the backend's normal -# operation time so a completed operation is not reported as a GUI timeout. -GUI_DBUS_TIMEOUT_SECONDS = 15.0 +# hardware work, and admin actions may present an interactive polkit prompt +# (~60s for a human to authenticate). Keep the frontend deadline above the +# backend's polkit deadline plus operation time so a completed (or still- +# authenticating) operation is not reported as a GUI timeout. +GUI_DBUS_TIMEOUT_SECONDS = 90.0 + + +@dataclass(frozen=True) +class UiError: + """Structured, user-facing error carried from the worker to the UI. + + Preserves the transport error ``code`` (previously discarded as a bare + string) so the UI can render a polkit authorization denial differently from + a transient busy/timeout or a fatal API-version mismatch. + """ + + code: str + message: str + retryable: bool = False + fatal: bool = False + + @classmethod + def from_client_error(cls, exc: ClientError) -> UiError: + retryable = exc.code in ( + TransportError.BUSY, + TransportError.TIMEOUT, + TransportError.SERVICE_UNAVAILABLE, + ) + return cls( + code=exc.code.value, + message=exc.message, + retryable=retryable, + fatal=exc.code == TransportError.API_MISMATCH, + ) + + @classmethod + def internal(cls, message: str) -> UiError: + return cls(code=TransportError.INTERNAL.value, message=message) class GuiWorker(QThread): @@ -46,7 +82,7 @@ class GuiWorker(QThread): snapshot_ready = Signal(object) # SystemSnapshot connection_changed = Signal(bool) # connected protocol_mismatch = Signal(str) # incompatible API/schema message - error = Signal(str) # error message + error = Signal(object) # UiError operation_result = Signal(str, object) # operation_id, OperationResult def __init__( @@ -79,7 +115,7 @@ def run(self) -> None: pass except Exception as exc: # noqa: BLE001 log.error("worker crashed: %s", exc) - self.error.emit(str(exc)) + self.error.emit(UiError.internal(str(exc))) finally: self._loop.close() @@ -111,9 +147,15 @@ async def _main(self) -> None: ) except ClientError as exc: self.connection_changed.emit(False) + ui_error = UiError.from_client_error(exc) if exc.code == TransportError.API_MISMATCH: self.protocol_mismatch.emit(exc.message) - self.error.emit(exc.message) + self.error.emit(ui_error) + if ui_error.fatal: + # An incompatible service version will not fix itself; stop + # reconnecting instead of re-emitting the error forever. + assert self._stop_event is not None + self._stop_event.set() finally: await self._cancel_tasks() await self._client.close() @@ -187,11 +229,11 @@ async def _execute() -> None: self.operation_result.emit(operation_id, None) raise except ClientError as exc: - self.error.emit(exc.message) + self.error.emit(UiError.from_client_error(exc)) self.operation_result.emit(operation_id, None) except Exception as exc: # noqa: BLE001 log.error("operation %s failed: %s", operation_id, exc) - self.error.emit(str(exc)) + self.error.emit(UiError.internal(str(exc))) self.operation_result.emit(operation_id, None) self._spawn(_execute()) @@ -218,7 +260,7 @@ async def _do() -> None: if client is self._client: if not client.connected: self.connection_changed.emit(False) - self.error.emit(exc.message) + self.error.emit(UiError.from_client_error(exc)) self._spawn(_do()) return None @@ -236,7 +278,7 @@ class GuiController(QObject): protocol_mismatch = Signal(str) # incompatible API/schema message operation_started = Signal(str) # operation_id operation_completed = Signal(str, object) # operation_id, OperationResult - error = Signal(str) # error message + error = Signal(object) # UiError def __init__( self, @@ -281,7 +323,7 @@ def call(self, operation_id: str, method: str, *args) -> bool: if operation_id in self._pending_ops: return False if not self._worker.submit_call(operation_id, method, *args): - self.error.emit("Service worker is not ready") + self.error.emit(UiError.internal("Service worker is not ready")) return False self._pending_ops.add(operation_id) self.operation_started.emit(operation_id) @@ -297,8 +339,8 @@ def _on_connection(self, connected: bool) -> None: self._connected = connected self.connection_changed.emit(connected) - def _on_error(self, message: str) -> None: - self.error.emit(message) + def _on_error(self, error: UiError) -> None: + self.error.emit(error) def _on_operation_result(self, operation_id: str, result: object) -> None: self._pending_ops.discard(operation_id) diff --git a/honor_control/frontend/gui/main_window.py b/honor_control/frontend/gui/main_window.py index 9adfa01..b9f05e5 100644 --- a/honor_control/frontend/gui/main_window.py +++ b/honor_control/frontend/gui/main_window.py @@ -239,9 +239,29 @@ def _connect_signals(self) -> None: self.state.connection_changed.connect(self._update_availability) self.state.snapshot_changed.connect(self._update_identity) self.state.stale_changed.connect(self._on_stale_changed) - self.state.error_occurred.connect( - lambda msg: self.statusBar().showMessage(f"Error: {msg}", 4000) - ) + self.state.error_occurred.connect(self._on_ui_error) + + def _on_ui_error(self, error: object) -> None: + """Render a structured UiError with severity-appropriate persistence. + + Authorization denials and fatal errors stay visible until replaced + (timeout 0) with actionable guidance; transient busy/timeout errors are + short-lived and labeled retryable. + """ + from honor_control.core.errors import TransportError + + code = str(getattr(error, "code", "")) + message = str(getattr(error, "message", error)) + if code == TransportError.NOT_AUTHORIZED.value: + self.statusBar().showMessage( + f"Permission denied: {message} — authenticate with PolicyKit", 0 + ) + elif bool(getattr(error, "fatal", False)): + self.statusBar().showMessage(f"Error: {message}", 0) + elif bool(getattr(error, "retryable", False)): + self.statusBar().showMessage(f"{message} (transient — retrying)", 4000) + else: + self.statusBar().showMessage(f"Error: {message}", 6000) # Navigation diff --git a/honor_control/frontend/gui/state.py b/honor_control/frontend/gui/state.py index 91b1684..6bd37d9 100644 --- a/honor_control/frontend/gui/state.py +++ b/honor_control/frontend/gui/state.py @@ -25,7 +25,7 @@ class GuiState(QObject): stale_changed = Signal(tuple) # stale_domains operation_pending = Signal(str) # operation_id operation_completed = Signal(str, object) # operation_id, OperationResult - error_occurred = Signal(str) # message + error_occurred = Signal(object) # UiError def __init__(self) -> None: super().__init__() @@ -84,6 +84,6 @@ def emit_completed(self, operation_id: str, result: object) -> None: self.clear_pending(operation_id) self.operation_completed.emit(operation_id, result) - def emit_error(self, message: str) -> None: - """Emit an error message.""" - self.error_occurred.emit(message) + def emit_error(self, error: object) -> None: + """Emit a structured UiError.""" + self.error_occurred.emit(error) diff --git a/honor_control/frontend/single_instance.py b/honor_control/frontend/single_instance.py index 0224343..95ab97a 100644 --- a/honor_control/frontend/single_instance.py +++ b/honor_control/frontend/single_instance.py @@ -2,11 +2,18 @@ from __future__ import annotations +import os + from PySide6.QtCore import QLockFile, QStandardPaths +#: Single per-user ownership lock shared by the GUI and the tray, so at most +#: one Honor Control frontend session runs at a time (HC-017). Previously the +#: GUI and tray used separate lock names and could run concurrently, yielding +#: two tray icons and two D-Bus clients. +FRONTEND_LOCK_NAME = "frontend" -def acquire_single_instance(name: str) -> QLockFile | None: - """Return a held per-user lock, or ``None`` when another instance owns it.""" + +def _runtime_dir() -> str: runtime_dir = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.RuntimeLocation ) @@ -14,6 +21,37 @@ def acquire_single_instance(name: str) -> QLockFile | None: runtime_dir = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.TempLocation ) - lock = QLockFile(f"{runtime_dir}/honor-control-{name}.lock") + return runtime_dir + + +def acquire_single_instance(name: str) -> QLockFile | None: + """Return a held per-user lock, or ``None`` when another instance owns it.""" + lock = QLockFile(f"{_runtime_dir()}/honor-control-{name}.lock") lock.setStaleLockTime(0) return lock if lock.tryLock(0) else None + + +def _handoff_flag_path() -> str: + return f"{_runtime_dir()}/honor-control-gui-requested" + + +def request_frontend_handoff() -> None: + """Ask a running tray to step aside so the GUI can take ownership.""" + try: + with open(_handoff_flag_path(), "w", encoding="utf-8") as handle: + handle.write(str(os.getpid())) + except OSError: + pass + + +def clear_frontend_handoff() -> None: + """Remove the handoff-request flag (idempotent).""" + try: + os.remove(_handoff_flag_path()) + except OSError: + pass + + +def frontend_handoff_requested() -> bool: + """Return whether the GUI has asked the current frontend to step aside.""" + return os.path.exists(_handoff_flag_path()) diff --git a/honor_control/frontend/tray/tray.py b/honor_control/frontend/tray/tray.py index 6728bbb..64dfe72 100644 --- a/honor_control/frontend/tray/tray.py +++ b/honor_control/frontend/tray/tray.py @@ -21,7 +21,12 @@ from honor_control import __version__ from honor_control.core.models import POWER_PROFILES, SystemSnapshot from honor_control.frontend.gui.controller import GuiController -from honor_control.frontend.single_instance import acquire_single_instance +from honor_control.frontend.single_instance import ( + FRONTEND_LOCK_NAME, + acquire_single_instance, + clear_frontend_handoff, + frontend_handoff_requested, +) #: Refresh interval (ms) for the tooltip + menu state. TRAY_REFRESH_MS = 5000 @@ -324,20 +329,39 @@ def main(argv: list[str] | None = None) -> int: app = QApplication.instance() or QApplication([sys.argv[0]]) app.setApplicationName("Honor Control Tray") app.setQuitOnLastWindowClosed(False) - instance_lock = acquire_single_instance("tray") + # HC-017: share one per-user frontend lock with the GUI. If the GUI (or + # another tray) owns it, the tray defers instead of running concurrently. + instance_lock = acquire_single_instance(FRONTEND_LOCK_NAME) if instance_lock is None: print( - "honor-control-tray: another instance is already running", file=sys.stderr + "honor-control-tray: the Honor Control frontend is already running", + file=sys.stderr, ) return 0 app._honor_instance_lock = instance_lock # type: ignore[attr-defined] + # Drop a stale handoff flag left by a GUI that has since exited. + clear_frontend_handoff() if not QSystemTrayIcon.isSystemTrayAvailable(): print( "honor-control-tray: no system tray available yet; the icon will " "appear when a host registers.", file=sys.stderr, ) - app._honor_tray = HonorTray(bus_kind=args.bus) # type: ignore[attr-defined] + tray = HonorTray(bus_kind=args.bus) + app._honor_tray = tray # type: ignore[attr-defined] + + def _watch_handoff() -> None: + # The GUI asks the tray to step aside so it can take over the session. + if frontend_handoff_requested(): + clear_frontend_handoff() + app.quit() + + handoff_watcher = QTimer() + handoff_watcher.setInterval(400) + handoff_watcher.timeout.connect(_watch_handoff) + handoff_watcher.start() + app._honor_handoff_watcher = handoff_watcher # type: ignore[attr-defined] + app.aboutToQuit.connect(tray.shutdown) return app.exec() diff --git a/tests/test_gui.py b/tests/test_gui.py index 8b46d47..ba1b3ee 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -116,7 +116,10 @@ def test_gui_dbus_timeout_has_backend_margin(self, qapp) -> None: worker = GuiWorker() controller = GuiController() - assert GUI_DBUS_TIMEOUT_SECONDS == 15.0 + # HC-004: the frontend deadline must sit above the backend's + # interactive polkit deadline (~60s for a human) plus operation time. + assert GUI_DBUS_TIMEOUT_SECONDS == 90.0 + assert GUI_DBUS_TIMEOUT_SECONDS > 60.0 assert worker._timeout == GUI_DBUS_TIMEOUT_SECONDS # noqa: SLF001 assert controller._worker._timeout == GUI_DBUS_TIMEOUT_SECONDS # noqa: SLF001 @@ -157,7 +160,8 @@ async def scenario() -> None: assert client.connected is True assert connection_events == [] - assert errors == ["temporary timeout"] + assert [e.message for e in errors] == ["temporary timeout"] + assert errors[0].retryable is True assert [snapshot.sequence for snapshot in snapshots] == [7] From fda0c829233adf0d2fa12cc2b759456f25943f77 Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 03:08:18 +0700 Subject: [PATCH 07/10] HC-010/011/020/025: touchpad exclusion lock, reload contract, diagnostics, cleanup HC-011: an advisory inter-process flock (core.touchpad.touchpad_firmware_lock) serializes touchpad firmware access between the service and standalone honor-touchpadctl; the service holds it across apply/query (returning touchpad_busy/BUSY on contention) and the CLI takes it for write/support with a --force recovery override. The standalone write path is now also gated behind the HC-001 qualification flag, and missing-profile errors exit 2. HC-010: reload() documents and reports battery/touchpad as loaded_not_applied rather than implying hardware convergence. HC-020: the debug bundle gains bounded recovery context (queue-stuck command/duration, fan fail-safe/restore-pending, power-rollback outcome, controller restart counts, config unknown keys, AC observation, qualification flag). HC-025: validate_log_lines rejects bool; backend package docstring lists the real submodules; pytest-cov added to the dev extra. --- honor_control/backend/__init__.py | 21 +++++--- honor_control/backend/application.py | 75 +++++++++++++++++++++++++++- honor_control/cli/touchpadctl.py | 57 ++++++++++++++++++++- honor_control/core/touchpad.py | 57 +++++++++++++++++++++ honor_control/core/validation.py | 4 +- pyproject.toml | 2 +- tests/test_core.py | 26 ++++++++++ tests/test_touchpad_firmware.py | 25 ++++++++++ 8 files changed, 256 insertions(+), 11 deletions(-) diff --git a/honor_control/backend/__init__.py b/honor_control/backend/__init__.py index ac447b6..4d89501 100644 --- a/honor_control/backend/__init__.py +++ b/honor_control/backend/__init__.py @@ -1,10 +1,19 @@ """Backend package: the privileged root D-Bus service. -The backend imports the upstream ``honor.*`` hardware modules and exposes -their functionality over D-Bus so unprivileged frontends can drive them. -Submodules: +The backend imports the upstream ``honor.*`` hardware modules (behind +``HonorToolsAdapter``) and exposes their functionality over D-Bus so +unprivileged frontends can drive them. Submodules: -* :mod:`honor_control.backend.service` — service entry point + main loop -* :mod:`honor_control.backend.dbus_api` — sdbus interface definitions -* :mod:`honor_control.backend.polkit` — polkit authorization checks +* :mod:`honor_control.backend.service` — service entry point + main loop +* :mod:`honor_control.backend.application` — ApplicationService use cases +* :mod:`honor_control.backend.command_queue` — serialized hardware queue +* :mod:`honor_control.backend.config_store` — atomic versioned state store +* :mod:`honor_control.backend.snapshot_store` — cached snapshots + signals +* :mod:`honor_control.backend.supervisor` — background controller lifecycle +* :mod:`honor_control.backend.hardware` — HonorToolsAdapter / HardwarePort +* :mod:`honor_control.backend.gesture_runtime` — hidraw -> uinput dispatch +* :mod:`honor_control.backend.touchpad_firmware`— typed HID firmware transport +* :mod:`honor_control.backend.dbus.api` — sdbus interface definitions +* :mod:`honor_control.backend.dbus.authorizer` — polkit authorization checks +* :mod:`honor_control.backend.dbus.codec` — DTO <-> D-Bus conversion """ diff --git a/honor_control/backend/application.py b/honor_control/backend/application.py index 16fe681..ac1e90b 100644 --- a/honor_control/backend/application.py +++ b/honor_control/backend/application.py @@ -76,8 +76,10 @@ ) from honor_control.core.touchpad import ( SUPPORTED_GESTURE_BITS, + TouchpadLockBusy, parse_touchpad_setting, parse_touchpad_value, + touchpad_firmware_lock, touchpad_firmware_writes_qualified, ) from honor_control.core.validation import ( @@ -290,7 +292,15 @@ async def get_schema_version(self) -> int: @_serialized_mutation async def reload(self) -> OperationResult: - """Reload config and reconcile only supported runtime transitions.""" + """Reload config and reconcile only supported runtime transitions. + + Contract: fan mode, gesture runtime, and power profile changes are + reconciled to hardware. Battery thresholds and touchpad firmware + settings are loaded into desired state but NOT re-applied on reload + (touchpad writes stay gated behind HC-001 qualification); the result + ``details`` reports those domains as ``loaded_not_applied`` so callers + never assume hardware convergence for them. + """ config_loaded = False try: previous = self._config.state @@ -357,6 +367,14 @@ async def reload(self) -> OperationResult: else: failures.append(power_result.message) + # Battery thresholds and touchpad firmware settings are loaded into + # desired state but intentionally not re-applied to hardware here. + # Surface that explicitly instead of implying convergence. + if previous.battery != current.battery: + details["battery"] = "loaded_not_applied" + if previous.touchpad != current.touchpad: + details["touchpad"] = "loaded_not_applied" + await self._refresh_all() if failures: return OperationResult.partial( @@ -1315,6 +1333,23 @@ async def apply_touchpad_settings( ), sequence=self._snapshots.sequence, ) + try: + with touchpad_firmware_lock(): + return await self._apply_touchpad_settings_unlocked(settings) + except TouchpadLockBusy: + return OperationResult.unavailable( + code="touchpad_busy", + message=( + "Touchpad firmware is busy (standalone honor-touchpadctl " + "holds the device); retry shortly" + ), + sequence=self._snapshots.sequence, + ) + + async def _apply_touchpad_settings_unlocked( + self, settings: dict[str, int] + ) -> OperationResult: + """Validate and apply one profile; the caller holds the device lock.""" if not isinstance(settings, dict) or not settings: raise DomainException( DomainError.INVALID_ARGUMENT, @@ -1440,6 +1475,17 @@ async def set_touchpad_setting(self, setting: str, value: int) -> OperationResul @_serialized_mutation async def query_touchpad_support(self) -> dict[str, Any]: """Query capabilities while holding exclusive ownership of the reader.""" + try: + with touchpad_firmware_lock(): + return await self._query_touchpad_support_unlocked() + except TouchpadLockBusy: + raise DomainException( + DomainError.BUSY, + "Touchpad firmware is busy (standalone honor-touchpadctl holds " + "the device)", + ) from None + + async def _query_touchpad_support_unlocked(self) -> dict[str, Any]: restart_runtime = bool( self._gesture_runtime is not None and self._config.state.gestures.daemon_enabled @@ -1566,6 +1612,8 @@ async def run_checks(self) -> dict[str, Any]: async def get_debug_bundle(self) -> dict[str, Any]: """Return a bounded, redacted JSON-serializable debug bundle.""" snap = self._snapshots.snapshot + stuck = self._queue.stuck_info() + rollback = self._power_recovery_detail.get("rollback", {}) return { "api_version": snap.api_version, "schema_version": snap.schema_version, @@ -1585,6 +1633,31 @@ async def get_debug_bundle(self) -> dict[str, Any]: for name, c in snap.capabilities.items() }, "stale_domains": list(snap.stale_domains), + # HC-020: bounded recovery/observability context so incidents can be + # diagnosed from the bundle alone (no unbounded payloads). + "recovery": { + "hardware_queue_stuck": stuck is not None, + "stuck_command": stuck.name if stuck is not None else "", + "stuck_seconds": round(self._queue.poisoned_duration(), 1), + "fan_fail_safe_error": self._fan_fail_safe_error, + "fan_restore_pending": self._fan_restore_pending, + "power_recovery_required": self._power_recovery_required, + "power_rollback": { + "profile": self._power_recovery_detail.get("profile", ""), + "attempted": bool(rollback.get("attempted")), + "ok": bool(rollback.get("ok")), + } + if self._power_recovery_required + else {}, + "controller_restart_counts": dict(self._supervisor.restart_counts()), + }, + "config": { + "valid": self._config.valid, + "last_error": self._config.last_error, + "unknown_keys": list(self._config.unknown_keys), + }, + "touchpad_firmware_writes_qualified": touchpad_firmware_writes_qualified(), + "ac_online": snap.power.ac_online, } async def get_recent_logs(self, lines: int) -> list[str]: diff --git a/honor_control/cli/touchpadctl.py b/honor_control/cli/touchpadctl.py index b3c5f59..c309502 100644 --- a/honor_control/cli/touchpadctl.py +++ b/honor_control/cli/touchpadctl.py @@ -15,10 +15,13 @@ from honor_control.core.touchpad import ( SUPPORTED_GESTURE_BITS, TOUCHPAD_SETTING_SPECS, + TouchpadLockBusy, TouchpadSetting, encode_touchpad_setting, parse_touchpad_setting, parse_touchpad_value, + touchpad_firmware_lock, + touchpad_firmware_writes_qualified, ) WMI_GUID = "ABBC0F5B-8EA1-11D1-A000-C90629100000" @@ -163,6 +166,12 @@ def _build_parser() -> argparse.ArgumentParser: ), ) parser.add_argument("--json", action="store_true", help="machine-readable output") + parser.add_argument( + "--force", + action="store_true", + help="bypass the inter-process touchpad lock (recovery only; unsafe " + "while honor-control.service is applying settings)", + ) parser.add_argument( "--sysfs-root", type=pathlib.Path, @@ -241,8 +250,7 @@ def _build_parser() -> argparse.ArgumentParser: return parser -def main(argv: list[str] | None = None) -> int: - args = _build_parser().parse_args(argv) +def _run(args: argparse.Namespace) -> int: try: transport = TouchpadFirmwareTransport( sysfs_root=args.sysfs_root, @@ -425,6 +433,10 @@ def main(argv: list[str] | None = None) -> int: print(f"honor-touchpadctl: {exc}", file=sys.stderr) if isinstance(exc, ValueError): return EXIT_USAGE + if isinstance(exc, (FileNotFoundError, IsADirectoryError)): + # A missing/invalid profile path is a usage error, not a hardware + # availability condition. + return EXIT_USAGE if isinstance(exc, TouchpadFirmwareError): return EXIT_OPERATION_FAILED if exc.reports_applied else EXIT_UNAVAILABLE if isinstance(exc, OSError): @@ -433,5 +445,46 @@ def main(argv: list[str] | None = None) -> int: return EXIT_OPERATION_FAILED +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + write_command = args.command in {"set", "master"} or ( + args.command == "apply" and not getattr(args, "dry_run", False) + ) + needs_lock = write_command or args.command == "support" + # HC-001: firmware setting writes are disabled until qualified; --force + # overrides for diagnostics/recovery. Probe/list/encode/support stay + # available either way. + if write_command and not args.force and not touchpad_firmware_writes_qualified(): + print( + "honor-touchpadctl: firmware setting writes are not qualified on " + "this build (probe/list/encode/support remain available). Use " + "--force to override for diagnostics/recovery.", + file=sys.stderr, + ) + return EXIT_UNAVAILABLE + # HC-011: write/support commands take an exclusive inter-process lock so a + # standalone run cannot interleave with honor-control.service. Read-only + # probe/list/encode (and --force recovery) skip it. + if not needs_lock: + return _run(args) + if args.force: + print( + "honor-touchpadctl: --force bypasses the touchpad lock; do not use " + "while honor-control.service is applying settings", + file=sys.stderr, + ) + return _run(args) + try: + with touchpad_firmware_lock(): + return _run(args) + except TouchpadLockBusy as exc: + print( + f"honor-touchpadctl: {exc}; is honor-control.service applying " + "touchpad settings? Use --force to override.", + file=sys.stderr, + ) + return EXIT_UNAVAILABLE + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/honor_control/core/touchpad.py b/honor_control/core/touchpad.py index 619c9cd..6ec8b3f 100644 --- a/honor_control/core/touchpad.py +++ b/honor_control/core/touchpad.py @@ -13,8 +13,11 @@ from __future__ import annotations +import contextlib +import fcntl import os import time +from collections.abc import Iterator from dataclasses import dataclass from enum import StrEnum from types import MappingProxyType @@ -46,6 +49,60 @@ def touchpad_firmware_writes_qualified() -> bool: return os.environ.get(_FIRMWARE_QUALIFIED_ENV, "").strip() == "1" +#: Environment override for the inter-process touchpad firmware lock path +#: (tests point this at a temporary file). +TOUCHPAD_LOCK_ENV: Final = "HONOR_CONTROL_TOUCHPAD_LOCK" +_DEFAULT_TOUCHPAD_LOCK: Final = "/run/honor-control/touchpad.lock" + + +class TouchpadLockBusy(RuntimeError): + """The touchpad firmware lock is held by another writer.""" + + +@contextlib.contextmanager +def touchpad_firmware_lock( + path: str | None = None, *, blocking: bool = False +) -> Iterator[None]: + """Serialize exclusive access to the touchpad firmware endpoint (HC-011). + + The root service holds this while applying/querying firmware settings and + the standalone ``honor-touchpadctl`` write/support commands take it too, so + two writers cannot interleave multi-report operations. Read-only + probe/list/encode callers need not take it. + """ + lock_path = path or os.environ.get(TOUCHPAD_LOCK_ENV) or _DEFAULT_TOUCHPAD_LOCK + directory = os.path.dirname(lock_path) + try: + if directory: + os.makedirs(directory, exist_ok=True) + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o640) + except OSError: + # The lock is advisory/best-effort: if it cannot be created (e.g. an + # unprivileged environment without a writable /run), proceed unlocked + # rather than failing the operation. In production both the root + # service and the root CLI can always create it. + yield + return + flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) + try: + fcntl.flock(fd, flags) + except BlockingIOError as exc: + os.close(fd) + raise TouchpadLockBusy( + "touchpad firmware is busy (held by another writer)" + ) from exc + except OSError: + os.close(fd) + yield + return + try: + yield + finally: + with contextlib.suppress(OSError): + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + class TouchpadSetting(StrEnum): """Public settings exposed by the updated Honor touchpad UI.""" diff --git a/honor_control/core/validation.py b/honor_control/core/validation.py index 99ac49f..498990a 100644 --- a/honor_control/core/validation.py +++ b/honor_control/core/validation.py @@ -377,7 +377,9 @@ def validate_power_profile( def validate_log_lines(lines: int) -> int: """Validate a recent-logs line count (1-500).""" - if not isinstance(lines, int): + # Reject bool explicitly (bool is an int subclass) to honor the module's + # no-silent-coercion charter, matching the other integer validators. + if isinstance(lines, bool) or not isinstance(lines, int): raise DomainException( DomainError.INVALID_ARGUMENT, "Lines must be an integer", diff --git a/pyproject.toml b/pyproject.toml index a6af514..b81bc87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] gui = ["PySide6==6.11.1"] -dev = ["ruff", "pytest", "pytest-asyncio", "build"] +dev = ["ruff", "pytest", "pytest-asyncio", "pytest-cov", "build"] [project.scripts] # Root backend service (runs under systemd as root). diff --git a/tests/test_core.py b/tests/test_core.py index 31f1d27..f98c11a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -360,3 +360,29 @@ def test_snapshot_defaults(self) -> None: assert snap.schema_version == SCHEMA_VERSION assert snap.sequence == 0 assert snap.battery == BatterySnapshot() + + +class TestTouchpadFirmwareLock: + """HC-011: the advisory firmware lock excludes concurrent writers.""" + + def test_lock_reports_busy_while_held_then_releases(self, tmp_path) -> None: + from honor_control.core.touchpad import ( + TouchpadLockBusy, + touchpad_firmware_lock, + ) + + lock_path = str(tmp_path / "touchpad.lock") + with touchpad_firmware_lock(lock_path): + with pytest.raises(TouchpadLockBusy): + with touchpad_firmware_lock(lock_path): + pass + # Released when the outer block exits: re-acquire now succeeds. + with touchpad_firmware_lock(lock_path): + pass + + def test_lock_degrades_when_path_unwritable(self, monkeypatch) -> None: + from honor_control.core.touchpad import touchpad_firmware_lock + + # An uncreatable lock path (unprivileged /run) must not fail the op. + with touchpad_firmware_lock("/proc/definitely-not-writable/x.lock"): + pass diff --git a/tests/test_touchpad_firmware.py b/tests/test_touchpad_firmware.py index 7eb5fd7..98233fb 100644 --- a/tests/test_touchpad_firmware.py +++ b/tests/test_touchpad_firmware.py @@ -27,6 +27,12 @@ ) +@pytest.fixture(autouse=True) +def _qualify_firmware_writes(monkeypatch: pytest.MonkeyPatch) -> None: + """This module exercises the (gated) firmware write path directly.""" + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") + + VENDOR_DESCRIPTOR = bytes.fromhex( # Usage Page (Vendor 0xff00), Usage 1, Application collection "06 00 ff 09 01 a1 01 " @@ -362,3 +368,22 @@ def partial_failure(*_args, **_kwargs): ) == 4 ) + + +def test_touchpadctl_write_is_gated_when_unqualified( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """HC-001: without the qualification flag, write commands refuse; --force + and probe/encode remain available.""" + monkeypatch.delenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", raising=False) + sysfs, dev = _fake_device(tmp_path) + base = ["--sysfs-root", str(sysfs), "--dev-root", str(dev)] + + # Unqualified write is refused (unavailable), hardware untouched. + assert touchpadctl_main([*base, "set", "sensitivity", "high"]) == 3 + + # Read-only encode still works. + assert touchpadctl_main(["encode", "sensitivity", "high"]) == 0 + + # --force overrides the gate (diagnostics/recovery). + assert touchpadctl_main([*base, "--force", "set", "sensitivity", "high"]) == 0 From 5d536dee50648bdebe24742958d1ff9ead778185 Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 15:40:18 +0700 Subject: [PATCH 08/10] Review fixes: tray UiError handler, enable controller restarts, deterministic teardown Post-implementation review fixes, all covered by the passing suite. Tray: the controller error signal (a structured UiError since HC-015) was connected to a str-typed handler that would raise at runtime; errors now route to a dedicated _on_error that extracts the message as a warning, while _notify keeps handling operation result strings. Supervisor: the HC-009 restart policy was added but never enabled, so a crashed refresh or auto-switch loop still died forever; enable restart for those two controllers (fan_curve keeps its own intentional fail-safe disable and is deliberately excluded). stop_all now cancels pending crash-restart backoffs so teardown is deterministic. --- honor_control/backend/application.py | 11 +++++++++-- honor_control/backend/supervisor.py | 5 +++++ honor_control/frontend/tray/tray.py | 10 +++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/honor_control/backend/application.py b/honor_control/backend/application.py index ac1e90b..8ee8554 100644 --- a/honor_control/backend/application.py +++ b/honor_control/backend/application.py @@ -232,8 +232,15 @@ async def initialize(self) -> None: async def start_background(self) -> None: """Start service-owned monitors after initial state is published.""" - self._supervisor.register("refresh", self._refresh_loop) - self._supervisor.register("auto_switch", self._auto_switch_loop) + # refresh and auto_switch should keep running; an unexpected crash is + # recovered by a bounded restart policy rather than silently killing + # state refresh or AC/battery switching forever. fan_curve is left + # without restart: it owns an intentional 3-strike fail-safe disable + # that must not be resurrected by the supervisor. + self._supervisor.register("refresh", self._refresh_loop, restart=True) + self._supervisor.register( + "auto_switch", self._auto_switch_loop, restart=True + ) self._supervisor.register( "fan_curve", self._fan_curve_loop, self._restore_fan_auto ) diff --git a/honor_control/backend/supervisor.py b/honor_control/backend/supervisor.py index b642177..42f49bb 100644 --- a/honor_control/backend/supervisor.py +++ b/honor_control/backend/supervisor.py @@ -282,6 +282,11 @@ async def stop(self, name: str, timeout: float = 5.0) -> None: async def stop_all(self, timeout: float = 5.0) -> None: """Stop all controllers in reverse registration order.""" + # Cancel pending crash-restart backoffs first so teardown is + # deterministic and a backoff cannot respawn a controller mid-shutdown. + for task in list(self._pending_restarts): + task.cancel() + self._pending_restarts.clear() names = list(self._controllers.keys()) for name in reversed(names): await self.stop(name, timeout=timeout) diff --git a/honor_control/frontend/tray/tray.py b/honor_control/frontend/tray/tray.py index 64dfe72..eef8307 100644 --- a/honor_control/frontend/tray/tray.py +++ b/honor_control/frontend/tray/tray.py @@ -77,7 +77,7 @@ def __init__( self.tray.activated.connect(self._on_activated) self.controller.snapshot_received.connect(self._on_snapshot) self.controller.connection_changed.connect(self._set_online) - self.controller.error.connect(self._notify) + self.controller.error.connect(self._on_error) self.controller.operation_completed.connect(self._on_operation) self._set_online(False) @@ -290,6 +290,14 @@ def _notify(self, message: str) -> None: "Honor Control", message, QSystemTrayIcon.MessageIcon.Information, 2000 ) + def _on_error(self, error: object) -> None: + # The controller emits a structured UiError (HC-015); surface its + # message as a warning so authorization denials and faults are visible. + message = str(getattr(error, "message", error)) + self.tray.showMessage( + "Honor Control", message, QSystemTrayIcon.MessageIcon.Warning, 4000 + ) + def _quit(self) -> None: if self._quit_application is not None: self._quit_application() From 0afcc0c52cbbec76a86571b69360bb35a0afbc8f Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 16:39:49 +0700 Subject: [PATCH 09/10] Complete production readiness hardening --- .github/workflows/ci.yml | 84 +++--- CHANGELOG.md | 26 +- README.md | 271 ++++-------------- docs/architecture.md | 2 +- docs/dbus-api.md | 2 +- docs/gesture-linux-remaining-work.md | 10 +- docs/hardware-validation/touchpad-linux.md | 14 +- docs/safety.md | 5 + honor_control/backend/application.py | 135 ++++++--- honor_control/backend/config_store.py | 63 +++- honor_control/backend/gesture_runtime.py | 7 +- honor_control/backend/supervisor.py | 2 + honor_control/cli/honorctl.py | 2 +- honor_control/cli/touchpadctl.py | 37 +-- honor_control/client/sdbus_client.py | 55 +++- honor_control/contract.py | 2 +- honor_control/core/touchpad.py | 46 ++- honor_control/frontend/gui/pages/touchpad.py | 24 +- honor_control/frontend/tray/tray.py | 17 +- .../touchpad/touchpad-firmware-qualified | 1 + pyproject.toml | 2 + requirements-audit.txt | 4 + scripts/install-local.sh | 90 +++++- scripts/install-touchpad-only.sh | 32 ++- scripts/make-release-bundle.sh | 256 ++++++++++++----- scripts/normalize-sdist.sh | 49 ++++ scripts/rollback.sh | 205 ++++++++++--- scripts/uninstall-local.sh | 1 + tests/conftest.py | 23 ++ tests/test_application.py | 19 +- tests/test_backend.py | 23 ++ tests/test_client.py | 46 +++ tests/test_config_store.py | 31 +- tests/test_core.py | 26 +- tests/test_dbus_roundtrip.py | 27 +- tests/test_gesture_runtime.py | 26 ++ tests/test_gui.py | 29 ++ tests/test_install_scripts.py | 49 +++- tests/test_touchpad_firmware.py | 15 +- 39 files changed, 1231 insertions(+), 527 deletions(-) create mode 100644 packaging/touchpad/touchpad-firmware-qualified create mode 100644 requirements-audit.txt create mode 100755 scripts/normalize-sdist.sh create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ba8bc..47f161e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.12" cache: pip - run: python -m pip install --upgrade pip - - run: python -m pip install ruff + - run: python -m pip install "ruff==0.15.20" - name: Ruff lint run: ruff check honor_control tests - name: Byte-compile sanity check @@ -46,45 +46,32 @@ jobs: - run: python -m pip install --upgrade pip # pytest-cov is installed here (not in pyproject) so the coverage floor # tooling stays a CI concern. - - run: python -m pip install ".[gui,dev]" pytest-cov + - run: python -m pip install ".[gui,dev]" "pytest-cov==7.1.0" # Branch coverage with a hard 72% floor (measured project baseline). # `-m "not hardware"` selects the whole suite today because no test # carries a `hardware` mark yet; it becomes meaningful once # hardware-marked tests exist for the separate self-hosted hardware gate. - name: Pytest with branch coverage (72% floor) - run: pytest -q -m "not hardware" --cov=honor_control --cov-branch --cov-fail-under=72 + run: pytest -q -W error -m "not hardware" --cov=honor_control --cov-branch --cov-fail-under=72 security: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" cache: pip - run: python -m pip install --upgrade pip - - run: python -m pip install ".[gui,dev]" pip-audit bandit - # Best effort: report dependency vulnerabilities but do not fail the gate - # (findings are often in transitive deps outside this repo's control). - - name: pip-audit (best effort) - run: pip-audit || echo "::warning::pip-audit reported findings or could not run" - # Best effort: surface medium/high bandit findings without failing the job. - - name: Bandit (medium/high, best effort) - run: bandit -r honor_control -ll || echo "::warning::bandit reported medium/high findings" - # Secret scanning is a HARD gate: gitleaks if present, otherwise a - # grep-based token/key pattern check that fails on any hit. - - name: Secret scan (hard gate) - run: | - if command -v gitleaks >/dev/null 2>&1; then - gitleaks detect --no-banner --verbose - else - echo "gitleaks unavailable; running grep-based secret scan" - if grep -rInE -e 'AKIA[0-9A-Z]{16}' -e '-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----' -e 'ghp_[0-9A-Za-z]{36}' -e 'xox[baprs]-[0-9A-Za-z-]{10,}' --exclude-dir=.git --exclude-dir=.github --exclude-dir=.venv --exclude-dir=build --exclude-dir=dist --exclude-dir='Reverse engineering' . ; then - echo "::error::secret scan matched a token/key pattern" - exit 1 - fi - echo "grep secret scan: no matches" - fi + - run: python -m pip install ".[gui,dev]" "pip-audit==2.10.1" "bandit==1.9.4" + - name: Dependency vulnerability gate + run: pip-audit --strict --requirement requirements-audit.txt + - name: Bandit medium/high gate + run: bandit -r honor_control -ll + - name: Gitleaks full-history gate + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 shell: runs-on: ubuntu-latest @@ -118,32 +105,31 @@ jobs: run: | xmllint --noout packaging/polkit/*.policy xmllint --noout packaging/dbus/*.conf - # Best effort / allow-unavailable: warn but do not fail when the tool is - # missing or the sandboxed unit references paths absent in CI. - - name: desktop-file-validate (allow unavailable) - run: | - if command -v desktop-file-validate >/dev/null 2>&1; then - desktop-file-validate packaging/desktop/*.desktop || echo "::warning::desktop-file-validate reported issues" - else - echo "desktop-file-validate unavailable; skipping" - fi - - name: systemd-analyze verify (allow unavailable) - run: | - if command -v systemd-analyze >/dev/null 2>&1; then - systemd-analyze verify packaging/systemd/honor-control.service packaging/systemd/honor-touchpad-restore.service || echo "::warning::systemd-analyze verify reported issues" - else - echo "systemd-analyze unavailable; skipping" - fi + - name: desktop-file-validate + run: desktop-file-validate packaging/desktop/*.desktop + - name: systemd-analyze verify + run: systemd-analyze verify packaging/systemd/*.service - run: python -m pip install --upgrade pip - - run: python -m pip install ".[gui,dev]" - - name: Build wheel + sdist - run: python -m build + - run: python -m pip install ".[gui,dev]" "build==1.5.1" "setuptools==83.0.0" "wheel==0.47.0" + - name: Build wheel + sdist reproducibly + run: | + set -e + export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" + export PYTHONHASHSEED=0 TZ=UTC + python -m build --no-isolation --outdir dist/first + bash scripts/normalize-sdist.sh dist/first/*.tar.gz "$SOURCE_DATE_EPOCH" + rm -rf build honor_control.egg-info + python -m build --no-isolation --outdir dist/second + bash scripts/normalize-sdist.sh dist/second/*.tar.gz "$SOURCE_DATE_EPOCH" + diff -u \ + <(cd dist/first && sha256sum * | sed 's# # #') \ + <(cd dist/second && sha256sum * | sed 's# # #') - name: Verify artifacts contain the package (hard gate) run: | set -e - ls -l dist/ - test "$(ls dist/*.whl | wc -l)" -ge 1 || { echo "no wheel built"; exit 1; } - test "$(ls dist/*.tar.gz | wc -l)" -ge 1 || { echo "no sdist built"; exit 1; } - python -m zipfile -l "$(ls dist/*.whl | head -n1)" | grep -q 'honor_control/' || { echo "wheel missing honor_control package"; exit 1; } - tar -tzf "$(ls dist/*.tar.gz | head -n1)" | grep -q 'honor_control/' || { echo "sdist missing honor_control package"; exit 1; } + ls -l dist/first/ + test "$(find dist/first -maxdepth 1 -name '*.whl' | wc -l)" -eq 1 || { echo "expected one wheel"; exit 1; } + test "$(find dist/first -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 1 || { echo "expected one sdist"; exit 1; } + python -m zipfile -l "$(find dist/first -maxdepth 1 -name '*.whl')" | grep -q 'honor_control/' || { echo "wheel missing honor_control package"; exit 1; } + tar -tzf "$(find dist/first -maxdepth 1 -name '*.tar.gz')" | grep -q 'honor_control/' || { echo "sdist missing honor_control package"; exit 1; } echo "wheel + sdist both contain honor_control/" diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8f87d..1be8970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,26 +10,24 @@ - The automatic `honor-touchpad-restore.service` unit is no longer installed or enabled unless firmware writes are qualified (installer flag `--enable-touchpad-firmware` / env `HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1`). -- The installer now supports a fully offline, hash-verified install: - `--wheelhouse DIR` requires and verifies a `SHA256SUMS` file and installs - with `--no-index --find-links --no-build-isolation`. -- Added a supported rollback path (`scripts/install-local.sh --rollback` or - `scripts/rollback.sh`) that atomically repoints `/opt/honor-control/current` - to the retained predecessor release and restarts the service. -- Added `scripts/make-release-bundle.sh` to assemble a versioned, checksummed - offline release bundle (wheel + sdist, packaging tree, installer scripts, - `SHA256SUMS`, `MANIFEST`, and a minimal `sbom.json` of pinned deps). +- The installer now supports a complete offline install. `--wheelhouse DIR` + verifies the exact checksummed artifact set and installs reviewed wheels + with `--no-index`. +- Added a transactional rollback path (`scripts/install-local.sh --rollback` + or `scripts/rollback.sh`) that restores the predecessor, managed system + files, service state, and the original release if health checks fail. +- Added `scripts/make-release-bundle.sh` to build and clean-room test a + reproducible wheelhouse containing the application and all dependencies. - CI split into `lint`, `test`, `security`, `shell`, and `packaging` jobs: branch-coverage floor (`--cov-branch --cov-fail-under=72`), shellcheck, - `xmllint` packaging validation, `pip-audit` / `bandit` / secret scanning, - and all actions pinned to full commit SHAs. + strict warning handling, `xmllint` packaging validation, hard + `pip-audit` / `bandit` / full-history gitleaks gates, and pinned actions. - Removed the unhardened direct-`Exec` / `User=root` D-Bus activation fallback; activation now requires systemd (`SystemdService=honor-control.service`). - Corrected repository URLs (`HonorLinux/honor-control` to - `ZachAR3/HonorControl`) in the systemd unit and polkit policy, and replaced - the README "personally tested all" claim with a per-feature verification - status table. + `ZachAR3/HonorControl`) in the systemd unit and polkit policy, and simplified + the README to the supported platform, install, usage, and development basics. ### Fixed diff --git a/README.md b/README.md index 1f22668..52cb5d3 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,103 @@ # Honor Control -D-Bus service and Qt6 GUI for managing Honor MagicBook laptops on Linux. +Honor Control is a Linux system service and Qt 6 desktop app for managing +supported Honor MagicBook laptops. -## Status +The project is currently alpha software. Test hardware changes carefully and +keep a recovery path available. -**Alpha.** This software is under active development. Features are -verified against fake hardware in CI; real hardware testing is an -explicit, manual pre-release gate. +## Supported hardware -**Supported hardware matrix.** Hardware writes are enabled only on an exact -positive platform match; there is no fallback to a default platform. +Hardware writes require an exact platform match: -| Requirement | Supported value | -|---|---| -| DMI vendor / product | `HONOR` / `MRA-XXX` (Honor MagicBook Art 14) | -| CPU (required for fan writes) | Intel Core Ultra 5 125H (Meteor Lake) — exact | -| Kernel modules | `acpi_call` (fan/EC) and `uinput` (gesture output) | +- DMI vendor/product: `HONOR` / `MRA-XXX` (MagicBook Art 14) +- Fan control CPU: Intel Core Ultra 5 125H +- Kernel modules: `acpi_call` for fan control and `uinput` for gestures -AMD and all other Intel models are not allowlisted and receive **no** power, -fan, or GPU writes. Power profiles coordinate through PPD plus standard Intel -RAPL/`intel_pstate` sysfs controls; the service never writes raw CPU MSRs or -disables host power-management services. No specific minimum BIOS or kernel -version is pinned — the tested platform is a current distribution kernel that -provides the modules above on that exact Meteor Lake SKU. +Other models remain read-only. GPU mitigation and unqualified touchpad firmware +writes are disabled. -- **Battery charge control:** verified (sysfs charge thresholds) -- **Power profiles:** enabled only on the verified MRA-XXX platform (requires - working PPD, Intel RAPL/intel_pstate sysfs controls, and exact readback) -- **Fan control:** enabled only on verified MRA-XXX hardware with a valid CPU - temperature sensor (EC writes via `acpi_call`) -- **Touchpad gesture actions:** input decoder and Linux uinput dispatcher - implemented; typed firmware settings are available through D-Bus and the - standalone `honor-touchpadctl` transport -- **GPU mitigation:** disabled until its original IRQ/C-state values can be - captured and restored reliably -- **Firmware setting writes:** implemented for the descriptor-verified vendor - HID collection on exact DMI `HONOR/MRA-XXX`; Linux on-hardware replay remains - the final validation step +## Features -See [touchpad protocol and Linux validation](docs/gesture-linux-remaining-work.md) for -the statically verified protocol, Windows capture procedure, and implementation -gate. The output direction still requires Linux hardware replay. +- Battery charge thresholds and presets +- Power profiles and AC/battery switching +- Stock, manual, and curve-based fan control +- Touchpad gesture mapping and firmware settings +- GUI, integrated tray, tray-only process, and CLI +- D-Bus API with polkit authorization +- Atomic configuration and structured diagnostics -## Architecture +## Install -``` -GUI / Tray CLI - │ intents + snapshots │ command + structured output - ▼ ▼ - Qt AppController worker async D-Bus client - └──────────┬──────────┘ - ▼ - org.honorlinux.Control1 - typed contract + timeouts - │ - D-Bus interface layer - caller capture → polkit → error mapping - │ - ▼ - ApplicationService - use cases + state transitions + lifecycle policy - ┌─────────────┼────────────────────┐ - ▼ ▼ ▼ - ConfigStore SnapshotStore RuntimeSupervisor - atomic desired sequence+events refresh/fan/power/gesture - └─────────────┼────────────────────┘ - ▼ - serialized HardwareCommandQueue - │ - HonorToolsAdapter + safe OS probes - │ - sysfs / EC / uinput / PPD - - ConfigStore: /var/lib/honor-control/state.toml - Per-user GUI settings: QSettings -``` - -### Safety invariants - -1. **One hardware owner:** only the root system service may instantiate - `HonorToolsAdapter`. Frontends depend only on the D-Bus client - protocol. -2. **Unknown hardware cannot write:** a positive platform match - (recognized DMI vendor/product) is required before any EC/fan/GPU - write. No fallback to a default platform. -3. **Polkit fails closed:** missing sender, credentials, or polkit - unavailability denies the call. No "active local user" fallback. -4. **Serialized mutations:** all hardware mutations go through one daemon - worker and a global async lock. A timed-out, non-cancellable hardware call - poisons the queue until it actually returns. -5. **Desired/applied/observed are separate:** persisted user intent, - last successfully applied state, and live hardware observation never - overwrite one another. -6. **Structured results:** every mutation returns an `OperationResult` - with `changed`, `persisted`, and `applied` booleans. Config - persistence is never reported as hardware success. -7. **Qt main thread is I/O-free in practice:** all D-Bus, filesystem, - subprocess, and hardware I/O happens on a dedicated worker thread. The - documented exception is tiny `QSettings` reads/writes (window geometry and - the close-to-tray preference), which touch a small per-user config file. - -## Installation +`honor-tools` 0.1.0 must be available at `../honor-tools`, or supplied as a +wheel in an offline release bundle. ```bash -# Clone and install (creates an isolated versioned venv under /opt/honor-control, -# symlinks entry points to /usr/bin, installs systemd/D-Bus/polkit files) git clone git@github.com:ZachAR3/HonorControl.git cd HonorControl sudo bash scripts/install-local.sh - -# Enable and start the service sudo systemctl enable --now honor-control.service ``` -`honor-tools` 0.1.0 is not published on PyPI. The installer therefore requires -its source tree at `../honor-tools` (the standard HonorTools workspace layout), -or a wheel supplied with `--wheelhouse DIR`. It fails before changing system -files when neither source is available. - -The installer removes the stock `honor-tools` power-supply udev hook because -it directly rewrites CPU EPP after calling `powerprofilesctl`, which conflicts -with KDE's power-profiles-daemon slider. Honor Control owns AC/battery -switching instead; PPD remains enabled for KDE. - -To uninstall: - -```bash -sudo bash scripts/uninstall-local.sh # keep state -sudo bash scripts/uninstall-local.sh --purge # remove state too -``` - -For development, use the fake session-bus service without installing it as root: +Touchpad firmware writes require completed hardware validation and an explicit +qualified install: ```bash -scripts/dev-run-service.sh -scripts/dev-run-gui.sh --bus session +sudo bash scripts/install-local.sh --enable-touchpad-firmware ``` -## Usage - -### CLI +Build a complete offline bundle with: ```bash -honorctl status # overall status -honorctl battery thresholds 80 75 # set charge thresholds -honorctl battery mode home # apply charge mode preset -honorctl power profile balanced # apply power profile -honorctl power save compile --pl1 30 --pl2 45 \ - --epp balance_performance --max-perf 90 -honorctl power auto-switch on --on-ac compile --on-battery silent -honorctl fan stock # restore stock auto mode -honorctl fan curve "40000:0,95000:100" # set fan curve -honorctl fan manual 50 --ttl 300 # manual fan speed with TTL -honorctl gestures batch on # enable all gestures -honorctl gestures daemon on # start HID-to-uinput dispatch -honorctl gpu enable # reports unavailable until restore is safe -honorctl diagnostics checks # run diagnostic checks -honorctl diagnostics export -o bundle.json # export debug bundle -honorctl reload # reload config +bash scripts/make-release-bundle.sh ``` -The GUI power page includes the three built-in profiles (Silent, Balanced, -Performance), editable PL1/PL2 wattage, governor, EPP, PPD mode, turbo and -maximum-performance settings, additional custom profiles, and explicit -AC/battery profile and script-hook selection. The fan page provides a -click/drag graphical curve editor. +The bundle must be signed before distribution. Its installer verifies the +bundle and exact wheelhouse contents before changing the system. -Install transition hooks in a root-owned location before selecting them, for -example: +Rollback and removal: ```bash -sudo install -D -o root -g root -m 0755 my-ac-hook \ - /usr/local/libexec/honor-control-ac-hook +sudo bash scripts/rollback.sh +sudo bash scripts/uninstall-local.sh +sudo bash scripts/uninstall-local.sh --purge ``` -### GUI +## Use ```bash -honor-control-gui # GUI + integrated system tray -honor-control-tray # optional tray-only process +honor-control-gui +honor-control-tray +honorctl status +honorctl battery thresholds 80 75 +honorctl power profile balanced +honorctl fan stock +honorctl diagnostics checks ``` -`honor-touchpadctl` is a root-only diagnostic that talks to hidraw directly. -Do not run its write commands while `honor-control.service` is applying -touchpad settings; normal GUI and `honorctl` operations should use the daemon. - -Closing the GUI window hides it to the tray by default. Left-click the tray -icon to restore the existing window; its menu provides quick power-profile, -charge-limit, gesture-daemon, and touchpad controls. Use **Quit** from the File -or tray menu to exit. This behavior can be disabled on the Settings page. +The standalone `honor-touchpadctl` tool accesses hidraw directly. Prefer the +GUI or `honorctl` for normal operation. ## Development ```bash -# Install dev dependencies -pip install -e ".[dev]" - -# Run the offline/fake-hardware test suite +python -m pip install -e ".[gui,dev]" pytest - -# Run lint ruff check honor_control tests - -# Run the service on the session bus (FakeHardware, no root) -python -m honor_control.backend.service --session-bus - -# Run the GUI against the session bus -honor-control-gui --bus session +scripts/dev-run-service.sh +scripts/dev-run-gui.sh --bus session ``` -## Configuration +The development service uses fake hardware on the session bus. + +## Documentation -- `/var/lib/honor-control/state.toml` — service-owned desired state - (battery thresholds, power profile, fan curves, touchpad firmware settings, - gesture mappings, GPU mitigation). Atomic, versioned, validated. -- `QSettings` — window geometry and close-to-tray preference only. +- [Architecture](docs/architecture.md) +- [Safety model](docs/safety.md) +- [Hardware support](docs/hardware-support.md) +- [D-Bus API](docs/dbus-api.md) +- [Development](docs/development.md) +- [Touchpad validation](docs/hardware-validation/touchpad-linux.md) ## License @@ -222,43 +105,7 @@ GPL-3.0-or-later. See [LICENSE](LICENSE). ## Acknowledgements -This project builds on research and prior work from the Honor MagicBook -Linux community. In particular: - -- **[honor-magicbook-art-touchpad-gestures](https://github.com/MadhiasM/honor-magicbook-art-touchpad-gestures)** - by [MadhiasM](https://github.com/MadhiasM) (GPL-3.0) — the touchpad - HID report `0x0e` gesture decoder and uinput dispatch approach that - informed this project's gesture runtime. -- **[art14-fan-daemon](https://github.com/mark-herbert42/art14-fan-daemon)** - by [mark-herbert42](https://github.com/mark-herbert42) — the documented - EC ACPI call sequences (`_SB.PC00.LPCB.H_EC.WTER ...`) used for fan - control on the Honor MagicBook Art 14. - -## Disclaimer - -This software is **AI-generated**. It is provided "AS IS", without warranty -of any kind, express or implied. I am **not responsible** for any issues, -data loss, or hardware damage that may occur from using this product. You use -it entirely at your own risk. - -The features below do not all carry the same level of evidence. Some are -exercised only against fake hardware in CI, some are reconstructed statically -from Windows protocol captures, some have been run on the author's own -machine, and some are disabled outright until they can be made safe. Judge the -risk for your use case accordingly. - -### Feature verification status - -| Feature | Status | Evidence / notes | -|---|---|---| -| Battery charge control | `physically-verified` | sysfs charge thresholds on the author's machine | -| Power profiles | `physically-verified` | MRA-XXX | -| Fan control | `physically-verified` | MRA-XXX + Intel Core Ultra 5 125H | -| Gesture input (decode + uinput) | `physically-verified` | HID report `0x0e` decode to uinput dispatch | -| Touchpad firmware setting writes | `disabled` | pending physical Linux replay gate | -| GPU IRQ mitigation | `disabled` | no safe restore of original IRQ/C-state values | - -Status labels: `physically-verified` = run on real hardware; `simulated` = -exercised against fake hardware in CI only; `static` = statically -reconstructed from protocol captures, not yet replayed on Linux hardware; -`disabled` = gated off in production builds. +Touchpad gesture work builds on +[honor-magicbook-art-touchpad-gestures](https://github.com/MadhiasM/honor-magicbook-art-touchpad-gestures). +Fan-control research builds on +[art14-fan-daemon](https://github.com/mark-herbert42/art14-fan-daemon). diff --git a/docs/architecture.md b/docs/architecture.md index 28f438b..2e17510 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,7 +98,7 @@ honor_control/ ├── backend/ │ ├── service.py # argument parsing, composition, signal shutdown │ ├── application.py # async use cases; no D-Bus or Qt imports -│ ├── config_store.py # versioned load/atomic save; forward-compatible field defaults +│ ├── config_store.py # versioned migration, strict validation, atomic save │ ├── snapshot_store.py # sequence, immutable snapshot, subscriptions │ ├── hardware.py # HardwarePort + HonorToolsAdapter + FakeHardware │ ├── supervisor.py # controller lifecycle and health diff --git a/docs/dbus-api.md b/docs/dbus-api.md index 421e7e8..e4c3746 100644 --- a/docs/dbus-api.md +++ b/docs/dbus-api.md @@ -11,7 +11,7 @@ Object path: `/org/honorlinux/Control1` | Method | Signature | Description | |---|---|---| | `GetApiVersion` | `() -> u` | D-Bus API version (currently 1) | -| `GetSchemaVersion` | `() -> u` | Snapshot schema version (currently 4) | +| `GetSchemaVersion` | `() -> u` | Snapshot schema version (currently 5) | | `GetSnapshot` | `() -> a{sv}` | Current system snapshot | | `RunChecks` | `() -> a{sv}` | Run diagnostic checks | diff --git a/docs/gesture-linux-remaining-work.md b/docs/gesture-linux-remaining-work.md index b9b7c85..0f5947c 100644 --- a/docs/gesture-linux-remaining-work.md +++ b/docs/gesture-linux-remaining-work.md @@ -143,13 +143,17 @@ honor-touchpadctl apply --dry-run \ Apply one reversible HID setting: ```bash -sudo honor-touchpadctl set edge_brightness off -sudo honor-touchpadctl set edge_brightness on +sudo systemctl stop honor-control.service 2>/dev/null || true +sudo honor-touchpadctl --allow-unqualified set edge_brightness off +sudo honor-touchpadctl --allow-unqualified set edge_brightness on +# Restart honor-control.service afterward if it was previously running. ``` -For a complete persistent profile: +After the physical replay gate passes, reinstall with persistent +qualification and create a complete profile: ```bash +sudo bash scripts/install-touchpad-only.sh --enable-touchpad-firmware sudo cp /usr/share/doc/honor-control/honor-touchpad.example.toml \ /etc/honor-touchpad.toml sudoedit /etc/honor-touchpad.toml diff --git a/docs/hardware-validation/touchpad-linux.md b/docs/hardware-validation/touchpad-linux.md index 9f73207..8866951 100644 --- a/docs/hardware-validation/touchpad-linux.md +++ b/docs/hardware-validation/touchpad-linux.md @@ -41,9 +41,13 @@ settings, types, and values are rejected. The captured original `edge_brightness` value is on: ```bash -sudo python3 -m honor_control.cli.touchpadctl set edge_brightness off +sudo systemctl stop honor-control.service 2>/dev/null || true +sudo python3 -m honor_control.cli.touchpadctl --allow-unqualified \ + set edge_brightness off # Physically test the edge gesture. -sudo python3 -m honor_control.cli.touchpadctl set edge_brightness on +sudo python3 -m honor_control.cli.touchpadctl --allow-unqualified \ + set edge_brightness on +# Restart honor-control.service afterward if it was previously running. ``` Record both JSON outputs and restore immediately. A successful write means the @@ -68,8 +72,8 @@ then query before writing: ```bash sudo python3 -m honor_control.cli.touchpadctl master -sudo python3 -m honor_control.cli.touchpadctl master off -sudo python3 -m honor_control.cli.touchpadctl master on +sudo python3 -m honor_control.cli.touchpadctl --allow-unqualified master off +sudo python3 -m honor_control.cli.touchpadctl --allow-unqualified master on ``` If module load/query returns `EPROTO` or `EMSGSIZE`, unload it and preserve the @@ -81,7 +85,7 @@ After the reversible tests pass, install just the dependency-free touchpad controller (Python 3.11+ standard library only) and create the profile: ```bash -sudo bash scripts/install-touchpad-only.sh +sudo bash scripts/install-touchpad-only.sh --enable-touchpad-firmware sudo cp /usr/share/doc/honor-control/honor-touchpad.example.toml \ /etc/honor-touchpad.toml sudoedit /etc/honor-touchpad.toml diff --git a/docs/safety.md b/docs/safety.md index 313dde1..1e4fd95 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -69,6 +69,11 @@ backup while service health remains degraded. - During a running reload, a corrupt primary leaves the newer in-memory last-known-good state active; it never replaces it with the older backup. +- Unknown keys in the current schema fail validation so misspelled hardware + intent cannot silently fall back to defaults. +- A failed power apply and rollback sets a persisted recovery marker. Automatic + switching remains blocked across service restarts until verified + reconciliation clears the marker. - The service retains last-known-good data on refresh failure and marks the domain stale. - A historical recovered error clears from active health. diff --git a/honor_control/backend/application.py b/honor_control/backend/application.py index 8ee8554..a1681e0 100644 --- a/honor_control/backend/application.py +++ b/honor_control/backend/application.py @@ -42,7 +42,6 @@ GpuState, PowerAutoSwitchState, PowerProfileState, - PowerState, ServiceState, TouchpadState, ) @@ -226,6 +225,13 @@ def queue(self) -> HardwareCommandQueue: async def initialize(self) -> None: """Load config, publish observed state, and reconcile user intent.""" self._config.load() + self._power_recovery_required = ( + self._config.state.power.recovery_required + ) + if self._power_recovery_required: + self._power_recovery_detail = { + "profile": self._config.state.power.recovery_profile + } await self._refresh_all() await self._initialize_fan_safety() await self._reconcile_power_profile() @@ -554,7 +560,7 @@ async def _apply_power_profile( ) if result.get("error"): await self._refresh_power() - self._record_power_rollback_failure(name, result) + await self._record_power_rollback_failure(name, result) return OperationResult.failed( code="power_apply_failed", message=str(result["error"]), @@ -578,11 +584,7 @@ async def _apply_power_profile( await self._config.update( lambda s: replace( s, - power=PowerState( - profile=name, - auto_switch=s.power.auto_switch, - profiles=s.power.profiles, - ), + power=replace(s.power, profile=name), ) ) persisted = True @@ -623,9 +625,21 @@ async def _apply_power_profile( }, ) # A fully converged, verified apply resolves any prior recovery - # fault and re-enables automatic switching/hooks. - self._power_recovery_required = False - self._power_recovery_detail = {} + # fault only after the cleared marker is durable. + if self._power_recovery_required and not await self._set_power_recovery( + False + ): + return OperationResult.partial( + code="power_reconciled_recovery_not_persisted", + message=( + f"Profile '{name}' converged, but the recovery marker " + "could not be cleared" + ), + persisted=False, + applied=True, + sequence=self._snapshots.sequence, + details=result, + ) return OperationResult.success( message=f"Profile '{name}' applied", changed=True, @@ -636,7 +650,7 @@ async def _apply_power_profile( ) await self._refresh_power() - self._record_power_rollback_failure(name, result) + await self._record_power_rollback_failure(name, result) return OperationResult.partial( code="power_partial_apply", message="Partial apply: " @@ -649,7 +663,9 @@ async def _apply_power_profile( details=result, ) - def _record_power_rollback_failure(self, name: str, result: dict[str, Any]) -> None: + async def _record_power_rollback_failure( + self, name: str, result: dict[str, Any] + ) -> None: """Latch a durable fault when both apply and rollback fail. A multi-resource power transaction (PPD/RAPL/EPP/governor/turbo) whose @@ -664,17 +680,47 @@ def _record_power_rollback_failure(self, name: str, result: dict[str, Any]) -> N and rollback.get("attempted") and not rollback.get("ok") ): - self._power_recovery_required = True - self._power_recovery_detail = { + detail = { "profile": name, "rollback": rollback, "observed": result.get("observed", {}), } + marker_persisted = await self._set_power_recovery(True, name, detail) + result["recovery_marker_persisted"] = marker_persisted log.error( "power apply and rollback both failed for '%s'; recovery required", name, ) + async def _set_power_recovery( + self, + required: bool, + profile: str = "", + detail: dict[str, Any] | None = None, + ) -> bool: + """Persist the power recovery latch before changing its safe state.""" + if required: + self._power_recovery_required = True + self._power_recovery_detail = detail or {"profile": profile} + try: + await self._config.update( + lambda state: replace( + state, + power=replace( + state.power, + recovery_required=required, + recovery_profile=profile if required else "", + ), + ) + ) + except Exception as exc: # noqa: BLE001 + log.critical("failed to persist power recovery marker: %s", exc) + return False + if not required: + self._power_recovery_required = False + self._power_recovery_detail = {} + return True + @_serialized_mutation async def reconcile_power(self) -> OperationResult: """Re-apply a known profile to clear an unresolved power-recovery fault. @@ -713,8 +759,8 @@ async def set_auto_switch(self, enabled: bool) -> OperationResult: await self._config.update( lambda s: replace( s, - power=PowerState( - profile=s.power.profile, + power=replace( + s.power, auto_switch=PowerAutoSwitchState( enabled=enabled, on_ac=s.power.auto_switch.on_ac, @@ -722,7 +768,6 @@ async def set_auto_switch(self, enabled: bool) -> OperationResult: on_ac_script=s.power.auto_switch.on_ac_script, on_battery_script=s.power.auto_switch.on_battery_script, ), - profiles=s.power.profiles, ), ) ) @@ -769,9 +814,8 @@ async def save_power_profile( await self._config.update( lambda state: replace( state, - power=PowerState( - profile=state.power.profile, - auto_switch=state.power.auto_switch, + power=replace( + state.power, profiles={**state.power.profiles, profile_name: definition}, ), ) @@ -871,9 +915,8 @@ async def configure_auto_switch( await self._config.update( lambda state: replace( state, - power=PowerState( - profile=state.power.profile, - profiles=state.power.profiles, + power=replace( + state.power, auto_switch=PowerAutoSwitchState( enabled=bool(enabled), on_ac=ac_profile, @@ -1340,6 +1383,12 @@ async def apply_touchpad_settings( ), sequence=self._snapshots.sequence, ) + restart_runtime = bool( + self._gesture_runtime is not None + and self._config.state.gestures.daemon_enabled + ) + if self._gesture_runtime is not None: + await self._supervisor.stop("gesture_daemon") try: with touchpad_firmware_lock(): return await self._apply_touchpad_settings_unlocked(settings) @@ -1352,6 +1401,18 @@ async def apply_touchpad_settings( ), sequence=self._snapshots.sequence, ) + except RuntimeError as exc: + return OperationResult.unavailable( + code="touchpad_lock_unavailable", + message=str(exc), + sequence=self._snapshots.sequence, + ) + finally: + if restart_runtime: + await self._supervisor.start("gesture_daemon") + await asyncio.sleep(0) + if self._gesture_runtime is not None: + await self._refresh_gestures() async def _apply_touchpad_settings_unlocked( self, settings: dict[str, int] @@ -1482,6 +1543,12 @@ async def set_touchpad_setting(self, setting: str, value: int) -> OperationResul @_serialized_mutation async def query_touchpad_support(self) -> dict[str, Any]: """Query capabilities while holding exclusive ownership of the reader.""" + restart_runtime = bool( + self._gesture_runtime is not None + and self._config.state.gestures.daemon_enabled + ) + if self._gesture_runtime is not None: + await self._supervisor.stop("gesture_daemon") try: with touchpad_firmware_lock(): return await self._query_touchpad_support_unlocked() @@ -1491,23 +1558,19 @@ async def query_touchpad_support(self) -> dict[str, Any]: "Touchpad firmware is busy (standalone honor-touchpadctl holds " "the device)", ) from None - - async def _query_touchpad_support_unlocked(self) -> dict[str, Any]: - restart_runtime = bool( - self._gesture_runtime is not None - and self._config.state.gestures.daemon_enabled - ) - if self._gesture_runtime is not None: - await self._supervisor.stop("gesture_daemon") - try: - bits = await self._queue.run( - "touchpad_support", self._hw.query_touchpad_support - ) + except RuntimeError as exc: + raise DomainException(DomainError.UNAVAILABLE, str(exc)) from exc finally: if restart_runtime: await self._supervisor.start("gesture_daemon") await asyncio.sleep(0) - await self._refresh_gestures() + if self._gesture_runtime is not None: + await self._refresh_gestures() + + async def _query_touchpad_support_unlocked(self) -> dict[str, Any]: + bits = await self._queue.run( + "touchpad_support", self._hw.query_touchpad_support + ) return { "supported_bits": sorted(bits), "known": { diff --git a/honor_control/backend/config_store.py b/honor_control/backend/config_store.py index b62615e..9d4f48f 100644 --- a/honor_control/backend/config_store.py +++ b/honor_control/backend/config_store.py @@ -54,7 +54,7 @@ log = logging.getLogger("honor_control.backend.config_store") #: Current state schema version. -STATE_SCHEMA_VERSION = 3 +STATE_SCHEMA_VERSION = 4 #: Suffix for the pre-migration snapshot of an older state file. PREMIGRATION_SUFFIX = ".premigration" @@ -124,6 +124,8 @@ class PowerState: profiles: dict[str, PowerProfileState] = field( default_factory=default_power_profiles ) + recovery_required: bool = False + recovery_profile: str = "" @dataclass(frozen=True) @@ -225,10 +227,23 @@ def _migrate_2_to_3(data: dict[str, Any]) -> dict[str, Any]: return migrated +def _migrate_3_to_4(data: dict[str, Any]) -> dict[str, Any]: + """Schema 3 -> 4: persist unresolved power rollback recovery.""" + migrated = dict(data) + power = migrated.get("power") + power = dict(power) if isinstance(power, dict) else {} + power.setdefault("recovery_required", False) + power.setdefault("recovery_profile", "") + migrated["power"] = power + migrated["schema_version"] = 4 + return migrated + + #: Ordered registry: key ``N`` holds the step upgrading schema N to N+1. _SCHEMA_MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = { 1: _migrate_1_to_2, 2: _migrate_2_to_3, + 3: _migrate_3_to_4, } @@ -280,7 +295,15 @@ def _apply_migrations(data: dict[str, Any], source_version: int) -> dict[str, An {"schema_version", "battery", "power", "fan", "gestures", "touchpad", "gpu"} ), "battery": frozenset({"end_threshold", "start_threshold", "mode"}), - "power": frozenset({"profile", "auto_switch", "profiles"}), + "power": frozenset( + { + "profile", + "auto_switch", + "profiles", + "recovery_required", + "recovery_profile", + } + ), "power.auto_switch": frozenset( {"enabled", "on_ac", "on_battery", "on_ac_script", "on_battery_script"} ), @@ -313,8 +336,8 @@ def _apply_migrations(data: dict[str, Any], source_version: int) -> dict[str, An def _find_unknown_keys(data: dict[str, Any]) -> list[str]: """Return exact dotted paths of keys absent from the current schema. - Unknown keys never fail parsing (callers warn instead), but they are - surfaced so typos in persisted hardware state stay diagnosable. + Unknown keys fail parsing because a typo in desired hardware state could + otherwise be replaced silently by a default. """ unknown: list[str] = [] @@ -359,9 +382,8 @@ def _state_from_dict( Older schema versions are upgraded by the ordered migration pipeline before parsing; a failed migration raises :class:`DomainException` naming the source schema version instead of silently adopting current - defaults. Unknown keys never fail a parse: when ``unknown_keys`` is - supplied they are collected there as exact dotted paths. Invalid - values raise :class:`DomainException`. + defaults. Unknown keys and invalid values raise + :class:`DomainException`. """ if not isinstance(data, dict): raise DomainException( @@ -405,11 +427,16 @@ def _parse_current_schema( ) -> ServiceState: """Parse a current-schema document into a validated ServiceState. - Unknown keys are collected into ``unknown_keys`` (when supplied) as - exact dotted paths; invalid values raise :class:`DomainException`. + Unknown keys and invalid values raise :class:`DomainException`. """ + unknown = _find_unknown_keys(data) if unknown_keys is not None: - unknown_keys.extend(_find_unknown_keys(data)) + unknown_keys.extend(unknown) + if unknown: + raise DomainException( + DomainError.INVALID_ARGUMENT, + f"Unknown state key(s): {', '.join(unknown)}", + ) def table(parent: dict[str, Any], key: str) -> dict[str, Any]: value = parent.get(key, {}) @@ -490,6 +517,8 @@ def string(parent: dict[str, Any], key: str, default: str) -> str: on_battery_script=string(as_data, "on_battery_script", ""), ), profiles=profiles, + recovery_required=boolean(pw_data, "recovery_required", False), + recovery_profile=string(pw_data, "recovery_profile", ""), ) # Fan @@ -948,6 +977,20 @@ def _validate_state(state: ServiceState) -> None: f"Max performance percentage out of range for '{name}'", ) validate_power_profile(state.power.profile, profile_names) + if not isinstance(state.power.recovery_required, bool): + raise DomainException( + DomainError.INVALID_ARGUMENT, + "Power recovery flag must be a boolean", + ) + if ( + not isinstance(state.power.recovery_profile, str) + or len(state.power.recovery_profile) > 64 + or "\0" in state.power.recovery_profile + ): + raise DomainException( + DomainError.INVALID_ARGUMENT, + "Invalid power recovery profile", + ) if not isinstance(state.power.auto_switch.enabled, bool): raise DomainException( DomainError.INVALID_ARGUMENT, diff --git a/honor_control/backend/gesture_runtime.py b/honor_control/backend/gesture_runtime.py index 92193de..777bbb2 100644 --- a/honor_control/backend/gesture_runtime.py +++ b/honor_control/backend/gesture_runtime.py @@ -28,6 +28,7 @@ TOUCHPAD_VENDOR_ID, gesture_keys_from_report, ) +from honor_control.core.touchpad import touchpad_firmware_lock from honor_control.core.validation import validate_key_combo log = logging.getLogger("honor_control.backend.gesture_runtime") @@ -238,7 +239,11 @@ async def run(self) -> None: while True: session_started = time.monotonic() try: - await self._run_session() + # The reader owns the vendor hidraw endpoint for the whole + # session. Firmware writes/support queries must stop this + # runtime before taking the same cross-process lock. + with touchpad_firmware_lock(): + await self._run_session() except asyncio.CancelledError: raise except PermissionError as exc: diff --git a/honor_control/backend/supervisor.py b/honor_control/backend/supervisor.py index 42f49bb..b4c1cd1 100644 --- a/honor_control/backend/supervisor.py +++ b/honor_control/backend/supervisor.py @@ -268,6 +268,8 @@ async def stop(self, name: str, timeout: float = 5.0) -> None: pass except TimeoutError: log.warning("supervisor: '%s' cleanup timed out", name) + health.last_fault = f"cleanup did not finish within {timeout:.1f}s" + cleanup_failed = True except Exception as exc: # noqa: BLE001 log.error("supervisor: '%s' cleanup failed: %s", name, exc) health.last_fault = str(exc) diff --git a/honor_control/cli/honorctl.py b/honor_control/cli/honorctl.py index 6f21653..b4c6fc5 100644 --- a/honor_control/cli/honorctl.py +++ b/honor_control/cli/honorctl.py @@ -482,7 +482,7 @@ def build_parser() -> argparse.ArgumentParser: "--timeout", type=float, default=90.0, - help="per-call timeout in seconds (default: 90; above the interactive polkit deadline)", + help="mutation timeout in seconds (default: 90; reads use 5 seconds)", ) sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND") diff --git a/honor_control/cli/touchpadctl.py b/honor_control/cli/touchpadctl.py index c309502..740b3d3 100644 --- a/honor_control/cli/touchpadctl.py +++ b/honor_control/cli/touchpadctl.py @@ -167,10 +167,10 @@ def _build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--json", action="store_true", help="machine-readable output") parser.add_argument( - "--force", + "--allow-unqualified", action="store_true", - help="bypass the inter-process touchpad lock (recovery only; unsafe " - "while honor-control.service is applying settings)", + help="allow a diagnostic firmware write before qualification; the " + "exclusive device lock remains enforced", ) parser.add_argument( "--sysfs-root", @@ -447,43 +447,44 @@ def _run(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: args = _build_parser().parse_args(argv) - write_command = args.command in {"set", "master"} or ( + write_command = args.command == "set" or ( args.command == "apply" and not getattr(args, "dry_run", False) + ) or ( + args.command == "master" and getattr(args, "value", None) is not None ) needs_lock = write_command or args.command == "support" - # HC-001: firmware setting writes are disabled until qualified; --force - # overrides for diagnostics/recovery. Probe/list/encode/support stay - # available either way. - if write_command and not args.force and not touchpad_firmware_writes_qualified(): + # HC-001: firmware setting writes are disabled until qualified. The + # diagnostic override deliberately does not bypass serialization. + if ( + write_command + and not args.allow_unqualified + and not touchpad_firmware_writes_qualified() + ): print( "honor-touchpadctl: firmware setting writes are not qualified on " "this build (probe/list/encode/support remain available). Use " - "--force to override for diagnostics/recovery.", + "--allow-unqualified for a supervised diagnostic replay.", file=sys.stderr, ) return EXIT_UNAVAILABLE # HC-011: write/support commands take an exclusive inter-process lock so a # standalone run cannot interleave with honor-control.service. Read-only - # probe/list/encode (and --force recovery) skip it. + # probe/list/encode skip it. if not needs_lock: return _run(args) - if args.force: - print( - "honor-touchpadctl: --force bypasses the touchpad lock; do not use " - "while honor-control.service is applying settings", - file=sys.stderr, - ) - return _run(args) try: with touchpad_firmware_lock(): return _run(args) except TouchpadLockBusy as exc: print( f"honor-touchpadctl: {exc}; is honor-control.service applying " - "touchpad settings? Use --force to override.", + "touchpad settings?", file=sys.stderr, ) return EXIT_UNAVAILABLE + except RuntimeError as exc: + print(f"honor-touchpadctl: {exc}", file=sys.stderr) + return EXIT_UNAVAILABLE if __name__ == "__main__": diff --git a/honor_control/client/sdbus_client.py b/honor_control/client/sdbus_client.py index 5026a18..e7ca2aa 100644 --- a/honor_control/client/sdbus_client.py +++ b/honor_control/client/sdbus_client.py @@ -45,10 +45,33 @@ log = logging.getLogger("honor_control.client.sdbus_client") -#: Default per-call timeout (seconds). Admin mutations may trigger an -#: interactive polkit prompt (the authorizer allows ~60s for a human), so the -#: client deadline must sit above that plus normal hardware operation time. -DEFAULT_TIMEOUT = 90.0 +DEFAULT_READ_TIMEOUT = 5.0 +DEFAULT_MUTATION_TIMEOUT = 90.0 +DEFAULT_TIMEOUT = DEFAULT_MUTATION_TIMEOUT +_LONG_RUNNING_METHODS = frozenset( + { + "RunChecks", + "SetThresholds", + "SetMode", + "SetProfile", + "SetAutoSwitch", + "SavePowerProfile", + "DeletePowerProfile", + "ConfigureAutoSwitch", + "SetStockAuto", + "SetCurve", + "SetManual", + "SetMapping", + "SetEnabled", + "SetAllEnabled", + "SetDaemonEnabled", + "ApplyTouchpadSettings", + "SetTouchpadSetting", + "QueryTouchpadSupport", + "SetMitigationEnabled", + "Reload", + } +) MAX_COALESCED_REFRESHES = 4 @@ -64,9 +87,11 @@ def __init__( self, bus_kind: str = "system", timeout: float = DEFAULT_TIMEOUT, + read_timeout: float = DEFAULT_READ_TIMEOUT, ) -> None: self._bus_kind = bus_kind - self._timeout = timeout + self._mutation_timeout = timeout + self._read_timeout = read_timeout self._bus: Any = None self._proxy: Any = None self._connected = False @@ -154,7 +179,12 @@ async def _call_method(self, method: str, *args: Any) -> Any: raise ClientError( TransportError.INTERNAL, f"Unknown client method: {method}" ) - result = await asyncio.wait_for(member(*args), timeout=self._timeout) + timeout = ( + self._mutation_timeout + if method in _LONG_RUNNING_METHODS + else self._read_timeout + ) + result = await asyncio.wait_for(member(*args), timeout=timeout) return _from_variant(result) except TimeoutError: raise ClientError(TransportError.TIMEOUT, f"{method} timed out") @@ -578,6 +608,16 @@ def _decode_snapshot(data: dict[str, Any]) -> SystemSnapshot: config_valid=_bool(service.get("config_valid")), stale_domains=_str_tuple(service.get("stale_domains")), last_fault=_str(service.get("last_fault")), + hardware_queue_stuck=_bool(service.get("hardware_queue_stuck")), + stuck_command=_str(service.get("stuck_command")), + fan_recovery_required=_bool(service.get("fan_recovery_required")), + power_recovery_required=_bool(service.get("power_recovery_required")), + controller_restart_counts={ + str(name): _int(count, 0) or 0 + for name, count in _dict( + service.get("controller_restart_counts") + ).items() + }, ), platform=PlatformInfo( vendor=_str(platform.get("vendor")), @@ -664,6 +704,9 @@ def _decode_snapshot(data: dict[str, Any]) -> SystemSnapshot: firmware_settings_supported=_bool( gestures.get("firmware_settings_supported") ), + firmware_writes_qualified=_bool( + gestures.get("firmware_writes_qualified") + ), firmware_settings={ str(name): int(value) for name, value in _dict(gestures.get("firmware_settings")).items() diff --git a/honor_control/contract.py b/honor_control/contract.py index 6aa915b..b9a191c 100644 --- a/honor_control/contract.py +++ b/honor_control/contract.py @@ -17,7 +17,7 @@ API_VERSION = 1 #: Snapshot schema version. Increment when snapshot field semantics change. -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 # -- Interface names ---------------------------------------------------------- diff --git a/honor_control/core/touchpad.py b/honor_control/core/touchpad.py index 6ec8b3f..66c513c 100644 --- a/honor_control/core/touchpad.py +++ b/honor_control/core/touchpad.py @@ -16,6 +16,7 @@ import contextlib import fcntl import os +import pathlib import time from collections.abc import Iterator from dataclasses import dataclass @@ -37,16 +38,35 @@ #: explicit, reviewed hardware qualification record exists. Probe, descriptor #: inspection, dry-run encoding, and gesture input are unaffected. _FIRMWARE_QUALIFIED_ENV: Final = "HONOR_TOUCHPAD_FIRMWARE_QUALIFIED" +_FIRMWARE_QUALIFIED_FILE_ENV: Final = ( + "HONOR_CONTROL_TOUCHPAD_QUALIFICATION_FILE" +) +_DEFAULT_FIRMWARE_QUALIFIED_FILE: Final = ( + "/etc/honor-control/touchpad-firmware-qualified" +) def touchpad_firmware_writes_qualified() -> bool: """Return True only if firmware writes were explicitly qualified. - Reads ``HONOR_TOUCHPAD_FIRMWARE_QUALIFIED=1`` (matching the installer's - ``--enable-touchpad-firmware`` flag). Defaults to False so that no - production path can write unqualified reports. + The environment flag is intended for tests and one-shot diagnostics. A + production install persists qualification as a root-managed marker file + so the backend and boot/resume restore unit make the same decision. """ - return os.environ.get(_FIRMWARE_QUALIFIED_ENV, "").strip() == "1" + if os.environ.get(_FIRMWARE_QUALIFIED_ENV, "").strip() == "1": + return True + marker = pathlib.Path( + os.environ.get( + _FIRMWARE_QUALIFIED_FILE_ENV, + _DEFAULT_FIRMWARE_QUALIFIED_FILE, + ) + ) + try: + return marker.is_file() and marker.read_text(encoding="ascii").strip() == ( + "qualified=1" + ) + except OSError: + return False #: Environment override for the inter-process touchpad firmware lock path @@ -76,13 +96,10 @@ def touchpad_firmware_lock( if directory: os.makedirs(directory, exist_ok=True) fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o640) - except OSError: - # The lock is advisory/best-effort: if it cannot be created (e.g. an - # unprivileged environment without a writable /run), proceed unlocked - # rather than failing the operation. In production both the root - # service and the root CLI can always create it. - yield - return + except OSError as exc: + raise RuntimeError( + f"cannot acquire touchpad firmware lock {lock_path}: {exc}" + ) from exc flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) try: fcntl.flock(fd, flags) @@ -91,10 +108,11 @@ def touchpad_firmware_lock( raise TouchpadLockBusy( "touchpad firmware is busy (held by another writer)" ) from exc - except OSError: + except OSError as exc: os.close(fd) - yield - return + raise RuntimeError( + f"cannot acquire touchpad firmware lock {lock_path}: {exc}" + ) from exc try: yield finally: diff --git a/honor_control/frontend/gui/pages/touchpad.py b/honor_control/frontend/gui/pages/touchpad.py index 962583f..1ee1133 100644 --- a/honor_control/frontend/gui/pages/touchpad.py +++ b/honor_control/frontend/gui/pages/touchpad.py @@ -409,7 +409,13 @@ def _discard_firmware_changes(self) -> None: def _update_firmware_actions(self) -> None: snap = self.state.snapshot supported = bool(snap and snap.gestures.firmware_settings_supported) - can_edit = self.state.connected and supported and not self._firmware_pending + qualified = bool(snap and snap.gestures.firmware_writes_qualified) + can_edit = ( + self.state.connected + and supported + and qualified + and not self._firmware_pending + ) for row in self._firmware_rows.values(): row.combo.setEnabled(can_edit) self.apply_button.setEnabled(can_edit and self._firmware_dirty) @@ -451,12 +457,16 @@ def _on_operation_completed(self, operation_id: str, result: object) -> None: def _on_snapshot(self, snap: SystemSnapshot) -> None: ges = snap.gestures firmware_ok = ges.firmware_settings_supported - self.firmware_dot.set_color(StatusDot.GREEN if firmware_ok else StatusDot.RED) - self.firmware_summary.setText( - "Firmware controls available" - if firmware_ok - else "Firmware controls unavailable" - ) + qualified = ges.firmware_writes_qualified + ready = firmware_ok and qualified + self.firmware_dot.set_color(StatusDot.GREEN if ready else StatusDot.RED) + if not firmware_ok: + summary = "Firmware controls unavailable" + elif not qualified: + summary = "Firmware writes not qualified" + else: + summary = "Firmware controls available" + self.firmware_summary.setText(summary) if ges.device_found: device = ges.device_path or "Found" if ges.permission_denied: diff --git a/honor_control/frontend/tray/tray.py b/honor_control/frontend/tray/tray.py index eef8307..8868268 100644 --- a/honor_control/frontend/tray/tray.py +++ b/honor_control/frontend/tray/tray.py @@ -184,15 +184,20 @@ def _on_snapshot(self, snap: SystemSnapshot) -> None: action.setEnabled( self.controller.connected and snap.gestures.firmware_settings_supported + and snap.gestures.firmware_writes_qualified and configured ) - action.setToolTip( - "" - if configured - else "Configure this setting in the Touchpad page first" - ) + if not snap.gestures.firmware_writes_qualified: + tooltip = "Touchpad firmware writes are not qualified" + elif not configured: + tooltip = "Configure this setting in the Touchpad page first" + else: + tooltip = "" + action.setToolTip(tooltip) self.touchpad_menu.setEnabled( - self.controller.connected and snap.gestures.firmware_settings_supported + self.controller.connected + and snap.gestures.firmware_settings_supported + and snap.gestures.firmware_writes_qualified ) def _open_gui(self) -> None: diff --git a/packaging/touchpad/touchpad-firmware-qualified b/packaging/touchpad/touchpad-firmware-qualified new file mode 100644 index 0000000..585bc9c --- /dev/null +++ b/packaging/touchpad/touchpad-firmware-qualified @@ -0,0 +1 @@ +qualified=1 diff --git a/pyproject.toml b/pyproject.toml index b81bc87..c64b21b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,4 +61,6 @@ ignore = ["E501"] [tool.pytest.ini_options] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +asyncio_default_test_loop_scope = "function" testpaths = ["tests"] diff --git a/requirements-audit.txt b/requirements-audit.txt new file mode 100644 index 0000000..0809f44 --- /dev/null +++ b/requirements-audit.txt @@ -0,0 +1,4 @@ +# Published runtime dependencies audited by CI. honor-tools 0.1.0 is supplied +# as a separately reviewed local wheel and is not available from PyPI. +sdbus==0.14.2 +PySide6==6.11.1 diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 29693c6..f84ec67 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -182,6 +182,15 @@ if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; the exit 2 fi systemctl is-active --quiet honor-control.service && WAS_ACTIVE=true +CONTROL_WAS_ENABLED=false +TOUCHPAD_WAS_ENABLED=false +TOUCHPAD_WAS_ACTIVE=false +systemctl is-enabled --quiet honor-control.service 2>/dev/null && \ + CONTROL_WAS_ENABLED=true +systemctl is-enabled --quiet honor-touchpad-restore.service 2>/dev/null && \ + TOUCHPAD_WAS_ENABLED=true +systemctl is-active --quiet honor-touchpad-restore.service 2>/dev/null && \ + TOUCHPAD_WAS_ACTIVE=true if $DEV_MODE; then echo "error: editable root-service installs are unsupported." >&2 @@ -201,6 +210,13 @@ if [[ -n "$WHEELHOUSE" && ! -d "$WHEELHOUSE" ]]; then echo "error: wheelhouse directory does not exist: $WHEELHOUSE" >&2 exit 2 fi +if [[ -f "$ROOT/SHA256SUMS" && -f "$ROOT/MANIFEST" ]]; then + echo "==> Verifying release bundle checksums" + if ! (cd "$ROOT" && sha256sum --strict -c SHA256SUMS >/dev/null); then + echo "error: release bundle checksum verification failed" >&2 + exit 2 + fi +fi if [[ -n "$WHEELHOUSE" ]]; then # Fully offline, hash-verified install: every artifact must be listed in # SHA256SUMS and match before we touch the system. @@ -210,11 +226,39 @@ if [[ -n "$WHEELHOUSE" ]]; then echo " (cd $WHEELHOUSE && sha256sum ./*.whl ./*.tar.gz > SHA256SUMS)" >&2 exit 2 fi - echo "==> Verifying wheelhouse checksums (offline install)" - if ! (cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS); then + echo "==> Verifying exact wheelhouse contents (offline install)" + if find "$WHEELHOUSE" -maxdepth 1 -type l | grep -q .; then + echo "error: wheelhouse must not contain symbolic links" >&2 + exit 2 + fi + awk 'NF >= 2 { + name=$2 + sub(/^[*]/, "", name) + sub(/^\\.\\//, "", name) + print name + }' "$WHEELHOUSE/SHA256SUMS" | LC_ALL=C sort -u > "$STAGE/listed-artifacts" + find "$WHEELHOUSE" -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' | + LC_ALL=C sort > "$STAGE/actual-artifacts" + if ! cmp -s "$STAGE/listed-artifacts" "$STAGE/actual-artifacts"; then + echo "error: wheelhouse contents do not exactly match SHA256SUMS" >&2 + diff -u "$STAGE/listed-artifacts" "$STAGE/actual-artifacts" >&2 || true + exit 2 + fi + if ! (cd "$WHEELHOUSE" && sha256sum --strict -c SHA256SUMS); then echo "error: wheelhouse checksum verification failed; refusing to install" >&2 exit 2 fi + shopt -s nullglob + HONOR_CONTROL_WHEELS=("$WHEELHOUSE"/honor_control-*.whl) + HONOR_TOOLS_WHEELS=("$WHEELHOUSE"/honor_tools-0.1.0-*.whl) + shopt -u nullglob + if [[ ${#HONOR_CONTROL_WHEELS[@]} -ne 1 || ${#HONOR_TOOLS_WHEELS[@]} -ne 1 ]]; then + echo "error: wheelhouse requires exactly one honor-control wheel and" >&2 + echo " exactly one honor-tools 0.1.0 wheel" >&2 + exit 2 + fi + HONOR_CONTROL_WHEEL="${HONOR_CONTROL_WHEELS[0]}" + HONOR_TOOLS_WHEEL="${HONOR_TOOLS_WHEELS[0]}" fi HONOR_TOOLS_ROOT="$(cd "$ROOT/../honor-tools" 2>/dev/null && pwd || true)" if [[ ( -z "$HONOR_TOOLS_ROOT" || \ @@ -244,9 +288,8 @@ assert_replaceable "$ROOT/packaging/polkit/org.honorlinux.control.policy" /usr/s assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.desktop" /usr/share/applications/org.honorlinux.Control.desktop assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /usr/share/applications/org.honorlinux.Control.Tray.desktop assert_replaceable "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop -if $TOUCHPAD_FIRMWARE; then - assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service -fi +assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service +assert_replaceable "$ROOT/packaging/touchpad/touchpad-firmware-qualified" /etc/honor-control/touchpad-firmware-qualified assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore assert_replaceable "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml @@ -270,7 +313,10 @@ if [[ -n "$WHEELHOUSE" ]]; then # (setuptools/wheel and the honor packages) needed for an offline install. PIP_ARGS+=(--no-index --find-links "$WHEELHOUSE" --no-build-isolation) fi -if [[ -n "$HONOR_TOOLS_ROOT" && -f "$HONOR_TOOLS_ROOT/pyproject.toml" ]]; then +if [[ -n "$WHEELHOUSE" ]]; then + "$PIP" install "${PIP_ARGS[@]}" "$HONOR_TOOLS_WHEEL" + "$PIP" install "${PIP_ARGS[@]}" "${HONOR_CONTROL_WHEEL}[gui]" +elif [[ -n "$HONOR_TOOLS_ROOT" && -f "$HONOR_TOOLS_ROOT/pyproject.toml" ]]; then mkdir -p "$STAGE/honor-tools" install -m 0644 "$HONOR_TOOLS_ROOT/pyproject.toml" \ "$STAGE/honor-tools/" @@ -280,10 +326,11 @@ if [[ -n "$HONOR_TOOLS_ROOT" && -f "$HONOR_TOOLS_ROOT/pyproject.toml" ]]; then find "$STAGE/honor-tools/honor" -type d -name __pycache__ -prune \ -exec rm -rf {} + "$PIP" install "${PIP_ARGS[@]}" "$STAGE/honor-tools" + "$PIP" install "${PIP_ARGS[@]}" "$STAGE/source[gui]" else "$PIP" install "${PIP_ARGS[@]}" "honor-tools==0.1.0" + "$PIP" install "${PIP_ARGS[@]}" "$STAGE/source[gui]" fi -"$PIP" install "${PIP_ARGS[@]}" "$STAGE/source[gui]" echo "==> [3/7] Validating isolated installation" "$PY" -m pip check @@ -316,7 +363,13 @@ install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.desktop" /usr/sh install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /usr/share/applications/org.honorlinux.Control.Tray.desktop 0644 install_managed "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop 0644 if $TOUCHPAD_FIRMWARE; then + install_managed "$ROOT/packaging/touchpad/touchpad-firmware-qualified" /etc/honor-control/touchpad-firmware-qualified 0644 install_managed "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service 0644 +else + backup_destination /etc/honor-control/touchpad-firmware-qualified + rm -f /etc/honor-control/touchpad-firmware-qualified + backup_destination /etc/systemd/system/honor-touchpad-restore.service + rm -f /etc/systemd/system/honor-touchpad-restore.service fi install_managed "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore 0755 install_managed "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml 0644 @@ -389,6 +442,29 @@ MANIFEST="$RELEASE_DIR/installed-files.sha256" for destination in "${MANAGED_DESTS[@]}"; do [[ -f "$destination" && ! -L "$destination" ]] && sha256sum "$destination" >> "$MANIFEST" done + +# Persist the exact pre-install system state inside this release. rollback.sh +# restores these files transactionally before selecting the predecessor. +ROLLBACK_STATE="$RELEASE_DIR/rollback-state" +install -d -m 0700 "$ROLLBACK_STATE/backups" +: > "$ROLLBACK_STATE/managed.tsv" +for ((index=0; index<${#MANAGED_DESTS[@]}; index++)); do + destination=${MANAGED_DESTS[$index]} + printf '%s\t%s\t%s\n' \ + "$index" "${MANAGED_EXISTED[$index]}" "$destination" \ + >> "$ROLLBACK_STATE/managed.tsv" + if [[ ${MANAGED_EXISTED[$index]} == 1 ]]; then + cp -a -- "${MANAGED_BACKUPS[$index]}" "$ROLLBACK_STATE/backups/$index" + fi +done +{ + printf 'previous_release=%s\n' "$OLD_RELEASE" + printf 'honor_control_enabled=%s\n' "$CONTROL_WAS_ENABLED" + printf 'honor_control_active=%s\n' "$WAS_ACTIVE" + printf 'touchpad_restore_enabled=%s\n' "$TOUCHPAD_WAS_ENABLED" + printf 'touchpad_restore_active=%s\n' "$TOUCHPAD_WAS_ACTIVE" +} > "$ROLLBACK_STATE/release.env" +chmod 0600 "$ROLLBACK_STATE/managed.tsv" "$ROLLBACK_STATE/release.env" COMMITTED=true # Keep the active release and one known-good predecessor for manual rollback. diff --git a/scripts/install-touchpad-only.sh b/scripts/install-touchpad-only.sh index cf76731..8d4d7c7 100755 --- a/scripts/install-touchpad-only.sh +++ b/scripts/install-touchpad-only.sh @@ -2,6 +2,18 @@ # Install only the standard-library Honor touchpad controller and restore unit. set -euo pipefail +TOUCHPAD_FIRMWARE=false +if [[ "${HONOR_TOUCHPAD_FIRMWARE_QUALIFIED:-0}" == "1" ]]; then + TOUCHPAD_FIRMWARE=true +fi +while [[ $# -gt 0 ]]; do + case "$1" in + --enable-touchpad-firmware) TOUCHPAD_FIRMWARE=true ;; + *) echo "error: unknown option: $1" >&2; exit 2 ;; + esac + shift +done + if [[ $EUID -ne 0 ]]; then echo "error: run this installer with sudo" >&2 exit 1 @@ -99,6 +111,7 @@ assert_replaceable "$ROOT/honor_control/backend/touchpad_firmware.py" "$DEST/bac assert_replaceable "$ROOT/honor_control/cli/__init__.py" "$DEST/cli/__init__.py" assert_replaceable "$ROOT/honor_control/cli/touchpadctl.py" "$DEST/cli/touchpadctl.py" assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service +assert_replaceable "$ROOT/packaging/touchpad/touchpad-firmware-qualified" /etc/honor-control/touchpad-firmware-qualified assert_replaceable "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore assert_replaceable "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml @@ -114,7 +127,15 @@ install_owned "$ROOT/honor_control/cli/touchpadctl.py" "$DEST/cli/touchpadctl.py install_owned "$ROOT/packaging/touchpad/honor-touchpadctl" /usr/bin/honor-touchpadctl 0755 echo "==> Installing profile restore files" -install_owned "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service 0644 +if $TOUCHPAD_FIRMWARE; then + install_owned "$ROOT/packaging/touchpad/touchpad-firmware-qualified" /etc/honor-control/touchpad-firmware-qualified 0644 + install_owned "$ROOT/packaging/systemd/honor-touchpad-restore.service" /etc/systemd/system/honor-touchpad-restore.service 0644 +else + backup_destination /etc/honor-control/touchpad-firmware-qualified + rm -f /etc/honor-control/touchpad-firmware-qualified + backup_destination /etc/systemd/system/honor-touchpad-restore.service + rm -f /etc/systemd/system/honor-touchpad-restore.service +fi install_owned "$ROOT/packaging/systemd/honor-touchpad-system-sleep" /usr/lib/systemd/system-sleep/honor-touchpad-restore 0755 install_owned "$ROOT/packaging/touchpad/honor-touchpad.example.toml" /usr/share/doc/honor-control/honor-touchpad.example.toml 0644 @@ -123,6 +144,9 @@ honor-touchpadctl list >/dev/null honor-touchpadctl apply --dry-run \ /usr/share/doc/honor-control/honor-touchpad.example.toml >/dev/null systemctl daemon-reload +if ! $TOUCHPAD_FIRMWARE; then + systemctl disable --now honor-touchpad-restore.service 2>/dev/null || true +fi MANIFEST_STAGE="$STAGE/installed-files.sha256" : > "$MANIFEST_STAGE" for destination in "${DESTINATIONS[@]}"; do @@ -134,4 +158,8 @@ COMMITTED=true echo "Done. Run the read-only check with:" echo " sudo honor-touchpadctl --json probe" -echo "The restore service remains disabled until you create /etc/honor-touchpad.toml." +if $TOUCHPAD_FIRMWARE; then + echo "Create /etc/honor-touchpad.toml, then enable the restore service." +else + echo "Firmware writes and automatic restore remain unqualified and disabled." +fi diff --git a/scripts/make-release-bundle.sh b/scripts/make-release-bundle.sh index 783aca1..d3da789 100755 --- a/scripts/make-release-bundle.sh +++ b/scripts/make-release-bundle.sh @@ -1,90 +1,205 @@ #!/usr/bin/env bash -# Assemble a versioned, hash-verified, offline release bundle. -# -# The bundle contains everything needed for a fully offline install on a -# supported host: the honor_control wheel + sdist (built with python -m build), -# the packaging tree, the install/uninstall/rollback scripts, a SHA256SUMS over -# every artifact, a MANIFEST (version / git commit / date), and a minimal -# sbom.json of the pinned dependencies. A drop-in directory is provided for the -# separately-reviewed honor-tools wheel. -# -# EXTERNAL STEPS (not performed here): obtaining the reviewed honor-tools 0.1.0 -# wheel, and signing/publishing the bundle. This script only assembles and -# checksums the artifacts it can build locally. +# Build a complete, platform-specific offline release bundle. # # Usage: -# bash scripts/make-release-bundle.sh [OUTPUT_DIR] +# bash scripts/make-release-bundle.sh [OUTPUT_DIR] [--honor-tools-wheel FILE] set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$HERE/.." && pwd)" -cd "$ROOT" +OUTPUT_DIR="" +HONOR_TOOLS_WHEEL="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --honor-tools-wheel) + [[ $# -ge 2 ]] || { + echo "error: --honor-tools-wheel requires a file" >&2 + exit 2 + } + HONOR_TOOLS_WHEEL="$2" + shift + ;; + --*) + echo "error: unknown option: $1" >&2 + exit 2 + ;; + *) + [[ -z "$OUTPUT_DIR" ]] || { + echo "error: only one output directory may be supplied" >&2 + exit 2 + } + OUTPUT_DIR="$1" + ;; + esac + shift +done +cd "$ROOT" +if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + echo "error: release bundles require a clean Git worktree" >&2 + exit 2 +fi VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' pyproject.toml | head -n1)" -if [[ -z "$VERSION" ]]; then +[[ -n "$VERSION" ]] || { echo "error: could not read version from pyproject.toml" >&2 exit 2 +} +GIT_COMMIT="$(git rev-parse --verify HEAD)" +SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" +BUILD_DATE="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%dT%H:%M:%SZ)" +BUNDLE="${OUTPUT_DIR:-dist/release/honor-control-$VERSION}" +BUNDLE="$(realpath -m -- "$BUNDLE")" +ROOT_REAL="$(realpath -- "$ROOT")" +USER_HOME="$(realpath -- "${HOME:?}")" + +case "$BUNDLE" in + /|"$ROOT_REAL"|"$USER_HOME") + echo "error: unsafe release output path: $BUNDLE" >&2 + exit 2 + ;; +esac +if [[ -e "$BUNDLE" || -L "$BUNDLE" ]]; then + echo "error: release output already exists; refusing to overwrite: $BUNDLE" >&2 + exit 2 +fi +if ! python3 -m build --version >/dev/null 2>&1; then + echo "error: install the Python build frontend (python3 -m pip install build)" >&2 + exit 2 +fi +if ! python3 -c ' +import importlib.metadata as metadata +raise SystemExit( + metadata.version("setuptools") != "83.0.0" + or metadata.version("wheel") != "0.47.0" +) +'; then + echo "error: reproducible builds require setuptools 83.0.0 and wheel 0.47.0" >&2 + exit 2 fi -GIT_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)" -BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -BUNDLE="${1:-dist/release/honor-control-$VERSION}" -echo "==> Assembling release bundle for honor-control $VERSION" +if [[ -n "$HONOR_TOOLS_WHEEL" ]]; then + HONOR_TOOLS_WHEEL="$(realpath -- "$HONOR_TOOLS_WHEEL")" + [[ -f "$HONOR_TOOLS_WHEEL" ]] || { + echo "error: honor-tools wheel not found: $HONOR_TOOLS_WHEEL" >&2 + exit 2 + } + [[ "$(basename "$HONOR_TOOLS_WHEEL")" == honor_tools-0.1.0-*.whl ]] || { + echo "error: expected a reviewed honor_tools-0.1.0 wheel" >&2 + exit 2 + } +else + HONOR_TOOLS_ROOT="$(realpath -m -- "$ROOT/../honor-tools")" + [[ -f "$HONOR_TOOLS_ROOT/pyproject.toml" ]] || { + echo "error: provide --honor-tools-wheel or place reviewed source at" >&2 + echo " $ROOT/../honor-tools" >&2 + exit 2 + } +fi + +echo "==> Building honor-control $VERSION offline bundle" echo " commit: $GIT_COMMIT" -echo " date: $BUILD_DATE" echo " output: $BUNDLE" +COMPLETE=false +install -d -m 0755 "$BUNDLE/wheelhouse" "$BUNDLE/scripts" +BUILD_ROOT="$(mktemp -d /tmp/honor-control-release-build.XXXXXX)" +cleanup() { + rm -rf -- "$BUILD_ROOT" + if [[ "$COMPLETE" == false ]]; then + rm -rf -- "$BUNDLE" + fi +} +trap cleanup EXIT -rm -rf "$BUNDLE" -install -d -m 0755 "$BUNDLE/wheelhouse" "$BUNDLE/honor-tools" "$BUNDLE/scripts" - -echo "==> [1/6] Building honor_control wheel + sdist" -if ! python3 -c "import build" 2>/dev/null; then - echo "error: python -m build requires the build package (pip install build)" >&2 +# Fixed timestamps and a pinned build backend make repeated builds of the same +# commit byte-for-byte reproducible. +export SOURCE_DATE_EPOCH PYTHONHASHSEED=0 TZ=UTC LC_ALL=C +install -d -m 0755 "$BUILD_ROOT/honor-control" +install -m 0644 pyproject.toml README.md LICENSE "$BUILD_ROOT/honor-control/" +cp -a honor_control tests "$BUILD_ROOT/honor-control/" +python3 -m build --no-isolation \ + --outdir "$BUNDLE/wheelhouse" "$BUILD_ROOT/honor-control" +shopt -s nullglob +APP_SDISTS=("$BUNDLE/wheelhouse"/honor_control-*.tar.gz) +shopt -u nullglob +[[ ${#APP_SDISTS[@]} -eq 1 ]] || { + echo "error: expected exactly one honor-control source distribution" >&2 exit 2 -fi -python3 -m build --outdir "$BUNDLE/wheelhouse" >/dev/null +} +bash "$HERE/normalize-sdist.sh" "${APP_SDISTS[0]}" "$SOURCE_DATE_EPOCH" -echo "==> [2/6] Copying packaging tree and installer scripts" -cp -a packaging "$BUNDLE/packaging" -install -m 0755 scripts/install-local.sh scripts/uninstall-local.sh scripts/rollback.sh "$BUNDLE/scripts/" +if [[ -n "$HONOR_TOOLS_WHEEL" ]]; then + install -m 0644 "$HONOR_TOOLS_WHEEL" "$BUNDLE/wheelhouse/" +else + cp -a "$HONOR_TOOLS_ROOT" "$BUILD_ROOT/honor-tools" + rm -rf -- \ + "$BUILD_ROOT/honor-tools/build" \ + "$BUILD_ROOT/honor-tools/honor_tools.egg-info" + python3 -m build --no-isolation --wheel \ + --outdir "$BUNDLE/wheelhouse" "$BUILD_ROOT/honor-tools" +fi -echo "==> [3/6] Creating honor-tools drop-in inbox" -cat > "$BUNDLE/honor-tools/README.txt" <<'NOTE' -Drop the REVIEWED honor-tools 0.1.0 wheel into the sibling wheelhouse/ -directory, then regenerate the checksums before distributing: +# Resolve all runtime and GUI dependencies to wheels now. Installation never +# reaches a package index and never runs a build backend. +python3 -m pip download \ + --dest "$BUNDLE/wheelhouse" \ + --only-binary=:all: \ + "sdbus==0.14.2" \ + "PySide6==6.11.1" - cd /wheelhouse && sha256sum ./*.whl ./*.tar.gz > SHA256SUMS - cd && find . -type f ! -path ./SHA256SUMS -printf "%P\n" \ - | LC_ALL=C sort | while read -r f; do sha256sum "$f"; done > SHA256SUMS +# Exercise the same wheel-only install path used by the system installer in a +# brand-new virtual environment before publishing any manifest. +python3 -m venv "$BUILD_ROOT/verify-venv" +"$BUILD_ROOT/verify-venv/bin/pip" install --quiet \ + --no-index \ + --find-links "$BUNDLE/wheelhouse" \ + "honor-tools==0.1.0" \ + "honor-control[gui]==$VERSION" +"$BUILD_ROOT/verify-venv/bin/python" -m pip check +"$BUILD_ROOT/verify-venv/bin/python" -c \ + 'import honor_control, honor, sdbus, PySide6' +for command in \ + honor-control-service honorctl honor-touchpadctl \ + honor-control-gui honor-control-tray; do + test -x "$BUILD_ROOT/verify-venv/bin/$command" +done -Obtaining and reviewing the honor-tools wheel is an EXTERNAL step; this -bundle ships without it. -NOTE +cp -a packaging "$BUNDLE/packaging" +cp -a honor_control "$BUNDLE/honor_control" +find "$BUNDLE/honor_control" -type d -name __pycache__ -prune -exec rm -rf {} + +install -m 0644 pyproject.toml README.md LICENSE "$BUNDLE/" +install -m 0755 \ + scripts/install-local.sh \ + scripts/uninstall-local.sh \ + scripts/rollback.sh \ + "$BUNDLE/scripts/" -echo "==> [4/6] Writing sbom.json (pinned dependencies)" cat > "$BUNDLE/sbom.json" <<'JSON' { - "bomFormat": "honor-control-minimal-sbom", - "specVersion": "1.0", + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, "metadata": { - "component": { "name": "honor-control", "type": "application" } + "component": {"type": "application", "name": "honor-control"} }, "components": [ - { "type": "library", "name": "sdbus", "version": "0.14.2", "purl": "pkg:pypi/sdbus@0.14.2", "scope": "runtime" }, - { "type": "library", "name": "PySide6", "version": "6.11.1", "purl": "pkg:pypi/pyside6@6.11.1", "scope": "runtime" }, - { "type": "library", "name": "setuptools", "version": "83.0.0", "purl": "pkg:pypi/setuptools@83.0.0", "scope": "build" }, - { "type": "library", "name": "wheel", "version": "0.47.0", "purl": "pkg:pypi/wheel@0.47.0", "scope": "build" } + {"type": "library", "name": "honor-tools", "version": "0.1.0"}, + {"type": "library", "name": "sdbus", "version": "0.14.2"}, + {"type": "library", "name": "PySide6", "version": "6.11.1"} ] } JSON -echo "==> [5/6] Writing MANIFEST and wheelhouse checksums" -# Checksums for the offline wheelhouse (consumed by install-local.sh --wheelhouse). ( cd "$BUNDLE/wheelhouse" - sha256sum ./*.whl ./*.tar.gz > SHA256SUMS + find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | + LC_ALL=C sort | + while IFS= read -r artifact; do + sha256sum -- "$artifact" + done > "$BUILD_ROOT/wheelhouse.SHA256SUMS" + mv "$BUILD_ROOT/wheelhouse.SHA256SUMS" SHA256SUMS ) -# MANIFEST records provenance plus the artifact list (paths relative to bundle). + ( cd "$BUNDLE" { @@ -92,28 +207,23 @@ echo "==> [5/6] Writing MANIFEST and wheelhouse checksums" echo "version=$VERSION" echo "git_commit=$GIT_COMMIT" echo "date=$BUILD_DATE" + echo "source_date_epoch=$SOURCE_DATE_EPOCH" echo "python=$(python3 --version 2>&1)" - echo "" + echo echo "# artifacts" - find . -type f ! -path ./SHA256SUMS ! -path ./MANIFEST -printf "%P\n" | LC_ALL=C sort + find . -type f ! -path ./SHA256SUMS ! -path ./MANIFEST -printf "%P\n" | + LC_ALL=C sort } > MANIFEST + find . -type f ! -path ./SHA256SUMS -printf '%P\n' | + LC_ALL=C sort | + while IFS= read -r artifact; do + sha256sum -- "$artifact" + done > "$BUILD_ROOT/bundle.SHA256SUMS" + mv "$BUILD_ROOT/bundle.SHA256SUMS" SHA256SUMS + sha256sum --strict -c SHA256SUMS >/dev/null ) -echo "==> [6/6] Writing top-level SHA256SUMS over all artifacts" -( - cd "$BUNDLE" - find . -type f ! -path ./SHA256SUMS -printf "%P\n" | LC_ALL=C sort \ - | while read -r f; do sha256sum "$f"; done > SHA256SUMS -) - -echo "" -echo "==> Release bundle assembled at: $BUNDLE" -echo "" -echo "EXTERNAL STEPS (not performed by this script):" -echo " 1. Obtain the REVIEWED honor-tools 0.1.0 wheel, drop it into" -echo " $BUNDLE/wheelhouse/ , and regenerate the checksums (see" -echo " $BUNDLE/honor-tools/README.txt)." -echo " 2. Sign and publish the bundle (for example, GPG-sign SHA256SUMS)." -echo "" -echo "Offline install on a supported host:" -echo " sudo bash scripts/install-local.sh --wheelhouse $BUNDLE/wheelhouse" +COMPLETE=true +echo "==> Complete offline bundle assembled at $BUNDLE" +echo " Sign SHA256SUMS before distribution." +echo " Install with: sudo bash scripts/install-local.sh --wheelhouse wheelhouse" diff --git a/scripts/normalize-sdist.sh b/scripts/normalize-sdist.sh new file mode 100755 index 0000000..c5be3d0 --- /dev/null +++ b/scripts/normalize-sdist.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Normalize a locally built source distribution for reproducible hashing. +set -euo pipefail + +[[ $# -eq 2 ]] || { + echo "usage: normalize-sdist.sh ARCHIVE SOURCE_DATE_EPOCH" >&2 + exit 2 +} +ARCHIVE="$(realpath -- "$1")" +EPOCH="$2" +[[ -f "$ARCHIVE" && "$ARCHIVE" == *.tar.gz ]] || { + echo "error: source distribution not found: $ARCHIVE" >&2 + exit 2 +} +[[ "$EPOCH" =~ ^[0-9]+$ ]] || { + echo "error: SOURCE_DATE_EPOCH must be an integer" >&2 + exit 2 +} + +WORK="$(mktemp -d /tmp/honor-control-sdist.XXXXXX)" +OUTPUT="$(mktemp "$(dirname "$ARCHIVE")/.normalized-sdist.XXXXXX")" +cleanup() { + rm -rf -- "$WORK" + rm -f -- "$OUTPUT" +} +trap cleanup EXIT + +tar -xzf "$ARCHIVE" -C "$WORK" +mapfile -t ROOTS < <( + find "$WORK" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' +) +[[ ${#ROOTS[@]} -eq 1 ]] || { + echo "error: expected one top-level source directory" >&2 + exit 2 +} + +tar \ + --sort=name \ + --format=posix \ + --pax-option=delete=atime,delete=ctime \ + --mtime="@$EPOCH" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -C "$WORK" \ + -cf - "${ROOTS[0]}" | + gzip -n -9 > "$OUTPUT" +chmod 0644 "$OUTPUT" +mv -f -- "$OUTPUT" "$ARCHIVE" diff --git a/scripts/rollback.sh b/scripts/rollback.sh index f2c8fb7..61f9c50 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -1,15 +1,5 @@ #!/usr/bin/env bash -# Roll back honor-control to the retained predecessor release. -# -# The system installer keeps exactly two releases under -# /opt/honor-control/releases: the active one (the target of the -# /opt/honor-control/current symlink) and one known-good predecessor. -# This script atomically repoints the current symlink to that predecessor -# and restarts the service. It refuses if no predecessor is retained. -# -# Usage: -# sudo bash scripts/rollback.sh -# sudo bash scripts/install-local.sh --rollback # equivalent +# Transactionally restore the predecessor release and its managed system files. set -euo pipefail INSTALL_ROOT=/opt/honor-control @@ -24,35 +14,186 @@ if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; the fi CURRENT="$(readlink -f "$INSTALL_ROOT/current" 2>/dev/null || true)" -if [[ -z "$CURRENT" || ! -d "$CURRENT" ]]; then +[[ -n "$CURRENT" && -d "$CURRENT" ]] || { echo "error: no active release at $INSTALL_ROOT/current" >&2 exit 1 -fi +} +ROLLBACK_STATE="$CURRENT/rollback-state" +MANAGED="$ROLLBACK_STATE/managed.tsv" +ENV_FILE="$ROLLBACK_STATE/release.env" +[[ -f "$MANAGED" && -f "$ENV_FILE" ]] || { + echo "error: active release has no complete rollback state" >&2 + echo " refusing a partial code-only rollback" >&2 + exit 1 +} + +read_value() { + local key=$1 + sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1 +} -# The predecessor is the most recent retained release that is not active. -PREDECESSOR="$( - for candidate in "$INSTALL_ROOT"/releases/*; do - [[ -d "$candidate" ]] || continue - [[ "$candidate" == "$CURRENT" ]] && continue - echo "$candidate" - done | sort | tail -n 1 -)" -if [[ -z "$PREDECESSOR" || ! -d "$PREDECESSOR/venv" ]]; then - echo "error: no retained predecessor release to roll back to" >&2 - echo " (the installer retains only the active release and one predecessor)" >&2 +PREDECESSOR="$(read_value previous_release)" +CONTROL_ENABLED="$(read_value honor_control_enabled)" +CONTROL_ACTIVE="$(read_value honor_control_active)" +TOUCHPAD_ENABLED="$(read_value touchpad_restore_enabled)" +TOUCHPAD_ACTIVE="$(read_value touchpad_restore_active)" + +PREDECESSOR="$(realpath -- "$PREDECESSOR" 2>/dev/null || true)" +[[ "$PREDECESSOR" == "$INSTALL_ROOT"/releases/* && -d "$PREDECESSOR/venv" ]] || { + echo "error: recorded predecessor is missing or invalid: $PREDECESSOR" >&2 exit 1 -fi +} +for value in \ + "$CONTROL_ENABLED" "$CONTROL_ACTIVE" \ + "$TOUCHPAD_ENABLED" "$TOUCHPAD_ACTIVE"; do + [[ "$value" == true || "$value" == false ]] || { + echo "error: invalid rollback service-state metadata" >&2 + exit 1 + } +done + +TRANSACTION="$(mktemp -d /tmp/honor-control-rollback.XXXXXX)" +CURRENT_CONTROL_ENABLED=false +CURRENT_CONTROL_ACTIVE=false +CURRENT_TOUCHPAD_ENABLED=false +CURRENT_TOUCHPAD_ACTIVE=false +systemctl is-enabled --quiet honor-control.service 2>/dev/null && \ + CURRENT_CONTROL_ENABLED=true +systemctl is-active --quiet honor-control.service 2>/dev/null && \ + CURRENT_CONTROL_ACTIVE=true +systemctl is-enabled --quiet honor-touchpad-restore.service 2>/dev/null && \ + CURRENT_TOUCHPAD_ENABLED=true +systemctl is-active --quiet honor-touchpad-restore.service 2>/dev/null && \ + CURRENT_TOUCHPAD_ACTIVE=true + +validate_destination() { + case "$1" in + /usr/bin/honor-control-service|/usr/bin/honor-control-gui|\ + /usr/bin/honor-control-tray|/usr/bin/honor-touchpadctl|\ + /usr/bin/honorctl|\ + /etc/honor-control/touchpad-firmware-qualified|\ + /etc/systemd/system/honor-control.service|\ + /etc/systemd/system/honor-touchpad-restore.service|\ + /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop|\ + /usr/lib/honor-touchpad/honor_control/__init__.py|\ + /usr/lib/honor-touchpad/honor_control/backend/__init__.py|\ + /usr/lib/honor-touchpad/honor_control/backend/touchpad_firmware.py|\ + /usr/lib/honor-touchpad/honor_control/cli/__init__.py|\ + /usr/lib/honor-touchpad/honor_control/cli/touchpadctl.py|\ + /usr/lib/honor-touchpad/honor_control/core/__init__.py|\ + /usr/lib/honor-touchpad/honor_control/core/touchpad.py|\ + /usr/lib/modules-load.d/honor-control.conf|\ + /usr/lib/systemd/system-sleep/honor-touchpad-restore|\ + /usr/share/applications/org.honorlinux.Control.desktop|\ + /usr/share/applications/org.honorlinux.Control.Tray.desktop|\ + /usr/share/dbus-1/system.d/org.honorlinux.Control1.conf|\ + /usr/share/dbus-1/system-services/org.honorlinux.Control1.service|\ + /usr/share/doc/honor-control/honor-touchpad.example.toml|\ + /usr/share/polkit-1/actions/org.honorlinux.control.policy) + ;; + *) + echo "error: unsafe rollback destination in manifest: $1" >&2 + return 1 + ;; + esac +} + +# Capture the current release's system files so every rollback step can itself +# be rolled back if the predecessor fails its health check. +while IFS=$'\t' read -r index _existed destination; do + [[ "$index" =~ ^[0-9]+$ ]] || { + echo "error: invalid rollback manifest index" >&2 + exit 1 + } + validate_destination "$destination" + if [[ -e "$destination" || -L "$destination" ]]; then + cp -a -- "$destination" "$TRANSACTION/$index" + printf '%s\t1\t%s\n' "$index" "$destination" >> "$TRANSACTION/current.tsv" + else + printf '%s\t0\t%s\n' "$index" "$destination" >> "$TRANSACTION/current.tsv" + fi +done < "$MANAGED" + +set_enabled() { + local unit=$1 enabled=$2 + if [[ "$enabled" == true ]]; then + systemctl enable "$unit" >/dev/null 2>&1 + else + systemctl disable "$unit" >/dev/null 2>&1 || true + fi +} + +restore_files() { + local manifest=$1 backups=$2 index existed destination + while IFS=$'\t' read -r index existed destination; do + validate_destination "$destination" + rm -f -- "$destination" + if [[ "$existed" == 1 ]]; then + install -d -m 0755 "$(dirname "$destination")" + cp -a -- "$backups/$index" "$destination" + fi + done < "$manifest" +} + +restore_current() { + set +e + restore_files "$TRANSACTION/current.tsv" "$TRANSACTION" + ln -sfn "$CURRENT" "$INSTALL_ROOT/current" + systemctl daemon-reload + set_enabled honor-control.service "$CURRENT_CONTROL_ENABLED" + set_enabled honor-touchpad-restore.service "$CURRENT_TOUCHPAD_ENABLED" + if [[ "$CURRENT_CONTROL_ACTIVE" == true ]]; then + systemctl restart honor-control.service + else + systemctl stop honor-control.service + fi + if [[ "$CURRENT_TOUCHPAD_ACTIVE" == true ]]; then + systemctl restart honor-touchpad-restore.service + else + systemctl stop honor-touchpad-restore.service + fi + set -e +} + +COMMITTED=false +cleanup() { + local status=$? + trap - EXIT + if [[ "$COMMITTED" == false ]]; then + echo "error: rollback failed; restoring the original release" >&2 + restore_current + fi + rm -rf -- "$TRANSACTION" + exit "$status" +} +trap cleanup EXIT + +echo "==> Rolling back $(basename "$CURRENT") -> $(basename "$PREDECESSOR")" +systemctl stop honor-control.service 2>/dev/null || true +systemctl stop honor-touchpad-restore.service 2>/dev/null || true +restore_files "$MANAGED" "$ROLLBACK_STATE/backups" -echo "==> Rolling back: $(basename "$CURRENT") -> $(basename "$PREDECESSOR")" -# Atomic switch: create a temporary symlink, then rename() it over current. TMP_LINK="$INSTALL_ROOT/.current.rollback.$$" ln -sfn "$PREDECESSOR" "$TMP_LINK" mv -T "$TMP_LINK" "$INSTALL_ROOT/current" -systemctl daemon-reload 2>/dev/null || true -if systemctl restart honor-control.service; then - echo "==> Rolled back to $(basename "$PREDECESSOR"); honor-control.service restarted." +systemctl daemon-reload +systemctl reload dbus 2>/dev/null || true +set_enabled honor-control.service "$CONTROL_ENABLED" +set_enabled honor-touchpad-restore.service "$TOUCHPAD_ENABLED" + +if [[ "$CONTROL_ACTIVE" == true ]]; then + systemctl restart honor-control.service + systemctl is-active --quiet honor-control.service else - echo "error: honor-control.service failed to restart after rollback" >&2 - exit 1 + systemctl stop honor-control.service 2>/dev/null || true +fi +if [[ "$TOUCHPAD_ACTIVE" == true ]]; then + systemctl restart honor-touchpad-restore.service + systemctl is-active --quiet honor-touchpad-restore.service +else + systemctl stop honor-touchpad-restore.service 2>/dev/null || true fi + +COMMITTED=true +echo "==> Rollback complete: $(basename "$PREDECESSOR")" diff --git a/scripts/uninstall-local.sh b/scripts/uninstall-local.sh index bff28a6..415318f 100755 --- a/scripts/uninstall-local.sh +++ b/scripts/uninstall-local.sh @@ -86,6 +86,7 @@ else remove_if_owned /usr/share/applications/org.honorlinux.Control.Tray.desktop "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" remove_if_owned /etc/xdg/autostart/org.honorlinux.Control.Tray.desktop "$ROOT/packaging/desktop/org.honorlinux.Control.Tray.desktop" remove_if_owned /etc/systemd/system/honor-touchpad-restore.service "$ROOT/packaging/systemd/honor-touchpad-restore.service" + remove_if_owned /etc/honor-control/touchpad-firmware-qualified "$ROOT/packaging/touchpad/touchpad-firmware-qualified" remove_if_owned /usr/lib/systemd/system-sleep/honor-touchpad-restore "$ROOT/packaging/systemd/honor-touchpad-system-sleep" remove_if_owned /usr/share/doc/honor-control/honor-touchpad.example.toml "$ROOT/packaging/touchpad/honor-touchpad.example.toml" fi diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..95d2d18 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared test isolation for process-wide hardware safety markers.""" + +from __future__ import annotations + +import pathlib + +import pytest + + +@pytest.fixture(autouse=True) +def isolate_touchpad_safety_files( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", raising=False) + monkeypatch.setenv( + "HONOR_CONTROL_TOUCHPAD_QUALIFICATION_FILE", + str(tmp_path / "touchpad-firmware-qualified"), + ) + monkeypatch.setenv( + "HONOR_CONTROL_TOUCHPAD_LOCK", + str(tmp_path / "touchpad.lock"), + ) diff --git a/tests/test_application.py b/tests/test_application.py index 50c505a..aae273a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1133,7 +1133,10 @@ def test_mapping_does_not_write_honor_tools_config(self, tmp_path) -> None: names = [name for name, _args, _kwargs in hardware.call_log] assert "set_gesture_mapping" not in names - def test_daemon_lifecycle_is_persisted_and_observable(self, tmp_path) -> None: + def test_daemon_lifecycle_is_persisted_and_observable( + self, tmp_path, monkeypatch + ) -> None: + monkeypatch.setenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", "1") runtime = _StubGestureRuntime() svc = ApplicationService( FakeHardware(), @@ -1147,6 +1150,9 @@ async def scenario() -> None: assert result.applied is True assert svc.config_store.state.gestures.daemon_enabled is True assert (await svc.get_snapshot()).gestures.daemon_running is True + applied = await svc.apply_touchpad_settings({"sensitivity": 1}) + assert applied.status == OperationStatus.SUCCESS + assert runtime.status.running is True result = await svc.set_gesture_daemon_enabled(False) assert result.applied is True assert (await svc.get_snapshot()).gestures.daemon_running is False @@ -1436,11 +1442,19 @@ def test_power_rollback_failure_latches_then_reconcile_clears( asyncio.run(svc.initialize()) async def scenario() -> None: - svc._record_power_rollback_failure( # noqa: SLF001 + await svc._record_power_rollback_failure( # noqa: SLF001 "balanced", {"rollback": {"attempted": True, "ok": False}, "observed": {}}, ) assert svc._power_recovery_required is True # noqa: SLF001 + assert svc.config_store.state.power.recovery_required is True + persisted = ConfigStore(svc.config_store.state_path).load() + assert persisted.power.recovery_required is True + assert persisted.power.recovery_profile == "balanced" + restarted = _make_service(tmp_path) + await restarted.initialize() + assert restarted._power_recovery_required is True # noqa: SLF001 + await restarted.shutdown() await svc._refresh_service_health() # noqa: SLF001 snap = await svc.get_snapshot() assert snap.service.power_recovery_required is True @@ -1449,5 +1463,6 @@ async def scenario() -> None: result = await svc.reconcile_power() assert result.status == OperationStatus.SUCCESS assert svc._power_recovery_required is False # noqa: SLF001 + assert svc.config_store.state.power.recovery_required is False asyncio.run(scenario()) diff --git a/tests/test_backend.py b/tests/test_backend.py index 98bbefa..8afa22f 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -378,6 +378,29 @@ async def scenario() -> None: asyncio.run(scenario()) + def test_cleanup_timeout_is_not_a_clean_stop(self) -> None: + sup = RuntimeSupervisor() + + async def start_func() -> None: + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + pass + + async def hanging_cleanup() -> None: + await asyncio.sleep(100) + + async def scenario() -> None: + sup.register("slow-cleanup", start_func, hanging_cleanup) + await sup.start("slow-cleanup") + await asyncio.sleep(0) + await sup.stop("slow-cleanup", timeout=0.01) + health = sup.get_health("slow-cleanup") + assert health.state == ControllerState.CLEANUP_FAILED + assert "cleanup did not finish" in health.last_fault + + asyncio.run(scenario()) + def test_restart_policy_is_bounded(self) -> None: sup = RuntimeSupervisor() attempts = 0 diff --git a/tests/test_client.py b/tests/test_client.py index 68391b6..dae0019 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -28,10 +28,12 @@ from honor_control.core.models import ( BatterySnapshot, BatteryStatusKind, + GesturesSnapshot, OperationResult, OperationStatus, PowerProfileEntry, PowerSnapshot, + ServiceHealth, SystemSnapshot, ) @@ -228,12 +230,20 @@ def test_decode_complete_snapshot(self) -> None: data = snapshot_to_vardict( SystemSnapshot( sequence=9, + service=ServiceHealth( + hardware_queue_stuck=True, + stuck_command="fan_read", + fan_recovery_required=True, + power_recovery_required=True, + controller_restart_counts={"refresh": 2}, + ), battery=BatterySnapshot( available=True, capacity_percent=72, status=BatteryStatusKind.DISCHARGING, ), power=PowerSnapshot(available=True, applied_profile="balanced"), + gestures=GesturesSnapshot(firmware_writes_qualified=True), ) ) snap = _decode_snapshot(data) @@ -241,6 +251,12 @@ def test_decode_complete_snapshot(self) -> None: assert snap.battery.capacity_percent == 72 assert str(snap.battery.status) == "discharging" assert snap.power.applied_profile == "balanced" + assert snap.service.hardware_queue_stuck is True + assert snap.service.stuck_command == "fan_read" + assert snap.service.fan_recovery_required is True + assert snap.service.power_recovery_required is True + assert snap.service.controller_restart_counts == {"refresh": 2} + assert snap.gestures.firmware_writes_qualified is True def test_decode_editable_power_profile(self) -> None: profile = PowerProfileEntry( @@ -353,6 +369,36 @@ async def timeout() -> SystemSnapshot: asyncio.run(scenario()) +class TestSdbusClientTimeouts: + def test_reads_and_mutations_use_separate_deadlines(self, monkeypatch) -> None: + client = SdbusClient(timeout=90.0, read_timeout=4.0) + observed: list[float] = [] + real_wait_for = asyncio.wait_for + + async def record_wait_for(awaitable, *, timeout): + observed.append(timeout) + return await real_wait_for(awaitable, timeout=timeout) + + async def method(*_args): + return 1 + + client._proxy = SimpleNamespace( # noqa: SLF001 + GetApiVersion=method, + SetProfile=method, + ) + monkeypatch.setattr( + "honor_control.client.sdbus_client.asyncio.wait_for", + record_wait_for, + ) + + async def scenario() -> None: + await client._call_method("GetApiVersion") # noqa: SLF001 + await client._call_method("SetProfile", "balanced") # noqa: SLF001 + + asyncio.run(scenario()) + assert observed == [4.0, 90.0] + + class TestSdbusClientHandshake: @pytest.mark.parametrize( ("api_version", "schema_version"), diff --git a/tests/test_config_store.py b/tests/test_config_store.py index 8ecf185..338352e 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -359,6 +359,18 @@ def test_v2_document_migrates_in_memory(self) -> None: assert state.gpu.mitigation_enabled is True assert state.touchpad.settings == {} + def test_v3_document_adds_power_recovery_marker(self) -> None: + data = _state_to_dict(default_state()) + data["schema_version"] = 3 + data["power"].pop("recovery_required") + data["power"].pop("recovery_profile") + + state = _state_from_dict(data) + + assert state.schema_version == STATE_SCHEMA_VERSION + assert state.power.recovery_required is False + assert state.power.recovery_profile == "" + def test_current_version_file_is_not_snapshotted( self, tmp_state_path: pathlib.Path ) -> None: @@ -408,9 +420,9 @@ def test_missing_migration_step_is_rejected(self, monkeypatch) -> None: class TestConfigStoreUnknownKeys: - """HC-012: unknown keys warn and surface, but never fail a valid load.""" + """HC-012: unknown hardware-state keys fail closed.""" - def test_unknown_keys_are_surfaced_not_fatal( + def test_unknown_keys_make_store_invalid( self, tmp_state_path: pathlib.Path ) -> None: data = _state_to_dict(default_state()) @@ -419,14 +431,13 @@ def test_unknown_keys_are_surfaced_not_fatal( tmp_state_path.write_text(_toml_dump(data), encoding="utf-8") store = ConfigStore(state_path=tmp_state_path) - state = store.load() + store.load() - assert store.valid is True - assert state == default_state() - assert "power.unknown_key" in store.unknown_keys - assert "bogus_section" in store.unknown_keys + assert store.valid is False + assert "power.unknown_key" in store.last_error + assert "bogus_section" in store.last_error - def test_unknown_keys_inside_dynamic_entries( + def test_unknown_keys_inside_dynamic_entries_are_rejected( self, tmp_state_path: pathlib.Path ) -> None: data = _state_to_dict(default_state()) @@ -436,8 +447,8 @@ def test_unknown_keys_inside_dynamic_entries( store = ConfigStore(state_path=tmp_state_path) store.load() - assert store.valid is True - assert "power.profiles.balanced.typo_field" in store.unknown_keys + assert store.valid is False + assert "power.profiles.balanced.typo_field" in store.last_error def test_clean_file_reports_no_unknown_keys( self, tmp_state_path: pathlib.Path diff --git a/tests/test_core.py b/tests/test_core.py index f98c11a..425ad78 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -380,9 +380,27 @@ def test_lock_reports_busy_while_held_then_releases(self, tmp_path) -> None: with touchpad_firmware_lock(lock_path): pass - def test_lock_degrades_when_path_unwritable(self, monkeypatch) -> None: + def test_lock_fails_closed_when_path_unwritable(self) -> None: from honor_control.core.touchpad import touchpad_firmware_lock - # An uncreatable lock path (unprivileged /run) must not fail the op. - with touchpad_firmware_lock("/proc/definitely-not-writable/x.lock"): - pass + with pytest.raises(RuntimeError, match="cannot acquire"): + with touchpad_firmware_lock("/proc/definitely-not-writable/x.lock"): + pass + + def test_qualification_marker_must_have_exact_content( + self, tmp_path, monkeypatch + ) -> None: + from honor_control.core.touchpad import ( + touchpad_firmware_writes_qualified, + ) + + marker = tmp_path / "qualified" + monkeypatch.delenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", raising=False) + monkeypatch.setenv( + "HONOR_CONTROL_TOUCHPAD_QUALIFICATION_FILE", str(marker) + ) + assert touchpad_firmware_writes_qualified() is False + marker.write_text("qualified=yes\n", encoding="ascii") + assert touchpad_firmware_writes_qualified() is False + marker.write_text("qualified=1\n", encoding="ascii") + assert touchpad_firmware_writes_qualified() is True diff --git a/tests/test_dbus_roundtrip.py b/tests/test_dbus_roundtrip.py index 457ed99..d4ce4e8 100644 --- a/tests/test_dbus_roundtrip.py +++ b/tests/test_dbus_roundtrip.py @@ -55,15 +55,24 @@ def private_bus_address(monkeypatch: pytest.MonkeyPatch): address = proc.stdout.readline().strip() if not address or proc.poll() is not None: proc.kill() + proc.wait() + proc.stdout.close() + assert proc.stderr is not None + proc.stderr.close() pytest.skip("could not start a private D-Bus daemon") monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", address) - yield address - proc.terminate() try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + yield address + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + proc.stdout.close() + assert proc.stderr is not None + proc.stderr.close() async def _connect_with_retry(attempts: int = 100, delay: float = 0.1) -> SdbusClient: @@ -74,7 +83,11 @@ async def _connect_with_retry(attempts: int = 100, delay: float = 0.1) -> SdbusC """ last: Exception | None = None for _ in range(attempts): - client = SdbusClient(bus_kind="session", timeout=30.0) + client = SdbusClient( + bus_kind="session", + timeout=30.0, + read_timeout=30.0, + ) try: await client.connect() return client diff --git a/tests/test_gesture_runtime.py b/tests/test_gesture_runtime.py index 4333b4e..b20ba61 100644 --- a/tests/test_gesture_runtime.py +++ b/tests/test_gesture_runtime.py @@ -9,6 +9,8 @@ import pathlib import struct +import pytest + from honor_control.backend.config_store import GestureMappingState from honor_control.backend.gesture_runtime import ( EV_KEY, @@ -22,6 +24,7 @@ probe_gesture_environment, ) from honor_control.core.gestures import gesture_keys_from_report +from honor_control.core.touchpad import TouchpadLockBusy, touchpad_firmware_lock VENDOR_DESCRIPTOR = bytes.fromhex( "06 00 ff 09 01 a1 01 85 0e 75 08 95 08 81 02 91 02 c0" @@ -310,3 +313,26 @@ async def exercise() -> None: assert "injected I/O failure" in runtime.status.last_error # A clean stop resets the backoff for the next run(). assert runtime._retry_backoff == 2.0 # noqa: SLF001 + + +def test_running_reader_holds_cross_process_firmware_lock(monkeypatch) -> None: + runtime = GestureRuntime(lambda: {}) + + async def exercise() -> None: + started = asyncio.Event() + + async def active_session() -> None: + started.set() + await asyncio.Future() + + monkeypatch.setattr(runtime, "_run_session", active_session) + task = asyncio.create_task(runtime.run()) + await started.wait() + with pytest.raises(TouchpadLockBusy): + with touchpad_firmware_lock(): + pass + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(exercise()) diff --git a/tests/test_gui.py b/tests/test_gui.py index ba1b3ee..b016fc4 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import gc import os import threading import time @@ -17,6 +18,7 @@ # Set offscreen before importing Qt. os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import shiboken6 # noqa: E402 from PySide6.QtCore import Qt # noqa: E402 from PySide6.QtTest import QTest # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402 @@ -43,6 +45,10 @@ def qapp(): """Single QApplication for all GUI tests.""" app = QApplication.instance() or QApplication([]) yield app + app.closeAllWindows() + app.processEvents() + shiboken6.delete(app) + gc.collect() class TestGuiState: @@ -480,6 +486,7 @@ def test_gestures_page_construction(self, qapp) -> None: snap = SystemSnapshot( gestures=GesturesSnapshot( firmware_settings_supported=True, + firmware_writes_qualified=True, firmware_settings={"edge_volume": 1, "sensitivity": 0}, ) ) @@ -497,6 +504,7 @@ def test_touchpad_editor_batches_and_preserves_dirty_values(self, qapp) -> None: sequence=1, gestures=GesturesSnapshot( firmware_settings_supported=True, + firmware_writes_qualified=True, firmware_settings={"edge_volume": 1}, ), ) @@ -517,6 +525,27 @@ def test_touchpad_editor_batches_and_preserves_dirty_values(self, qapp) -> None: ) ] + def test_touchpad_editor_is_disabled_until_writes_are_qualified( + self, qapp + ) -> None: + from honor_control.frontend.gui.pages.touchpad import TouchpadPage + + state = GuiState() + state.set_connected(True) + page = TouchpadPage(state) + state.set_snapshot( + SystemSnapshot( + gestures=GesturesSnapshot( + firmware_settings_supported=True, + firmware_writes_qualified=False, + firmware_settings={"edge_volume": 1}, + ) + ) + ) + + assert page.apply_button.isEnabled() is False + assert "not qualified" in page.firmware_summary.text().lower() + def test_overview_quick_profile_action_preserves_controller_intent( self, qapp ) -> None: diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index e4a9fb5..4854d1c 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -8,6 +8,11 @@ ROOT = pathlib.Path(__file__).parent.parent INSTALL = ROOT / "scripts/install-local.sh" UNINSTALL = ROOT / "scripts/uninstall-local.sh" +INSTALL_TOUCHPAD = ROOT / "scripts/install-touchpad-only.sh" +UNINSTALL_TOUCHPAD = ROOT / "scripts/uninstall-touchpad-only.sh" +RELEASE = ROOT / "scripts/make-release-bundle.sh" +ROLLBACK = ROOT / "scripts/rollback.sh" +NORMALIZE_SDIST = ROOT / "scripts/normalize-sdist.sh" SMOKE = ROOT / "scripts/smoke-test.sh" TOUCHPAD_WRAPPER = ROOT / "packaging/touchpad/honor-touchpadctl" SLEEP_HOOK = ROOT / "packaging/systemd/honor-touchpad-system-sleep" @@ -15,7 +20,17 @@ def test_install_scripts_parse_with_bash() -> None: subprocess.run( - ["bash", "-n", str(INSTALL), str(UNINSTALL)], + [ + "bash", + "-n", + str(INSTALL), + str(UNINSTALL), + str(INSTALL_TOUCHPAD), + str(UNINSTALL_TOUCHPAD), + str(RELEASE), + str(ROLLBACK), + str(NORMALIZE_SDIST), + ], check=True, capture_output=True, text=True, @@ -40,6 +55,9 @@ def test_installer_has_transaction_and_offline_mode() -> None: assert "COMMITTED=true" in source assert "Keep the active release and one known-good predecessor" in source assert "Adopt a prior touchpad-only install" in source + assert "wheelhouse contents do not exactly match SHA256SUMS" in source + assert '"${HONOR_CONTROL_WHEEL}[gui]"' in source + assert "rollback-state" in source def test_uninstaller_checks_ownership_and_stops_touchpad_service() -> None: @@ -48,6 +66,35 @@ def test_uninstaller_checks_ownership_and_stops_touchpad_service() -> None: assert "preserving modified or unowned" in source assert "disable --now honor-touchpad-restore.service" in source assert "rm -f /etc/honor-touchpad.toml" in source + assert "touchpad-firmware-qualified" in source + + +def test_release_bundle_is_complete_and_never_overwrites_output() -> None: + source = RELEASE.read_text(encoding="utf-8") + assert "release output already exists; refusing to overwrite" in source + assert 'cp -a honor_control "$BUNDLE/honor_control"' in source + assert "pip download" in source + assert "honor_tools-0.1.0" in source + assert "verify-venv" in source + assert "python\" -m pip check" in source + assert 'rm -rf "$BUNDLE"' not in source + assert "normalize-sdist.sh" in source + + +def test_rollback_restores_system_files_and_reverts_on_failure() -> None: + source = ROLLBACK.read_text(encoding="utf-8") + assert "active release has no complete rollback state" in source + assert "restore_files" in source + assert "restore_current" in source + assert "rollback failed; restoring the original release" in source + + +def test_touchpad_installers_persist_qualification() -> None: + full = INSTALL.read_text(encoding="utf-8") + touchpad = INSTALL_TOUCHPAD.read_text(encoding="utf-8") + for source in (full, touchpad): + assert "--enable-touchpad-firmware" in source + assert "/etc/honor-control/touchpad-firmware-qualified" in source def test_privileged_touchpad_wrapper_uses_isolated_trusted_imports() -> None: diff --git a/tests/test_touchpad_firmware.py b/tests/test_touchpad_firmware.py index 98233fb..2ef610e 100644 --- a/tests/test_touchpad_firmware.py +++ b/tests/test_touchpad_firmware.py @@ -286,6 +286,7 @@ def test_apply_profile_cli_writes_hid_and_verifies_master( str(dev), "--wmi-root", str(wmi_root), + "--allow-unqualified", "apply", str(profile), ] @@ -323,6 +324,7 @@ def fail_apply(*_args, **_kwargs): str(dev), "--wmi-root", str(wmi_root), + "--allow-unqualified", "apply", str(profile), ] @@ -361,6 +363,7 @@ def partial_failure(*_args, **_kwargs): str(sysfs), "--dev-root", str(dev), + "--allow-unqualified", "set", "edge_volume", "on", @@ -373,8 +376,8 @@ def partial_failure(*_args, **_kwargs): def test_touchpadctl_write_is_gated_when_unqualified( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """HC-001: without the qualification flag, write commands refuse; --force - and probe/encode remain available.""" + """HC-001: writes require qualification or a deliberate diagnostic + override, while probe/encode remain available.""" monkeypatch.delenv("HONOR_TOUCHPAD_FIRMWARE_QUALIFIED", raising=False) sysfs, dev = _fake_device(tmp_path) base = ["--sysfs-root", str(sysfs), "--dev-root", str(dev)] @@ -385,5 +388,9 @@ def test_touchpadctl_write_is_gated_when_unqualified( # Read-only encode still works. assert touchpadctl_main(["encode", "sensitivity", "high"]) == 0 - # --force overrides the gate (diagnostics/recovery). - assert touchpadctl_main([*base, "--force", "set", "sensitivity", "high"]) == 0 + assert ( + touchpadctl_main( + [*base, "--allow-unqualified", "set", "sensitivity", "high"] + ) + == 0 + ) From 31fe71a5f2d92f552b76d09acae14c05a479dbc4 Mon Sep 17 00:00:00 2001 From: ZachAR3 Date: Tue, 28 Jul 2026 16:51:32 +0700 Subject: [PATCH 10/10] Fix CI portability and validation gates --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++-------------- scripts/install-local.sh | 15 ++++++++++---- scripts/uninstall-local.sh | 7 +++++-- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47f161e..ac9180b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: pip @@ -36,8 +36,8 @@ jobs: matrix: python: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python }} cache: pip @@ -57,10 +57,10 @@ jobs: security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: pip @@ -72,11 +72,13 @@ jobs: run: bandit -r honor_control -ll - name: Gitleaks full-history gate uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 + env: + GITHUB_TOKEN: ${{ github.token }} shell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Install shellcheck run: sudo apt-get update && sudo apt-get install -y shellcheck - name: bash -n syntax check @@ -93,8 +95,8 @@ jobs: packaging: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: pip @@ -108,22 +110,30 @@ jobs: - name: desktop-file-validate run: desktop-file-validate packaging/desktop/*.desktop - name: systemd-analyze verify - run: systemd-analyze verify packaging/systemd/*.service + run: | + # The units intentionally use stable /usr/bin entry points installed + # by install-local.sh. Provide inert stubs so verification checks the + # unit definitions without depending on a system-wide CI install. + sudo install -m 0755 /bin/true /usr/bin/honor-control-service + sudo install -m 0755 /bin/true /usr/bin/honor-touchpadctl + trap 'sudo rm -f /usr/bin/honor-control-service /usr/bin/honor-touchpadctl' EXIT + systemd-analyze verify packaging/systemd/*.service - run: python -m pip install --upgrade pip - run: python -m pip install ".[gui,dev]" "build==1.5.1" "setuptools==83.0.0" "wheel==0.47.0" - name: Build wheel + sdist reproducibly run: | set -e - export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" + SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" + export SOURCE_DATE_EPOCH export PYTHONHASHSEED=0 TZ=UTC python -m build --no-isolation --outdir dist/first - bash scripts/normalize-sdist.sh dist/first/*.tar.gz "$SOURCE_DATE_EPOCH" + bash scripts/normalize-sdist.sh ./dist/first/*.tar.gz "$SOURCE_DATE_EPOCH" rm -rf build honor_control.egg-info python -m build --no-isolation --outdir dist/second - bash scripts/normalize-sdist.sh dist/second/*.tar.gz "$SOURCE_DATE_EPOCH" + bash scripts/normalize-sdist.sh ./dist/second/*.tar.gz "$SOURCE_DATE_EPOCH" diff -u \ - <(cd dist/first && sha256sum * | sed 's# # #') \ - <(cd dist/second && sha256sum * | sed 's# # #') + <(cd dist/first && sha256sum -- * | sed 's# # #') \ + <(cd dist/second && sha256sum -- * | sed 's# # #') - name: Verify artifacts contain the package (hard gate) run: | set -e diff --git a/scripts/install-local.sh b/scripts/install-local.sh index f84ec67..784f074 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -129,8 +129,9 @@ cleanup() { if $LEGACY_REMOVED && [[ -f "$LEGACY_BACKUP" ]] && \ [[ ! -e "$LEGACY_HONOR_POWER_RULE" ]]; then install -D -m 0644 "$LEGACY_BACKUP" "$LEGACY_HONOR_POWER_RULE" - command -v udevadm >/dev/null 2>&1 && \ + if command -v udevadm >/dev/null 2>&1; then udevadm control --reload-rules 2>/dev/null || true + fi fi if $WAS_ACTIVE && [[ -n "$OLD_RELEASE" && -d "$OLD_RELEASE" ]]; then systemctl daemon-reload 2>/dev/null || true @@ -260,7 +261,10 @@ if [[ -n "$WHEELHOUSE" ]]; then HONOR_CONTROL_WHEEL="${HONOR_CONTROL_WHEELS[0]}" HONOR_TOOLS_WHEEL="${HONOR_TOOLS_WHEELS[0]}" fi -HONOR_TOOLS_ROOT="$(cd "$ROOT/../honor-tools" 2>/dev/null && pwd || true)" +HONOR_TOOLS_ROOT="" +if [[ -d "$ROOT/../honor-tools" ]]; then + HONOR_TOOLS_ROOT="$(cd "$ROOT/../honor-tools" && pwd)" +fi if [[ ( -z "$HONOR_TOOLS_ROOT" || \ ! -f "$HONOR_TOOLS_ROOT/pyproject.toml" ) && -z "$WHEELHOUSE" ]]; then echo "error: honor-tools 0.1.0 is not published on the package index" >&2 @@ -397,8 +401,9 @@ done echo "==> [7/7] Reloading services" systemctl daemon-reload systemctl reload dbus 2>/dev/null || true -command -v update-desktop-database >/dev/null 2>&1 && \ +if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database /usr/share/applications 2>/dev/null || true +fi if $TOUCHPAD_FIRMWARE; then echo "==> Touchpad firmware writes QUALIFIED: enabling honor-touchpad-restore.service" systemctl enable honor-touchpad-restore.service 2>/dev/null || true @@ -435,7 +440,9 @@ if [[ -f "$LEGACY_HONOR_POWER_RULE" ]] && \ fi rm -f "$LEGACY_HONOR_POWER_RULE" LEGACY_REMOVED=true - command -v udevadm >/dev/null 2>&1 && udevadm control --reload-rules || true + if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules || true + fi fi MANIFEST="$RELEASE_DIR/installed-files.sha256" diff --git a/scripts/uninstall-local.sh b/scripts/uninstall-local.sh index 415318f..74c6980 100755 --- a/scripts/uninstall-local.sh +++ b/scripts/uninstall-local.sh @@ -102,7 +102,9 @@ LEGACY_RULE=/etc/udev/rules.d/99-honor-power.rules if [[ -f "$LEGACY_BACKUP" ]]; then if [[ ! -e "$LEGACY_RULE" ]]; then install -D -m 0644 "$LEGACY_BACKUP" "$LEGACY_RULE" - command -v udevadm >/dev/null 2>&1 && udevadm control --reload-rules || true + if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules || true + fi else echo "warning: preserving legacy-rule backup because $LEGACY_RULE exists" >&2 fi @@ -123,8 +125,9 @@ fi echo "==> Reloading systemd" systemctl daemon-reload systemctl reload dbus 2>/dev/null || true -command -v update-desktop-database >/dev/null 2>&1 && \ +if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database /usr/share/applications 2>/dev/null || true +fi echo echo "Done. honor-control has been removed."