Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
|---|---|
Expand Down
91 changes: 0 additions & 91 deletions Sources/MXSwipe/MenuHeader.swift

This file was deleted.

95 changes: 30 additions & 65 deletions Sources/MXSwipe/main.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import AppKit
import MenuHub
import OSLog
import ServiceManagement
import Sparkle

let appName = "MXSwipe"

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate {
enum Status {
case off
case needsAccessibility
Expand All @@ -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<Void, Never>?
private var retry: Task<Void, Never>?
private var retryDelay: Duration = .seconds(2)
Expand All @@ -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.
Expand All @@ -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() }
Expand All @@ -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 ?? ""]
Expand Down Expand Up @@ -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.
Expand Down
Loading