From 0f5532961ce72f6ed69870efffc610c5beb970cf Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Fri, 18 Sep 2026 00:39:00 +0000 Subject: [PATCH 01/11] fix: guarantee menu bar access when the status item fails to render The status item can silently fail to show up in the menu bar even when there is free space (no overflow chevron, not crowded) -- confirmed by screenshot while diagnosing this against upstream #9 and #11, which also both report the icon simply never appearing. - Give the status item a stable autosaveName so AppKit can persist and restore its slot across launches instead of treating it as a fresh, position-less item every time. This is the maintainer's own leading theory in both linked issues. - Replace the one-shot, UserDefaults-gated first-launch alert with a check that runs on every launch. If the item is genuinely visible, show the popover once per install as before. If it isn't, guarantee a way in: temporarily switch to .regular activation policy and open a real window hosting the same content, instead of an alert that a process with no Dock icon and no visible menu bar item may never be able to reliably present. The activation policy reverts to .accessory when that window is closed. --- Sources/NagaController/AppDelegate.swift | 70 ++++++++++++++++++------ 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index 9ace48f..4d2c762 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import UserNotifications -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var statusItem: NSStatusItem! private let popover = NSPopover() private let eventTapManager = EventTapManager.shared @@ -9,6 +9,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var profileObserver: NSObjectProtocol? private var didAlertLowBattery = false private var useEmojiInStatus = false + private var fallbackWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { // Ensure Accessibility permissions @@ -25,6 +26,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Status bar item (variable length to show %) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + // A stable autosaveName lets AppKit persist/restore this item's slot across + // launches instead of treating it as a brand-new, position-less item every time, + // which is the leading theory (see upstream #9 and #11) for why the item can + // silently fail to render even when the menu bar has free space. + statusItem.autosaveName = "NagaController.statusItem" if let button = statusItem.button { if let icon = NSImage(named: "MenuBar") { icon.isTemplate = true @@ -68,15 +74,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } updateStatusItemBattery(level: BatteryMonitor.shared.batteryLevel) - // First launch: the app has no window or Dock icon, so open the popover once to - // show where it lives. If the status item didn't make it onto the menu bar (a full - // menu bar on a notched MacBook hides items), fall back to an alert. - let firstLaunchKey = "NagaController.didShowFirstLaunchPopover" - if !UserDefaults.standard.bool(forKey: firstLaunchKey) { - UserDefaults.standard.set(true, forKey: firstLaunchKey) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.showFirstLaunchHint() - } + // The status item can silently fail to render — seen even with plenty of free + // space in the menu bar, not just a full one. Check on every launch (not only the + // first) and guarantee access via a real window if it's genuinely not on screen, + // rather than relying on a one-shot alert that only ever fires once per install. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.verifyStatusItemVisibleOrFallback() } // Start event tap based on persisted setting @@ -89,15 +92,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let profileObserver { NotificationCenter.default.removeObserver(profileObserver) } } - private func showFirstLaunchHint() { - NSApp.activate(ignoringOtherApps: true) - if let button = statusItem.button, button.window?.isVisible == true, !popover.isShown { - togglePopover(nil) + private func verifyStatusItemVisibleOrFallback() { + if statusItem.isVisible, let button = statusItem.button, button.window?.isVisible == true { + // The item is genuinely on screen. Show the popover once per install so + // first-time users know where the app lives. + let firstLaunchKey = "NagaController.didShowFirstLaunchPopover" + if !UserDefaults.standard.bool(forKey: firstLaunchKey) { + UserDefaults.standard.set(true, forKey: firstLaunchKey) + NSApp.activate(ignoringOtherApps: true) + if !popover.isShown { + togglePopover(nil) + } + } return } + // The status item didn't make it onto the menu bar. A process with no Dock icon + // and no visible menu bar item has no reliable surface to present modal UI on, so + // don't just show an alert and hope — guarantee a way in with a real window. + activateFallbackWindow() + } + + private func activateFallbackWindow() { + NSLog("[MenuBar] Status item not visible after launch; opening fallback window.") + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + + let controller = MainViewController() + let window = NSWindow(contentViewController: controller) + window.title = "NagaController" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.center() + window.delegate = self + fallbackWindow = window + window.makeKeyAndOrderFront(nil) + let alert = NSAlert() - alert.messageText = "NagaController runs in the menu bar" - alert.informativeText = "There is no window or Dock icon. Look for the mouse icon in the menu bar to enable remapping and configure buttons. If you don't see it, your menu bar may be full; remove or hide a few other items so it fits." + alert.messageText = "NagaController's menu bar icon didn't appear" + alert.informativeText = "This can happen even when the menu bar has free space. Use this window instead — it stays reachable from the Dock while it's open, and NagaController goes back to running quietly in the background once you close it." alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() @@ -145,6 +177,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, window === fallbackWindow else { return } + fallbackWindow = nil + NSApp.setActivationPolicy(.accessory) + } + private func requestNotificationAuthorizationIfPossible() { guard Bundle.main.bundleIdentifier != nil else { NSLog("[Notifications] Skipping authorization; bundle identifier missing (likely running via swift run).") From 6c548cab110e5d4307ba1da4908e2d83a33239a3 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 15:47:38 -0400 Subject: [PATCH 02/11] fix: reopen the popover when the running app is launched again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the one-time first-launch popover had fired, double-clicking NagaController again (or `open`-ing it, or clicking a Dock icon) did nothing visible — the process was already running and had no other way to surface UI, which reads as "the app doesn't open." Implement applicationShouldHandleReopen to bring the popover (or the fallback window, if the status item never rendered) forward every time the app is reopened, not just on the very first launch. Co-Authored-By: Claude Sonnet 5 --- Sources/NagaController/AppDelegate.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index 4d2c762..aed48a9 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -92,6 +92,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { if let profileObserver { NotificationCenter.default.removeObserver(profileObserver) } } + // Called when the already-running app is "opened" again — double-clicking it in + // Finder, clicking a Dock icon, `open /Applications/NagaController.app`. Without this, + // re-opening does nothing visible after the one-time first-launch popover has already + // fired, which reads as "the app doesn't open." + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + NSApp.activate(ignoringOtherApps: true) + if let fallbackWindow { + fallbackWindow.makeKeyAndOrderFront(nil) + } else if !popover.isShown { + togglePopover(nil) + } + return true + } + private func verifyStatusItemVisibleOrFallback() { if statusItem.isVisible, let button = statusItem.button, button.window?.isVisible == true { // The item is genuinely on screen. Show the popover once per install so From cab899ace2cdb2a161f7142b15085a0a1621b26a Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 15:49:09 -0400 Subject: [PATCH 03/11] fix: sign release builds with a stable identity instead of ad-hoc Ad-hoc signing (`codesign --sign -`) derives the code identity from the binary's own hash, so it's different on every single build. macOS ties Accessibility and Input Monitoring grants to that identity, so every rebuild silently revoked both and forced a re-grant in System Settings before the app could read the mouse's HID input again. Prefer any local codesigning identity already in the keychain (a real certificate, tied to a Team ID rather than the binary's content) and only fall back to ad-hoc when none exists. Override via NAGA_CODESIGN_IDENTITY. Co-Authored-By: Claude Sonnet 5 --- Scripts/build_app.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 66fbf18..52ba217 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -71,8 +71,23 @@ PLIST fi echo "Code signing..." -# Ad-hoc codesign (helps TCC and launching) -codesign --force --deep --sign - "$APP_BUNDLE" 2>/dev/null || true +# Prefer a real local signing identity over ad-hoc ("-"). Ad-hoc produces a +# fresh, content-derived identity on every build, so macOS treats each build +# as a different app and silently revokes previously granted Accessibility / +# Input Monitoring permissions, forcing a re-grant after every rebuild. A +# stable identity (tied to a certificate, not the binary's hash) keeps those +# grants across rebuilds. Override with NAGA_CODESIGN_IDENTITY if needed. +CODESIGN_IDENTITY="${NAGA_CODESIGN_IDENTITY:-}" +if [[ -z "$CODESIGN_IDENTITY" ]]; then + CODESIGN_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | grep -m1 -oE '"[^"]+"' | tr -d '"')" +fi +if [[ -z "$CODESIGN_IDENTITY" ]]; then + echo "No local signing identity found; falling back to ad-hoc (permissions will need re-granting after every rebuild)." + CODESIGN_IDENTITY="-" +else + echo "Signing with: $CODESIGN_IDENTITY" +fi +codesign --force --deep --sign "$CODESIGN_IDENTITY" "$APP_BUNDLE" 2>/dev/null || true # Remove quarantine attributes if present xattr -dr com.apple.quarantine "$APP_BUNDLE" 2>/dev/null || true From 01cf2f80d9deddb1d0e314855d5577310ce2e9f4 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:10:39 -0400 Subject: [PATCH 04/11] fix: guarantee a Dock icon and window instead of detecting menu bar failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status item can silently fail to render with no icon and no overflow chevron, reproduced live on a stock, notarized v0.2.2 release. AppKit's own visibility flags (statusItem.isVisible, button.window?.isVisible) don't reliably reflect that failure — they reported the item as visible in the same session where it plainly wasn't on screen. A fix built on detecting the failure is therefore not trustworthy. Stop trying to detect it. Switch LSUIElement to false so the app always has a Dock icon, and always show the main window on launch and on reopen (double-click, `open`, clicking the Dock icon), independent of whatever the menu bar is doing. The status item is still created as a convenience for the (apparently more common) case where it does work, but it's no longer the only way in. This is a bigger behavioral change than a bug fix — a persistent Dock icon changes the app's default feel from "background menu-bar utility" to "regular app" — so it's not proposed upstream yet pending discussion with the maintainer. Co-Authored-By: Claude Sonnet 5 --- Resources/Info.plist | 2 +- Sources/NagaController/AppDelegate.swift | 80 +++++++----------------- 2 files changed, 24 insertions(+), 58 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index 58cc295..2faad9d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,7 +17,7 @@ LSMinimumSystemVersion 13.0 LSUIElement - + NSAppleEventsUsageDescription NagaController needs to send keyboard events for button remapping. NSBluetoothAlwaysUsageDescription diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index aed48a9..2a12d95 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -9,7 +9,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var profileObserver: NSObjectProtocol? private var didAlertLowBattery = false private var useEmojiInStatus = false - private var fallbackWindow: NSWindow? + private var mainWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { // Ensure Accessibility permissions @@ -24,12 +24,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // Start Bluetooth battery monitoring (BLE Battery Service 0x180F) BatteryMonitor.shared.start() - // Status bar item (variable length to show %) + // Status bar item (variable length to show %). This is a bonus, convenient path + // to the popover — but its rendering has been observed to silently fail even with + // an autosaveName set and free space in the menu bar (see upstream #9 and #11), + // and AppKit's own visibility flags (`isVisible`, `button.window?.isVisible`) + // don't reliably reflect that failure either. Reachability must not depend on + // this succeeding: the Dock icon and main window below are the guaranteed way in. statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - // A stable autosaveName lets AppKit persist/restore this item's slot across - // launches instead of treating it as a brand-new, position-less item every time, - // which is the leading theory (see upstream #9 and #11) for why the item can - // silently fail to render even when the menu bar has free space. statusItem.autosaveName = "NagaController.statusItem" if let button = statusItem.button { if let icon = NSImage(named: "MenuBar") { @@ -74,13 +75,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } updateStatusItemBattery(level: BatteryMonitor.shared.batteryLevel) - // The status item can silently fail to render — seen even with plenty of free - // space in the menu bar, not just a full one. Check on every launch (not only the - // first) and guarantee access via a real window if it's genuinely not on screen, - // rather than relying on a one-shot alert that only ever fires once per install. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.verifyStatusItemVisibleOrFallback() - } + // The app now always runs with a Dock icon (see Info.plist) instead of trying to + // detect whether the status item rendered and guessing at a fallback. Show the + // main window on launch so there's something on screen immediately, and rely on + // the Dock icon for every future launch/reopen. + showMainWindow() // Start event tap based on persisted setting let remapEnabled = ConfigManager.shared.getRemappingEnabled() @@ -92,45 +91,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { if let profileObserver { NotificationCenter.default.removeObserver(profileObserver) } } - // Called when the already-running app is "opened" again — double-clicking it in - // Finder, clicking a Dock icon, `open /Applications/NagaController.app`. Without this, - // re-opening does nothing visible after the one-time first-launch popover has already - // fired, which reads as "the app doesn't open." + // Double-clicking the Dock icon, re-opening from Finder, or `open`-ing the app again + // while it's already running should always bring the window back. This is the + // guaranteed way in — it does not depend on the menu bar icon having rendered. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - NSApp.activate(ignoringOtherApps: true) - if let fallbackWindow { - fallbackWindow.makeKeyAndOrderFront(nil) - } else if !popover.isShown { - togglePopover(nil) - } + showMainWindow() return true } - private func verifyStatusItemVisibleOrFallback() { - if statusItem.isVisible, let button = statusItem.button, button.window?.isVisible == true { - // The item is genuinely on screen. Show the popover once per install so - // first-time users know where the app lives. - let firstLaunchKey = "NagaController.didShowFirstLaunchPopover" - if !UserDefaults.standard.bool(forKey: firstLaunchKey) { - UserDefaults.standard.set(true, forKey: firstLaunchKey) - NSApp.activate(ignoringOtherApps: true) - if !popover.isShown { - togglePopover(nil) - } - } + private func showMainWindow() { + NSApp.activate(ignoringOtherApps: true) + if let mainWindow { + mainWindow.makeKeyAndOrderFront(nil) return } - // The status item didn't make it onto the menu bar. A process with no Dock icon - // and no visible menu bar item has no reliable surface to present modal UI on, so - // don't just show an alert and hope — guarantee a way in with a real window. - activateFallbackWindow() - } - - private func activateFallbackWindow() { - NSLog("[MenuBar] Status item not visible after launch; opening fallback window.") - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - let controller = MainViewController() let window = NSWindow(contentViewController: controller) window.title = "NagaController" @@ -138,15 +112,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { window.isReleasedWhenClosed = false window.center() window.delegate = self - fallbackWindow = window + mainWindow = window window.makeKeyAndOrderFront(nil) - - let alert = NSAlert() - alert.messageText = "NagaController's menu bar icon didn't appear" - alert.informativeText = "This can happen even when the menu bar has free space. Use this window instead — it stays reachable from the Dock while it's open, and NagaController goes back to running quietly in the background once you close it." - alert.alertStyle = .informational - alert.addButton(withTitle: "OK") - alert.runModal() } @objc private func togglePopover(_ sender: Any?) { @@ -192,9 +159,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } func windowWillClose(_ notification: Notification) { - guard let window = notification.object as? NSWindow, window === fallbackWindow else { return } - fallbackWindow = nil - NSApp.setActivationPolicy(.accessory) + guard let window = notification.object as? NSWindow, window === mainWindow else { return } + mainWindow = nil } private func requestNotificationAuthorizationIfPossible() { From 4e10c487489c9543950195e2746534275b0abf14 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:54:31 -0400 Subject: [PATCH 05/11] feat: add Launch at Login Every comparable menu-bar utility has this; NagaController didn't. Uses SMAppService (macOS 13+, already the app's stated minimum), so no helper app or legacy SMLoginItem bookkeeping is needed. A checkbox next to the existing remapping toggle mirrors SMAppService.mainApp.status on open and registers/unregisters on change. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 + .../UI/MainViewController.swift | 97 ++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 87d2f53..4ac1c7e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ A macOS menu bar app to remap the 12 side buttons of the Razer Naga V2 Hyperspee - Profile switching - Toggle remapping ON/OFF from the menu bar - Configure mappings in a dedicated window +- Test a mapping instantly from the editor, without pressing the physical button - Battery percentage display via Bluetooth (UUID 0x180F / 0x2A19) +- Launch at login +- Notifies you in the app window when a newer release is available (no auto-install) +- Warns before importing a profile that runs shell commands - Modern dark UI with Razer-green accents ## Requirements diff --git a/Sources/NagaController/UI/MainViewController.swift b/Sources/NagaController/UI/MainViewController.swift index 02f94d1..bbebe66 100644 --- a/Sources/NagaController/UI/MainViewController.swift +++ b/Sources/NagaController/UI/MainViewController.swift @@ -1,4 +1,5 @@ import Cocoa +import ServiceManagement final class MainViewController: NSViewController { private let titleLabel: NSTextField = { @@ -24,6 +25,15 @@ final class MainViewController: NSViewController { private let batteryGlass = GlassyBatteryView() private let toggle = NSButton(checkboxWithTitle: "Enable remapping (blocks original keys)", target: nil, action: nil) + private let launchAtLoginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil) + private let updateBanner: NSButton = { + let b = NSButton(title: "", target: nil, action: nil) + b.isBordered = false + b.font = .systemFont(ofSize: 11, weight: .semibold) + b.contentTintColor = UIStyle.razerGreen + b.isHidden = true + return b + }() private let configureButton: NSButton = { let b = NSButton(title: "Configure mappings…", target: nil, action: nil) b.image = UIStyle.symbol("slider.horizontal.3", size: 14, weight: .semibold) @@ -42,6 +52,7 @@ final class MainViewController: NSViewController { private var batteryObserver: NSObjectProtocol? private var permissionObserver: NSObjectProtocol? + private var updateObserver: NSObjectProtocol? private let permissionHeaderLabel: NSTextField = { let label = NSTextField(labelWithString: "Permissions") @@ -104,11 +115,14 @@ final class MainViewController: NSViewController { batteryGlass.widthAnchor.constraint(equalToConstant: 60).isActive = true batteryGlass.heightAnchor.constraint(equalToConstant: 12).isActive = true - let headerStack = NSStackView(views: [titleLabel, statusLabel, batteryRow]) + updateBanner.target = self + updateBanner.action = #selector(openReleasesPage) + + let headerStack = NSStackView(views: [titleLabel, statusLabel, batteryRow, updateBanner]) headerStack.orientation = .vertical headerStack.spacing = 8 headerStack.alignment = .centerX - + container.addArrangedSubview(headerStack) // 2. Actions Section (in a card) @@ -143,8 +157,20 @@ final class MainViewController: NSViewController { toggleContainer.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) (toggle.cell as? NSButtonCell)?.wraps = true toggleContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true - + + launchAtLoginToggle.target = self + launchAtLoginToggle.action = #selector(launchAtLoginChanged(_:)) + launchAtLoginToggle.state = (SMAppService.mainApp.status == .enabled) ? .on : .off + launchAtLoginToggle.font = .systemFont(ofSize: 13, weight: .medium) + launchAtLoginToggle.contentTintColor = .white + + let launchAtLoginContainer = NSStackView(views: [launchAtLoginToggle]) + launchAtLoginContainer.alignment = .centerX + launchAtLoginContainer.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + launchAtLoginContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true + actionsStack.addArrangedSubview(toggleContainer) + actionsStack.addArrangedSubview(launchAtLoginContainer) actionsStack.addArrangedSubview(configureButton) actionsStack.addArrangedSubview(quitButton) @@ -194,6 +220,11 @@ final class MainViewController: NSViewController { self?.refreshPermissionStatuses() } refreshPermissionStatuses() + + updateObserver = NotificationCenter.default.addObserver(forName: UpdateChecker.didFindUpdateNotification, object: nil, queue: .main) { [weak self] note in + self?.showUpdateBanner(version: note.object as? String) + } + showUpdateBanner(version: UpdateChecker.shared.availableVersion) } @objc private func toggleChanged(_ sender: NSButton) { @@ -204,6 +235,33 @@ final class MainViewController: NSViewController { ConfigManager.shared.setRemappingEnabled(enabled) } + @objc private func launchAtLoginChanged(_ sender: NSButton) { + let enabled = (sender.state == .on) + do { + if enabled, SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } else if !enabled, SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } + } catch { + NSLog("[LoginItem] Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)") + sender.state = enabled ? .off : .on + } + } + + @objc private func openReleasesPage() { + NSWorkspace.shared.open(UpdateChecker.releasesPageURL) + } + + private func showUpdateBanner(version: String?) { + guard let version else { + updateBanner.isHidden = true + return + } + updateBanner.title = "Update available: v\(version)" + updateBanner.isHidden = false + } + @objc private func openMappings() { MappingWindowController.shared.show() } @@ -229,8 +287,34 @@ final class MainViewController: NSViewController { } func refreshPermissionStatuses() { - updateStatus(label: accessibilityStatusLabel, granted: PermissionManager.shared.hasAccessibilityPermission()) - updateStatus(label: inputmonitoringStatusLabel, granted: PermissionManager.shared.hasInputMonitoringPermission()) + let accessibilityGranted = PermissionManager.shared.hasAccessibilityPermission() + let inputMonitoringGranted = PermissionManager.shared.hasInputMonitoringPermission() + updateStatus(label: accessibilityStatusLabel, granted: accessibilityGranted) + updateStatus(label: inputmonitoringStatusLabel, granted: inputMonitoringGranted) + + if accessibilityGranted && inputMonitoringGranted { + promptToEnableRemappingIfNeeded() + } + } + + // Both permissions granted has never meant remapping is actually on — that's a + // separate switch, and forgetting to flip it reads as "the app doesn't do anything" + // (it did, twice, in testing). Nudge once per install rather than nagging forever. + private func promptToEnableRemappingIfNeeded() { + let nudgeKey = "NagaController.didNudgeEnableRemapping" + guard !ConfigManager.shared.getRemappingEnabled(), !UserDefaults.standard.bool(forKey: nudgeKey) else { return } + UserDefaults.standard.set(true, forKey: nudgeKey) + + let alert = NSAlert() + alert.messageText = "Turn on remapping?" + alert.informativeText = "Accessibility and Input Monitoring are both granted. Saved button mappings only take effect once remapping is switched on." + alert.alertStyle = .informational + alert.addButton(withTitle: "Turn On") + alert.addButton(withTitle: "Not Now") + if alert.runModal() == .alertFirstButtonReturn { + toggle.state = .on + toggleChanged(toggle) + } } private func updateStatus(label: NSTextField, granted: Bool) { @@ -296,5 +380,8 @@ final class MainViewController: NSViewController { if let obs = permissionObserver { NotificationCenter.default.removeObserver(obs) } + if let obs = updateObserver { + NotificationCenter.default.removeObserver(obs) + } } } From 76ab8b73e02c0002e71f1947863ef1851cd945a9 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:55:07 -0400 Subject: [PATCH 06/11] feat: show a brief on-screen cue when Hypershift toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user guide already admits the toggle-mode Hypershift state is easy to lose track of — that's the documented reason the long-hold force-deactivate safety valve exists. A HUD is the other half of that fix: something visible at the moment it toggles, not just a menu bar title text change that requires already knowing to look for it (and which the current status-item rendering issues make an unreliable place to put state anyway). HUDNotifier is a small, dependency-free borderless window, reused singleton-style so a second call just replaces what's showing rather than stacking. Co-Authored-By: Claude Sonnet 5 --- .../ButtonMapping/ButtonMapper.swift | 9 ++ Sources/NagaController/UI/HUDNotifier.swift | 83 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 Sources/NagaController/UI/HUDNotifier.swift diff --git a/Sources/NagaController/ButtonMapping/ButtonMapper.swift b/Sources/NagaController/ButtonMapping/ButtonMapper.swift index 55b9956..b5b8331 100644 --- a/Sources/NagaController/ButtonMapping/ButtonMapper.swift +++ b/Sources/NagaController/ButtonMapping/ButtonMapper.swift @@ -107,6 +107,7 @@ final class ButtonMapper { } else if mode == .toggle { isHypershiftToggled.toggle() NSLog("[Mapping] Hypershift toggled to \(isHypershiftToggled) by button \(buttonIndex)") + HUDNotifier.shared.show(isHypershiftToggled ? "Hypershift ON" : "Hypershift OFF") } lastHypershiftPressTime = CFAbsoluteTimeGetCurrent() return @@ -181,6 +182,7 @@ final class ButtonMapper { if holdDuration > 0.5, isHypershiftToggled { isHypershiftToggled = false NSLog("[Mapping] Hypershift TOGGLE force-deactivated via long hold (\(String(format: "%.2f", holdDuration))s)") + HUDNotifier.shared.show("Hypershift OFF") } } return @@ -214,6 +216,13 @@ final class ButtonMapper { } } + /// Runs an action once, immediately — lets the mapping editor's "Test" button verify + /// a mapping (including one not yet saved) without physically pressing the mouse + /// button it will end up bound to. + func test(action: ActionType) { + perform(action: action) + } + private func perform(action: ActionType) { switch action { case .keySequence(let keys, _): diff --git a/Sources/NagaController/UI/HUDNotifier.swift b/Sources/NagaController/UI/HUDNotifier.swift new file mode 100644 index 0000000..adae5ec --- /dev/null +++ b/Sources/NagaController/UI/HUDNotifier.swift @@ -0,0 +1,83 @@ +import Cocoa + +/// A brief, auto-dismissing on-screen message for state changes that have no other +/// visible confirmation — e.g. Hypershift toggling, which the user guide already admits +/// is easy to lose track of (that's why the long-hold safety release exists). Deliberately +/// has no queue or stacking: a second call just replaces whatever is currently showing. +final class HUDNotifier { + static let shared = HUDNotifier() + + private var window: NSWindow? + private var dismissWorkItem: DispatchWorkItem? + + private init() {} + + func show(_ text: String, duration: TimeInterval = 1.2) { + DispatchQueue.main.async { [weak self] in + self?.present(text, duration: duration) + } + } + + private func present(_ text: String, duration: TimeInterval) { + dismissWorkItem?.cancel() + + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.textColor = .white + label.alignment = .center + label.sizeToFit() + + let padding: CGFloat = 20 + let contentSize = NSSize(width: label.frame.width + padding * 2, height: label.frame.height + padding) + + let win = window ?? { + let w = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), + styleMask: [.borderless], backing: .buffered, defer: false) + w.isOpaque = false + w.backgroundColor = .clear + w.hasShadow = true + w.level = .statusBar + w.ignoresMouseEvents = true + w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + window = w + return w + }() + + let effectView = NSVisualEffectView(frame: NSRect(origin: .zero, size: contentSize)) + effectView.material = .hudWindow + effectView.state = .active + effectView.wantsLayer = true + effectView.layer?.cornerRadius = 14 + effectView.blendingMode = .behindWindow + label.frame = NSRect(x: padding, y: padding / 2, width: label.frame.width, height: label.frame.height) + effectView.addSubview(label) + + win.setContentSize(contentSize) + win.contentView = effectView + + if let screen = NSScreen.main { + let x = screen.frame.midX - contentSize.width / 2 + let y = screen.frame.maxY - screen.frame.height * 0.22 + win.setFrameOrigin(NSPoint(x: x, y: y)) + } + + win.alphaValue = 0 + win.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + win.animator().alphaValue = 1 + } + + let work = DispatchWorkItem { [weak self] in + guard let self, let win = self.window else { return } + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.25 + win.animator().alphaValue = 0 + }, completionHandler: { + win.orderOut(nil) + }) + } + dismissWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work) + } +} From 2f8976de88abfc314e5ca912e2e7fb2cc7da0b7c Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:55:08 -0400 Subject: [PATCH 07/11] feat: add a Test button to the mapping editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying a shell command, app launch, or macro mapping meant saving it and then physically pressing the mouse button — awkward mid-edit, and impossible for triggers not yet learned. Test runs buildActionFromUI()'s result once through ButtonMapper immediately, unsaved, the same builder Save already uses. Co-Authored-By: Claude Sonnet 5 --- .../UI/ActionEditorViewController.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/NagaController/UI/ActionEditorViewController.swift b/Sources/NagaController/UI/ActionEditorViewController.swift index 9a06e42..fdabe65 100644 --- a/Sources/NagaController/UI/ActionEditorViewController.swift +++ b/Sources/NagaController/UI/ActionEditorViewController.swift @@ -151,6 +151,7 @@ final class ActionEditorViewController: NSViewController { // saved and closed the editor. Suspend the equivalents while the box has focus. private var saveButton: NSButton! private var cancelButton: NSButton! + private var testButton: NSButton! init(buttonIndex: Int, initialLayer: Int = 0, onComplete: @escaping () -> Void) { self.buttonIndex = buttonIndex @@ -387,6 +388,14 @@ final class ActionEditorViewController: NSViewController { UIStyle.stylePrimaryButton(saveButton) saveButton.widthAnchor.constraint(equalToConstant: 100).isActive = true saveButton.heightAnchor.constraint(equalToConstant: 36).isActive = true + + testButton = NSButton(title: "Test", target: self, action: #selector(testTapped)) + testButton.image = UIStyle.symbol("play.fill", size: 12, weight: .bold) + testButton.imagePosition = .imageLeading + testButton.toolTip = "Run this action once, right now, without pressing the mouse button" + UIStyle.styleSecondaryButton(testButton) + testButton.widthAnchor.constraint(equalToConstant: 90).isActive = true + testButton.heightAnchor.constraint(equalToConstant: 36).isActive = true learnButton.target = self learnButton.action = #selector(learnHardwareTapped) @@ -420,6 +429,7 @@ final class ActionEditorViewController: NSViewController { hardwareRow.spacing = 8 hardwareStack.widthAnchor.constraint(equalTo: hardwareRow.widthAnchor).isActive = true + buttonsStack.addArrangedSubview(testButton) buttonsStack.addArrangedSubview(NSView()) // Spacer buttonsStack.addArrangedSubview(cancelButton) buttonsStack.addArrangedSubview(saveButton) @@ -628,6 +638,14 @@ final class ActionEditorViewController: NSViewController { onComplete() } + @objc private func testTapped() { + guard let action = buildActionFromUI() else { + NSSound.beep() + return + } + ButtonMapper.shared.test(action: action) + } + @objc private func saveTapped() { // Finalise the currently visible layer's UI state into temp storage let finalAction = buildActionFromUI() From 41af6d6963cd0dc6fc9b0eec10bf0e6c335b3829 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:55:08 -0400 Subject: [PATCH 08/11] fix: reset cached device state when the Naga disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IOHIDManagerRegisterDeviceMatchingCallback was registered without its counterpart, IOHIDManagerRegisterDeviceRemovalCallback, so nothing cleared lastDPI/lastDPIDirection, the synthetic button-down states, or the pointer router across a Bluetooth drop, sleep/wake, or dongle reseat. The user guide already claims a fresh DPI baseline gets recorded "after launching the app or reconnecting the mouse" — that was only ever true for launch. A button physically held down at the moment of disconnect also never got its release, leaving it stuck. Co-Authored-By: Claude Sonnet 5 --- Sources/NagaController/HID/HIDListener.swift | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Sources/NagaController/HID/HIDListener.swift b/Sources/NagaController/HID/HIDListener.swift index 9240c6f..eac24b3 100644 --- a/Sources/NagaController/HID/HIDListener.swift +++ b/Sources/NagaController/HID/HIDListener.swift @@ -57,6 +57,12 @@ final class HIDListener { Log.debug("[HID] Device plugged/matched: vendor=0x\(String(vendor, radix: 16)), product=\(product)") }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + IOHIDManagerRegisterDeviceRemovalCallback(manager, { context, result, sender, device in + guard let context = context else { return } + let this = Unmanaged.fromOpaque(context).takeUnretainedValue() + this.handleDeviceRemoval(device: device) + }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + IOHIDManagerRegisterInputValueCallback(manager, { context, result, sender, value in guard let context = context else { return } let this = Unmanaged.fromOpaque(context).takeUnretainedValue() @@ -91,6 +97,25 @@ final class HIDListener { } } + // A Bluetooth drop, sleep/wake, or dongle reseat leaves stale per-device state behind: + // the DPI baseline (see handle(report:)) compares against a value from before the + // gap and can report a bogus direction, and a button physically held at disconnect + // time never gets its release event, leaving it stuck "down" forever. Reset on + // removal so the next connection starts clean, matching what the docs already claim + // happens on "launch or reconnect" but which only actually happened on launch. + private func handleDeviceRemoval(device: IOHIDDevice) { + guard HIDListener.isWhitelistedMouse(device: device) else { return } + let product = (IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String) ?? "" + Log.debug("[HID] Device removed: product=\(product). Resetting DPI baseline and in-flight button state.") + lastDPI = nil + lastDPIDirection = 0 + pointerRouter = PointerInputRouter() + queue.sync { + syntheticStates.removeAll() + recentPressTimestamps.removeAll() + } + } + private func record(buttonIndex: Int) { let now = CFAbsoluteTimeGetCurrent() queue.sync { From 167f72f134123ac66e876627abd5809c63d9b7e6 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:55:08 -0400 Subject: [PATCH 09/11] fix: warn before importing a profile that runs shell commands Profiles are importable from any JSON file, and a systemCommand action runs on the mouse button press with no per-press confirmation. A shared profiles.json (someone's public "Naga setup", found online) could carry one silently. Split the decode step out of importProfiles so the caller can inspect a file before committing to it, and warn with a count when it contains shell-command actions. Co-Authored-By: Claude Sonnet 5 --- .../UI/MappingViewController.swift | 13 ++++++- .../NagaController/Utils/ConfigManager.swift | 24 +++++++++++- .../ImportSafetyTests.swift | 37 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 Tests/NagaControllerTests/ImportSafetyTests.swift diff --git a/Sources/NagaController/UI/MappingViewController.swift b/Sources/NagaController/UI/MappingViewController.swift index ca9eaa5..e5859b2 100644 --- a/Sources/NagaController/UI/MappingViewController.swift +++ b/Sources/NagaController/UI/MappingViewController.swift @@ -576,7 +576,18 @@ final class MappingViewController: NSViewController { p.beginSheetModal(for: view.window!) { resp in guard resp == .OK, let url = p.url else { return } do { - try ConfigManager.shared.importProfiles(from: url, merge: true) + let file = try ConfigManager.shared.loadProfilesFile(from: url) + let shellCount = ConfigManager.countShellCommandActions(in: file) + if shellCount > 0 { + let alert = NSAlert() + alert.messageText = "This profile runs shell commands" + alert.informativeText = "\(shellCount) button\(shellCount == 1 ? "" : "s") in this file run a shell command as soon as it's pressed. Only import it if you trust where it came from." + alert.alertStyle = .warning + alert.addButton(withTitle: "Import Anyway") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + } + ConfigManager.shared.importProfiles(file, merge: true) ConfigManager.shared.saveUserProfiles() self.refreshRows() } catch { diff --git a/Sources/NagaController/Utils/ConfigManager.swift b/Sources/NagaController/Utils/ConfigManager.swift index 40c4ba6..3ae5eef 100644 --- a/Sources/NagaController/Utils/ConfigManager.swift +++ b/Sources/NagaController/Utils/ConfigManager.swift @@ -217,9 +217,18 @@ final class ConfigManager { // MARK: - Import / Export - func importProfiles(from url: URL, merge: Bool = true) throws { + /// Decodes without applying anything, so a caller can inspect the file (e.g. warn + /// about shell-command actions) before committing to the import. + func loadProfilesFile(from url: URL) throws -> ProfilesFile { let data = try Data(contentsOf: url) - let pf = try JSONDecoder().decode(ProfilesFile.self, from: data) + return try JSONDecoder().decode(ProfilesFile.self, from: data) + } + + func importProfiles(from url: URL, merge: Bool = true) throws { + importProfiles(try loadProfilesFile(from: url), merge: merge) + } + + func importProfiles(_ pf: ProfilesFile, merge: Bool = true) { if merge { for (k, v) in pf.profiles { profiles[k] = v } } else { @@ -233,6 +242,17 @@ final class ConfigManager { } } + /// A profile bound to a shell command runs arbitrary code the moment its button is + /// pressed, with no per-press confirmation. A shared profiles.json (a "friend's setup" + /// found online) could carry one silently, so surface a count before an import commits. + static func countShellCommandActions(in file: ProfilesFile) -> Int { + file.profiles.values.reduce(0) { total, profile in + let standard = profile.buttons.values.filter { $0.type == "systemCommand" }.count + let hypershift = profile.hypershiftMappings?.values.filter { $0.type == "systemCommand" }.count ?? 0 + return total + standard + hypershift + } + } + func exportCurrentProfile(to url: URL) throws { guard let p = profiles[currentProfileName] else { return } let pf = ProfilesFile(profiles: [currentProfileName: p], settings: Settings(currentProfile: currentProfileName, autoSwitchProfiles: nil, showNotifications: nil)) diff --git a/Tests/NagaControllerTests/ImportSafetyTests.swift b/Tests/NagaControllerTests/ImportSafetyTests.swift new file mode 100644 index 0000000..c724951 --- /dev/null +++ b/Tests/NagaControllerTests/ImportSafetyTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import NagaController + +final class ImportSafetyTests: XCTestCase { + func testCountsShellCommandsAcrossStandardAndHypershiftLayers() { + let file = ProfilesFile( + profiles: [ + "Default": Profile( + buttons: [ + "1": ButtonAction(type: "systemCommand", keys: nil, description: nil, path: nil, command: "rm -rf ~/Desktop", text: nil, steps: nil, profile: nil, mediaKey: nil, mode: nil), + "2": ButtonAction(type: "keySequence", keys: [KeyStroke(key: "c", modifiers: ["cmd"])], description: nil, path: nil, command: nil, text: nil, steps: nil, profile: nil, mediaKey: nil, mode: nil) + ], + hardwareBindings: nil, + hypershiftMappings: [ + "1": ButtonAction(type: "systemCommand", keys: nil, description: nil, path: nil, command: "open /Applications", text: nil, steps: nil, profile: nil, mediaKey: nil, mode: nil) + ] + ) + ], + settings: nil + ) + XCTAssertEqual(ConfigManager.countShellCommandActions(in: file), 2) + } + + func testProfileWithoutShellCommandsCountsZero() { + let file = ProfilesFile( + profiles: [ + "Default": Profile( + buttons: ["1": ButtonAction(type: "keySequence", keys: [KeyStroke(key: "v", modifiers: ["cmd"])], description: nil, path: nil, command: nil, text: nil, steps: nil, profile: nil, mediaKey: nil, mode: nil)], + hardwareBindings: nil, + hypershiftMappings: nil + ) + ], + settings: nil + ) + XCTAssertEqual(ConfigManager.countShellCommandActions(in: file), 0) + } +} From 934cc2519fcbfeb715413fb9bce53d4698bc39b4 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 16:55:08 -0400 Subject: [PATCH 10/11] feat: check for newer releases and surface them in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every version bump otherwise requires noticing a new GitHub release by hand. Hits the releases API at most once a day, compares semver tags, and posts a notification MainViewController's banner listens for — no auto-download, no auto-install, just visibility. Deliberately not Sparkle: a full auto-update framework is a lot of dependency and maintenance surface for what a one-line version check mostly solves. Co-Authored-By: Claude Sonnet 5 --- Sources/NagaController/AppDelegate.swift | 4 ++ .../NagaController/Utils/UpdateChecker.swift | 50 +++++++++++++++++++ .../UpdateCheckerTests.swift | 22 ++++++++ 3 files changed, 76 insertions(+) create mode 100644 Sources/NagaController/Utils/UpdateChecker.swift create mode 100644 Tests/NagaControllerTests/UpdateCheckerTests.swift diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index 2a12d95..c889ec9 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -24,6 +24,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // Start Bluetooth battery monitoring (BLE Battery Service 0x180F) BatteryMonitor.shared.start() + // Fire-and-forget: at most once a day, posts a notification if a newer release + // exists. No auto-update, just visibility. + UpdateChecker.shared.checkIfNeeded() + // Status bar item (variable length to show %). This is a bonus, convenient path // to the popover — but its rendering has been observed to silently fail even with // an autosaveName set and free space in the menu bar (see upstream #9 and #11), diff --git a/Sources/NagaController/Utils/UpdateChecker.swift b/Sources/NagaController/Utils/UpdateChecker.swift new file mode 100644 index 0000000..1741597 --- /dev/null +++ b/Sources/NagaController/Utils/UpdateChecker.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Checks GitHub's releases API for a newer tagged release than the running build, at +/// most once a day. No auto-download and no auto-install — just enough visibility that +/// "am I on the latest version?" stops requiring a manual trip to GitHub. +final class UpdateChecker { + static let shared = UpdateChecker() + static let didFindUpdateNotification = Notification.Name("UpdateChecker.didFindUpdate") + static let releasesPageURL = URL(string: "https://github.com/DParent10/NagaController/releases/latest")! + + private(set) var availableVersion: String? + private let lastCheckKey = "NagaController.lastUpdateCheckDate" + private let apiURL = URL(string: "https://api.github.com/repos/DParent10/NagaController/releases/latest")! + + private init() {} + + func checkIfNeeded() { + if let last = UserDefaults.standard.object(forKey: lastCheckKey) as? Date, + Date().timeIntervalSince(last) < 86_400 { + return + } + UserDefaults.standard.set(Date(), forKey: lastCheckKey) + + var request = URLRequest(url: apiURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + guard let self, let data, error == nil, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = json["tag_name"] as? String else { return } + let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + let current = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0" + guard UpdateChecker.isNewer(latest, than: current) else { return } + DispatchQueue.main.async { + self.availableVersion = latest + NotificationCenter.default.post(name: UpdateChecker.didFindUpdateNotification, object: latest) + } + }.resume() + } + + static func isNewer(_ a: String, than b: String) -> Bool { + let aParts = a.split(separator: ".").compactMap { Int($0) } + let bParts = b.split(separator: ".").compactMap { Int($0) } + for i in 0.. y } + } + return false + } +} diff --git a/Tests/NagaControllerTests/UpdateCheckerTests.swift b/Tests/NagaControllerTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..a7f0880 --- /dev/null +++ b/Tests/NagaControllerTests/UpdateCheckerTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import NagaController + +final class UpdateCheckerTests: XCTestCase { + func testNewerPatchVersionIsDetected() { + XCTAssertTrue(UpdateChecker.isNewer("0.2.3", than: "0.2.2")) + XCTAssertFalse(UpdateChecker.isNewer("0.2.2", than: "0.2.2")) + XCTAssertFalse(UpdateChecker.isNewer("0.2.1", than: "0.2.2")) + } + + func testNewerMinorAndMajorVersionsAreDetected() { + XCTAssertTrue(UpdateChecker.isNewer("0.3.0", than: "0.2.9")) + XCTAssertTrue(UpdateChecker.isNewer("1.0.0", than: "0.9.9")) + XCTAssertFalse(UpdateChecker.isNewer("0.9.9", than: "1.0.0")) + } + + func testDifferentSegmentCountsCompareCorrectly() { + // "0.3" vs "0.2.9": missing trailing segments count as 0, not "shorter therefore older". + XCTAssertTrue(UpdateChecker.isNewer("0.3", than: "0.2.9")) + XCTAssertFalse(UpdateChecker.isNewer("0.2", than: "0.2.0")) + } +} From d0b02e10cc4168d7de1ebf8fe92b45c9c84e5908 Mon Sep 17 00:00:00 2001 From: Rony Karim Date: Sat, 19 Sep 2026 17:20:26 -0400 Subject: [PATCH 11/11] docs: notate this branch's changes in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Changes on this branch" section, separate from the general Features list, so anyone looking at this fork can see at a glance what's here beyond upstream v0.2.2 and why — without digging through commit messages. Co-Authored-By: Claude Sonnet 5 --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 4ac1c7e..169cb33 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,37 @@ A macOS menu bar app to remap the 12 side buttons of the Razer Naga V2 Hyperspee **[⬇️ Download Latest Release (v0.2.2)](https://github.com/DParent10/NagaController/releases/latest)** +## Changes on this branch (`feature/quality-of-life`, built on v0.2.2) + +Not yet part of an upstream release. Builds on `fix/menubar-item-not-visible` (also in this fork). + +**Reliability** +- The menu bar icon has been observed to silently fail to render — reproduced even on the + official notarized v0.2.2 release, with plenty of free menu bar space — and AppKit's own + visibility checks can't reliably detect the failure either. Rather than keep guessing at + the cause, the app now always runs with a Dock icon and shows a real window on launch and + on reopen, independent of whether the menu bar item renders. The status item is still + created as a convenience for the (more common) case where it works. +- Fixed the app's own dev-build signing to use a stable local certificate instead of + ad-hoc, so a rebuild no longer silently revokes Accessibility/Input Monitoring grants. +- Fixed stale cached device state (DPI baseline, in-flight button presses) surviving a + Bluetooth disconnect/reconnect — it previously only ever reset on app launch, despite + the user guide's claim that reconnecting also recalibrates it. + +**Quality of life** +- **Launch at Login** — a checkbox in the main window (`SMAppService`, no helper app). +- **Remap-enable nudge** — once both permissions are granted, offers to turn remapping on + immediately, instead of leaving that as an easy-to-forget separate step. +- **Test button** in the mapping editor — runs an action once, immediately, without saving + or physically pressing the mouse button. +- **Hypershift on-screen cue** — a brief HUD when Hypershift toggles, since its state was + already documented as easy to lose track of. +- **Update check** — checks GitHub once a day and shows a banner if a newer release + exists. No auto-download or auto-install. +- **Import warning** — flags profiles that contain shell-command actions before importing + them, since a shared `profiles.json` could otherwise silently bind a mouse button to + arbitrary shell code. + ## Features - Remap Naga side buttons 1–12 to: