diff --git a/README.md b/README.md index 87d2f53..1014894 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, and an optional Dock icon (the app is menu-bar only by default) +- Notifies you in the app window when a newer release is available. This checks GitHub's releases API at most once a day; nothing is downloaded or sent beyond that request. +- Warns before importing a profile that runs shell commands - Modern dark UI with Razer-green accents ## Requirements 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..5e9911b 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,14 +79,17 @@ 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" + // Reachability must not depend on the status item rendering. The app stays a + // menu-bar app by default, but (a) the main window opens on the very first launch, + // (b) opening the app again while it runs (Finder, Spotlight, the Dock) always + // brings the window back via applicationShouldHandleReopen, and (c) a "Show icon + // in Dock" preference switches the activation policy for anyone who wants it. + AppDelegate.applyDockIconPreference() + let firstLaunchKey = "NagaController.didShowFirstLaunchWindow" if !UserDefaults.standard.bool(forKey: firstLaunchKey) { UserDefaults.standard.set(true, forKey: firstLaunchKey) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.showFirstLaunchHint() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.showMainWindow() } } @@ -89,18 +103,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let profileObserver { NotificationCenter.default.removeObserver(profileObserver) } } - private func showFirstLaunchHint() { + // Opening the app again while it's running — Finder, Spotlight, `open`, or the Dock + // icon when enabled — always brings the window back. This is the guaranteed way in; + // it does not depend on the menu bar icon having rendered, and it works for + // accessory (LSUIElement) apps too. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showMainWindow() + return true + } + + static let showDockIconKey = "NagaController.showDockIcon" + + /// Applies the persisted "Show icon in Dock" preference. Default is off (menu-bar only). + static func applyDockIconPreference() { + let show = UserDefaults.standard.bool(forKey: showDockIconKey) + NSApp.setActivationPolicy(show ? .regular : .accessory) + } + + 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) + // The main view is styled for a dark background (white labels on HUD material); + // the popover forces that, a standalone window would follow the system appearance + // and render unreadable in Light mode. + window.appearance = NSAppearance(named: .darkAqua) + 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 +183,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..bec0a31 100644 --- a/Sources/NagaController/UI/ActionEditorViewController.swift +++ b/Sources/NagaController/UI/ActionEditorViewController.swift @@ -136,6 +136,7 @@ final class ActionEditorViewController: NSViewController { private let clearHardwareButton = NSButton(title: "", target: nil, action: nil) private var isLearningHardware = false private var isCoolingDown = false + private var isTesting = false // Hypershift mode picker (hold / toggle) private let hypershiftModeSegmented = NSSegmentedControl( @@ -151,6 +152,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 +389,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 +430,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 +639,35 @@ final class ActionEditorViewController: NSViewController { onComplete() } + @objc private func testTapped() { + guard let action = buildActionFromUI() else { + NSSound.beep() + return + } + // A profile switch would change which profile Save writes into while the editor + // is still open, and a Hypershift action has nothing to run on its own. + switch action { + case .profileSwitch, .hypershift: + NSSound.beep() + case .keySequence, .textSnippet, .macro: + // Keystrokes go to the frontmost app, which is us while this sheet is open: + // they'd land in the capture box and rewrite the mapping being edited. Hand + // focus to the previous app, send, then come back. + isTesting = true + NSApp.hide(nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + ButtonMapper.shared.test(action: action) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self?.isTesting = false } + } + } + default: + ButtonMapper.shared.test(action: action) + } + } + @objc private func saveTapped() { // Finalise the currently visible layer's UI state into temp storage let finalAction = buildActionFromUI() @@ -704,7 +744,7 @@ final class ActionEditorViewController: NSViewController { private func set(mod: NSButton, from on: Bool) { mod.state = on ? .on : .off } private func capture(event: NSEvent) { - if isLearningHardware || isCoolingDown { return } + if isLearningHardware || isCoolingDown || isTesting { return } if event.type == .flagsChanged { applyModifiers(from: event.modifierFlags) updateKeyFieldDisplay() 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..897623d 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,16 @@ 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 dockIconToggle = NSButton(checkboxWithTitle: "Show icon in Dock", 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 +53,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 +116,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 +158,33 @@ 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 + + dockIconToggle.target = self + dockIconToggle.action = #selector(dockIconChanged(_:)) + dockIconToggle.state = UserDefaults.standard.bool(forKey: AppDelegate.showDockIconKey) ? .on : .off + dockIconToggle.font = .systemFont(ofSize: 13, weight: .medium) + dockIconToggle.contentTintColor = .white + dockIconToggle.toolTip = "The app lives in the menu bar. Turn this on if the menu bar icon is hidden on your Mac, or you prefer a Dock icon." + + let dockIconContainer = NSStackView(views: [dockIconToggle]) + dockIconContainer.alignment = .centerX + dockIconContainer.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + dockIconContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true + actionsStack.addArrangedSubview(toggleContainer) + actionsStack.addArrangedSubview(launchAtLoginContainer) + actionsStack.addArrangedSubview(dockIconContainer) actionsStack.addArrangedSubview(configureButton) actionsStack.addArrangedSubview(quitButton) @@ -194,6 +234,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 +249,41 @@ 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 dockIconChanged(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: AppDelegate.showDockIconKey) + AppDelegate.applyDockIconPreference() + // Switching to .regular can leave the app inactive; bring it forward so the window + // and the new Dock icon are both visible immediately. + NSApp.activate(ignoringOtherApps: true) + } + + @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 +309,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 +402,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")) + } +} diff --git a/USER-GUIDE.md b/USER-GUIDE.md index 74f0637..024668d 100644 --- a/USER-GUIDE.md +++ b/USER-GUIDE.md @@ -10,6 +10,8 @@ Saving a mapping does not turn remapping on. If a button still types a number, check the switch in the menu bar first. If access was just granted, restart the app. +The app lives in the menu bar and has no Dock icon by default. Its window opens on the first launch, and opening NagaController again from Applications or Spotlight while it's running always brings the window back, so a hidden menu bar icon never locks you out. If you'd rather have a Dock icon, tick **Show icon in Dock** in the window. **Launch at login** is next to it. + ## Before you start: reset the mouse's on-board profile The Naga stores its own button assignments in the mouse. If you ever remapped buttons in Razer Synapse on Windows, those assignments stay active over Bluetooth on the Mac and NagaController will not see the buttons it expects (a side button may type `w`, or act as a middle click). If some buttons do nothing in NagaController, connect the mouse to a Windows machine, open Synapse, reset the on-board profile to defaults, then reconnect it to the Mac. NagaController cannot change the on-board profile itself; Razer only exposes that protocol over USB, never over Bluetooth. @@ -20,6 +22,8 @@ Select a profile, click **Configure** on a button card, and choose the **Key** t The **Label / Description** field is only a label. Naming an action "Paste" does not make it paste: the shortcut must be ⌘V. +**Test** runs the action once without saving or pressing the mouse button. For keystroke and text actions the app briefly steps aside so the keys go to the app you were using before, then comes back. + The other tabs are **App** (open an application), **Cmd** (run a shell command), **Text** (type a snippet), **Profile** (switch profiles, see below), **macOS** (media keys, volume, brightness, Mission Control, Show Desktop) and **Hypershift** (see below). The trash icon on a card clears that button's mapping.