From 8ef1ec932864c34c59f03cecea3a9f385153e9aa Mon Sep 17 00:00:00 2001 From: HelloAxoxelol <258263841+HelloAxoxelol@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:22:43 +0200 Subject: [PATCH] Add a macOS and Linux preview built with .NET 10, Avalonia and HidSharp The protocol code in src/Device is now separate from the Windows HID transport (WinHid.cs), so the Windows build (csc.exe, C# 5) and the new cross-platform build share CompxDevice, ConfigMap and MouseConfig. cross/ holds the new build: - Core: the shared files plus a HidSharp transport - App: a menu bar / tray icon with the battery level, low battery notifications, start at login and a read-only settings window - Cli: compxtray-cli with battery, settings, diagnose and devices - linux/ and macos/: packaging, an installer, a udev rule for access without root, and an ad-hoc signed CompxTray.app The build workflow gets Linux and macOS jobs, and tagged releases now collect the Windows, Linux and macOS packages in one release job. Tested against a Svive Flow 7 on Windows only; not yet run on a real Mac or Linux PC. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 4 + .github/workflows/build-workflow.yml | 68 +++++++++- .gitignore | 1 + CHANGELOG.md | 8 ++ README.md | 32 ++++- cross/App/CompxTray.App.csproj | 36 +++++ cross/App/Palette.cs | 40 ++++++ cross/App/Platform.cs | 132 ++++++++++++++++++ cross/App/Program.cs | 30 +++++ cross/App/StatusWindow.cs | 184 +++++++++++++++++++++++++ cross/App/TrayApp.cs | 195 +++++++++++++++++++++++++++ cross/Cli/CompxTray.Cli.csproj | 24 ++++ cross/Cli/Program.cs | 107 +++++++++++++++ cross/Core/CompxTray.Core.csproj | 22 +++ cross/Core/HidSharpChannel.cs | 139 +++++++++++++++++++ cross/THIRD-PARTY-NOTICES.txt | 23 ++++ cross/linux/70-compxtray.rules | 5 + cross/linux/compxtray.desktop | 8 ++ cross/linux/install.sh | 20 +++ cross/linux/package.sh | 22 +++ cross/macos/Info.plist | 17 +++ cross/macos/package.sh | 36 +++++ src/Device/CompxDevice.cs | 188 +++++--------------------- src/Device/WinHid.cs | 189 ++++++++++++++++++++++++++ tools/read-settings.ps1 | 2 +- tools/verify-writes.ps1 | 2 +- 26 files changed, 1371 insertions(+), 163 deletions(-) create mode 100644 cross/App/CompxTray.App.csproj create mode 100644 cross/App/Palette.cs create mode 100644 cross/App/Platform.cs create mode 100644 cross/App/Program.cs create mode 100644 cross/App/StatusWindow.cs create mode 100644 cross/App/TrayApp.cs create mode 100644 cross/Cli/CompxTray.Cli.csproj create mode 100644 cross/Cli/Program.cs create mode 100644 cross/Core/CompxTray.Core.csproj create mode 100644 cross/Core/HidSharpChannel.cs create mode 100644 cross/THIRD-PARTY-NOTICES.txt create mode 100644 cross/linux/70-compxtray.rules create mode 100644 cross/linux/compxtray.desktop create mode 100755 cross/linux/install.sh create mode 100755 cross/linux/package.sh create mode 100644 cross/macos/Info.plist create mode 100755 cross/macos/package.sh create mode 100644 src/Device/WinHid.cs diff --git a/.gitattributes b/.gitattributes index 9d662b1..c174b62 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,9 @@ *.bat text eol=crlf *.ps1 text eol=crlf *.cs text eol=crlf +*.sh text eol=lf +*.rules text eol=lf +*.desktop text eol=lf +*.plist text eol=lf *.ico binary *.png binary diff --git a/.github/workflows/build-workflow.yml b/.github/workflows/build-workflow.yml index b9a6f69..af1be97 100644 --- a/.github/workflows/build-workflow.yml +++ b/.github/workflows/build-workflow.yml @@ -10,8 +10,14 @@ on: permissions: contents: write +env: + # The tag without its "v" on a release, otherwise a development version. + VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '1.1.0-dev' }} + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + jobs: - build: + windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -30,16 +36,66 @@ jobs: - name: Upload exe uses: actions/upload-artifact@v4 with: - name: CompxTray + name: CompxTray-windows path: | bin/CompxTray.exe bin/CompxTray.exe.sha256 + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build and package + run: sh cross/linux/package.sh linux-x64 "$VERSION" + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: CompxTray-linux-x64 + path: | + out/CompxTray-linux-x64.tar.gz + out/CompxTray-linux-x64.tar.gz.sha256 + + macos: + runs-on: macos-latest + strategy: + matrix: + rid: [ osx-arm64, osx-x64 ] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build and package + run: sh cross/macos/package.sh ${{ matrix.rid }} "$VERSION" + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: CompxTray-${{ matrix.rid }} + path: | + out/CompxTray-${{ matrix.rid }}.zip + out/CompxTray-${{ matrix.rid }}.zip.sha256 + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [ windows, linux, macos ] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Publish release - if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: - files: | - bin/CompxTray.exe - bin/CompxTray.exe.sha256 + files: dist/* generate_release_notes: true diff --git a/.gitignore b/.gitignore index d160a1a..8fea9bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # build output bin/ obj/ +out/ *.exe *.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f8ed6..22a0481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- macOS and Linux preview (`cross/`, .NET 10 with Avalonia and HidSharp): battery level in the menu bar or tray with low battery notifications, start at login, and a read-only settings window. Not yet tested on a real Mac or Linux PC. +- `compxtray-cli` for macOS and Linux: battery, settings, diagnostics and device listing, read-only. +- Linux package with an installer and a udev rule for access to the receiver without root. macOS packages for Apple silicon and Intel as an ad-hoc signed `CompxTray.app`. +- The protocol code is split from the Windows HID transport (`src/Device/WinHid.cs`), so both builds share it. +- The log now records every change in battery level or charging state and every voltage change of 10 mV or more, not only when the mouse wakes up. + ## 1.0.0 - 2026-10-24 First public release. diff --git a/README.md b/README.md index de2401b..aba8432 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Det här kan du göra: **Så kommer du igång:** ladda ner `CompxTray.exe` under [Releases](../../releases/latest) och starta den. Dubbelklicka på batteriikonen nere vid klockan för att öppna inställningarna. Appen är på engelska. +**Mac och Linux:** det finns en förhandsversion för macOS och Linux som visar batterinivån i menyraden eller aktivitetsfältet och musens inställningar (bara läsning än så länge). Den är ännu inte testad på en riktig Mac eller Linux-dator. Se [macOS and Linux](#macos-and-linux-preview). + ### Vanliga frågor **Finns det någon programvara till Svive Flow 7?** @@ -132,6 +134,31 @@ To run a downloaded release: *More info → Run anyway*. Code signing through [S Settings, the log and backups are stored in `%APPDATA%\CompxTray`. +## macOS and Linux (preview) + +A separate build for macOS and Linux shows the battery level in the menu bar or tray, with low battery notifications, and a window with the mouse's settings. Settings are **read-only** in this version. It shares the protocol code with the Windows app and has been tested against a real Svive Flow 7 on Windows, but **it has not been tested on a Mac or a Linux PC yet**. If you try it, please open an issue with what you see and the output of `compxtray-cli devices`. + +| Download from [Releases](../../releases/latest) | For | +|---|---| +| `CompxTray-osx-arm64.zip` | Macs with Apple silicon (M1 and later) | +| `CompxTray-osx-x64.zip` | Intel Macs | +| `CompxTray-linux-x64.tar.gz` | 64-bit Linux (x86-64) | + +**macOS:** unzip and move `CompxTray.app` to *Applications*. The app is not notarized by Apple, so macOS blocks it the first time you open it. Go to *System Settings → Privacy & Security* and click *Open Anyway*. If macOS asks for *Input Monitoring*, allow it so CompxTray can reach the receiver. The log is in `~/Library/Application Support/CompxTray`. + +**Linux:** unpack the archive and run `./install.sh`. It copies CompxTray to `~/.local/bin`, adds it to your applications menu and installs a udev rule (asks for your password) so you can open the receiver without root. Unplug and replug the receiver afterwards. The tray icon needs a desktop with StatusNotifier support: KDE Plasma, Xfce, Cinnamon and Ubuntu work out of the box, and on plain GNOME you need the *AppIndicator and KStatusNotifierItem Support* extension. The log is in `~/.config/CompxTray`. + +Both packages include `compxtray-cli`, a read-only command-line tool: + +| Command | What it shows | +|---|---| +| `compxtray-cli battery` | Battery level, charging state and voltage | +| `compxtray-cli settings --wait 30` | DPI stages, polling rate, lift-off, debounce, motion sync and sleep. `--wait` keeps trying while the mouse is asleep | +| `compxtray-cli diagnose` | Raw replies and the settings memory, for bug reports | +| `compxtray-cli devices` | The receiver's HID interfaces, for bug reports | + +To build it yourself you need the [.NET 10 SDK](https://dot.net). On Linux run `sh cross/linux/package.sh linux-x64 1.1.0`, on a Mac `sh cross/macos/package.sh osx-arm64 1.1.0`. The result ends up in `out/`. + ## Build from source You don't need Visual Studio or an SDK. The C# compiler that ships with Windows (.NET Framework 4.x) is enough. @@ -174,9 +201,10 @@ If the memory matches the known layout, the model can be added to the verified l ## Project layout ``` -src/Device/ HID transport, memory map, read/write/verify logic +src/Device/ protocol, memory map, read/write/verify logic (shared with cross/), and WinHid.cs, the Windows HID transport src/UI/ dark theme and owner-drawn controls, settings window src/ tray app, app settings, entry point +cross/ the macOS and Linux build (.NET 10): Core (HidSharp transport), App (Avalonia), Cli, packaging tools/ diagnostics and the guided hardware test docs/ protocol notes, screenshots and the project website (GitHub Pages, English and Swedish) ``` @@ -191,6 +219,8 @@ The protocol was pieced together from public reverse-engineering work by others. CompxTray's code is written from scratch; only protocol facts were taken from these projects. The memory map was confirmed by reading a real Svive Flow 7. +The macOS and Linux build uses [HidSharp](https://software.seekye.com/hidsharp) (Apache-2.0) for USB access and [Avalonia](https://avaloniaui.net) (MIT) for the interface. + ## Disclaimer CompxTray is an independent project. It is not affiliated with or endorsed by Svive, Compx or any mouse manufacturer, and product names are trademarks of their owners. Changing settings writes to your mouse's memory. CompxTray verifies every write and keeps a backup, but you use it at your own risk. diff --git a/cross/App/CompxTray.App.csproj b/cross/App/CompxTray.App.csproj new file mode 100644 index 0000000..4571532 --- /dev/null +++ b/cross/App/CompxTray.App.csproj @@ -0,0 +1,36 @@ + + + + WinExe + net10.0 + CompxTray + CompxTray.App + 1.1.0 + disable + disable + true + ..\..\assets\app.manifest + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cross/App/Palette.cs b/cross/App/Palette.cs new file mode 100644 index 0000000..5d7174d --- /dev/null +++ b/cross/App/Palette.cs @@ -0,0 +1,40 @@ +// Palette.cs - the colours of the Windows version, for the Avalonia app. +using Avalonia.Media; + +namespace CompxTray.App +{ + static class Palette + { + public static readonly Color Bg = Hex(0x0D0F13); + public static readonly Color Surface = Hex(0x15181E); + public static readonly Color Surface2 = Hex(0x1E222A); + public static readonly Color Border = Hex(0x252A33); + public static readonly Color Text = Hex(0xECEFF4); + public static readonly Color Dim = Hex(0x8A93A3); + public static readonly Color Faint = Hex(0x5A6272); + public static readonly Color Accent = Hex(0x1EC8FF); + public static readonly Color OnAccent = Hex(0x041018); + public static readonly Color Good = Hex(0x35D07F); + public static readonly Color Warn = Hex(0xF4B740); + public static readonly Color Bad = Hex(0xFF5D62); + + public static Color Hex(int rgb) { return Color.FromRgb((byte)(rgb >> 16), (byte)(rgb >> 8), (byte)rgb); } + + public static IBrush Brush(Color c) { return new SolidColorBrush(c); } + + public static Color LevelColor(int percent, bool charging) + { + if (charging) return Accent; + if (percent <= 20) return Bad; + if (percent <= 50) return Warn; + return Good; + } + + /// HSL lightness, as System.Drawing.Color.GetBrightness on Windows. + public static double Brightness(Color c) + { + int max = System.Math.Max(c.R, System.Math.Max(c.G, c.B)), min = System.Math.Min(c.R, System.Math.Min(c.G, c.B)); + return (max + min) / 510.0; + } + } +} diff --git a/cross/App/Platform.cs b/cross/App/Platform.cs new file mode 100644 index 0000000..69fd2a7 --- /dev/null +++ b/cross/App/Platform.cs @@ -0,0 +1,132 @@ +// Platform.cs - data folder, log, start at login and desktop notifications on macOS and Linux. +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace CompxTray.App +{ + static class Platform + { + public const string AppName = "CompxTray"; + public static readonly string Version = (typeof(Platform).Assembly.GetCustomAttribute()?.InformationalVersion ?? "?").Split('+')[0]; + public const string RepoUrl = "https://github.com/HelloAxoxelol/CompxTray"; + const string LaunchAgentId = "io.github.helloaxoxelol.compxtray"; + + public static bool IsMac { get { return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); } } + public static bool IsLinux { get { return RuntimeInformation.IsOSPlatform(OSPlatform.Linux); } } + + /// ~/Library/Application Support/CompxTray on macOS, ~/.config/CompxTray on Linux. + /// On Windows, where this build only runs for testing, a subfolder keeps it apart from the Windows app's log. + public static string DataDir + { + get + { + var root = Environment.GetFolderPath(IsMac ? Environment.SpecialFolder.LocalApplicationData : Environment.SpecialFolder.ApplicationData); + string d = Path.Combine(root, AppName); + if (!IsMac && !IsLinux) d = Path.Combine(d, "cross"); + try { Directory.CreateDirectory(d); } catch { } + return d; + } + } + + public static void Log(string msg) + { + try + { + string path = Path.Combine(DataDir, "log.txt"); + var fi = new FileInfo(path); + if (fi.Exists && fi.Length > 256 * 1024) fi.Delete(); + File.AppendAllText(path, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " " + msg + Environment.NewLine); + } + catch { } + } + + // ---------- Start at login ---------- + static string AutostartFile + { + get + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (IsMac) return Path.Combine(home, "Library", "LaunchAgents", LaunchAgentId + ".plist"); + string config = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrEmpty(config)) config = Path.Combine(home, ".config"); + return Path.Combine(config, "autostart", "compxtray.desktop"); + } + } + + public static bool StartAtLogin + { + get { return File.Exists(AutostartFile); } + set + { + try + { + if (!value) { if (File.Exists(AutostartFile)) File.Delete(AutostartFile); return; } + Directory.CreateDirectory(Path.GetDirectoryName(AutostartFile)); + string exe = Environment.ProcessPath; + File.WriteAllText(AutostartFile, IsMac ? LaunchAgent(exe) : DesktopEntry(exe)); + } + catch (Exception ex) { Log("Start at login: " + ex.Message); } + } + } + + static string LaunchAgent(string exe) + { + return "\n" + + "\n" + + "\n" + + " Label" + LaunchAgentId + "\n" + + " ProgramArguments" + Xml(exe) + "\n" + + " RunAtLoad\n" + + "\n"; + } + + static string DesktopEntry(string exe) + { + return "[Desktop Entry]\nType=Application\nName=CompxTray\nComment=Mouse battery level in the tray\n" + + "Exec=\"" + exe.Replace("\"", "\\\"") + "\"\nTerminal=false\nX-GNOME-Autostart-enabled=true\n"; + } + + static string Xml(string s) { return s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } + + // ---------- Notifications ---------- + public static void Notify(string title, string text) + { + try + { + var psi = new ProcessStartInfo { UseShellExecute = false, CreateNoWindow = true }; + if (IsMac) + { + psi.FileName = "osascript"; + psi.ArgumentList.Add("-e"); + psi.ArgumentList.Add("display notification " + AppleString(text) + " with title " + AppleString(title)); + } + else if (IsLinux) + { + psi.FileName = "notify-send"; + psi.ArgumentList.Add("--app-name=CompxTray"); + psi.ArgumentList.Add(title); + psi.ArgumentList.Add(text); + } + else return; + using (Process.Start(psi)) { } + } + catch (Exception ex) { Log("Notification failed: " + ex.Message); } + } + + static string AppleString(string s) { return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } + + public static void OpenUrl(string url) + { + try + { + var psi = new ProcessStartInfo { UseShellExecute = false, CreateNoWindow = true, FileName = IsMac ? "open" : IsLinux ? "xdg-open" : url }; + if (IsMac || IsLinux) psi.ArgumentList.Add(url); else psi.UseShellExecute = true; + using (Process.Start(psi)) { } + } + catch (Exception ex) { Log("Could not open " + url + ": " + ex.Message); } + } + } +} diff --git a/cross/App/Program.cs b/cross/App/Program.cs new file mode 100644 index 0000000..3f91079 --- /dev/null +++ b/cross/App/Program.cs @@ -0,0 +1,30 @@ +// Program.cs - CompxTray for macOS and Linux: a menu bar / tray icon with the battery level and a read-only settings window. +using System; +using System.Threading; +using Avalonia; +using Avalonia.Controls; + +namespace CompxTray.App +{ + static class Program + { + [STAThread] + static int Main(string[] args) + { + using (var single = new Mutex(true, "CompxTray.Cross", out bool first)) + { + if (!first) { Console.Error.WriteLine("CompxTray is already running."); return 0; } + return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown); + } + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .With(new MacOSPlatformOptions { ShowInDock = false }) + .LogToTrace(); + } + } +} diff --git a/cross/App/StatusWindow.cs b/cross/App/StatusWindow.cs new file mode 100644 index 0000000..f97e77b --- /dev/null +++ b/cross/App/StatusWindow.cs @@ -0,0 +1,184 @@ +// StatusWindow.cs - shows the battery and the mouse's settings. Read-only on macOS and Linux for now. +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Threading; +using CompxTray.Device; + +namespace CompxTray.App +{ + public class StatusWindow : Window + { + readonly TextBlock lblName = Text("Looking for the mouse…", 18, Palette.Text, FontWeight.SemiBold); + readonly TextBlock lblModel = Text("", 12, Palette.Dim); + readonly TextBlock lblPercent = Text("–", 44, Palette.Text, FontWeight.Bold); + readonly TextBlock lblBattery = Text("", 12, Palette.Dim); + readonly ProgressBar bar = new ProgressBar { Minimum = 0, Maximum = 100, Height = 6, MinHeight = 6, Background = Palette.Brush(Palette.Surface2) }; + readonly StackPanel rows = new StackPanel { Spacing = 2 }; + readonly TextBlock lblStatus = Text("", 12, Palette.Dim); + readonly Button btnRefresh = new Button { Content = "Read again" }; + bool reading; + + class ReadResult { public MouseConfig Config; public string ConfigError; public MouseStatus Battery; public string Firmware; public MouseModel Model; public string DeviceName; } + + public StatusWindow() + { + Title = "CompxTray"; + Width = 440; Height = 700; MinWidth = 380; MinHeight = 480; + Background = Palette.Brush(Palette.Bg); + WindowStartupLocation = WindowStartupLocation.CenterScreen; + try { Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://CompxTray/Assets/icon.png"))); } catch { } + + var header = new StackPanel { Spacing = 2, Children = { lblName, lblModel } }; + + var batteryCard = Card(new StackPanel + { + Spacing = 6, + Children = + { + Text("BATTERY", 11, Palette.Dim, FontWeight.SemiBold), + lblPercent, + bar, + lblBattery, + } + }); + + var settingsCard = Card(new StackPanel + { + Spacing = 8, + Children = { Text("SETTINGS", 11, Palette.Dim, FontWeight.SemiBold), rows } + }); + + var note = Text("Settings are shown read-only on macOS and Linux in this version. Change them with the Windows app for now.", 12, Palette.Faint); + note.TextWrapping = TextWrapping.Wrap; + + var login = new CheckBox { Content = "Start at login", IsChecked = Platform.StartAtLogin, Foreground = Palette.Brush(Palette.Text) }; + login.IsCheckedChanged += (s, e) => Platform.StartAtLogin = login.IsChecked == true; + var repo = new Button { Content = "Project page" }; + repo.Click += (s, e) => Platform.OpenUrl(Platform.RepoUrl); + btnRefresh.Click += (s, e) => ReadAll(); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { btnRefresh, repo } }; + + var footer = new StackPanel { Spacing = 10, Children = { lblStatus, note, login, buttons, Text("CompxTray " + Platform.Version + " · not affiliated with Svive or Compx", 11, Palette.Faint) } }; + + Content = new ScrollViewer + { + Content = new StackPanel { Margin = new Thickness(20), Spacing = 14, Children = { header, batteryCard, settingsCard, footer } } + }; + Opened += (s, e) => ReadAll(); + } + + static TextBlock Text(string text, double size, Color color, FontWeight weight = FontWeight.Normal) + { + return new TextBlock { Text = text, FontSize = size, Foreground = Palette.Brush(color), FontWeight = weight }; + } + + static Border Card(Control child) + { + return new Border + { + Background = Palette.Brush(Palette.Surface), BorderBrush = Palette.Brush(Palette.Border), BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), Padding = new Thickness(16), Child = child + }; + } + + void Row(string label, string value) + { + var g = new Grid { ColumnDefinitions = new ColumnDefinitions("140,*") }; + var l = Text(label, 13, Palette.Dim); + var v = Text(value, 13, Palette.Text); + Grid.SetColumn(v, 1); + g.Children.Add(l); g.Children.Add(v); + rows.Children.Add(g); + } + + // ---------- Battery ---------- + public void ShowBattery(MouseStatus st, MouseStatus lastOk) + { + var show = st != null && st.Ok ? st : lastOk; + if (show == null) + { + lblPercent.Text = "–"; + lblBattery.Text = st != null ? st.Message : ""; + bar.Value = 0; + return; + } + bool stale = show != st; + lblPercent.Text = show.Percent + " %"; + bar.Foreground = Palette.Brush(stale ? Palette.Faint : Palette.LevelColor(show.Percent, show.Charging)); + bar.Value = show.Percent; + string state = stale ? "Asleep, last seen " + show.Time.ToString("HH:mm") : show.Charging ? "Charging" : "On battery"; + lblBattery.Text = string.Format("{0} · {1:0.00} V · updated {2:HH:mm:ss}", state, show.MilliVolts / 1000.0, show.Time); + } + + // ---------- Read ---------- + void ReadAll() + { + if (reading) return; + reading = true; + btnRefresh.IsEnabled = false; + lblStatus.Text = "Reading the mouse…"; + Task.Run(() => + { + string openErr = null; + ReadResult r = null; + try + { + r = CompxHid.WithDevice(d => + { + var res = new ReadResult(); + res.Model = MouseModel.Find(d.Vid, d.Pid, d.ProductName); + res.DeviceName = d.Name; + res.Battery = d.ReadBattery(800); + res.Firmware = d.ReadFirmware() ?? "?"; + try { res.Config = MouseConfig.Read(d); } + catch (Exception ex) { res.ConfigError = ex.Message; } + return res; + }, out openErr); + } + catch (Exception ex) { openErr = ex.Message; } + Dispatcher.UIThread.Post(() => ShowResult(r, openErr)); + }); + } + + void ShowResult(ReadResult r, string openErr) + { + reading = false; + btnRefresh.IsEnabled = true; + rows.Children.Clear(); + if (r == null) + { + lblName.Text = "No mouse found"; + lblModel.Text = ""; + lblStatus.Text = openErr ?? "No Compx receiver found."; + lblStatus.Foreground = Palette.Brush(Palette.Warn); + return; + } + lblName.Text = r.Model.DisplayName; + lblModel.Text = r.DeviceName + " · firmware " + r.Firmware; + if (r.Battery.Ok) ShowBattery(r.Battery, r.Battery); + if (r.Config == null) + { + lblStatus.Text = r.ConfigError; + lblStatus.Foreground = Palette.Brush(Palette.Warn); + return; + } + var c = r.Config; + Row("Polling rate", c.PollingHz + " Hz"); + Row("DPI stages", c.StageCount + ", active stage " + (c.ActiveStage + 1)); + for (int i = 0; i < c.StageCount; i++) + Row(" Stage " + (i + 1), c.Dpi[i] + " DPI" + (i == c.ActiveStage ? " ●" : "")); + Row("Lift-off", c.LiftOffCode == 1 ? "Low (1)" : c.LiftOffCode == 2 ? "High (2)" : "code " + c.LiftOffCode); + Row("Debounce", c.DebounceMs + " ms"); + Row("Motion sync", c.MotionSync ? "On" : "Off"); + Row("Sleep after", MouseConfig.FormatSleep(c.SleepSeconds)); + lblStatus.Text = r.Model.SettingsVerified ? "Read " + DateTime.Now.ToString("HH:mm:ss") + "." + : "This mouse model has not been verified yet, so the values may be wrong."; + lblStatus.Foreground = Palette.Brush(r.Model.SettingsVerified ? Palette.Dim : Palette.Warn); + } + } +} diff --git a/cross/App/TrayApp.cs b/cross/App/TrayApp.cs new file mode 100644 index 0000000..cc13276 --- /dev/null +++ b/cross/App/TrayApp.cs @@ -0,0 +1,195 @@ +// TrayApp.cs - menu bar / tray icon with the battery level, menu and low-battery notifications. +using System; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Styling; +using Avalonia.Themes.Fluent; +using Avalonia.Threading; +using CompxTray.Device; + +namespace CompxTray.App +{ + public class TrayApp : Application + { + TrayIcon tray; + NativeMenuItem statusItem, timeItem, loginItem; + readonly DispatcherTimer timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(60) }; + StatusWindow window; + + MouseStatus lastOk, lastLogged; + string lastMessage = ""; + // Voltage readings jitter by a few mV, so smaller changes are not logged. + const int LogMilliVoltStep = 10; + bool busy, warned20, warned10, notifiedFull; + + public override void Initialize() + { + Name = Platform.AppName; + Styles.Add(new FluentTheme()); + RequestedThemeVariant = ThemeVariant.Dark; + } + + public override void OnFrameworkInitializationCompleted() + { + BuildTray(); + timer.Tick += (s, e) => Refresh(); + timer.Start(); + Platform.Log("Started " + Platform.AppName + " " + Platform.Version + " (" + System.Runtime.InteropServices.RuntimeInformation.OSDescription + ")"); + Refresh(); + var desktop = ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime; + if (desktop != null && desktop.Args != null && Array.IndexOf(desktop.Args, "--open") >= 0) OpenWindow(); + base.OnFrameworkInitializationCompleted(); + } + + void BuildTray() + { + var menu = new NativeMenu(); + statusItem = new NativeMenuItem("Reading battery…") { IsEnabled = false }; + timeItem = new NativeMenuItem("") { IsEnabled = false }; + var open = new NativeMenuItem("Open CompxTray…"); + open.Click += (s, e) => OpenWindow(); + var now = new NativeMenuItem("Check now"); + now.Click += (s, e) => Refresh(); + loginItem = new NativeMenuItem("Start at login") { ToggleType = MenuItemToggleType.CheckBox, IsChecked = Platform.StartAtLogin }; + loginItem.Click += (s, e) => { Platform.StartAtLogin = !Platform.StartAtLogin; loginItem.IsChecked = Platform.StartAtLogin; }; + var quit = new NativeMenuItem("Quit"); + quit.Click += (s, e) => { tray.IsVisible = false; ((Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)ApplicationLifetime).Shutdown(); }; + menu.Add(statusItem); + menu.Add(timeItem); + menu.Add(new NativeMenuItemSeparator()); + menu.Add(open); + menu.Add(now); + menu.Add(loginItem); + menu.Add(new NativeMenuItemSeparator()); + menu.Add(quit); + + tray = new TrayIcon { Icon = MakeIcon("…", Palette.Faint), ToolTipText = "CompxTray: reading battery…", Menu = menu, IsVisible = true }; + tray.Clicked += (s, e) => OpenWindow(); + TrayIcon.SetIcons(this, new TrayIcons { tray }); + } + + void OpenWindow() + { + if (window == null) + { + window = new StatusWindow(); + window.Closed += (s, e) => window = null; + window.Show(); + if (lastOk != null) window.ShowBattery(lastOk, lastOk); + } + else + { + if (window.WindowState == WindowState.Minimized) window.WindowState = WindowState.Normal; + window.Activate(); + } + } + + // ---------- Battery ---------- + public void Refresh() + { + if (busy) return; + busy = true; + Task.Run(() => + { + MouseStatus st; + try { st = CompxHid.QueryBattery(1500); } + catch (Exception ex) { st = new MouseStatus(); st.Message = "Error: " + ex.Message; } + Dispatcher.UIThread.Post(() => { busy = false; Apply(st); }); + }); + } + + void Apply(MouseStatus st) + { + bool batteryChanged = st.Ok && (lastLogged == null || st.Percent != lastLogged.Percent || st.Charging != lastLogged.Charging + || Math.Abs(st.MilliVolts - lastLogged.MilliVolts) >= LogMilliVoltStep); + if (st.Message != lastMessage || batteryChanged) + { + Platform.Log(st.Ok ? string.Format("Battery {0} % charging={1} {2} mV", st.Percent, st.Charging, st.MilliVolts) : st.Message); + lastMessage = st.Message; + if (st.Ok) lastLogged = st; + } + string name = st.DeviceName.Length > 0 ? st.DeviceName : lastOk != null ? lastOk.DeviceName : "Mouse"; + + if (st.Ok) + { + lastOk = st; + tray.Icon = MakeIcon(st.Percent.ToString(), Palette.LevelColor(st.Percent, st.Charging)); + string charge = st.Charging ? " (charging)" : ""; + tray.ToolTipText = string.Format("{0}: {1} %{2}\n{3:0.00} V · {4:HH:mm}", name, st.Percent, charge, st.MilliVolts / 1000.0, st.Time); + statusItem.Header = string.Format("{0}: {1} %{2}", name, st.Percent, charge); + timeItem.Header = "Updated " + st.Time.ToString("HH:mm:ss"); + CheckWarnings(st, name); + } + else if (lastOk != null && st.ReceiverFound) + { + tray.Icon = MakeIcon(lastOk.Percent.ToString(), Palette.Faint); + tray.ToolTipText = string.Format("{0}: {1} % (at {2:HH:mm})\nMouse is asleep", name, lastOk.Percent, lastOk.Time); + statusItem.Header = string.Format("{0}: {1} % (at {2:HH:mm})", name, lastOk.Percent, lastOk.Time); + timeItem.Header = "Mouse is asleep"; + } + else + { + tray.Icon = MakeIcon("?", Palette.Faint); + tray.ToolTipText = "CompxTray\n" + st.Message; + statusItem.Header = "Battery unknown"; + timeItem.Header = st.Message; + } + if (window != null) window.ShowBattery(st, lastOk); + } + + void CheckWarnings(MouseStatus st, string name) + { + if (st.Charging || st.Percent > 25) { warned20 = false; warned10 = false; } + if (!st.Charging) notifiedFull = false; + if (!st.Charging) + { + if (st.Percent <= 10 && !warned10) + { + warned10 = true; warned20 = true; + Platform.Notify(name + ": " + st.Percent + " %", "The battery is almost empty. Time to charge."); + } + else if (st.Percent <= 20 && !warned20) + { + warned20 = true; + Platform.Notify(name + ": " + st.Percent + " %", "The battery is running low."); + } + } + if (st.Charging && st.Percent >= 100 && !notifiedFull) + { + notifiedFull = true; + Platform.Notify(name, "Fully charged."); + } + } + + // ---------- Tray icon ---------- + static WindowIcon MakeIcon(string text, Color bg) + { + const int size = 64; + var bmp = new RenderTargetBitmap(new PixelSize(size, size), new Vector(96, 96)); + using (var ctx = bmp.CreateDrawingContext()) + { + ctx.DrawRectangle(new SolidColorBrush(bg), null, new RoundedRect(new Rect(0, 0, size, size), 13)); + var fg = Palette.Brightness(bg) > 0.55 ? Palette.OnAccent : Colors.White; + var typeface = new Typeface(FontFamily.Default, FontStyle.Normal, FontWeight.Bold); + double fontSize = size * 0.78; + FormattedText ft; + while (true) + { + ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, fontSize, new SolidColorBrush(fg)); + if ((ft.Width <= size - 4 && ft.Height <= size + 6) || fontSize <= 12) break; + fontSize -= 2; + } + ctx.DrawText(ft, new Point((size - ft.Width) / 2, (size - ft.Height) / 2)); + } + var ms = new MemoryStream(); + bmp.Save(ms, PngBitmapEncoderOptions.Default); + ms.Position = 0; + return new WindowIcon(ms); + } + } +} diff --git a/cross/Cli/CompxTray.Cli.csproj b/cross/Cli/CompxTray.Cli.csproj new file mode 100644 index 0000000..8ee8e16 --- /dev/null +++ b/cross/Cli/CompxTray.Cli.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + compxtray-cli + CompxTray.Cli + disable + disable + true + true + true + + + + + + + + + + + + diff --git a/cross/Cli/Program.cs b/cross/Cli/Program.cs new file mode 100644 index 0000000..3ef7d2b --- /dev/null +++ b/cross/Cli/Program.cs @@ -0,0 +1,107 @@ +// Program.cs - compxtray-cli, a read-only command line tool for Compx mice on macOS, Linux and Windows. +using System; +using System.Diagnostics; +using System.Threading; +using CompxTray.Device; + +namespace CompxTray.Cli +{ + static class Program + { + const string Usage = +@"compxtray-cli - battery and settings of Compx-based wireless mice (Svive Flow 7 and others). Read-only. + +Usage: compxtray-cli [--wait ] [--trace] + + battery battery level, charging state and voltage + settings DPI stages, polling rate, lift-off, debounce, motion sync and sleep + diagnose raw replies and the settings memory, for bug reports + devices the receiver's HID interfaces, for bug reports + + --wait keep trying for up to s seconds while the mouse is asleep (move it to wake it) + --trace print the raw HID traffic"; + + static int Main(string[] args) + { + if (args.Length == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help") { Console.WriteLine(Usage); return args.Length == 0 ? 1 : 0; } + int wait = 0; + bool trace = false; + for (int i = 1; i < args.Length; i++) + { + if (args[i] == "--wait" && i + 1 < args.Length && int.TryParse(args[i + 1], out wait)) i++; + else if (args[i] == "--trace") trace = true; + else { Console.Error.WriteLine("Unknown option " + args[i] + "\n\n" + Usage); return 1; } + } + + switch (args[0]) + { + case "battery": return Retry(wait, trace, Battery); + case "settings": return Retry(wait, trace, Settings); + case "diagnose": return Retry(0, trace, Diagnose); + case "devices": + foreach (var line in HidChannels.Describe(CompxDevice.VendorIds, CompxDevice.PacketLen)) Console.WriteLine(line); + return 0; + default: + Console.Error.WriteLine("Unknown command " + args[0] + "\n\n" + Usage); + return 1; + } + } + + /// Runs action until it succeeds (returns 0) or the wait time is up. Exit code 2 means the mouse did not answer. + static int Retry(int waitSeconds, bool trace, Func action) + { + var sw = Stopwatch.StartNew(); + bool told = false; + while (true) + { + CompxDevice d = CompxDevice.Open(out string err); + if (d == null) { Console.Error.WriteLine(err); return 3; } + int rc; + using (d) + { + rc = action(d); + if (trace) foreach (var line in d.Log) Console.Error.WriteLine(line); + } + if (rc != 2 || sw.Elapsed.TotalSeconds >= waitSeconds) return rc; + if (!told) { Console.Error.WriteLine("Waiting for the mouse. Move it to wake it up..."); told = true; } + Thread.Sleep(1000); + } + } + + static int Battery(CompxDevice d) + { + var st = d.ReadBattery(1500); + if (!st.Ok) { Console.Error.WriteLine(st.Message); return 2; } + Console.WriteLine("{0}: {1} %{2}, {3:0.000} V", d.Name, st.Percent, st.Charging ? " (charging)" : "", st.MilliVolts / 1000.0); + return 0; + } + + static int Settings(CompxDevice d) + { + var st = d.ReadBattery(1500); + if (!st.Ok) { Console.Error.WriteLine(st.Message); return 2; } + MouseConfig c; + try { c = MouseConfig.Read(d); } + catch (Exception ex) { Console.Error.WriteLine(ex.Message); return 2; } + var model = MouseModel.Find(d.Vid, d.Pid, d.ProductName); + Console.WriteLine("{0}{1}", d.Name, model.SettingsVerified ? "" : " (model not verified, values may be wrong)"); + Console.WriteLine(" firmware {0}", d.ReadFirmware() ?? "?"); + Console.WriteLine(" battery {0} %{1}, {2:0.000} V", st.Percent, st.Charging ? " (charging)" : "", st.MilliVolts / 1000.0); + Console.WriteLine(" polling rate {0} Hz", c.PollingHz); + Console.WriteLine(" DPI stages {0}, active stage {1}", c.StageCount, c.ActiveStage + 1); + for (int i = 0; i < c.StageCount; i++) + Console.WriteLine(" stage {0} {1} DPI{2}", i + 1, c.Dpi[i], i == c.ActiveStage ? " <" : ""); + Console.WriteLine(" lift-off code {0}", c.LiftOffCode); + Console.WriteLine(" debounce {0} ms", c.DebounceMs); + Console.WriteLine(" motion sync {0}", c.MotionSync ? "on" : "off"); + Console.WriteLine(" sleep after {0}", MouseConfig.FormatSleep(c.SleepSeconds)); + return 0; + } + + static int Diagnose(CompxDevice d) + { + foreach (var line in ConfigMap.Diagnose(d)) Console.WriteLine(line); + return 0; + } + } +} diff --git a/cross/Core/CompxTray.Core.csproj b/cross/Core/CompxTray.Core.csproj new file mode 100644 index 0000000..6a31024 --- /dev/null +++ b/cross/Core/CompxTray.Core.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + CompxTray.Core + CompxTray + disable + disable + + + + + + + + + + + + + + diff --git a/cross/Core/HidSharpChannel.cs b/cross/Core/HidSharpChannel.cs new file mode 100644 index 0000000..6945bf5 --- /dev/null +++ b/cross/Core/HidSharpChannel.cs @@ -0,0 +1,139 @@ +// HidSharpChannel.cs - the receiver's configuration interface on macOS and Linux, through HidSharp. +// Also works on Windows, which is how it is tested against a real mouse. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using HidSharp; + +namespace CompxTray.Device +{ + public static class HidChannels + { + /// Opens the first vendor interface with packetLen-byte reports on one of the vendor IDs. Returns null and a reason if none is available. + public static IHidChannel OpenFirst(ushort[] vendorIds, int packetLen, out string error) + { + error = null; + var cands = FindCandidates(vendorIds, packetLen); + if (cands.Count == 0) { error = "No Compx receiver found. Is the dongle plugged in?"; return null; } + foreach (var dev in cands) + { + try + { + if (dev.TryOpen(out HidStream stream)) return new HidSharpChannel(dev, stream); + error = "Could not open the receiver." + PermissionHint(); + } + catch (Exception ex) { error = "Could not open the receiver: " + ex.Message + PermissionHint(); } + } + return null; + } + + static List FindCandidates(ushort[] vendorIds, int packetLen) + { + var list = new List(); + foreach (var dev in DeviceList.Local.GetHidDevices()) + { + if (Array.IndexOf(vendorIds, (ushort)dev.VendorID) < 0) continue; + try { if (IsConfigInterface(dev, packetLen)) list.Add(dev); } catch { } + } + return list; + } + + // The configuration channel: a vendor usage page with report 0x08 of packetLen bytes in both directions. + static bool IsConfigInterface(HidDevice dev, int packetLen) + { + var desc = dev.GetReportDescriptor(); + bool vendor = desc.DeviceItems.Any(item => item.Usages.GetAllValues().Any(u => (u >> 16) >= 0xFF00)); + bool output = desc.OutputReports.Any(r => r.ReportID == CompxDevice.ReportId && r.Length == packetLen); + bool input = desc.InputReports.Any(r => r.ReportID == CompxDevice.ReportId && r.Length >= packetLen); + return vendor && output && input; + } + + static string PermissionHint() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return " On Linux, install the udev rule that comes with CompxTray, then unplug and replug the receiver."; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return " On macOS, allow CompxTray under System Settings > Privacy & Security > Input Monitoring."; + return ""; + } + + /// One line per HID interface on a Compx vendor ID, for bug reports. + public static List Describe(ushort[] vendorIds, int packetLen) + { + var o = new List(); + foreach (var dev in DeviceList.Local.GetHidDevices()) + { + if (Array.IndexOf(vendorIds, (ushort)dev.VendorID) < 0) continue; + string product = "", usages = "?", config = "?"; + try { product = dev.GetProductName(); } catch { } + try + { + var desc = dev.GetReportDescriptor(); + usages = string.Join(" ", desc.DeviceItems.SelectMany(i => i.Usages.GetAllValues()).Select(u => u.ToString("X8"))); + config = IsConfigInterface(dev, packetLen) ? "yes" : "no"; + } + catch (Exception ex) { usages = "descriptor unreadable: " + ex.Message; } + o.Add(string.Format("{0:X4}:{1:X4} {2} in {3} / out {4} bytes usages {5} config interface: {6}\n {7}", + dev.VendorID, dev.ProductID, product, SafeLen(dev.GetMaxInputReportLength), SafeLen(dev.GetMaxOutputReportLength), + usages, config, dev.DevicePath)); + } + if (o.Count == 0) o.Add("No HID interfaces with a Compx vendor ID (" + string.Join(", ", vendorIds.Select(v => v.ToString("X4"))) + ")."); + return o; + } + + static string SafeLen(Func f) + { + try { return f().ToString(); } catch { return "?"; } + } + } + + sealed class HidSharpChannel : IHidChannel + { + readonly HidStream stream; + readonly int inLen; + + public string ProductName { get; } + public ushort Vid { get; } + public ushort Pid { get; } + + public HidSharpChannel(HidDevice dev, HidStream stream) + { + this.stream = stream; + inLen = dev.GetMaxInputReportLength(); + Vid = (ushort)dev.VendorID; + Pid = (ushort)dev.ProductID; + string name = ""; + try { name = dev.GetProductName() ?? ""; } catch { } + ProductName = name; + } + + public bool Write(byte[] packet, out string error) + { + error = null; + try { stream.Write(packet); return true; } + catch (Exception ex) { error = ex.Message; return false; } + } + + public byte[] Read(int timeoutMs) + { + if (timeoutMs < 1) return null; + var buf = new byte[inLen]; + try + { + stream.ReadTimeout = timeoutMs; + int n = stream.Read(buf, 0, buf.Length); + if (n < buf.Length) Array.Resize(ref buf, n); + return buf; + } + catch (TimeoutException) { return null; } + catch (IOException) { return null; } + } + + public void Dispose() + { + try { stream.Dispose(); } catch { } + } + } +} diff --git a/cross/THIRD-PARTY-NOTICES.txt b/cross/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..0d566ab --- /dev/null +++ b/cross/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,23 @@ +CompxTray for macOS and Linux includes the following third-party software. +CompxTray itself is MIT licensed: https://github.com/HelloAxoxelol/CompxTray/blob/main/LICENSE + +HIDSharp 2.6.4 + Copyright 2010-2025 James F. Bellinger + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under the License is + distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and limitations under the License. + +Avalonia 12.1.4 (MIT) + Copyright (c) AvaloniaUI OÜ and contributors. https://github.com/AvaloniaUI/Avalonia + +SkiaSharp and HarfBuzzSharp (MIT) + Copyright (c) Microsoft Corporation. https://github.com/mono/SkiaSharp + +Inter font (SIL Open Font License 1.1) + Copyright (c) The Inter Project Authors. https://github.com/rsms/inter + +.NET 10 runtime (MIT) + Copyright (c) .NET Foundation and Contributors. https://github.com/dotnet/runtime diff --git a/cross/linux/70-compxtray.rules b/cross/linux/70-compxtray.rules new file mode 100644 index 0000000..bb6486a --- /dev/null +++ b/cross/linux/70-compxtray.rules @@ -0,0 +1,5 @@ +# CompxTray: lets the logged-in user talk to Compx mouse receivers (Svive Flow 7 and others) through hidraw. +# Install: sudo install -m 644 70-compxtray.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules +# Then unplug and replug the receiver. +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="373b", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3554", TAG+="uaccess" diff --git a/cross/linux/compxtray.desktop b/cross/linux/compxtray.desktop new file mode 100644 index 0000000..ceb64ba --- /dev/null +++ b/cross/linux/compxtray.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=CompxTray +Comment=Battery level and settings of Compx wireless mice (Svive Flow 7) +Exec=@BIN@/CompxTray +Icon=compxtray +Terminal=false +Categories=Utility;Settings;HardwareSettings; diff --git a/cross/linux/install.sh b/cross/linux/install.sh new file mode 100755 index 0000000..1fb9214 --- /dev/null +++ b/cross/linux/install.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# install.sh - installs CompxTray for the current user, plus the udev rule that gives it access to the receiver. +# Run from the unpacked folder: ./install.sh +set -eu +here="$(cd "$(dirname "$0")" && pwd)" +bin="$HOME/.local/bin" +data="${XDG_DATA_HOME:-$HOME/.local/share}" + +mkdir -p "$bin" "$data/applications" "$data/icons/hicolor/256x256/apps" +install -m 755 "$here/CompxTray" "$bin/CompxTray" +install -m 755 "$here/compxtray-cli" "$bin/compxtray-cli" +install -m 644 "$here/compxtray.png" "$data/icons/hicolor/256x256/apps/compxtray.png" +sed "s|@BIN@|$bin|" "$here/compxtray.desktop" > "$data/applications/compxtray.desktop" +echo "Installed CompxTray and compxtray-cli in $bin" + +echo "Installing the udev rule in /etc/udev/rules.d (asks for your password)..." +sudo install -m 644 "$here/70-compxtray.rules" /etc/udev/rules.d/70-compxtray.rules +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=hidraw +echo "Done. Unplug and replug the receiver, then start CompxTray from your applications menu." diff --git a/cross/linux/package.sh b/cross/linux/package.sh new file mode 100755 index 0000000..1e49102 --- /dev/null +++ b/cross/linux/package.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# package.sh - publishes CompxTray for Linux and packs it with the udev rule and the installer. +# Usage (from the repository root): sh cross/linux/package.sh linux-x64 1.1.0 +set -eu +rid="$1"; version="${2#v}" +dist="out/CompxTray-$rid" + +dotnet publish cross/App -c Release -r "$rid" --self-contained -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -p:Version="$version" -o "out/app-$rid" +dotnet publish cross/Cli -c Release -r "$rid" --self-contained -p:PublishSingleFile=true \ + -p:Version="$version" -o "out/cli-$rid" + +rm -rf "$dist"; mkdir -p "$dist" +cp "out/app-$rid/CompxTray" "out/cli-$rid/compxtray-cli" "$dist/" +cp cross/linux/install.sh cross/linux/70-compxtray.rules cross/linux/compxtray.desktop "$dist/" +cp LICENSE cross/THIRD-PARTY-NOTICES.txt "$dist/" +cp assets/icon.png "$dist/compxtray.png" +chmod 755 "$dist/CompxTray" "$dist/compxtray-cli" "$dist/install.sh" + +tar -C out -czf "out/CompxTray-$rid.tar.gz" "CompxTray-$rid" +(cd out && sha256sum "CompxTray-$rid.tar.gz" > "CompxTray-$rid.tar.gz.sha256") +echo "Built out/CompxTray-$rid.tar.gz" diff --git a/cross/macos/Info.plist b/cross/macos/Info.plist new file mode 100644 index 0000000..010cd13 --- /dev/null +++ b/cross/macos/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleNameCompxTray + CFBundleDisplayNameCompxTray + CFBundleIdentifierio.github.helloaxoxelol.compxtray + CFBundleVersion@VERSION@ + CFBundleShortVersionString@VERSION@ + CFBundleExecutableCompxTray + CFBundleIconFileCompxTray + CFBundlePackageTypeAPPL + LSUIElement + NSHighResolutionCapable + NSHumanReadableCopyrightMIT license. Not affiliated with Svive or Compx. + + diff --git a/cross/macos/package.sh b/cross/macos/package.sh new file mode 100755 index 0000000..74fdfcb --- /dev/null +++ b/cross/macos/package.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# package.sh - publishes CompxTray for macOS, wraps it in CompxTray.app and zips it with the command line tool. +# Runs on macOS (needs sips, iconutil, codesign and ditto). Usage (from the repository root): +# sh cross/macos/package.sh osx-arm64 1.1.0 +set -eu +rid="$1"; version="${2#v}" +dist="out/CompxTray-$rid" +app="$dist/CompxTray.app" + +dotnet publish cross/App -c Release -r "$rid" --self-contained -p:Version="$version" -o "out/app-$rid" +dotnet publish cross/Cli -c Release -r "$rid" --self-contained -p:PublishSingleFile=true \ + -p:Version="$version" -o "out/cli-$rid" + +rm -rf "$dist"; mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp -R "out/app-$rid/." "$app/Contents/MacOS/" +rm -f "$app/Contents/MacOS/"*.pdb +sed "s/@VERSION@/$version/g" cross/macos/Info.plist > "$app/Contents/Info.plist" + +iconset="out/CompxTray.iconset" +rm -rf "$iconset"; mkdir -p "$iconset" +for s in 16 32 128 256; do + sips -z "$s" "$s" assets/icon.png --out "$iconset/icon_${s}x${s}.png" >/dev/null + d=$((s * 2)) + sips -z "$d" "$d" assets/icon.png --out "$iconset/icon_${s}x${s}@2x.png" >/dev/null +done +iconutil -c icns "$iconset" -o "$app/Contents/Resources/CompxTray.icns" + +cp "out/cli-$rid/compxtray-cli" LICENSE cross/THIRD-PARTY-NOTICES.txt "$dist/" +cp cross/THIRD-PARTY-NOTICES.txt "$app/Contents/Resources/" +# Ad-hoc signatures: required to run on Apple Silicon. Not notarized, so Gatekeeper still asks the first time. +codesign --force --deep --sign - "$app" +codesign --force --sign - "$dist/compxtray-cli" + +(cd out && ditto -c -k --keepParent "CompxTray-$rid" "CompxTray-$rid.zip") +(cd out && shasum -a 256 "CompxTray-$rid.zip" > "CompxTray-$rid.zip.sha256") +echo "Built out/CompxTray-$rid.zip" diff --git a/src/Device/CompxDevice.cs b/src/Device/CompxDevice.cs index 739141b..702d212 100644 --- a/src/Device/CompxDevice.cs +++ b/src/Device/CompxDevice.cs @@ -1,4 +1,4 @@ -// CompxDevice.cs - low-level access to Compx-based wireless mice through the receiver's vendor HID interface. +// CompxDevice.cs - the configuration protocol of Compx-based wireless mice, spoken through the receiver's vendor HID interface. // // Frame (17 bytes incl. report ID), sent as output report 0x08, answered on input report 0x08: // [0]=0x08 [1]=command [2]=status [3..4]=address (big-endian) [5]=length [6..15]=data [16]=checksum @@ -7,13 +7,11 @@ // 0x12 mouse firmware, 0x1D receiver firmware. // // Written for C# 5 so it compiles with the csc.exe that ships with Windows (.NET Framework 4.x). +// The HID access itself lives in HidChannels: WinHid.cs on Windows, cross/Core/HidSharpChannel.cs on macOS and Linux. using System; using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; using System.Text; using System.Threading; -using Microsoft.Win32.SafeHandles; namespace CompxTray.Device { @@ -30,6 +28,18 @@ public class MouseStatus public DateTime Time = DateTime.Now; } + /// An open configuration interface of a receiver, provided by the platform's HidChannels. + public interface IHidChannel : IDisposable + { + string ProductName { get; } + ushort Vid { get; } + ushort Pid { get; } + /// Sends one output report. On failure, error says why. + bool Write(byte[] packet, out string error); + /// Returns the next input report (report ID first), or null on timeout. + byte[] Read(int timeoutMs); + } + public sealed class CompxDevice : IDisposable { public const byte ReportId = 0x08; @@ -43,150 +53,34 @@ public sealed class CompxDevice : IDisposable public const byte CmdReceiverVersion = 0x1D; // Compx uses these vendor IDs for its own and white-label receivers. - static readonly ushort[] VendorIds = { 0x373B, 0x3554 }; + public static readonly ushort[] VendorIds = { 0x373B, 0x3554 }; + + readonly IHidChannel channel; public string ProductName { get; private set; } - public ushort Vid { get; private set; } - public ushort Pid { get; private set; } + public ushort Vid { get { return channel.Vid; } } + public ushort Pid { get { return channel.Pid; } } public string Name { get { return string.Format("{0} ({1:X4}:{2:X4})", ProductName, Vid, Pid); } } /// Raw traffic, useful for diagnostics. public readonly List Log = new List(); - SafeFileHandle readHandle, writeHandle; - FileStream readStream; - int inLen; - IAsyncResult pending; - byte[] pendingBuf; - - // ---------- Win32 ---------- - [StructLayout(LayoutKind.Sequential)] - struct SP_DEVICE_INTERFACE_DATA { public uint cbSize; public Guid InterfaceClassGuid; public uint Flags; public IntPtr Reserved; } - [StructLayout(LayoutKind.Sequential)] - struct HIDD_ATTRIBUTES { public int Size; public ushort VendorID; public ushort ProductID; public ushort VersionNumber; } - [StructLayout(LayoutKind.Sequential)] - struct HIDP_CAPS + public CompxDevice(IHidChannel channel) { - public ushort Usage; public ushort UsagePage; - public ushort InputReportByteLength; public ushort OutputReportByteLength; public ushort FeatureReportByteLength; - [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)] public ushort[] Reserved; - public ushort NumberLinkCollectionNodes; - public ushort NumberInputButtonCaps; public ushort NumberInputValueCaps; public ushort NumberInputDataIndices; - public ushort NumberOutputButtonCaps; public ushort NumberOutputValueCaps; public ushort NumberOutputDataIndices; - public ushort NumberFeatureButtonCaps; public ushort NumberFeatureValueCaps; public ushort NumberFeatureDataIndices; - } - [DllImport("hid.dll")] static extern void HidD_GetHidGuid(out Guid g); - [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetAttributes(SafeFileHandle h, ref HIDD_ATTRIBUTES a); - [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetPreparsedData(SafeFileHandle h, out IntPtr p); - [DllImport("hid.dll")] static extern bool HidD_FreePreparsedData(IntPtr p); - [DllImport("hid.dll")] static extern int HidP_GetCaps(IntPtr p, ref HIDP_CAPS c); - [DllImport("hid.dll", CharSet = CharSet.Unicode)] static extern bool HidD_GetProductString(SafeFileHandle h, StringBuilder b, int len); - [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_SetOutputReport(SafeFileHandle h, byte[] buf, int len); - [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_FlushQueue(SafeFileHandle h); - [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] - static extern IntPtr SetupDiGetClassDevs(ref Guid g, IntPtr enumerator, IntPtr hwnd, uint flags); - [DllImport("setupapi.dll", SetLastError = true)] - static extern bool SetupDiEnumDeviceInterfaces(IntPtr set, IntPtr devInfo, ref Guid g, uint idx, ref SP_DEVICE_INTERFACE_DATA d); - [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] - static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr set, ref SP_DEVICE_INTERFACE_DATA d, IntPtr detail, uint size, out uint req, IntPtr devInfo); - [DllImport("setupapi.dll")] static extern bool SetupDiDestroyDeviceInfoList(IntPtr set); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr tmpl); - [DllImport("kernel32.dll", SetLastError = true)] - static extern bool WriteFile(SafeFileHandle h, byte[] buf, int len, out int written, IntPtr overlapped); - - const uint GENERIC_RW = 0xC0000000; - const uint SHARE_RW = 3; - const uint OPEN_EXISTING = 3; - const uint FLAG_OVERLAPPED = 0x40000000; - - class Candidate { public string Path; public string Product; public ushort Vid; public ushort Pid; public int InLen; } - - static List ListHidPaths() - { - var list = new List(); - Guid g; HidD_GetHidGuid(out g); - IntPtr set = SetupDiGetClassDevs(ref g, IntPtr.Zero, IntPtr.Zero, 0x12); // PRESENT | DEVICEINTERFACE - if (set == IntPtr.Zero || set == new IntPtr(-1)) return list; - var d = new SP_DEVICE_INTERFACE_DATA(); - d.cbSize = (uint)Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA)); - for (uint i = 0; SetupDiEnumDeviceInterfaces(set, IntPtr.Zero, ref g, i, ref d); i++) - { - uint req; - SetupDiGetDeviceInterfaceDetail(set, ref d, IntPtr.Zero, 0, out req, IntPtr.Zero); - if (req == 0) continue; - IntPtr buf = Marshal.AllocHGlobal((int)req); - try - { - Marshal.WriteInt32(buf, IntPtr.Size == 8 ? 8 : 6); - if (SetupDiGetDeviceInterfaceDetail(set, ref d, buf, req, out req, IntPtr.Zero)) - list.Add(Marshal.PtrToStringUni(new IntPtr(buf.ToInt64() + 4))); - } - finally { Marshal.FreeHGlobal(buf); } - } - SetupDiDestroyDeviceInfoList(set); - return list; - } - - static List FindCandidates() - { - var result = new List(); - foreach (var path in ListHidPaths()) - { - string lower = path.ToLowerInvariant(); - bool vidMatch = false; - foreach (var v in VendorIds) if (lower.Contains("vid_" + v.ToString("x4"))) vidMatch = true; - if (!vidMatch) continue; - using (var h = CreateFile(path, 0, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero)) - { - if (h.IsInvalid) continue; - var a = new HIDD_ATTRIBUTES(); a.Size = Marshal.SizeOf(typeof(HIDD_ATTRIBUTES)); - if (!HidD_GetAttributes(h, ref a)) continue; - IntPtr pp; - if (!HidD_GetPreparsedData(h, out pp)) continue; - var c = new HIDP_CAPS(); - try { HidP_GetCaps(pp, ref c); } finally { HidD_FreePreparsedData(pp); } - // The configuration channel: vendor usage page with 17-byte reports. - if (c.UsagePage < 0xFF00 || c.OutputReportByteLength != PacketLen || c.InputReportByteLength < PacketLen) continue; - var prod = new StringBuilder(256); HidD_GetProductString(h, prod, 512); - var cand = new Candidate(); - cand.Path = path; cand.InLen = c.InputReportByteLength; - cand.Product = prod.ToString(); cand.Vid = a.VendorID; cand.Pid = a.ProductID; - result.Add(cand); - } - } - return result; + this.channel = channel; + ProductName = channel.ProductName.Length > 0 ? channel.ProductName : "Compx mouse"; } /// Opens the first Compx configuration interface. Returns null and a reason if none is available. public static CompxDevice Open(out string error) { - error = null; - var cands = FindCandidates(); - if (cands.Count == 0) { error = "No Compx receiver found. Is the dongle plugged in?"; return null; } - foreach (var c in cands) - { - var rh = CreateFile(c.Path, GENERIC_RW, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, FLAG_OVERLAPPED, IntPtr.Zero); - if (rh.IsInvalid) { error = "Could not open the receiver (error " + Marshal.GetLastWin32Error() + ")."; continue; } - var wh = CreateFile(c.Path, GENERIC_RW, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); - var d = new CompxDevice(); - d.readHandle = rh; - d.writeHandle = wh.IsInvalid ? null : wh; - d.inLen = c.InLen; - d.readStream = new FileStream(rh, FileAccess.Read, c.InLen, true); - d.ProductName = c.Product.Length > 0 ? c.Product : "Compx mouse"; - d.Vid = c.Vid; d.Pid = c.Pid; - HidD_FlushQueue(rh); - error = null; - return d; - } - return null; + var ch = HidChannels.OpenFirst(VendorIds, PacketLen, out error); + return ch == null ? null : new CompxDevice(ch); } public void Dispose() { - try { if (readStream != null) readStream.Dispose(); } catch { } - try { if (writeHandle != null) writeHandle.Dispose(); } catch { } + try { channel.Dispose(); } catch { } } // ---------- Frames ---------- @@ -230,40 +124,26 @@ public static string Hex(byte[] b) bool Send(byte[] packet) { Log.Add("-> " + Hex(packet)); - int written; - if (writeHandle != null && WriteFile(writeHandle, packet, packet.Length, out written, IntPtr.Zero) && written == packet.Length) return true; - if (writeHandle != null && HidD_SetOutputReport(writeHandle, packet, packet.Length)) return true; - return HidD_SetOutputReport(readHandle, packet, packet.Length); - } - - byte[] ReadReport(int timeoutMs) - { - if (pending == null) - { - pendingBuf = new byte[inLen]; - pending = readStream.BeginRead(pendingBuf, 0, inLen, null, null); - } - if (timeoutMs < 1 || !pending.AsyncWaitHandle.WaitOne(timeoutMs)) return null; - int n = readStream.EndRead(pending); - var b = pendingBuf; - pending = null; pendingBuf = null; - if (n < PacketLen) return new byte[0]; - Log.Add("<- " + Hex(b)); - return b; + string error; + if (channel.Write(packet, out error)) return true; + Log.Add(" send failed, " + error); + return false; } /// Sends a frame and returns the first valid reply accepted by match, or null on timeout. public byte[] Exchange(byte[] packet, Func match, int timeoutMs) { - if (!Send(packet)) { Log.Add(" send failed, error " + Marshal.GetLastWin32Error()); return null; } + if (!Send(packet)) return null; DateTime deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (true) { int left = (int)(deadline - DateTime.UtcNow).TotalMilliseconds; if (left < 1) break; - var r = ReadReport(left); + var r = channel.Read(left); if (r == null) break; - if (r.Length >= PacketLen && r[0] == ReportId && ChecksumOk(r) && match(r)) return r; + if (r.Length < PacketLen) continue; + Log.Add("<- " + Hex(r)); + if (r[0] == ReportId && ChecksumOk(r) && match(r)) return r; } Log.Add(" timeout"); return null; diff --git a/src/Device/WinHid.cs b/src/Device/WinHid.cs new file mode 100644 index 0000000..5d39a2a --- /dev/null +++ b/src/Device/WinHid.cs @@ -0,0 +1,189 @@ +// WinHid.cs - the receiver's configuration interface on Windows, through hid.dll and setupapi.dll. +// Written for C# 5 so it compiles with the csc.exe that ships with Windows (.NET Framework 4.x). +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace CompxTray.Device +{ + public static class HidChannels + { + /// Opens the first vendor interface with packetLen-byte reports on one of the vendor IDs. Returns null and a reason if none is available. + public static IHidChannel OpenFirst(ushort[] vendorIds, int packetLen, out string error) + { + error = null; + var cands = WinHidChannel.FindCandidates(vendorIds, packetLen); + if (cands.Count == 0) { error = "No Compx receiver found. Is the dongle plugged in?"; return null; } + foreach (var c in cands) + { + var ch = WinHidChannel.Open(c, out error); + if (ch != null) return ch; + } + return null; + } + } + + sealed class WinHidChannel : IHidChannel + { + public string ProductName { get; private set; } + public ushort Vid { get; private set; } + public ushort Pid { get; private set; } + + SafeFileHandle readHandle, writeHandle; + FileStream readStream; + int inLen; + IAsyncResult pending; + byte[] pendingBuf; + + // ---------- Win32 ---------- + [StructLayout(LayoutKind.Sequential)] + struct SP_DEVICE_INTERFACE_DATA { public uint cbSize; public Guid InterfaceClassGuid; public uint Flags; public IntPtr Reserved; } + [StructLayout(LayoutKind.Sequential)] + struct HIDD_ATTRIBUTES { public int Size; public ushort VendorID; public ushort ProductID; public ushort VersionNumber; } + [StructLayout(LayoutKind.Sequential)] + struct HIDP_CAPS + { + public ushort Usage; public ushort UsagePage; + public ushort InputReportByteLength; public ushort OutputReportByteLength; public ushort FeatureReportByteLength; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)] public ushort[] Reserved; + public ushort NumberLinkCollectionNodes; + public ushort NumberInputButtonCaps; public ushort NumberInputValueCaps; public ushort NumberInputDataIndices; + public ushort NumberOutputButtonCaps; public ushort NumberOutputValueCaps; public ushort NumberOutputDataIndices; + public ushort NumberFeatureButtonCaps; public ushort NumberFeatureValueCaps; public ushort NumberFeatureDataIndices; + } + [DllImport("hid.dll")] static extern void HidD_GetHidGuid(out Guid g); + [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetAttributes(SafeFileHandle h, ref HIDD_ATTRIBUTES a); + [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetPreparsedData(SafeFileHandle h, out IntPtr p); + [DllImport("hid.dll")] static extern bool HidD_FreePreparsedData(IntPtr p); + [DllImport("hid.dll")] static extern int HidP_GetCaps(IntPtr p, ref HIDP_CAPS c); + [DllImport("hid.dll", CharSet = CharSet.Unicode)] static extern bool HidD_GetProductString(SafeFileHandle h, StringBuilder b, int len); + [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_SetOutputReport(SafeFileHandle h, byte[] buf, int len); + [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_FlushQueue(SafeFileHandle h); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr SetupDiGetClassDevs(ref Guid g, IntPtr enumerator, IntPtr hwnd, uint flags); + [DllImport("setupapi.dll", SetLastError = true)] + static extern bool SetupDiEnumDeviceInterfaces(IntPtr set, IntPtr devInfo, ref Guid g, uint idx, ref SP_DEVICE_INTERFACE_DATA d); + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr set, ref SP_DEVICE_INTERFACE_DATA d, IntPtr detail, uint size, out uint req, IntPtr devInfo); + [DllImport("setupapi.dll")] static extern bool SetupDiDestroyDeviceInfoList(IntPtr set); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr tmpl); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool WriteFile(SafeFileHandle h, byte[] buf, int len, out int written, IntPtr overlapped); + + const uint GENERIC_RW = 0xC0000000; + const uint SHARE_RW = 3; + const uint OPEN_EXISTING = 3; + const uint FLAG_OVERLAPPED = 0x40000000; + + public class Candidate { public string Path; public string Product; public ushort Vid; public ushort Pid; public int InLen; } + + static List ListHidPaths() + { + var list = new List(); + Guid g; HidD_GetHidGuid(out g); + IntPtr set = SetupDiGetClassDevs(ref g, IntPtr.Zero, IntPtr.Zero, 0x12); // PRESENT | DEVICEINTERFACE + if (set == IntPtr.Zero || set == new IntPtr(-1)) return list; + var d = new SP_DEVICE_INTERFACE_DATA(); + d.cbSize = (uint)Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA)); + for (uint i = 0; SetupDiEnumDeviceInterfaces(set, IntPtr.Zero, ref g, i, ref d); i++) + { + uint req; + SetupDiGetDeviceInterfaceDetail(set, ref d, IntPtr.Zero, 0, out req, IntPtr.Zero); + if (req == 0) continue; + IntPtr buf = Marshal.AllocHGlobal((int)req); + try + { + Marshal.WriteInt32(buf, IntPtr.Size == 8 ? 8 : 6); + if (SetupDiGetDeviceInterfaceDetail(set, ref d, buf, req, out req, IntPtr.Zero)) + list.Add(Marshal.PtrToStringUni(new IntPtr(buf.ToInt64() + 4))); + } + finally { Marshal.FreeHGlobal(buf); } + } + SetupDiDestroyDeviceInfoList(set); + return list; + } + + public static List FindCandidates(ushort[] vendorIds, int packetLen) + { + var result = new List(); + foreach (var path in ListHidPaths()) + { + string lower = path.ToLowerInvariant(); + bool vidMatch = false; + foreach (var v in vendorIds) if (lower.Contains("vid_" + v.ToString("x4"))) vidMatch = true; + if (!vidMatch) continue; + using (var h = CreateFile(path, 0, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero)) + { + if (h.IsInvalid) continue; + var a = new HIDD_ATTRIBUTES(); a.Size = Marshal.SizeOf(typeof(HIDD_ATTRIBUTES)); + if (!HidD_GetAttributes(h, ref a)) continue; + IntPtr pp; + if (!HidD_GetPreparsedData(h, out pp)) continue; + var c = new HIDP_CAPS(); + try { HidP_GetCaps(pp, ref c); } finally { HidD_FreePreparsedData(pp); } + // The configuration channel: vendor usage page with 17-byte reports. + if (c.UsagePage < 0xFF00 || c.OutputReportByteLength != packetLen || c.InputReportByteLength < packetLen) continue; + var prod = new StringBuilder(256); HidD_GetProductString(h, prod, 512); + var cand = new Candidate(); + cand.Path = path; cand.InLen = c.InputReportByteLength; + cand.Product = prod.ToString(); cand.Vid = a.VendorID; cand.Pid = a.ProductID; + result.Add(cand); + } + } + return result; + } + + public static WinHidChannel Open(Candidate c, out string error) + { + error = null; + var rh = CreateFile(c.Path, GENERIC_RW, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, FLAG_OVERLAPPED, IntPtr.Zero); + if (rh.IsInvalid) { error = "Could not open the receiver (error " + Marshal.GetLastWin32Error() + ")."; return null; } + var wh = CreateFile(c.Path, GENERIC_RW, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); + var ch = new WinHidChannel(); + ch.readHandle = rh; + ch.writeHandle = wh.IsInvalid ? null : wh; + ch.inLen = c.InLen; + ch.readStream = new FileStream(rh, FileAccess.Read, c.InLen, true); + ch.ProductName = c.Product; + ch.Vid = c.Vid; ch.Pid = c.Pid; + HidD_FlushQueue(rh); + return ch; + } + + public void Dispose() + { + try { if (readStream != null) readStream.Dispose(); } catch { } + try { if (writeHandle != null) writeHandle.Dispose(); } catch { } + } + + public bool Write(byte[] packet, out string error) + { + error = null; + int written; + if (writeHandle != null && WriteFile(writeHandle, packet, packet.Length, out written, IntPtr.Zero) && written == packet.Length) return true; + if (writeHandle != null && HidD_SetOutputReport(writeHandle, packet, packet.Length)) return true; + if (HidD_SetOutputReport(readHandle, packet, packet.Length)) return true; + error = "error " + Marshal.GetLastWin32Error(); + return false; + } + + public byte[] Read(int timeoutMs) + { + if (pending == null) + { + pendingBuf = new byte[inLen]; + pending = readStream.BeginRead(pendingBuf, 0, inLen, null, null); + } + if (timeoutMs < 1 || !pending.AsyncWaitHandle.WaitOne(timeoutMs)) return null; + int n = readStream.EndRead(pending); + var b = pendingBuf; + pending = null; pendingBuf = null; + if (n < b.Length) Array.Resize(ref b, n); + return b; + } + } +} diff --git a/tools/read-settings.ps1 b/tools/read-settings.ps1 index b4c5da4..b75442f 100644 --- a/tools/read-settings.ps1 +++ b/tools/read-settings.ps1 @@ -8,7 +8,7 @@ $out = Join-Path $here 'settings-report.txt' $lines = @("=== CompxTray settings report $(Get-Date -Format s) ===") $dev = $null try { - Add-Type -Path @((Join-Path $src 'CompxDevice.cs'), (Join-Path $src 'ConfigMap.cs'), (Join-Path $src 'MouseConfig.cs')) + Add-Type -Path @((Join-Path $src 'CompxDevice.cs'), (Join-Path $src 'WinHid.cs'), (Join-Path $src 'ConfigMap.cs'), (Join-Path $src 'MouseConfig.cs')) $err = $null $dev = [CompxTray.Device.CompxDevice]::Open([ref]$err) if ($dev -eq $null) { throw "Could not open the receiver: $err" } diff --git a/tools/verify-writes.ps1 b/tools/verify-writes.ps1 index e7cceaf..dd4c01f 100644 --- a/tools/verify-writes.ps1 +++ b/tools/verify-writes.ps1 @@ -7,7 +7,7 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path $root = Split-Path -Parent $here $src = Join-Path $root 'src\Device' $out = Join-Path $here 'verify-report.txt' -Add-Type -Path @((Join-Path $src 'CompxDevice.cs'), (Join-Path $src 'ConfigMap.cs'), (Join-Path $src 'MouseConfig.cs')) +Add-Type -Path @((Join-Path $src 'CompxDevice.cs'), (Join-Path $src 'WinHid.cs'), (Join-Path $src 'ConfigMap.cs'), (Join-Path $src 'MouseConfig.cs')) $report = New-Object 'System.Collections.Generic.List[string]' function Log([string]$s) { $report.Add($s) }