-
-
Notifications
You must be signed in to change notification settings - Fork 62
Feature/smart profiles #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Implemented profile management system with create, rename, duplicate, delete Added Smart Profile Switching to auto-switch based on active programs Created ProfilesSettings page with modern WinUI 3 card-based UI Detection modes: Windows Hook (instant) and Interval-based (fallback) Profile selector in top bar for quick switching Right-click context menu for profile actions Auto-text selection in dialogs for better UX
- Add grace period for device removal to handle reconnections - Implement RefreshDevices() to re-enumerate devices on change events - Add device activity tracking (hasSentInput) to distinguish active vs stale devices - Extract normalized hardware IDs from device paths for better deduplication - Implement three-tier priority system for device deduplication: 1. Devices actively sending input 2. Newly discovered devices 3. Most recently seen devices - Add deviceName field to TouchpadDeviceInfo for connection type detection - Update ContactsManager to call RefreshDevices on WM_INPUT_DEVICE_CHANGE - Improve logging for device addition, removal, and deduplication These changes significantly improve handling of touchpads that switch between Bluetooth and wired connections, though edge cases with stale Windows drivers may still occur.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces Smart Profiles functionality and significantly improves touchpad device handling for devices that switch between Bluetooth and wired modes. The implementation uses a temporary heuristic-based approach to reduce duplicate device entries while a full device manager is planned for a future PR.
Changes:
- Added Smart Profiles UI with profile management, switching, and per-device smart switching configuration
- Implemented device deduplication logic using normalized hardware IDs extracted from device paths
- Refactored three-finger drag settings to use profile-based configuration instead of global settings
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 49 comments.
Show a summary per file
| File | Description |
|---|---|
| TouchpadHelper.cs | Added device refresh, deduplication, and cleanup logic with tracking for device activity |
| TouchpadDeviceInfo.cs | Added deviceName property to store raw device path for connection type detection |
| ContactsManager.cs | Integrated RefreshDevices call on device change events |
| MouseOperations.cs | Updated to use ActiveProfile settings instead of global settings |
| SmartProfileSwitcher.cs | New class implementing automatic profile switching based on foreground application |
| ThreeFingerDragProfile.cs | New profile class with XML serialization and per-device smart switching configuration |
| SettingsData.cs | Added profile management, loading, switching, and renaming functionality |
| ProfilesSettings.xaml/.cs | New profile management UI with device-specific smart switching settings |
| SettingsWindow.xaml/.cs | Added profile selector in title bar and profile management dialogs |
| ThreeFingerDrag.cs | Updated to use ActiveProfile settings instead of global/device-specific settings |
| DistanceManager.cs | Simplified to use ActiveProfile settings instead of per-device configs |
| FingerCounter.cs | Updated comments to reference ActiveProfile settings |
| HandlerWindow.xaml.cs | Added SmartProfileSwitcher device tracking and UI refresh on device changes |
| App.xaml.cs | Initialized and started SmartProfileSwitcher |
| MouseSpeedSettings.cs | Removed (obsolete per-device settings class) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var now = DateTime.Now; | ||
| var devicesToRemove = new List<IntPtr>(); | ||
|
|
||
| foreach (var kvp in availableDeviceInfos) | ||
| { | ||
| var device = kvp.Key; | ||
|
|
||
| // Check if device is still valid | ||
| if (!IsDeviceStillValid(device)) | ||
| { | ||
| // If we haven't tracked this device's last seen time, set it now | ||
| if (!deviceLastSeenTime.ContainsKey(device)) | ||
| { | ||
| deviceLastSeenTime[device] = now; | ||
| } | ||
| // Only remove if grace period has elapsed | ||
| else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod) | ||
| { | ||
| devicesToRemove.Add(device); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| // Device is valid, update last seen time | ||
| deviceLastSeenTime[device] = now; | ||
| } | ||
| } | ||
|
|
||
| foreach (var device in devicesToRemove) | ||
| { | ||
| var deviceInfo = availableDeviceInfos[device]; | ||
| availableDeviceInfos.Remove(device); | ||
| deviceLastSeenTime.Remove(device); | ||
| deviceHasSentInput.Remove(device); | ||
| Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}"); | ||
| } |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a potential race condition in the device cleanup logic. The deviceLastSeenTime dictionary is checked and modified in multiple places without synchronization. If CleanupDisconnectedDevices() and ParseInput() run concurrently (which is possible since one is called from device change events and the other from input events), there could be issues with concurrent dictionary access. Consider adding proper locking or using a thread-safe collection.
| var now = DateTime.Now; | |
| var devicesToRemove = new List<IntPtr>(); | |
| foreach (var kvp in availableDeviceInfos) | |
| { | |
| var device = kvp.Key; | |
| // Check if device is still valid | |
| if (!IsDeviceStillValid(device)) | |
| { | |
| // If we haven't tracked this device's last seen time, set it now | |
| if (!deviceLastSeenTime.ContainsKey(device)) | |
| { | |
| deviceLastSeenTime[device] = now; | |
| } | |
| // Only remove if grace period has elapsed | |
| else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod) | |
| { | |
| devicesToRemove.Add(device); | |
| } | |
| } | |
| else | |
| { | |
| // Device is valid, update last seen time | |
| deviceLastSeenTime[device] = now; | |
| } | |
| } | |
| foreach (var device in devicesToRemove) | |
| { | |
| var deviceInfo = availableDeviceInfos[device]; | |
| availableDeviceInfos.Remove(device); | |
| deviceLastSeenTime.Remove(device); | |
| deviceHasSentInput.Remove(device); | |
| Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}"); | |
| } | |
| lock (deviceLastSeenTime) | |
| { | |
| var now = DateTime.Now; | |
| var devicesToRemove = new List<IntPtr>(); | |
| foreach (var kvp in availableDeviceInfos) | |
| { | |
| var device = kvp.Key; | |
| // Check if device is still valid | |
| if (!IsDeviceStillValid(device)) | |
| { | |
| // If we haven't tracked this device's last seen time, set it now | |
| if (!deviceLastSeenTime.ContainsKey(device)) | |
| { | |
| deviceLastSeenTime[device] = now; | |
| } | |
| // Only remove if grace period has elapsed | |
| else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod) | |
| { | |
| devicesToRemove.Add(device); | |
| } | |
| } | |
| else | |
| { | |
| // Device is valid, update last seen time | |
| deviceLastSeenTime[device] = now; | |
| } | |
| } | |
| foreach (var device in devicesToRemove) | |
| { | |
| var deviceInfo = availableDeviceInfos[device]; | |
| availableDeviceInfos.Remove(device); | |
| deviceLastSeenTime.Remove(device); | |
| deviceHasSentInput.Remove(device); | |
| Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}"); | |
| } | |
| } |
|
|
||
| // Update smart profile switcher with current device | ||
| var deviceInfo = TouchpadHelper.GetDeivceInfo(currentDevice); | ||
| App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId); |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential null reference exception. If TouchpadHelper.GetDeivceInfo(currentDevice) returns null (which is possible based on the implementation in TouchpadHelper), then deviceInfo.deviceId will throw a NullReferenceException. Add a null check before accessing deviceInfo properties.
| App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId); | |
| if (deviceInfo != null){ | |
| App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId); | |
| } |
| App.Instance.DispatcherQueue.TryEnqueue(() => { | ||
| if(App.SettingsWindow is SettingsWindow settingsWindow){ | ||
| settingsWindow.LoadProfiles(); | ||
| } | ||
| }); |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cross-thread UI access issue. The code calls App.Instance.DispatcherQueue.TryEnqueue() to marshal UI updates to the UI thread, but if the DispatcherQueue is null or not initialized, this will throw a NullReferenceException. Add a null check before attempting to enqueue.
| App.Instance.DispatcherQueue.TryEnqueue(() => { | |
| if(App.SettingsWindow is SettingsWindow settingsWindow){ | |
| settingsWindow.LoadProfiles(); | |
| } | |
| }); | |
| var dispatcherQueue = App.Instance?.DispatcherQueue; | |
| if (dispatcherQueue != null) | |
| { | |
| dispatcherQueue.TryEnqueue(() => { | |
| if(App.SettingsWindow is SettingsWindow settingsWindow){ | |
| settingsWindow.LoadProfiles(); | |
| } | |
| }); | |
| } | |
| else | |
| { | |
| Logger.Log("[SmartSwitcher] DispatcherQueue is not available; skipping settings window refresh."); | |
| } |
| if (File.Exists(oldPath)) | ||
| { | ||
| try | ||
| { | ||
| File.Delete(oldPath); | ||
| Logger.Log($"Renamed profile file from {oldPath} to {newPath}"); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| Logger.Log($"Error deleting old profile file: {e.Message}"); | ||
| } | ||
| } |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential file system race condition. Between checking File.Exists(oldPath) and calling File.Delete(oldPath), another process or thread could delete the file, causing an exception. While this is unlikely in practice, consider wrapping the File.Delete call in a try-catch or using File.Exists check inside the try block for more robust error handling.
| if (File.Exists(oldPath)) | |
| { | |
| try | |
| { | |
| File.Delete(oldPath); | |
| Logger.Log($"Renamed profile file from {oldPath} to {newPath}"); | |
| } | |
| catch (Exception e) | |
| { | |
| Logger.Log($"Error deleting old profile file: {e.Message}"); | |
| } | |
| } | |
| try | |
| { | |
| File.Delete(oldPath); | |
| Logger.Log($"Renamed profile file from {oldPath} to {newPath}"); | |
| } | |
| catch (Exception e) | |
| { | |
| Logger.Log($"Error deleting old profile file: {e.Message}"); | |
| } |
| newDeviceInfo.productId = deviceInfo.hid.dwProductId.ToString(); | ||
|
|
||
| availableDeviceInfos[hwnd] = newDeviceInfo; | ||
| deviceLastSeenTime[hwnd] = DateTime.Now; // Only set timestamp for NEW devices |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says "Only set timestamp for NEW devices", but the timestamp is set unconditionally here. The logic is correct (it's inside the if (isNewDevice) block), but the comment placement could be misleading. Consider moving the comment to line 89 before the assignment to make it clearer that this entire block only executes for new devices.
| deviceLastSeenTime[hwnd] = DateTime.Now; // Only set timestamp for NEW devices | |
| // Only set timestamp for NEW devices | |
| deviceLastSeenTime[hwnd] = DateTime.Now; |
| // 1. Devices that have sent actual input (active devices) | ||
| // 2. Newly added devices (likely the new connection) | ||
| // 3. Most recent timestamp (fallback) | ||
| var sortedDevices = group.OrderByDescending(kvp => deviceHasSentInput.ContainsKey(kvp.Key) && deviceHasSentInput[kvp.Key] ? 1 : 0) |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inefficient use of 'ContainsKey' and indexer.
| int idObject, int idChild, uint dwEventThread, uint dwmsEventTime); | ||
|
|
||
| private readonly Timer _checkTimer; | ||
| private WinEventDelegate _hookDelegate; |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Field '_hookDelegate' can be 'readonly'.
|
|
||
| public sealed partial class ProfilesSettings : Page | ||
| { | ||
| private ObservableCollection<ProfileViewModel> _profiles = new ObservableCollection<ProfileViewModel>(); |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Field '_profiles' can be 'readonly'.
| public const int RIM_INPUTSINK = 1; | ||
|
|
||
| private static Dictionary<IntPtr, TouchpadDeviceInfo> availableDeviceInfos = new Dictionary<IntPtr, TouchpadDeviceInfo>(2); | ||
| private static Dictionary<IntPtr, DateTime> deviceLastSeenTime = new Dictionary<IntPtr, DateTime>(2); |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Field 'deviceLastSeenTime' can be 'readonly'.
|
|
||
| private static Dictionary<IntPtr, TouchpadDeviceInfo> availableDeviceInfos = new Dictionary<IntPtr, TouchpadDeviceInfo>(2); | ||
| private static Dictionary<IntPtr, DateTime> deviceLastSeenTime = new Dictionary<IntPtr, DateTime>(2); | ||
| private static Dictionary<IntPtr, bool> deviceHasSentInput = new Dictionary<IntPtr, bool>(2); |
Copilot
AI
Jan 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Field 'deviceHasSentInput' can be 'readonly'.
…ality - Add thread synchronization locks around deviceLastSeenTime dictionary access - Add null checks for GetDeivceInfo() result to prevent NullReferenceException - Add null check for App.Instance.DispatcherQueue in SmartProfileSwitcher - Remove redundant File.Exists check (File.Delete handles non-existent files) - Replace inefficient ContainsKey+indexer pattern with TryGetValue - Mark fields as readonly: deviceLastSeenTime, deviceHasSentInput, _hookDelegate, _profiles - Improve comment placement for better code clarity Addresses all issues identified by GitHub Copilot automated code review.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
This PR adds Smart Profiles UI and significantly improves touchpad device handling to better support devices that switch between Bluetooth and wired modes. It introduces a temporary, heuristic-based device manager that reduces duplicate/stale entries and prefers the active connection. A follow-up PR will replace this temporary behavior with a proper device manager.
Current device management functions as such:
Profile switching based on app is on a per-device basis.
This allows you to use different profiles per device rather than the mouse speed settings and other settings changing by itself when you swittch devices.
The current system, however does not support instant switching between profiles when a device is connected. This functionalitty will be updated on my next PR.
Summary of changes
• Touchpad device handling
• TouchpadHelper:
• Added RefreshDevices() to re-enumerate HID devices on device-change events.
• Introduced deviceName, deviceLastSeenTime, and deviceHasSentInput tracking in TouchpadDeviceInfo/TouchpadHelper.
• Extracted normalized hardware IDs from raw device paths (GetNormalizedHardwareId(string, string, string)) to match wired/Bluetooth connections of the same hardware.
• Implemented deduplication of devices by hardware ID with a three-tier priority:
• Added a short grace period and cleanup logic (CleanupDisconnectedDevices()) to reduce flapping during mode switches.
• Update ParseInput() to mark devices as active when they send input.
• TouchpadDeviceInfo:
• Added deviceName property to store the raw device path used for normalization.
• ContactsManager:
• Calls RefreshDevices() on WM_INPUT_DEVICE_CHANGE.
• Uses Exists() checks and triggers UI updates after refresh.
• Logging: improved logging for device add/remove/deduplication events for easier debugging.
• Smart Profiles and profile UI
• Added/updated profile management UI and settings:
• ProfilesSettings.xaml.cs / ProfilesSettings.xaml.cs
• ThreeFingerDragSettings.xaml / ThreeFingerDragSettings.xaml.cs
• TouchpadSettings.xaml.cs
• SettingsWindow.xaml.cs / SettingsData.cs (profile support + JSON settings handling)
• SmartProfileSwitcher.cs (smart profile logic)
• ThreeFingerDragProfile updates (under settings/profiles)
• SettingsData updated to store profile info and load/save active profile.
• Profile file handling and rename/switch operations included.
• Misc
• Adjusted related code to call device refresh and update UI (HandlerWindow, Logger, etc.).
• Removed old/unused MouseSpeedSettings.cs.
• Other small fixes and logging improvements for stability during device change events.
Temporary behavior and limitations
• This PR implements a heuristic (temporary) approach to handle Bluetooth vs wired connections:
• It normalizes hardware IDs from device paths and deduplicates devices by that normalized ID.
• It prefers devices that have sent real input (touch reports), then newly discovered devices, then most recently seen.
• Limitations:
• Some devices/drivers may report different or inconsistent device path formats; heuristics might not match every vendor.
• Windows often leaves Bluetooth HID entries in Device Manager even when the device is powered off — those "zombie" entries can persist and in rare cases still appear in enumeration. The heuristics prefer active connections but cannot change Windows driver behavior.
• Edge cases remain (some devices still produce duplicate/stale entries in specific sequences). Logging has been added to help root-cause remaining cases.
• This is intentionally a pragmatic interim solution to improve user experience while a dedicated device manager is implemented.
Next steps (planned follow-up PR)
• A follow-up PR will replace heuristics with a proper device manager that:
• Uses Windows SetupAPI/WMI (or equivalent) to query device present/connected state and device-instance properties.
• Builds a robust mapping between device-instance identifiers and connection endpoints (Bluetooth vs USB).
• Features a UI tab for device management, including managing smart profiles with devices.
• Will also add better smart profiles, having a global profile that is switched to if no appplications witth a smart profile are open.
• Provides explicit device lifecycle handling and deterministic removal of stale driver entries.
• Adds unit/integration tests around device enumeration and profile switching.
• This PR is intentionally limited to heuristics + UI to make the feature usable now while the full device manager is developed.
Branch & commit
• Branch: feature/smart_profiles
• Primary commit: d0ee499 (improve touchpad device management for Bluetooth/wired mode switching)