Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -15,7 +46,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
Expand Down
2 changes: 1 addition & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<false/>
<key>NSAppleEventsUsageDescription</key>
<string>NagaController needs to send keyboard events for button remapping.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
Expand Down
19 changes: 17 additions & 2 deletions Scripts/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 43 additions & 21 deletions Sources/NagaController/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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
private var batteryObserver: NSObjectProtocol?
private var profileObserver: NSObjectProtocol?
private var didAlertLowBattery = false
private var useEmojiInStatus = false
private var mainWindow: NSWindow?

func applicationDidFinishLaunching(_ notification: Notification) {
// Ensure Accessibility permissions
Expand All @@ -23,8 +24,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Start Bluetooth battery monitoring (BLE Battery Service 0x180F)
BatteryMonitor.shared.start()

// Status bar item (variable length to show %)
// 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),
// 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)
statusItem.autosaveName = "NagaController.statusItem"
if let button = statusItem.button {
if let icon = NSImage(named: "MenuBar") {
icon.isTemplate = true
Expand Down Expand Up @@ -68,16 +79,11 @@ 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 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()
Expand All @@ -89,18 +95,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let profileObserver { NotificationCenter.default.removeObserver(profileObserver) }
}

private func showFirstLaunchHint() {
// 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 {
showMainWindow()
return true
}

private func showMainWindow() {
NSApp.activate(ignoringOtherApps: true)
if let button = statusItem.button, button.window?.isVisible == true, !popover.isShown {
togglePopover(nil)
if let mainWindow {
mainWindow.makeKeyAndOrderFront(nil)
return
}
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.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
let controller = MainViewController()
let window = NSWindow(contentViewController: controller)
window.title = "NagaController"
window.styleMask = [.titled, .closable, .miniaturizable]
window.isReleasedWhenClosed = false
window.center()
window.delegate = self
mainWindow = window
window.makeKeyAndOrderFront(nil)
}

@objc private func togglePopover(_ sender: Any?) {
Expand Down Expand Up @@ -145,6 +162,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

func windowWillClose(_ notification: Notification) {
guard let window = notification.object as? NSWindow, window === mainWindow else { return }
mainWindow = nil
}

private func requestNotificationAuthorizationIfPossible() {
guard Bundle.main.bundleIdentifier != nil else {
NSLog("[Notifications] Skipping authorization; bundle identifier missing (likely running via swift run).")
Expand Down
9 changes: 9 additions & 0 deletions Sources/NagaController/ButtonMapping/ButtonMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, _):
Expand Down
25 changes: 25 additions & 0 deletions Sources/NagaController/HID/HIDListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<HIDListener>.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<HIDListener>.fromOpaque(context).takeUnretainedValue()
Expand Down Expand Up @@ -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) ?? "<unknown>"
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 {
Expand Down
18 changes: 18 additions & 0 deletions Sources/NagaController/UI/ActionEditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
83 changes: 83 additions & 0 deletions Sources/NagaController/UI/HUDNotifier.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading