diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e4a6d9..f5e93d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Test run: swift test + - name: Type-check the README tool + run: swiftc -parse-as-library -typecheck Tools/make-readme-assets.swift .build/checkouts/menuhub/Sources/MenuHub/*.swift + - name: Build universal binary run: swift build -c release --arch arm64 --arch x86_64 diff --git a/Package.resolved b/Package.resolved index 586e002..42dafa1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "9411c717398a2f046654d98d3f89998f39b22fe789375a7caa1874ef6d41d1df", + "originHash" : "6cbbafad3188ca73d4323b67ec257ff43a4176cd441acad2cc7a5e922d335b04", "pins" : [ + { + "identity" : "menuhub", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mstallone/menuhub", + "state" : { + "revision" : "62930793d5bfb53e8173aca342d8017fb1f0175f", + "version" : "0.1.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d68189b..c2f3bd9 100644 --- a/Package.swift +++ b/Package.swift @@ -6,11 +6,16 @@ let package = Package( platforms: [.macOS(.v26)], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.4"), + .package(url: "https://github.com/mstallone/menuhub", exact: "0.1.0"), ], targets: [ .executableTarget( name: "MXSwipe", - dependencies: ["SystemEvents", .product(name: "Sparkle", package: "Sparkle")], + dependencies: [ + "SystemEvents", + .product(name: "Sparkle", package: "Sparkle"), + .product(name: "MenuHub", package: "menuhub"), + ], path: "Sources/MXSwipe", swiftSettings: [.swiftLanguageMode(.v6)], linkerSettings: [ diff --git a/README.md b/README.md index be188e7..d40a4e0 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ and the desktop jumps. - A tap on the panel opens Mission Control. - The horizontal thumb wheel sends continuous, pixel-precise scroll events with the phases a two-finger trackpad scroll has, following your natural scrolling setting. Each app decides what - they do, so documents scroll sideways and Safari can swipe between pages. The main wheel is + the events do, so documents scroll sideways and Safari can swipe between pages. The main wheel is unchanged, and there is no momentum after you let go. - The menu-bar icon is bright while gestures work and faded while they're off or the mouse isn't - connected. The menu shows the mouse and its battery. + connected. The menu shows the mouse and its battery. With [RetinaShot](https://github.com/mstallone/retinashot) + also running, the two share one icon and menu through [MenuHub](https://github.com/mstallone/menuhub). | Sense Panel | Action | |---|---| @@ -83,8 +84,7 @@ MX Master 4 without changing it, and is skipped unless asked for: MXMASTER_RUN_HARDWARE_PROBE=1 swift test --filter SecureInputHardwareProbeTests - Sources/MXSwipe/main.swift app lifecycle, menu, permission, reconnecting - Sources/MXSwipe/MenuHeader.swift the menu's first row: the mouse and its battery or status + Sources/MXSwipe/main.swift app lifecycle, menu section, permission, reconnecting Sources/MXSwipe/Session.swift HID++ session: discovery, configuration, wake recovery Sources/MXSwipe/HIDPP.swift HID++ reports, feature IDs, battery readings Sources/MXSwipe/HIDDevice.swift IOKit access to the receiver's HID++ interface @@ -94,7 +94,7 @@ MX Master 4 without changing it, and is skipped unless asked for: Sources/SystemEvents/ the macOS 27 HIDEvent and the Accessibility key press Resources/Info.plist bundle template; the version is stamped at build time Tools/make-icon.swift renders AppIcon.icns - Tools/make-readme-assets.swift renders the menu images in this README + Tools/make-readme-assets.swift renders the menu images in this README, laid out by MenuHub Scripts/build-app.sh stages MXSwipe.app from a built binary Scripts/sign.sh signs the app and Sparkle inside out Scripts/build-release.sh CI: universal build, Developer ID, notarize, staple, zip @@ -107,7 +107,7 @@ The tag is the version. Pushing `vMAJOR.MINOR.PATCH` runs the Release workflow: Developer ID signature with hardened runtime and timestamp, notarization, stapling, Gatekeeper check, then a GitHub Release with the zip, its SHA-256, and a signed Sparkle `appcast.xml`. - git tag v0.2.1 && git push origin v0.2.1 + git tag v0.3.0 && git push origin v0.3.0 | Secret | Contents | |---|---| diff --git a/Sources/MXSwipe/MenuHeader.swift b/Sources/MXSwipe/MenuHeader.swift deleted file mode 100644 index 314b404..0000000 --- a/Sources/MXSwipe/MenuHeader.swift +++ /dev/null @@ -1,91 +0,0 @@ -import AppKit - -/// The menu's first row: the mouse's name, with its battery or the app's status. A view rather than a -/// disabled item, so it reads as a heading in full-strength text and never highlights. -final class MenuHeaderView: NSView { - enum Detail { - case battery(Int) - /// A word or two, on the right like the battery. - case status(String) - /// A sentence, under the title. - case message(String) - } - - /// Where AppKit starts item titles, and ends key equivalents, in a menu with a checkmark column. - private static let titleInset: CGFloat = 30 - private static let trailingInset: CGFloat = 16 - - init(title: String, detail: Detail?) { - super.init(frame: .zero) - let size = NSFont.menuFont(ofSize: 0).pointSize - let titleLabel = label(title, font: .systemFont(ofSize: size, weight: .semibold), color: .labelColor) - titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.titleInset).isActive = true - titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 3).isActive = true - var bottom = titleLabel.bottomAnchor - var accessibility = title - - switch detail { - case let .battery(percent): - let text = label("\(percent)%", font: .systemFont(ofSize: size), color: .secondaryLabelColor) - let glyph = NSImageView(image: Self.batteryImage(percent, pointSize: size)) - glyph.contentTintColor = percent <= 10 ? .systemRed : .secondaryLabelColor - glyph.translatesAutoresizingMaskIntoConstraints = false - addSubview(glyph) - NSLayoutConstraint.activate([ - glyph.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.trailingInset), - glyph.centerYAnchor.constraint(equalTo: text.centerYAnchor), - text.trailingAnchor.constraint(equalTo: glyph.leadingAnchor, constant: -5), - text.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), - text.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 16), - ]) - accessibility += ", battery \(percent)%" - case let .status(status): - let text = label(status, font: .systemFont(ofSize: size), color: .secondaryLabelColor) - NSLayoutConstraint.activate([ - text.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.trailingInset), - text.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), - text.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 16), - ]) - accessibility += ", \(status)" - case let .message(message): - let text = label(message, font: .systemFont(ofSize: NSFont.smallSystemFontSize), color: .secondaryLabelColor) - text.preferredMaxLayoutWidth = 170 - NSLayoutConstraint.activate([ - text.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), - text.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Self.trailingInset), - text.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), - ]) - bottom = text.bottomAnchor - accessibility += ", \(message)" - case nil: - titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Self.trailingInset).isActive = true - } - bottom.constraint(equalTo: bottomAnchor, constant: -4).isActive = true - - // The menu widens the row to its own width; this is only the minimum it asks for. - frame.size = fittingSize - autoresizingMask = .width - setAccessibilityElement(true) - setAccessibilityRole(.staticText) - setAccessibilityLabel(accessibility) - } - - required init?(coder: NSCoder) { nil } - - private func label(_ text: String, font: NSFont, color: NSColor) -> NSTextField { - let label = NSTextField(wrappingLabelWithString: text) - label.font = font - label.textColor = color - label.isSelectable = false - label.translatesAutoresizingMaskIntoConstraints = false - addSubview(label) - return label - } - - /// The system's battery glyphs come in quarters; round to the nearest. - private static func batteryImage(_ percent: Int, pointSize: CGFloat) -> NSImage { - let quarter = min(4, (percent + 12) / 25) * 25 - return NSImage(systemSymbolName: "battery.\(quarter)percent", accessibilityDescription: nil)! - .withSymbolConfiguration(.init(pointSize: pointSize, weight: .regular))! - } -} diff --git a/Sources/MXSwipe/main.swift b/Sources/MXSwipe/main.swift index b0ab9b4..b8b9ef4 100644 --- a/Sources/MXSwipe/main.swift +++ b/Sources/MXSwipe/main.swift @@ -1,4 +1,5 @@ import AppKit +import MenuHub import OSLog import ServiceManagement import Sparkle @@ -6,7 +7,7 @@ import Sparkle let appName = "MXSwipe" @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { enum Status { case off case needsAccessibility @@ -17,12 +18,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private static let logger = Logger(subsystem: "com.mattstallone.mxmasterinput", category: "App") - private static let activeIcon = icon(opacity: 1, description: appName) - private static let inactiveIcon = icon(opacity: 0.4, description: "\(appName), inactive") private let session = MXMasterSession() private let updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) - private let menu = NSMenu() - private var statusItem: NSStatusItem? + private var hub: MenuHub? private var connection: Task? private var retry: Task? private var retryDelay: Duration = .seconds(2) @@ -32,7 +30,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var mouseName: String? private var status = Status.off { - didSet { statusItem?.button?.image = status.isActive ? Self.activeIcon : Self.inactiveIcon } + didSet { hub?.update() } } /// Stored under the key 0.1.x used, so an upgrade keeps the user's choice. @@ -43,11 +41,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: ["autoEnable": true]) - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) - item.button?.image = Self.inactiveIcon - menu.delegate = self - item.menu = menu - statusItem = item + hub = MenuHub(icon: NSImage(systemSymbolName: "computermouse", accessibilityDescription: nil)!) { self.section } NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemDidWake), name: NSWorkspace.didWakeNotification, object: nil) if isEnabled { connect() } @@ -57,72 +51,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { session.stopSynchronously() } - // MARK: Menu, rebuilt on open so every state it shows is current + // MARK: Menu - func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - let header = NSMenuItem() - header.view = headerView - menu.addItem(header) + /// This app's part of the menu, read again whenever it may be shown. + private var section: MenuSection { + var items: [MenuItem] = [] switch status { case .needsAccessibility: - menu.addItem(withTitle: "Open Accessibility Settings…", action: #selector(openAccessibilitySettings), keyEquivalent: "") - let reset = menu.addItem(withTitle: "Reset Accessibility Permission", action: #selector(resetAccessibility), keyEquivalent: "") - reset.isAlternate = true - reset.keyEquivalentModifierMask = .option + items += [.action("Open Accessibility Settings…") { self.openAccessibilitySettings() }, + .alternate("Reset Accessibility Permission") { self.resetAccessibility() }] case .failed(_, retrying: false): - menu.addItem(withTitle: "Try Again", action: #selector(tryAgain), keyEquivalent: "") + items.append(.action("Try Again") { self.connect() }) default: break } - let toggle = isEnabled ? "Turn Gestures Off" : "Turn Gestures On" - menu.addItem(withTitle: toggle, action: #selector(toggleEnabled), keyEquivalent: "") - menu.addItem(.separator()) - - let login = menu.addItem(withTitle: "Open at Login", action: #selector(toggleLogin), keyEquivalent: "") - login.state = SMAppService.mainApp.status == .enabled ? .on : .off - let update = menu.addItem(withTitle: "Check for Updates…", - action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "") - update.target = updater - menu.addItem(.separator()) - - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - menu.addItem(withTitle: "\(appName) \(version)", action: nil, keyEquivalent: "") - menu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate), keyEquivalent: "q") + items += [ + .action(isEnabled ? "Turn Gestures Off" : "Turn Gestures On") { self.toggleEnabled() }, + .separator, + .action("Open at Login", isOn: SMAppService.mainApp.status == .enabled) { self.toggleLogin() }, + .action("Check for Updates…", isEnabled: updater.updater.canCheckForUpdates) { self.updater.checkForUpdates(nil) }, + ] + return MenuSection(header: header, items: items, isActive: status.isActive) } /// The mouse, and its battery or what's keeping gestures from working. - private var headerView: MenuHeaderView { + private var header: MenuHeader { switch status { case let .connected(name, battery, asleep): - MenuHeaderView(title: name, detail: asleep ? .status("Asleep") : battery.map { .battery($0) }) - case .off: MenuHeaderView(title: "MX Master 4", detail: .status("Gestures Off")) - case .connecting: MenuHeaderView(title: "MX Master 4", detail: .status("Connecting…")) - case .needsAccessibility: MenuHeaderView(title: "MX Master 4", detail: .message("\(appName) needs Accessibility permission.")) - case let .failed(message, _): MenuHeaderView(title: "MX Master 4", detail: .message(message)) + MenuHeader(title: name, detail: asleep ? .status("Asleep") : battery.map { .battery($0) }) + case .off: MenuHeader(title: "MX Master 4", detail: .status("Gestures Off")) + case .connecting: MenuHeader(title: "MX Master 4", detail: .status("Connecting…")) + case .needsAccessibility: MenuHeader(title: "MX Master 4", detail: .message("\(appName) needs Accessibility permission.")) + case let .failed(message, _): MenuHeader(title: "MX Master 4", detail: .message(message)) } } - @objc private func toggleEnabled() { + private func toggleEnabled() { isEnabled.toggle() - Self.logger.notice("Gestures turned \(self.isEnabled ? "on" : "off")") + Self.logger.notice("Gestures turned \(self.isEnabled ? "on" : "off", privacy: .public)") if isEnabled { connect() } else { disconnect() } } - @objc private func tryAgain() { connect() } - - @objc private func toggleLogin() { + private func toggleLogin() { do { try SMAppService.mainApp.status == .enabled ? SMAppService.mainApp.unregister() : SMAppService.mainApp.register() } catch { NSApp.presentError(error) } } - @objc private func openAccessibilitySettings() { + private func openAccessibilitySettings() { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) } /// Clears a stale entry, which is what macOS keeps when the app's signature changes: the switch /// shows as on, but the app is not trusted. - @objc private func resetAccessibility() { + private func resetAccessibility() { let tccutil = Process() tccutil.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") tccutil.arguments = ["reset", "Accessibility", Bundle.main.bundleIdentifier ?? ""] @@ -223,22 +204,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } -extension AppDelegate { - /// The menu-bar glyph, at full strength while gestures work and faded otherwise. Faded by drawing at - /// partial opacity rather than with `appearsDisabled`, so the level is the same on every menu bar; - /// it stays a template, so the menu bar still tints it. - private static func icon(opacity: CGFloat, description: String) -> NSImage { - let symbol = NSImage(systemSymbolName: "computermouse", accessibilityDescription: nil)! - let image = NSImage(size: symbol.size, flipped: false) { rect in - symbol.draw(in: rect, from: .zero, operation: .sourceOver, fraction: opacity) - return true - } - image.isTemplate = true - image.accessibilityDescription = description - return image - } -} - private extension AppDelegate.Status { /// Connected, including while the mouse sleeps: it wakes ready, and fading at every idle pause would /// make the icon flicker. diff --git a/Tools/make-readme-assets.swift b/Tools/make-readme-assets.swift index 83337f2..b0fea4b 100644 --- a/Tools/make-readme-assets.swift +++ b/Tools/make-readme-assets.swift @@ -1,6 +1,8 @@ // Renders the README's menu images, light and dark, from synthetic content: a plain backdrop and menu -// bar drawn here, and the app's real menu header, so nothing from the machine that runs it appears. -// swiftc -O -parse-as-library Tools/make-readme-assets.swift Sources/MXSwipe/MenuHeader.swift -o .build/make-readme-assets +// bar drawn here, and the menu laid out by MenuHub's own code, so nothing from the machine that runs it +// appears. Needs MenuHub checked out (swift package resolve): +// swiftc -O -parse-as-library Tools/make-readme-assets.swift .build/checkouts/menuhub/Sources/MenuHub/*.swift \ +// -o .build/make-readme-assets // .build/make-readme-assets assets import AppKit @@ -14,27 +16,15 @@ let createImage = unsafeBitCast(dlsym(UnsafeMutableRawPointer(bitPattern: -2), " let size = NSSize(width: 440, height: 214) let barHeight: CGFloat = 24 -final class Target: NSObject { @objc func noop() {} } -let target = Target() - -/// The menu as the app shows it with the mouse connected. +/// The menu as MXSwipe shows it on its own, with the mouse connected. @MainActor func makeMenu() -> NSMenu { + let mxswipe = Member(pid: 1, name: "MXSwipe", launched: Date(), revision: 0, isActive: true, + header: MenuHeader(title: "MX Master 4", detail: .battery(83)), items: [ + .action("Turn Gestures Off") {}, .separator, + .action("Open at Login", isOn: true) {}, .action("Check for Updates…") {}, + ]) let menu = NSMenu() - func add(_ title: String, key: String = "") -> NSMenuItem { - let item = menu.addItem(withTitle: title, action: #selector(Target.noop), keyEquivalent: key) - item.target = target - return item - } - let header = NSMenuItem() - header.view = MenuHeaderView(title: "MX Master 4", detail: .battery(83)) - menu.addItem(header) - _ = add("Turn Gestures Off") - menu.addItem(.separator()) - add("Open at Login").state = .on - _ = add("Check for Updates…") - menu.addItem(.separator()) - menu.addItem(withTitle: "MXSwipe 0.2.1", action: nil, keyEquivalent: "") - _ = add("Quit MXSwipe", key: "q") + MenuHub.populate(menu, with: [mxswipe], version: "0.3.0", target: nil) return menu } diff --git a/assets/menu-dark.png b/assets/menu-dark.png index 17b2e06..d9b2dbd 100644 Binary files a/assets/menu-dark.png and b/assets/menu-dark.png differ diff --git a/assets/menu-light.png b/assets/menu-light.png index 88e5aa6..138d8d4 100644 Binary files a/assets/menu-light.png and b/assets/menu-light.png differ