diff --git a/README.md b/README.md index 3c710c3..7cbf465 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ High-performance, lightweight, zero-bloat hardware control suite for Acer gaming [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4.svg)](https://dotnet.microsoft.com/) [![NativeAOT](https://img.shields.io/badge/Runtime-NativeAOT-success.svg)]() -[![Platform](https://img.shields.io/badge/Platform-Windows-lightgrey.svg)]() +[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-lightgrey.svg)]() @@ -15,8 +15,8 @@ High-performance, lightweight, zero-bloat hardware control suite for Acer gaming > [!WARNING] > **Work in Progress Notice:** -> - **Graphical User Interface (GUI)**: The standalone GUI client is currently under active development. The background service daemon (`openpredator-service`) and CLI utility (`openpredator`) should be fully functional. -> - **Linux Support**: Basic Linux abstractions exist in `Core`, but active development and physical testing for Linux are on the back burner for now. +> - **Graphical User Interface (GUI)**: The standalone GUI client is currently under active development. The background service daemon (`openpredator-service`) and CLI utility (`openpredator`) are fully functional. +> - **Linux Support**: Linux hardware control (ACPI WMI, fans, telemetry, and power modes) is functional and verified on physical hardware. However, installation and service setup are currently manual (standalone binaries work; automated installers are planned). --- @@ -47,7 +47,7 @@ OpenPredator's daemon is also wire-compatible with the OEM named pipe interface | :--- | :---: | :--- | | Windows 11 | ✅ | Tested on Windows 11 (25H2) | | Windows 10 | ❓ | Expected working (uses identical ACPI WMI drivers) | -| Linux | 🚧 | In active development (ACPI backend and Unix sockets implemented; physical testing in progress) | +| Linux | ✅ | Tested on MX Linux (23.6). Manual binary deployment for now. | --- @@ -131,44 +131,107 @@ openpredator config apply --- +## Linux Support & Manual Setup + +Linux support is fully implemented and verified on real hardware, providing real-time hardware telemetry, fan speed and curve control, power modes, and keyboard backlight management through ACPI firmware calls. While automated packaging (e.g., `.deb`/`.rpm` or systemd service packages) is planned, binaries are fully usable right now with manual execution. + +### 1. Prerequisites (Kernel Modules) +OpenPredator communicates directly with Acer ACPI WMI tables on Linux via `/proc/acpi/call` and decompresses the BIOS BMOF table via `wmi-bmof`. + +**Debian / Ubuntu / MX Linux / Linux Mint:** +```bash +sudo apt update && sudo apt install -y acpi-call-dkms +sudo modprobe acpi_call +sudo modprobe wmi-bmof + +# Verify interfaces are loaded: +ls -l /proc/acpi/call +ls -l /sys/bus/wmi/devices/*/bmof +``` + +**Fedora / RHEL:** +```bash +sudo dnf install -y akmod-acpi_call +sudo modprobe acpi_call +sudo modprobe wmi-bmof +``` + +**Arch Linux:** +```bash +sudo pacman -S acpi_call-dkms +sudo modprobe acpi_call +sudo modprobe wmi-bmof +``` + +### 2. Running OpenPredator on Linux + +#### Option A: Background Service + Unprivileged CLI / GUI (Recommended) +Run the service daemon as root. It creates a Unix domain socket (`/tmp/predatorsense_service_namedpipe`) with open permissions (`0666`) and caches discovered WMI methods to `~/.config/openpredator/wmi_methods.json`, allowing non-root users and desktop sessions to interact with hardware safely without `sudo`: + +```bash +# Terminal 1: Start the service daemon +sudo ./openpredator-service + +# Terminal 2: Run commands as a regular user +./openpredator watch # Live dashboard +./openpredator fan max # 100% fan speed +./openpredator fan auto -c # Auto curve with CoolBoost +./openpredator mode perf # Switch power mode +./openpredator-gui # Desktop GUI +``` + +#### Option B: Standalone CLI (Direct Hardware Access) +If the background service is not running, the CLI can interact with hardware directly, but requires root privileges to write to `/proc/acpi/call`: + +```bash +sudo ./openpredator status +sudo ./openpredator fan max +sudo ./openpredator fan auto +sudo ./openpredator coolboost on +sudo ./openpredator mode perf +sudo ./openpredator watch +``` + +--- + ## Building from Source ### Prerequisites - [.NET 10.0 SDK](https://dotnet.microsoft.com/) -- Visual Studio 2022 Desktop development with C++ (required for NativeAOT compilation on Windows) +- Native compilation toolchain (required for NativeAOT: Visual Studio C++ on Windows; `clang`, `build-essential`, `zlib1g-dev` on Linux) - [Inno Setup 6](https://jrsoftware.org/isdl.php) (optional, for compiling Windows installer package) ### Build Pipeline (`builder`) -OpenPredator features a cross-platform, zero-dependency C# build runner located in `builder/`. You can invoke it via the root wrappers (`build.bat` on Windows, `./build.sh` on Linux) or `dotnet run --project builder`: +OpenPredator features a cross-platform, zero-dependency C# build runner located in `builder/`. You can invoke it via the root wrappers (`./build` / `build.bat` on Windows, `./build` / `./build.sh` on Linux) or `dotnet run --project builder`: -```cmd +```bash # 1. Release build for current OS (dist//) - excludes test suite -build.bat -# or: build.bat all +./build +# or: ./build all # 2. Release build + package Windows Setup Installer (dist/installer/) -build.bat installer +./build installer -# 3. Release build + package Portable standalone ZIP (dist/portable/) -build.bat portable +# 3. Release build + package Portable standalone archive (dist/portable/) +./build portable -# 4. Full Release Packaging (Installer + Portable ZIP + SHA256SUMS manifest) -build.bat pack +# 4. Full Release Packaging (Installer + Portable archive + SHA256SUMS manifest) +./build pack # 5. Development debug build with interactive Testing Suite included -build.bat dev +./build dev # 6. Clean all build outputs, .temp caches, and bin/obj directories -build.bat clean +./build clean # 7. Version management -build.bat version # Inspect current version -build.bat version 1.0.1 # Bump version across build props & installer +./build version # Inspect current version +./build version 1.0.1 # Bump version across build props & installer # 8. Explicit OS target prefixes -build.bat windows:pack -build.bat linux:all +./build windows:pack +./build linux:all ``` --- diff --git a/builder/Program.cs b/builder/Program.cs index 1281e23..ca26352 100644 --- a/builder/Program.cs +++ b/builder/Program.cs @@ -189,7 +189,7 @@ private static void PrintHelp() Console.WriteLine(" pack Full release packaging (Installer + Portable ZIP + SHA256SUMS)"); Console.WriteLine(" dev Debug build including test suite (openpredator-testsuite)"); Console.WriteLine(" clean Clean all dist/, .temp/, artifacts/, bin/, and obj/ directories"); - Console.WriteLine(" version [new_ver] Inspect or bump version across Directory.Build.props & Inno Setup"); + Console.WriteLine(" version [new_ver] Inspect or bump version in Directory.Build.props (auto-propagated to binaries & installer)"); Console.WriteLine(" help Display this help screen\n"); Console.WriteLine("Target Prefix & Targeted Build Examples:"); Console.WriteLine(" build project:Gui Quick release build of OpenPredator.Gui -> dist/win-x64/"); @@ -578,7 +578,6 @@ private static async Task BuildProjectCommandAsync(string targetProject, st private static int HandleVersionCommand(string? newVersion) { string propsPath = Path.Combine(RootDir, "Directory.Build.props"); - string issPath = Path.Combine(RootDir, "builder", "OpenPredatorSetup.iss"); if (string.IsNullOrWhiteSpace(newVersion)) { @@ -588,14 +587,9 @@ private static int HandleVersionCommand(string? newVersion) var content = File.ReadAllText(propsPath); var mVer = System.Text.RegularExpressions.Regex.Match(content, @"(.*?)"); var mAss = System.Text.RegularExpressions.Regex.Match(content, @"(.*?)"); - Console.WriteLine($" Directory.Build.props : Version={mVer.Groups[1].Value}, AssemblyVersion={mAss.Groups[1].Value}"); - } - if (File.Exists(issPath)) - { - var content = File.ReadAllText(issPath); - var mIss = System.Text.RegularExpressions.Regex.Match(content, @"#define MyAppVersion ""(.*?)"""); - var mInfo = System.Text.RegularExpressions.Regex.Match(content, @"VersionInfoVersion=(.*)"); - Console.WriteLine($" OpenPredatorSetup.iss : MyAppVersion={mIss.Groups[1].Value}, VersionInfoVersion={mInfo.Groups[1].Value}"); + var mFile = System.Text.RegularExpressions.Regex.Match(content, @"(.*?)"); + Console.WriteLine($" Directory.Build.props : Version={mVer.Groups[1].Value}, AssemblyVersion={mAss.Groups[1].Value}, FileVersion={mFile.Groups[1].Value}"); + Console.WriteLine($" OpenPredatorSetup.iss : (Inherits dynamically via /DMyAppVersion & /DVersionInfoVersion during build)"); } Console.WriteLine("\n\x1b[90mTo update version: build version (e.g. 1.0.1 or 1.0.1.1)\x1b[0m\n"); return 0; @@ -632,7 +626,7 @@ private static int HandleVersionCommand(string? newVersion) Console.WriteLine($"\x1b[1;36m>>> Updating Project Version to {semVer} ({fourPartVer})...\x1b[0m\n"); - // 1. Update Directory.Build.props + // 1. Update Directory.Build.props (Single Source of Truth) if (File.Exists(propsPath)) { string content = File.ReadAllText(propsPath); @@ -643,16 +637,7 @@ private static int HandleVersionCommand(string? newVersion) Console.WriteLine($"\x1b[1;32m[OK]\x1b[0m Updated {Path.GetRelativePath(RootDir, propsPath)}"); } - // 2. Update OpenPredatorSetup.iss - if (File.Exists(issPath)) - { - string content = File.ReadAllText(issPath); - content = System.Text.RegularExpressions.Regex.Replace(content, @"#define MyAppVersion "".*?""", $"#define MyAppVersion \"{semVer}\""); - content = System.Text.RegularExpressions.Regex.Replace(content, @"VersionInfoVersion=.*", $"VersionInfoVersion={fourPartVer}"); - File.WriteAllText(issPath, content); - Console.WriteLine($"\x1b[1;32m[OK]\x1b[0m Updated {Path.GetRelativePath(RootDir, issPath)}"); - } - + Console.WriteLine($"\x1b[1;32m[OK]\x1b[0m OpenPredatorSetup.iss dynamically inherits from build props at compile time."); Console.WriteLine($"\n\x1b[1;32m[Success]\x1b[0m All version fields updated to: \x1b[1;37m{semVer}\x1b[0m (Assembly/File: \x1b[1;36m{fourPartVer}\x1b[0m)\n"); return 0; } diff --git a/src/OpenPredator.Cli/Program.cs b/src/OpenPredator.Cli/Program.cs index f03aa9f..9561896 100644 --- a/src/OpenPredator.Cli/Program.cs +++ b/src/OpenPredator.Cli/Program.cs @@ -24,6 +24,17 @@ public static async Task Main(string[] args) bool connected = await client.ConnectAsync(1500); if (!connected) { + if (cmd != "config" && !ProcessElevation.IsElevated) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("[Notice] Background daemon (OpenPredator Service / OEM PSSvc) is not active on pipe."); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("[Error] Direct hardware access requires elevated privileges (Administrator on Windows, sudo/root on Linux)."); + Console.ResetColor(); + Console.WriteLine(" Please start the service (e.g. 'sudo openpredator-service' or Windows Service) or run this command with elevation.\n"); + return 1; + } + Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[Notice] No background daemon (OpenPredator Service / OEM PSSvc) active on pipe."); Console.WriteLine(" Falling back to direct hardware access (ACPI WMI + SMBIOS + NVML)...\n"); diff --git a/src/OpenPredator.Core/Backends/WindowsWmiBackend.cs b/src/OpenPredator.Core/Backends/AcerHardwareBackend.cs similarity index 86% rename from src/OpenPredator.Core/Backends/WindowsWmiBackend.cs rename to src/OpenPredator.Core/Backends/AcerHardwareBackend.cs index 6c44820..e3ea64c 100644 --- a/src/OpenPredator.Core/Backends/WindowsWmiBackend.cs +++ b/src/OpenPredator.Core/Backends/AcerHardwareBackend.cs @@ -1,23 +1,33 @@ using System; -using System.Runtime.Versioning; using System.Threading.Tasks; using OpenPredator.Core.Enums; using OpenPredator.Core.Models; namespace OpenPredator.Core.Backends; -[SupportedOSPlatform("windows")] -public class WindowsWmiBackend : IHardwareBackend +/// +/// Universal shared hardware control backend for Acer Nitro and Predator laptops. +/// Implements full feature parity across Windows and Linux using clear WMI/ACPI method names. +/// Platform-specific subclasses (WindowsHardwareBackend, LinuxHardwareBackend) only need to implement raw method dispatch. +/// +public abstract class AcerHardwareBackend : IHardwareBackend { - private const string WmiNamespace = @"\\.\root\wmi"; - private const string GamingClassName = "AcerGamingFunction"; - private const string GenericClassName = "APGeAction"; - private FanMode _lastMode = FanMode.Auto; + protected FanMode _lastMode = FanMode.Auto; - public string BackendName => "Windows WMI (root\\wmi -> AcerGamingFunction)"; - public bool IsSupported => OperatingSystem.IsWindows(); + public abstract string BackendName { get; } + public abstract bool IsSupported { get; } - public async Task GetSensorDataAsync() + // Platform-specific WMI/ACPI method dispatchers + public abstract ulong InvokeGamingFunctionSync(string methodName, ulong input); + public abstract ulong InvokeGenericMethodSync(string methodName, ulong input); + + public virtual Task InvokeGamingFunctionAsync(string methodName, ulong input) => + Task.Run(() => InvokeGamingFunctionSync(methodName, input)); + + public virtual Task InvokeGenericMethodAsync(string methodName, ulong input) => + Task.Run(() => InvokeGenericMethodSync(methodName, input)); + + public virtual async Task GetSensorDataAsync() { return await Task.Run(() => { @@ -70,7 +80,7 @@ public async Task GetSensorDataAsync() }); } - public async Task SetFanModeAsync( + public virtual async Task SetFanModeAsync( FanMode mode, bool coolBoost, int cpuPercentage = 50, @@ -82,6 +92,7 @@ public async Task SetFanModeAsync( { return await Task.Run(() => { + if (!IsSupported) return false; try { FanMode effectiveCpuMode = cpuMode ?? (mode == FanMode.Custom ? (cpuAuto ? FanMode.Auto : FanMode.Custom) : mode); @@ -154,10 +165,11 @@ public async Task SetFanModeAsync( }); } - public async Task SetCoolBoostAsync(bool enabled) + public virtual async Task SetCoolBoostAsync(bool enabled) { return await Task.Run(() => { + if (!IsSupported) return false; try { // 1. APGeAction.SetFunction(0x00010007 for ON, 0x00000007 for OFF) @@ -177,7 +189,7 @@ public async Task SetCoolBoostAsync(bool enabled) }); } - public async Task GetCoolBoostAsync() + public virtual async Task GetCoolBoostAsync() { return await Task.Run(() => { @@ -206,10 +218,11 @@ public async Task GetCoolBoostAsync() }); } - public async Task SetFanSpeedAsync(int fanIndex, int percentage) + public virtual async Task SetFanSpeedAsync(int fanIndex, int percentage) { return await Task.Run(() => { + if (!IsSupported) return false; try { ulong input = (ulong)((Math.Clamp(percentage, 0, 100) << 8) | (fanIndex & 0xFF)); @@ -224,10 +237,11 @@ public async Task SetFanSpeedAsync(int fanIndex, int percentage) }); } - public async Task SetPowerModeAsync(PowerMode mode) + public virtual async Task SetPowerModeAsync(PowerMode mode) { return await Task.Run(() => { + if (!IsSupported) return false; try { // Setting ID 0x0B (11) is Operation Mode in AcerGamingFunction.SetGamingMiscSetting @@ -243,29 +257,26 @@ public async Task SetPowerModeAsync(PowerMode mode) }); } - public async Task SetRgbKeyboardAsync(RgbConfig config) + public virtual async Task SetRgbKeyboardAsync(RgbConfig config) { return await Task.Run(() => { + if (!IsSupported) return false; try { // Brightness setting (0-100) SetKbBacklightAsync(config.Brightness * 25).GetAwaiter().GetResult(); // Set 4-zone colors: wire format (B << 24) | (G << 16) | (R << 8) | zoneId - // Zone 1: ID 1 ulong z1Input = (ulong)((uint)(config.Zone1.B << 24) | (uint)(config.Zone1.G << 16) | (uint)(config.Zone1.R << 8) | 1u); InvokeGamingFunctionSync("SetGamingLEDColor", z1Input); - // Zone 2: ID 2 ulong z2Input = (ulong)((uint)(config.Zone2.B << 24) | (uint)(config.Zone2.G << 16) | (uint)(config.Zone2.R << 8) | 2u); InvokeGamingFunctionSync("SetGamingLEDColor", z2Input); - // Zone 3: ID 3 ulong z3Input = (ulong)((uint)(config.Zone3.B << 24) | (uint)(config.Zone3.G << 16) | (uint)(config.Zone3.R << 8) | 3u); InvokeGamingFunctionSync("SetGamingLEDColor", z3Input); - // Zone 4: ID 4 ulong z4Input = (ulong)((uint)(config.Zone4.B << 24) | (uint)(config.Zone4.G << 16) | (uint)(config.Zone4.R << 8) | 4u); InvokeGamingFunctionSync("SetGamingLEDColor", z4Input); @@ -282,10 +293,11 @@ public async Task SetRgbKeyboardAsync(RgbConfig config) }); } - public async Task SetKbBacklightAsync(int brightnessPercentage) + public virtual async Task SetKbBacklightAsync(int brightnessPercentage) { return await Task.Run(() => { + if (!IsSupported) return false; try { int clamped = Math.Clamp(brightnessPercentage, 0, 100); @@ -326,7 +338,7 @@ public async Task SetKbBacklightAsync(int brightnessPercentage) }); } - public async Task GetKbBacklightAsync() + public virtual async Task GetKbBacklightAsync() { return await Task.Run(() => { @@ -345,7 +357,7 @@ public async Task GetKbBacklightAsync() }); } - public async Task SetKbTimeoutAsync(bool enabled) + public virtual async Task SetKbTimeoutAsync(bool enabled) { return await Task.Run(() => { @@ -377,7 +389,7 @@ public async Task SetKbTimeoutAsync(bool enabled) }); } - public async Task GetKbTimeoutAsync() + public virtual async Task GetKbTimeoutAsync() { return await Task.Run(() => { @@ -396,7 +408,7 @@ public async Task GetKbTimeoutAsync() }); } - public async Task GetPowerModeAsync() + public virtual async Task GetPowerModeAsync() { return await Task.Run(() => { @@ -413,7 +425,7 @@ public async Task GetPowerModeAsync() }); } - public async Task SetLcdOverdriveAsync(bool enabled) + public virtual async Task SetLcdOverdriveAsync(bool enabled) { return await Task.Run(() => { @@ -431,7 +443,7 @@ public async Task SetLcdOverdriveAsync(bool enabled) }); } - public async Task GetLcdOverdriveAsync() + public virtual async Task GetLcdOverdriveAsync() { return await Task.Run(() => { @@ -448,7 +460,7 @@ public async Task GetLcdOverdriveAsync() }); } - public async Task SetWinKeyLockAsync(bool locked) + public virtual async Task SetWinKeyLockAsync(bool locked) { return await Task.Run(() => { @@ -466,7 +478,7 @@ public async Task SetWinKeyLockAsync(bool locked) }); } - public async Task SetMiscSettingAsync(uint settingId, uint value) + public virtual async Task SetMiscSettingAsync(uint settingId, uint value) { return await Task.Run(() => { @@ -483,31 +495,11 @@ public async Task SetMiscSettingAsync(uint settingId, uint value) }); } - public async Task GetMiscSettingAsync(uint settingId) + public virtual async Task GetMiscSettingAsync(uint settingId) { return await Task.Run(() => { return InvokeGamingFunctionSync("GetGamingMiscSetting", settingId); }); } - - public Task InvokeGamingFunctionAsync(string methodName, ulong input) - { - return Task.Run(() => InvokeGamingFunctionSync(methodName, input)); - } - - public Task InvokeGenericMethodAsync(string methodName, ulong input) - { - return Task.Run(() => InvokeGenericMethodSync(methodName, input)); - } - - private ulong InvokeGamingFunctionSync(string methodName, ulong input) - { - return NativeWmi.ExecuteGamingFunction(methodName, input); - } - - private ulong InvokeGenericMethodSync(string methodName, ulong input) - { - return NativeWmi.ExecuteGenericMethod(methodName, input); - } } diff --git a/src/OpenPredator.Core/Backends/Linux/BmofReader.cs b/src/OpenPredator.Core/Backends/Linux/BmofReader.cs new file mode 100644 index 0000000..684d34c --- /dev/null +++ b/src/OpenPredator.Core/Backends/Linux/BmofReader.cs @@ -0,0 +1,665 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenPredator.Core.Services; + +namespace OpenPredator.Core.Backends.Linux; + +/// +/// Discovers, decompresses, and parses Binary MOF (BMOF) from ACPI PNP0C14 devices on Linux. +/// Embedded pure C# DoubleSpace (DS-0-2) decompressor and BMF parser: zero external bmfdec dependency. +/// +public static class BmofReader +{ + private static readonly Dictionary _gamingMethodMap = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _genericMethodMap = new(StringComparer.OrdinalIgnoreCase); + public static string? DiscoveredAcpiBasePath { get; private set; } + private static bool _initialized = false; + private static readonly object _lock = new(); + + public static uint? GetGamingMethodId(string methodName) + { + EnsureInitialized(); + return _gamingMethodMap.TryGetValue(methodName, out uint id) ? id : null; + } + + public static uint? GetGenericMethodId(string methodName) + { + EnsureInitialized(); + return _genericMethodMap.TryGetValue(methodName, out uint id) ? id : null; + } + + public static void EnsureInitialized() + { + if (_initialized) return; + + lock (_lock) + { + if (_initialized) return; + + // 1. Try reading from cached JSON file first + string cachePath = GetCacheFilePath(); + if (File.Exists(cachePath) && TryLoadFromCache(cachePath)) + { + AppLogger.Info($"[BmofReader] Loaded WMI method map from cache ({cachePath})"); + _initialized = true; + return; + } + + // 2. Discover ALL BMOF sysfs files in /sys/bus/wmi/devices/*/bmof + var bmofPaths = FindAllBmofSysfsPaths(); + // Sort by file size descending so the primary ACPI table (e.g. ~12KB) is parsed first + bmofPaths.Sort((a, b) => + { + try + { + return new FileInfo(b).Length.CompareTo(new FileInfo(a).Length); + } + catch + { + return 0; + } + }); + + foreach (var bmofPath in bmofPaths) + { + AppLogger.Info($"[BmofReader] Probing BMOF candidate: {bmofPath}"); + TryDecompressAndParse(bmofPath); + } + + if (_gamingMethodMap.Count > 0 || _genericMethodMap.Count > 0) + { + SaveToCache(cachePath); + AppLogger.Info($"[BmofReader] Successfully discovered {_gamingMethodMap.Count} AcerGamingFunction and {_genericMethodMap.Count} APGeAction methods (cached to {cachePath})"); + _initialized = true; + return; + } + + AppLogger.Error("[BmofReader] Failed to discover WMI methods: neither cached wmi_methods.json nor bmof decompression succeeded. WMI calls disabled."); + _initialized = true; + } + } + + private static List FindAllBmofSysfsPaths() + { + var list = new List(); + const string wmiDevicesPath = "/sys/bus/wmi/devices"; + if (!Directory.Exists(wmiDevicesPath)) return list; + + foreach (var dir in Directory.GetDirectories(wmiDevicesPath)) + { + string bmofFile = Path.Combine(dir, "bmof"); + if (File.Exists(bmofFile)) + { + list.Add(bmofFile); + } + } + return list; + } + + private static bool TryDecompressAndParse(string bmofPath) + { + try + { + byte[] raw = File.ReadAllBytes(bmofPath); + AppLogger.Info($"[BmofReader] Reading {bmofPath} ({raw.Length} bytes)"); + if (raw.Length <= 16) return false; + + // BMOF Header: + // 0..3: 0x424D4F46 ("BMOF" / "FOMB") + // 4..7: 0x00000001 (Version 1) + // 8..11: Compressed size (raw.Length - 16) + // 12..15: Decompressed size + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(0, 4)); + uint version = BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(4, 4)); + if ((magic != 0x424D4F46 && magic != 0x464F4D42) || version != 0x00000001) + { + AppLogger.Warn($"[BmofReader] Invalid BMOF magic (0x{magic:X8}) or version (0x{version:X8}) in {bmofPath}"); + return false; + } + + int decompressedSize = (int)BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(12, 4)); + if (decompressedSize <= 0 || decompressedSize > 0x2000000) + { + AppLogger.Warn($"[BmofReader] Invalid decompressed size: {decompressedSize}"); + return false; + } + + ReadOnlySpan compressedPayload = raw.AsSpan(16); + byte[]? bmfData; + + // Check if already uncompressed BMF (starts with BMOF magic) + if (compressedPayload.Length >= 4) + { + uint payloadMagic = BinaryPrimitives.ReadUInt32LittleEndian(compressedPayload.Slice(0, 4)); + if (payloadMagic == 0x424D4F46 || payloadMagic == 0x464F4D42) + { + bmfData = compressedPayload.ToArray(); + } + else + { + bmfData = DoubleSpaceDecoder.Decompress(compressedPayload, decompressedSize); + } + } + else + { + bmfData = DoubleSpaceDecoder.Decompress(compressedPayload, decompressedSize); + } + + if (bmfData == null) + { + AppLogger.Warn($"[BmofReader] DoubleSpace decompression failed for {bmofPath}"); + return false; + } + + int oldGaming = _gamingMethodMap.Count; + int oldGeneric = _genericMethodMap.Count; + bool parsed = BmfParser.Parse(bmfData, _gamingMethodMap, _genericMethodMap); + int newGaming = _gamingMethodMap.Count - oldGaming; + int newGeneric = _genericMethodMap.Count - oldGeneric; + + if (newGaming > 0 || newGeneric > 0) + { + AppLogger.Info($"[BmofReader] Parsed {newGaming} AcerGamingFunction and {newGeneric} APGeAction methods from {bmofPath}"); + + // Associate ACPI device path from parent device directory if present + string? deviceDir = Path.GetDirectoryName(bmofPath); + if (!string.IsNullOrEmpty(deviceDir)) + { + string fnPath = Path.Combine(deviceDir, "firmware_node", "path"); + if (File.Exists(fnPath)) + { + try + { + string acpiPath = File.ReadAllText(fnPath).Trim(); + if (!string.IsNullOrEmpty(acpiPath)) + { + DiscoveredAcpiBasePath = acpiPath; + AppLogger.Info($"[BmofReader] Associated ACPI device path from sysfs: {acpiPath}"); + } + } + catch { } + } + } + + return true; + } + + return false; + } + catch (Exception ex) + { + AppLogger.Error($"[BmofReader] Error reading {bmofPath}: {ex.Message}"); + return false; + } + } + + private static string GetCacheFilePath() + { + string dir; + if (OperatingSystem.IsWindows()) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OpenPredator"); + } + else + { + string? home = Environment.GetEnvironmentVariable("HOME"); + if (string.IsNullOrEmpty(home)) + { + home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + dir = Path.Combine(home, ".config", "openpredator"); + } + + try { Directory.CreateDirectory(dir); } catch { } + return Path.Combine(dir, "wmi_methods.json"); + } + + private static bool TryLoadFromCache(string cachePath) + { + try + { + string json = File.ReadAllText(cachePath); + var cache = JsonSerializer.Deserialize(json, BmofJsonContext.Default.BmofCache); + if (cache != null) + { + if (cache.AcerGamingFunction != null) + { + foreach (var kvp in cache.AcerGamingFunction) _gamingMethodMap[kvp.Key] = kvp.Value; + } + if (cache.APGeAction != null) + { + foreach (var kvp in cache.APGeAction) _genericMethodMap[kvp.Key] = kvp.Value; + } + if (!string.IsNullOrEmpty(cache.AcpiBasePath)) + { + DiscoveredAcpiBasePath = cache.AcpiBasePath; + } + } + + return _gamingMethodMap.Count > 0; + } + catch + { + return false; + } + } + + private static void SaveToCache(string cachePath) + { + try + { + var cache = new BmofCache(_gamingMethodMap, _genericMethodMap, DiscoveredAcpiBasePath); + string json = JsonSerializer.Serialize(cache, BmofJsonContext.Default.BmofCache); + File.WriteAllText(cachePath, json); + } + catch { } + } + + + #region Pure C# DoubleSpace (DS-0-2) Decompressor + + private static class DoubleSpaceDecoder + { + private ref struct BitStream + { + private readonly ReadOnlySpan _input; + private int _inputPos; + public uint Buf; + public int Pb; + + public BitStream(ReadOnlySpan input) + { + _input = input; + _inputPos = 0; + Buf = 0; + Pb = 32; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Get16() + { + Buf >>= 16; + Pb -= 16; + if (_inputPos + 1 < _input.Length) + { + ushort val = unchecked((ushort)(_input[_inputPos] | (_input[_inputPos + 1] << 8))); + _inputPos += 2; + Buf |= (uint)val << 16; + } + else if (_inputPos < _input.Length) + { + ushort val = _input[_inputPos]; + _inputPos++; + Buf |= (uint)val << 16; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public uint Prepare() + { + if (Pb >= 16) Get16(); + return Buf >> Pb; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public uint ReadBits(int n) + { + uint u = Prepare(); + Pb += n; + return u & ((1u << n) - 1u); + } + + public bool HasMoreBits => _inputPos < _input.Length || Pb < 16; + } + + private static int ReadLength(ref BitStream stream) + { + uint u = stream.Prepare(); + switch (u & 15) + { + case 1: case 3: case 5: case 7: + case 9: case 11: case 13: case 15: + stream.Pb++; + return 3; + case 2: case 6: + case 10: case 14: + stream.Pb += 3; + return (int)((1 & (u >> 2)) + 4); + case 4: case 12: + stream.Pb += 5; + return (int)((3 & (u >> 3)) + 6); + case 8: + stream.Pb += 7; + return (int)((7 & (u >> 4)) + 10); + case 0: + break; + } + + switch ((u >> 4) & 15) + { + case 1: case 3: case 5: case 7: + case 9: case 11: case 13: case 15: + stream.Pb += 9; + return (int)((15 & (u >> 5)) + 18); + case 2: case 6: + case 10: case 14: + stream.Pb += 11; + return (int)((31 & (u >> 6)) + 34); + case 4: case 12: + stream.Pb += 13; + return (int)((63 & (u >> 7)) + 66); + case 8: + stream.Pb += 15; + return (int)((127 & (u >> 8)) + 130); + case 0: + break; + } + + stream.Pb += 9; + if ((u & 256) != 0) + { + return (int)(stream.ReadBits(8) + 258); + } + return -1; + } + + private static bool Decrep(ref BitStream stream, byte[] pout, ref int outPos, int repOffs, int k, int flg) + { + if (repOffs == 0) return false; + if (repOffs == 0x113F) + { + if ((outPos % 512 != 0) && (flg & 0x4000) == 0) return false; + return true; + } + + int repLen = ReadLength(ref stream) + k; + if (repLen <= 0 || repOffs > outPos || outPos + repLen > pout.Length) + return false; + + int src = outPos - repOffs; + for (int i = 0; i < repLen; i++) + { + pout[outPos++] = pout[src++]; + } + return true; + } + + public static byte[]? Decompress(ReadOnlySpan compressed, int decompressedSize) + { + if (decompressedSize <= 0 || decompressedSize > 0x2000000) return null; + byte[] pout = new byte[decompressedSize]; + int outPos = 0; + + var stream = new BitStream(compressed); + if (stream.ReadBits(16) != 0x5344) return null; // "DS" signature + + uint ver = stream.ReadBits(16); + + bool ok = true; + while (ok && outPos < pout.Length && stream.HasMoreBits) + { + uint u = stream.Prepare(); + switch (u & 3) + { + case 0: + stream.Pb += 8; + int repOffs0 = (int)((u >> 2) & 63); + ok = Decrep(ref stream, pout, ref outPos, repOffs0, -1, 0); + break; + case 1: + stream.Pb += 9; + if (outPos < pout.Length) pout[outPos++] = (byte)(((u >> 2) & 0x7F) | 128); + else ok = false; + break; + case 2: + stream.Pb += 9; + if (outPos < pout.Length) pout[outPos++] = (byte)((u >> 2) & 127); + else ok = false; + break; + case 3: + int repOffs3; + if ((u & 4) != 0) + { + stream.Pb += 15; + repOffs3 = (int)(((u >> 3) & 4095) + 320); + } + else + { + stream.Pb += 11; + repOffs3 = (int)(((u >> 3) & 255) + 64); + } + ok = Decrep(ref stream, pout, ref outPos, repOffs3, -1, 0); + break; + } + } + + return ok && outPos == decompressedSize ? pout : (outPos > 0 ? pout : null); + } + } + + #endregion + + #region Pure C# Binary MOF (BMF) Parser + + private static class BmfParser + { + public static bool Parse( + byte[] bmf, + Dictionary gamingMap, + Dictionary genericMap) + { + if (bmf.Length < 20) return false; + + // Decompressed BMF starts with 0x424D4F46 ("BMOF") and length of first part + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(bmf.AsSpan(0, 4)); + if (magic != 0x424D4F46 && magic != 0x464F4D42) return false; + + uint firstPartLen = BinaryPrimitives.ReadUInt32LittleEndian(bmf.AsSpan(4, 4)); + if (firstPartLen > bmf.Length || firstPartLen < 20) return false; + + // Root structure at offset 8: + // offset 8: unknown1 (4 bytes, 0x1) + // offset 12: unknown2 (4 bytes, 0x1) + // offset 16: classCount (4 bytes) + uint classCount = BinaryPrimitives.ReadUInt32LittleEndian(bmf.AsSpan(16, 4)); + int offset = 20; + + for (uint c = 0; c < classCount && offset + 20 <= firstPartLen; c++) + { + uint classLen = BinaryPrimitives.ReadUInt32LittleEndian(bmf.AsSpan(offset, 4)); + if (classLen < 20 || offset + classLen > firstPartLen) break; + + ParseClass(bmf.AsSpan(offset, (int)classLen), gamingMap, genericMap); + offset += (int)classLen; + } + + return gamingMap.Count > 0 || genericMap.Count > 0; + } + + private static void ParseClass( + ReadOnlySpan classSpan, + Dictionary gamingMap, + Dictionary genericMap) + { + if (classSpan.Length < 20) return; + + uint qualifiersLen = BinaryPrimitives.ReadUInt32LittleEndian(classSpan.Slice(8, 4)); + uint dataLen = BinaryPrimitives.ReadUInt32LittleEndian(classSpan.Slice(12, 4)); + + if (20 + dataLen > classSpan.Length) return; + + // Parse class name from __CLASS property inside class data + string? className = GetClassName(classSpan.Slice(20, (int)dataLen)); + if (string.IsNullOrEmpty(className)) return; + + Dictionary? targetMap = null; + if (className.Equals("AcerGamingFunction", StringComparison.OrdinalIgnoreCase)) + targetMap = gamingMap; + else if (className.Equals("APGeAction", StringComparison.OrdinalIgnoreCase)) + targetMap = genericMap; + + if (targetMap == null) return; + + // Methods array follows immediately after class data + int methodOffset = 20 + (int)dataLen; + if (methodOffset + 8 > classSpan.Length) return; + + uint methodsTotalLen = BinaryPrimitives.ReadUInt32LittleEndian(classSpan.Slice(methodOffset, 4)); + uint methodsCount = BinaryPrimitives.ReadUInt32LittleEndian(classSpan.Slice(methodOffset + 4, 4)); + methodOffset += 8; + + for (uint m = 0; m < methodsCount && methodOffset + 20 <= classSpan.Length; m++) + { + uint methodLen = BinaryPrimitives.ReadUInt32LittleEndian(classSpan.Slice(methodOffset, 4)); + if (methodLen < 20 || methodOffset + methodLen > classSpan.Length) break; + + ParseMethod(classSpan.Slice(methodOffset, (int)methodLen), targetMap); + methodOffset += (int)methodLen; + } + } + + private static void ParseMethod(ReadOnlySpan methodSpan, Dictionary targetMap) + { + if (methodSpan.Length < 20) return; + + uint nameLen = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(12, 4)); + uint paramLen = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(16, 4)); + + if (nameLen == 0xFFFFFFFF) nameLen = paramLen; + if (20 + nameLen > methodSpan.Length) return; + + string methodName = ParseString(methodSpan.Slice(20, (int)nameLen)); + if (string.IsNullOrWhiteSpace(methodName)) return; + + // Qualifiers block begins at 20 + paramLen + int qualOffset = 20 + (int)paramLen; + if (qualOffset + 8 > methodSpan.Length) return; + + uint qualTotalLen = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset, 4)); + uint qualCount = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset + 4, 4)); + qualOffset += 8; + + for (uint q = 0; q < qualCount && qualOffset + 16 <= methodSpan.Length; q++) + { + uint qLen = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset, 4)); + if (qLen < 16 || qualOffset + qLen > methodSpan.Length) break; + + uint qType = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset + 4, 4)); + uint qNameLen = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset + 12, 4)); + + if (qualOffset + 16 + qNameLen <= methodSpan.Length) + { + string qName = ParseString(methodSpan.Slice(qualOffset + 16, (int)qNameLen)); + if (qName.Equals("WmiMethodId", StringComparison.OrdinalIgnoreCase) && qType == 0x03) + { + if (qualOffset + 16 + qNameLen + 4 <= methodSpan.Length) + { + uint methodId = BinaryPrimitives.ReadUInt32LittleEndian(methodSpan.Slice(qualOffset + 16 + (int)qNameLen, 4)); + targetMap[methodName] = methodId; + AppLogger.Info($"[BmofReader] Method mapped: {methodName} -> WmiMethodId {methodId}"); + } + } + } + + qualOffset += (int)qLen; + } + } + + private static string? GetClassName(ReadOnlySpan classData) + { + if (classData.Length < 16) return null; + uint len1 = BinaryPrimitives.ReadUInt32LittleEndian(classData.Slice(0, 4)); + int offset = (int)len1; // Qualifiers block length (includes its 8-byte header) + + if (offset + 8 > classData.Length) return null; + uint len2 = BinaryPrimitives.ReadUInt32LittleEndian(classData.Slice(offset, 4)); + uint count2 = BinaryPrimitives.ReadUInt32LittleEndian(classData.Slice(offset + 4, 4)); + int varEnd = offset + (int)len2; + offset += 8; + + // 1. Scan variables/properties block + for (int i = 0; i < count2 && offset + 20 <= varEnd; i++) + { + uint propLen = BinaryPrimitives.ReadUInt32LittleEndian(classData.Slice(offset, 4)); + if (propLen < 20 || offset + propLen > varEnd) break; + + string? name = TryExtractClassName(classData.Slice(offset, (int)propLen)); + if (name != null) return name; + + offset += (int)propLen; + } + + // 2. Scan trailing class properties block (where __CLASS typically resides) + offset = varEnd; + while (offset + 20 <= classData.Length) + { + uint propLen = BinaryPrimitives.ReadUInt32LittleEndian(classData.Slice(offset, 4)); + if (propLen < 20 || offset + propLen > classData.Length) break; + + string? name = TryExtractClassName(classData.Slice(offset, (int)propLen)); + if (name != null) return name; + + offset += (int)propLen; + } + + return null; + } + + private static string? TryExtractClassName(ReadOnlySpan propSpan) + { + if (propSpan.Length < 20) return null; + uint propType = BinaryPrimitives.ReadUInt32LittleEndian(propSpan.Slice(4, 4)); + uint isClassProp = BinaryPrimitives.ReadUInt32LittleEndian(propSpan.Slice(16, 4)); + + if (isClassProp == 0xFFFFFFFF && propType == 0x08) + { + uint slen = BinaryPrimitives.ReadUInt32LittleEndian(propSpan.Slice(12, 4)); + if (20 + slen <= propSpan.Length) + { + string propName = ParseString(propSpan.Slice(20, (int)slen)); + if (string.Equals(propName, "__CLASS", StringComparison.OrdinalIgnoreCase)) + { + int valLen = (int)(propSpan.Length - slen - 20); + if (valLen > 0) + { + return ParseString(propSpan.Slice(20 + (int)slen, valLen)); + } + } + } + } + return null; + } + + private static string ParseString(ReadOnlySpan buf) + { + int len = 0; + while (len + 1 < buf.Length) + { + if (buf[len] == 0 && buf[len + 1] == 0) break; + len += 2; + } + return Encoding.Unicode.GetString(buf.Slice(0, len)); + } + } + + #endregion +} + +internal sealed record BmofCache( + Dictionary AcerGamingFunction, + Dictionary APGeAction, + string? AcpiBasePath = null +); + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(BmofCache))] +internal partial class BmofJsonContext : JsonSerializerContext +{ +} diff --git a/src/OpenPredator.Core/Backends/Linux/LinuxAcpiCall.cs b/src/OpenPredator.Core/Backends/Linux/LinuxAcpiCall.cs new file mode 100644 index 0000000..8022a50 --- /dev/null +++ b/src/OpenPredator.Core/Backends/Linux/LinuxAcpiCall.cs @@ -0,0 +1,285 @@ +using System; +using System.Globalization; +using System.IO; +using OpenPredator.Core.Services; + +namespace OpenPredator.Core.Backends.Linux; + +/// +/// Direct Linux ACPI transport for Acer laptops. +/// Evaluates WMI ACPI methods directly via /proc/acpi/call using method IDs dynamically resolved by BmofReader. +/// Discovers ACPI PNP0C14 device path dynamically from sysfs with zero hardcoded fallbacks. +/// +public static class LinuxAcpiCall +{ + private const string AcpiCallPath = "/proc/acpi/call"; + private const string GamingFunctionGuid = "7A4DDFE7-5B5D-40B4-8595-4408E0CC7F56"; + private const string GenericActionGuid = "BD6FED80-9DA7-47CE-827D-5A46C495B422"; + + private static string? _acpiBasePath = null; + private static string? _gamingMethodName = null; + private static string? _genericMethodName = null; + private static bool _discoveryAttempted = false; + private static readonly object _syncLock = new(); + public static bool IsAvailable + { + get + { + if (!File.Exists(AcpiCallPath)) return false; + if (ProcessElevation.IsElevated) return true; + try + { + using var fs = File.Open(AcpiCallPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + return true; + } + catch + { + return false; + } + } + } + + public static ulong ExecuteGamingFunction(string methodName, ulong input) + { + uint? methodId = BmofReader.GetGamingMethodId(methodName); + if (methodId == null) + { + AppLogger.Warn($"[LinuxAcpiCall] Unknown or undiscovered AcerGamingFunction method '{methodName}'"); + return 0; + } + + string? basePath = GetAcpiDeviceBasePath(); + if (basePath == null) + { + AppLogger.Error("[LinuxAcpiCall] ACPI device path undiscovered in sysfs. Cannot execute AcerGamingFunction."); + return 0; + } + + if (_gamingMethodName == null) + { + _gamingMethodName = GetWmiMethodName(GamingFunctionGuid); + if (_gamingMethodName == null) + { + AppLogger.Error($"[LinuxAcpiCall] Could not discover WMI object_id for AcerGamingFunction ({GamingFunctionGuid}) in sysfs."); + return 0; + } + AppLogger.Info($"[LinuxAcpiCall] Discovered AcerGamingFunction WMI method: {_gamingMethodName}"); + } + + string fullPath = $"{basePath}.{_gamingMethodName}"; + ulong result = Evaluate(fullPath, 0, methodId.Value, input); + AppLogger.Debug($"[LinuxAcpiCall] ExecuteGamingFunction({methodName}, 0x{input:X}) -> 0x{result:X}"); + return result; + } + + public static ulong ExecuteGenericMethod(string methodName, ulong input) + { + uint? methodId = BmofReader.GetGenericMethodId(methodName); + if (methodId == null) + { + AppLogger.Warn($"[LinuxAcpiCall] Unknown or undiscovered APGeAction method '{methodName}'"); + return 0; + } + + string? basePath = GetAcpiDeviceBasePath(); + if (basePath == null) + { + AppLogger.Error("[LinuxAcpiCall] ACPI device path undiscovered in sysfs. Cannot execute APGeAction."); + return 0; + } + + if (_genericMethodName == null) + { + _genericMethodName = GetWmiMethodName(GenericActionGuid); + if (_genericMethodName == null) + { + AppLogger.Error($"[LinuxAcpiCall] Could not discover WMI object_id for APGeAction ({GenericActionGuid}) in sysfs."); + return 0; + } + AppLogger.Info($"[LinuxAcpiCall] Discovered APGeAction WMI method: {_genericMethodName}"); + } + + string fullPath = $"{basePath}.{_genericMethodName}"; + ulong result = Evaluate(fullPath, 0, methodId.Value, input); + AppLogger.Debug($"[LinuxAcpiCall] ExecuteGenericMethod({methodName}, 0x{input:X}) -> 0x{result:X}"); + return result; + } + + private static string? GetWmiMethodName(string guid) + { + const string wmiDevicesPath = "/sys/bus/wmi/devices"; + if (!Directory.Exists(wmiDevicesPath)) return null; + + foreach (var dir in Directory.GetDirectories(wmiDevicesPath)) + { + string dirName = Path.GetFileName(dir); + if (dirName.StartsWith(guid, StringComparison.OrdinalIgnoreCase)) + { + string objIdFile = Path.Combine(dir, "object_id"); + if (File.Exists(objIdFile)) + { + try + { + string id = File.ReadAllText(objIdFile).Trim(); + if (!string.IsNullOrEmpty(id)) + { + return $"WM{id}"; + } + } + catch (Exception ex) + { + AppLogger.Warn($"[LinuxAcpiCall] Failed to read {objIdFile}: {ex.Message}"); + } + } + } + } + return null; + } + + private static string? GetAcpiDeviceBasePath() + { + if (_discoveryAttempted) return _acpiBasePath; + + lock (_syncLock) + { + if (_discoveryAttempted) return _acpiBasePath; + _discoveryAttempted = true; + + // 0. Use ACPI device path directly associated with the Acer BMOF table + if (!string.IsNullOrEmpty(BmofReader.DiscoveredAcpiBasePath)) + { + _acpiBasePath = BmofReader.DiscoveredAcpiBasePath; + AppLogger.Info($"[LinuxAcpiCall] Using ACPI device path from BMOF reader: {_acpiBasePath}"); + return _acpiBasePath; + } + + // 1. Discover via /sys/bus/wmi/devices/*/firmware_node/path + const string wmiPath = "/sys/bus/wmi/devices"; + if (Directory.Exists(wmiPath)) + { + foreach (var dir in Directory.GetDirectories(wmiPath)) + { + string fnPath = Path.Combine(dir, "firmware_node", "path"); + if (File.Exists(fnPath)) + { + try + { + string path = File.ReadAllText(fnPath).Trim(); + if (!string.IsNullOrEmpty(path)) + { + _acpiBasePath = path; + AppLogger.Info($"[LinuxAcpiCall] Discovered ACPI device path from WMI sysfs: {path}"); + return _acpiBasePath; + } + } + catch { } + } + } + } + + // 2. Discover via /sys/bus/acpi/devices/PNP0C14:*/path + const string acpiPath = "/sys/bus/acpi/devices"; + if (Directory.Exists(acpiPath)) + { + foreach (var dir in Directory.GetDirectories(acpiPath, "PNP0C14*")) + { + string pFile = Path.Combine(dir, "path"); + if (File.Exists(pFile)) + { + try + { + string path = File.ReadAllText(pFile).Trim(); + if (!string.IsNullOrEmpty(path)) + { + _acpiBasePath = path; + AppLogger.Info($"[LinuxAcpiCall] Discovered ACPI device path from ACPI sysfs: {path}"); + return _acpiBasePath; + } + } + catch { } + } + } + } + + AppLogger.Error("[LinuxAcpiCall] Failed to locate PNP0C14 ACPI device path in sysfs (/sys/bus/wmi or /sys/bus/acpi)."); + return null; + } + } + + public static ulong Evaluate(string acpiPath, uint instance, uint methodId, ulong input) + { + lock (_syncLock) + { + return EvaluateDirect(acpiPath, instance, methodId, input); + } + } + + private static ulong EvaluateDirect(string acpiPath, uint instance, uint methodId, ulong input) + { + if (!File.Exists(AcpiCallPath)) + { + return 0; + } + + try + { + byte[] inputBytes = BitConverter.GetBytes(input); + string bufferArg = "b" + Convert.ToHexString(inputBytes).ToLowerInvariant(); + string cmd = $"{acpiPath} {instance} {methodId} {bufferArg}"; + File.WriteAllText(AcpiCallPath, cmd); + + string raw = File.ReadAllText(AcpiCallPath).Trim().Trim('\0'); + if (string.IsNullOrEmpty(raw)) + { + return 0; + } + + if (raw.StartsWith("Error", StringComparison.OrdinalIgnoreCase)) + { + AppLogger.Warn($"[LinuxAcpiCall] ACPI error for '{cmd}': {raw}"); + return 0; + } + + // Case 1: ACPI buffer format: "{0x01, 0x28, 0x00, 0x00, ...}" + if (raw.StartsWith("{") && raw.EndsWith("}")) + { + string inner = raw[1..^1].Trim(); + if (string.IsNullOrEmpty(inner)) return 0; + + string[] parts = inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + ulong val = 0; + for (int i = 0; i < Math.Min(parts.Length, 8); i++) + { + string p = parts[i]; + if (p.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) p = p[2..]; + if (byte.TryParse(p, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte b)) + { + val |= ((ulong)b) << (i * 8); + } + } + return val; + } + + // Case 2: Hex integer: "0x1234" + if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + if (ulong.TryParse(raw[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hexVal)) + { + return hexVal; + } + } + + // Case 3: Decimal integer: "1234" + if (ulong.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong decVal)) + { + return decVal; + } + } + catch (Exception ex) + { + AppLogger.Warn($"[LinuxAcpiCall] Evaluation exception ({acpiPath}): {ex.Message}"); + } + + return 0; + } +} diff --git a/src/OpenPredator.Core/Backends/Linux/LinuxHardwareBackend.cs b/src/OpenPredator.Core/Backends/Linux/LinuxHardwareBackend.cs new file mode 100644 index 0000000..9b29bd6 --- /dev/null +++ b/src/OpenPredator.Core/Backends/Linux/LinuxHardwareBackend.cs @@ -0,0 +1,20 @@ +using System; + +namespace OpenPredator.Core.Backends.Linux; + +/// +/// Linux hardware backend. +/// Inherits full Acer gaming control logic from AcerHardwareBackend, dispatching directly +/// to firmware via /proc/acpi/call (LinuxAcpiCall). +/// +public class LinuxHardwareBackend : AcerHardwareBackend +{ + public override string BackendName => "Linux ACPI / Direct WMI (/proc/acpi/call)"; + public override bool IsSupported => OperatingSystem.IsLinux() && LinuxAcpiCall.IsAvailable; + + public override ulong InvokeGamingFunctionSync(string methodName, ulong input) => + LinuxAcpiCall.ExecuteGamingFunction(methodName, input); + + public override ulong InvokeGenericMethodSync(string methodName, ulong input) => + LinuxAcpiCall.ExecuteGenericMethod(methodName, input); +} diff --git a/src/OpenPredator.Core/Backends/LinuxAcpiBackend.cs b/src/OpenPredator.Core/Backends/LinuxAcpiBackend.cs deleted file mode 100644 index ede0f3e..0000000 --- a/src/OpenPredator.Core/Backends/LinuxAcpiBackend.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using OpenPredator.Core.Enums; -using OpenPredator.Core.Models; - -namespace OpenPredator.Core.Backends; - -public class LinuxAcpiBackend : IHardwareBackend -{ - private const string AcerWmiPlatformPath = "/sys/devices/platform/acer-wmi"; - private const string AcerWmiSysfsPath = "/sys/bus/wmi/devices/7A4D91EE-9E47-41E7-9E90-F338E0822AB4"; - private const string HwmonBasePath = "/sys/class/hwmon"; - - public string BackendName => "Linux ACPI / Sysfs (/sys/devices/platform/acer-wmi)"; - public bool IsSupported => OperatingSystem.IsLinux() && (Directory.Exists(AcerWmiPlatformPath) || Directory.Exists(AcerWmiSysfsPath)); - - public async Task GetSensorDataAsync() - { - return await Task.Run(() => - { - int cpuTemp = ReadSysfsInt($"{AcerWmiPlatformPath}/cpu_temp", ReadHwmonTemp("coretemp") ?? 45); - int gpuTemp = ReadSysfsInt($"{AcerWmiPlatformPath}/gpu_temp", 40); - int cpuRpm = ReadSysfsInt($"{AcerWmiPlatformPath}/cpu_fan_rpm", ReadHwmonFan(1) ?? 2500); - int gpuRpm = ReadSysfsInt($"{AcerWmiPlatformPath}/gpu_fan_rpm", ReadHwmonFan(2) ?? 2700); - - return new SensorData - { - CpuTemperature = cpuTemp, - GpuTemperature = gpuTemp, - SystemTemperature = 0, - CpuFanRpm = cpuRpm, - GpuFanRpm = gpuRpm, - SystemFanRpm = 0, - GpuCount = 1, - GpuUsagePercent = 0, - GpuFrequencyMhz = 0 - }; - }); - } - - public async Task SetFanModeAsync( - FanMode mode, - bool coolBoost, - int cpuPercentage = 50, - int gpuPercentage = 50, - bool cpuAuto = true, - bool gpuAuto = true, - FanMode? cpuMode = null, - FanMode? gpuMode = null) - { - return await Task.Run(() => - { - try - { - WriteSysfs($"{AcerWmiPlatformPath}/coolboost", coolBoost ? "1" : "0"); - WriteSysfs($"{AcerWmiPlatformPath}/fan_mode", ((int)mode).ToString()); - - if (mode == FanMode.Custom || (cpuMode == FanMode.Custom || gpuMode == FanMode.Custom)) - { - if (!cpuAuto || cpuMode == FanMode.Custom) WriteSysfs($"{AcerWmiPlatformPath}/cpu_fan_speed", cpuPercentage.ToString()); - if (!gpuAuto || gpuMode == FanMode.Custom) WriteSysfs($"{AcerWmiPlatformPath}/gpu_fan_speed", gpuPercentage.ToString()); - } - return true; - } - catch - { - return false; - } - }); - } - - public async Task SetCoolBoostAsync(bool enabled) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/coolboost", enabled ? "1" : "0"); - }); - } - - public async Task SetFanSpeedAsync(int fanIndex, int percentage) - { - return await Task.Run(() => - { - string target = fanIndex == 1 ? "cpu_fan_speed" : "gpu_fan_speed"; - return WriteSysfs($"{AcerWmiPlatformPath}/{target}", percentage.ToString()); - }); - } - - public async Task SetPowerModeAsync(PowerMode mode) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/gaming_mode", ((int)mode).ToString()); - }); - } - - public async Task SetRgbKeyboardAsync(RgbConfig config) - { - return await Task.Run(() => - { - try - { - WriteSysfs($"{AcerWmiPlatformPath}/rgb_brightness", config.Brightness.ToString()); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_mode", ((int)config.Effect).ToString()); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_speed", config.Speed.ToString()); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_zone1", $"{config.Zone1.R:X2}{config.Zone1.G:X2}{config.Zone1.B:X2}"); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_zone2", $"{config.Zone2.R:X2}{config.Zone2.G:X2}{config.Zone2.B:X2}"); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_zone3", $"{config.Zone3.R:X2}{config.Zone3.G:X2}{config.Zone3.B:X2}"); - WriteSysfs($"{AcerWmiPlatformPath}/rgb_zone4", $"{config.Zone4.R:X2}{config.Zone4.G:X2}{config.Zone4.B:X2}"); - return true; - } - catch - { - return false; - } - }); - } - - public async Task SetKbBacklightAsync(int brightnessPercentage) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/kbd_backlight", brightnessPercentage.ToString()); - }); - } - - public async Task GetKbBacklightAsync() - { - return await Task.Run(() => - { - return ReadSysfsInt($"{AcerWmiPlatformPath}/kbd_backlight", 100); - }); - } - - public async Task SetKbTimeoutAsync(bool enabled) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/kbd_timeout", enabled ? "1" : "0"); - }); - } - - public async Task GetKbTimeoutAsync() - { - return await Task.Run(() => - { - return ReadSysfsInt($"{AcerWmiPlatformPath}/kbd_timeout", 0) == 1; - }); - } - - public async Task GetPowerModeAsync() - { - return await Task.Run(() => - { - int val = ReadSysfsInt($"{AcerWmiPlatformPath}/gaming_mode", 1); - return (PowerMode)Math.Clamp(val, 0, 3); - }); - } - - public async Task GetCoolBoostAsync() - { - return await Task.Run(() => - { - return ReadSysfsInt($"{AcerWmiPlatformPath}/coolboost", 0) == 1; - }); - } - - public async Task SetLcdOverdriveAsync(bool enabled) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/lcd_overdrive", enabled ? "1" : "0"); - }); - } - - public async Task GetLcdOverdriveAsync() - { - return await Task.Run(() => - { - return ReadSysfsInt($"{AcerWmiPlatformPath}/lcd_overdrive", 0) == 1; - }); - } - - public async Task SetWinKeyLockAsync(bool locked) - { - return await Task.Run(() => - { - return WriteSysfs($"{AcerWmiPlatformPath}/winkey_lock", locked ? "1" : "0"); - }); - } - - public Task SetMiscSettingAsync(uint settingId, uint value) => Task.FromResult(true); - public Task GetMiscSettingAsync(uint settingId) => Task.FromResult(0UL); - public Task InvokeGamingFunctionAsync(string methodName, ulong input) => Task.FromResult(0UL); - public Task InvokeGenericMethodAsync(string methodName, ulong input) => Task.FromResult(0UL); - - private static int ReadSysfsInt(string path, int fallback = 0) - { - try - { - if (File.Exists(path)) - { - string text = File.ReadAllText(path).Trim(); - if (int.TryParse(text, out int val)) return val; - } - } - catch { } - return fallback; - } - - private static bool WriteSysfs(string path, string value) - { - try - { - if (File.Exists(path)) - { - File.WriteAllText(path, value); - return true; - } - } - catch { } - return false; - } - - private static int? ReadHwmonTemp(string chipName) - { - try - { - if (!Directory.Exists(HwmonBasePath)) return null; - foreach (var dir in Directory.GetDirectories(HwmonBasePath)) - { - string nameFile = Path.Combine(dir, "name"); - if (File.Exists(nameFile) && File.ReadAllText(nameFile).Trim() == chipName) - { - string tempInput = Path.Combine(dir, "temp1_input"); - if (File.Exists(tempInput) && int.TryParse(File.ReadAllText(tempInput).Trim(), out int milliC)) - return milliC / 1000; - } - } - } - catch { } - return null; - } - - private static int? ReadHwmonFan(int fanNum) - { - try - { - if (!Directory.Exists(HwmonBasePath)) return null; - foreach (var dir in Directory.GetDirectories(HwmonBasePath)) - { - string fanFile = Path.Combine(dir, $"fan{fanNum}_input"); - if (File.Exists(fanFile) && int.TryParse(File.ReadAllText(fanFile).Trim(), out int rpm)) - return rpm; - } - } - catch { } - return null; - } -} diff --git a/src/OpenPredator.Core/Backends/NativeWmi.cs b/src/OpenPredator.Core/Backends/Windows/NativeWmi.cs similarity index 99% rename from src/OpenPredator.Core/Backends/NativeWmi.cs rename to src/OpenPredator.Core/Backends/Windows/NativeWmi.cs index 47c2958..92a9904 100644 --- a/src/OpenPredator.Core/Backends/NativeWmi.cs +++ b/src/OpenPredator.Core/Backends/Windows/NativeWmi.cs @@ -551,4 +551,3 @@ private static ulong ParseBstrToUlong(IntPtr bstr) return 0; } } - diff --git a/src/OpenPredator.Core/Backends/SmbiosReader.cs b/src/OpenPredator.Core/Backends/Windows/SmbiosReader.cs similarity index 100% rename from src/OpenPredator.Core/Backends/SmbiosReader.cs rename to src/OpenPredator.Core/Backends/Windows/SmbiosReader.cs diff --git a/src/OpenPredator.Core/Backends/Windows/WindowsHardwareBackend.cs b/src/OpenPredator.Core/Backends/Windows/WindowsHardwareBackend.cs new file mode 100644 index 0000000..83f49e1 --- /dev/null +++ b/src/OpenPredator.Core/Backends/Windows/WindowsHardwareBackend.cs @@ -0,0 +1,23 @@ +using System; +using System.Runtime.Versioning; +using System.Threading.Tasks; + +namespace OpenPredator.Core.Backends.Windows; + +/// +/// Windows hardware backend. +/// Inherits full Acer gaming control logic from AcerHardwareBackend, dispatching directly +/// to COM WMI (root\wmi) via NativeWmi. +/// +[SupportedOSPlatform("windows")] +public class WindowsHardwareBackend : AcerHardwareBackend +{ + public override string BackendName => "Windows WMI (root\\wmi -> AcerGamingFunction)"; + public override bool IsSupported => OperatingSystem.IsWindows(); + + public override ulong InvokeGamingFunctionSync(string methodName, ulong input) => + NativeWmi.ExecuteGamingFunction(methodName, input); + + public override ulong InvokeGenericMethodSync(string methodName, ulong input) => + NativeWmi.ExecuteGenericMethod(methodName, input); +} diff --git a/src/OpenPredator.Core/Services/HardwareManager.cs b/src/OpenPredator.Core/Services/HardwareManager.cs index 55c843b..482ae3f 100644 --- a/src/OpenPredator.Core/Services/HardwareManager.cs +++ b/src/OpenPredator.Core/Services/HardwareManager.cs @@ -2,6 +2,8 @@ using System.IO; using System.Threading.Tasks; using OpenPredator.Core.Backends; +using OpenPredator.Core.Backends.Linux; +using OpenPredator.Core.Backends.Windows; using OpenPredator.Core.Enums; using OpenPredator.Core.Models; @@ -112,11 +114,11 @@ public static IHardwareBackend CreateBestBackend() { if (OperatingSystem.IsWindows()) { - return new WindowsWmiBackend(); + return new WindowsHardwareBackend(); } if (OperatingSystem.IsLinux()) { - return new LinuxAcpiBackend(); + return new LinuxHardwareBackend(); } throw new PlatformNotSupportedException("OpenPredator requires a supported Windows or Linux environment with ACPI/WMI hardware."); diff --git a/src/OpenPredator.Core/Services/ProcessElevation.cs b/src/OpenPredator.Core/Services/ProcessElevation.cs new file mode 100644 index 0000000..5ee6c9b --- /dev/null +++ b/src/OpenPredator.Core/Services/ProcessElevation.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace OpenPredator.Core.Services; + +/// +/// Cross-platform process privilege and elevation detector. +/// Identifies Administrator role on Windows and root/euid 0 on Linux. +/// +public static class ProcessElevation +{ + private static bool? _isElevatedCached = null; + + public static bool IsElevated + { + get + { + if (_isElevatedCached.HasValue) return _isElevatedCached.Value; + + if (OperatingSystem.IsWindows()) + { + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + _isElevatedCached = principal.IsInRole(WindowsBuiltInRole.Administrator); + return _isElevatedCached.Value; + } + catch + { + _isElevatedCached = false; + return false; + } + } + + if (OperatingSystem.IsLinux()) + { + try + { + _isElevatedCached = geteuid() == 0; + return _isElevatedCached.Value; + } + catch + { + _isElevatedCached = Environment.UserName == "root"; + return _isElevatedCached.Value; + } + } + + _isElevatedCached = false; + return false; + } + } + + [DllImport("libc", EntryPoint = "geteuid")] + private static extern uint geteuid(); +} diff --git a/src/OpenPredator.Gui/Settings/GuiSettingsManager.cs b/src/OpenPredator.Gui/Settings/GuiSettingsManager.cs index 6b6f194..a729a6d 100644 --- a/src/OpenPredator.Gui/Settings/GuiSettingsManager.cs +++ b/src/OpenPredator.Gui/Settings/GuiSettingsManager.cs @@ -603,16 +603,6 @@ public static bool ExecuteElevatedReg(string args) public static bool IsAdministrator() { - if (OperatingSystem.IsWindows()) - { - try - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - } - catch { return false; } - } - return false; + return ProcessElevation.IsElevated; } } diff --git a/src/OpenPredator.Service/Ipc/NamedPipeServer.cs b/src/OpenPredator.Service/Ipc/NamedPipeServer.cs index d770a28..c5dca8e 100644 --- a/src/OpenPredator.Service/Ipc/NamedPipeServer.cs +++ b/src/OpenPredator.Service/Ipc/NamedPipeServer.cs @@ -55,13 +55,26 @@ private async Task RunWindowsPipeLoopAsync(CancellationToken ct) var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null); var authUsers = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + try + { + using var currentIdentity = WindowsIdentity.GetCurrent(); + if (currentIdentity.User != null) + { + pipeSecurity.AddAccessRule(new PipeAccessRule( + currentIdentity.User, + PipeAccessRights.FullControl, + AccessControlType.Allow)); + } + } + catch { } + pipeSecurity.AddAccessRule(new PipeAccessRule( everyone, - PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, + PipeAccessRights.ReadWrite, AccessControlType.Allow)); pipeSecurity.AddAccessRule(new PipeAccessRule( authUsers, - PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, + PipeAccessRights.ReadWrite, AccessControlType.Allow)); serverStream = NamedPipeServerStreamAcl.Create(