diff --git a/README.md b/README.md
index 87d2f53..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:
@@ -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
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/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
diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift
index 9ace48f..c889ec9 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 mainWindow: NSWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
// Ensure Accessibility permissions
@@ -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
@@ -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()
@@ -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?) {
@@ -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).")
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/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 {
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()
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)
+ }
+}
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)
+ }
}
}
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/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/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)
+ }
+}
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"))
+ }
+}