381 lines
12 KiB
Swift
381 lines
12 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import ServiceManagement
|
|
|
|
final class StatusBarController: NSObject, NSMenuDelegate {
|
|
private let inputController: MacOSInputController
|
|
private let lifecycle: ApplicationLifecycleController
|
|
private let statusItem: NSStatusItem
|
|
private let menu = NSMenu()
|
|
|
|
private let currentLayoutItem = NSMenuItem(
|
|
title: "",
|
|
action: nil,
|
|
keyEquivalent: ""
|
|
)
|
|
private let diagnosticsItem = NSMenuItem(
|
|
title: "",
|
|
action: nil,
|
|
keyEquivalent: ""
|
|
)
|
|
private let errorItem = NSMenuItem(
|
|
title: "",
|
|
action: nil,
|
|
keyEquivalent: ""
|
|
)
|
|
private let tapStatusItem = NSMenuItem(
|
|
title: "",
|
|
action: nil,
|
|
keyEquivalent: ""
|
|
)
|
|
private let hidMappingItem = NSMenuItem(
|
|
title: "",
|
|
action: nil,
|
|
keyEquivalent: ""
|
|
)
|
|
private let reapplyMappingItem = NSMenuItem(
|
|
title: "Retry Caps → F19 mapping",
|
|
action: #selector(reapplyMapping),
|
|
keyEquivalent: ""
|
|
)
|
|
private let permissionItem = NSMenuItem(
|
|
title: "",
|
|
action: #selector(openAccessibilitySettings),
|
|
keyEquivalent: ""
|
|
)
|
|
private let inputMonitoringItem = NSMenuItem(
|
|
title: "",
|
|
action: #selector(openInputMonitoringSettings),
|
|
keyEquivalent: ""
|
|
)
|
|
private let systemSwitchItem = NSMenuItem(
|
|
title: "",
|
|
action: #selector(openKeyboardSettings),
|
|
keyEquivalent: ""
|
|
)
|
|
private let pauseItem = NSMenuItem(
|
|
title: "",
|
|
action: #selector(togglePaused),
|
|
keyEquivalent: ""
|
|
)
|
|
private let installItem = NSMenuItem(
|
|
title: "Install to ~/Applications…",
|
|
action: #selector(installApplication),
|
|
keyEquivalent: ""
|
|
)
|
|
private let launchAtLoginItem = NSMenuItem(
|
|
title: "Launch at Login",
|
|
action: #selector(toggleLaunchAtLogin),
|
|
keyEquivalent: ""
|
|
)
|
|
private let loginSettingsItem = NSMenuItem(
|
|
title: "Open Login Items Settings…",
|
|
action: #selector(openLoginItemsSettings),
|
|
keyEquivalent: ""
|
|
)
|
|
private let restartItem = NSMenuItem(
|
|
title: "Restart Capswitch",
|
|
action: #selector(restartApplication),
|
|
keyEquivalent: ""
|
|
)
|
|
|
|
private var refreshTimer: Timer?
|
|
private var nextStartAttempt = Date.distantPast
|
|
private var lastDeepRefresh = Date.distantPast
|
|
|
|
init(inputController: MacOSInputController = MacOSInputController()) {
|
|
self.inputController = inputController
|
|
self.lifecycle = ApplicationLifecycleController(inputController: inputController)
|
|
self.statusItem = NSStatusBar.system.statusItem(
|
|
withLength: NSStatusItem.variableLength
|
|
)
|
|
super.init()
|
|
|
|
configureMenu()
|
|
inputController.onStateChanged = { [weak self] in
|
|
self?.update()
|
|
}
|
|
|
|
refreshTimer = Timer.scheduledTimer(
|
|
withTimeInterval: 1,
|
|
repeats: true
|
|
) { [weak self] _ in
|
|
self?.refreshController()
|
|
}
|
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(
|
|
self,
|
|
selector: #selector(didWake),
|
|
name: NSWorkspace.didWakeNotification,
|
|
object: nil
|
|
)
|
|
|
|
inputController.requestRequiredPermissions()
|
|
refreshController()
|
|
}
|
|
|
|
deinit {
|
|
refreshTimer?.invalidate()
|
|
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
|
inputController.stop()
|
|
statusItem.menu = nil
|
|
NSStatusBar.system.removeStatusItem(statusItem)
|
|
}
|
|
|
|
private func configureMenu() {
|
|
menu.delegate = self
|
|
menu.addItem(currentLayoutItem)
|
|
menu.addItem(diagnosticsItem)
|
|
menu.addItem(tapStatusItem)
|
|
menu.addItem(hidMappingItem)
|
|
menu.addItem(errorItem)
|
|
menu.addItem(reapplyMappingItem)
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(pauseItem)
|
|
menu.addItem(permissionItem)
|
|
menu.addItem(inputMonitoringItem)
|
|
menu.addItem(systemSwitchItem)
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(installItem)
|
|
menu.addItem(launchAtLoginItem)
|
|
menu.addItem(loginSettingsItem)
|
|
menu.addItem(restartItem)
|
|
menu.addItem(NSMenuItem.separator())
|
|
|
|
let quitItem = NSMenuItem(
|
|
title: "Quit Capswitch",
|
|
action: #selector(quit),
|
|
keyEquivalent: "q"
|
|
)
|
|
menu.addItem(quitItem)
|
|
|
|
for item in [
|
|
pauseItem,
|
|
permissionItem,
|
|
inputMonitoringItem,
|
|
systemSwitchItem,
|
|
reapplyMappingItem,
|
|
installItem,
|
|
launchAtLoginItem,
|
|
loginSettingsItem,
|
|
restartItem,
|
|
quitItem,
|
|
] {
|
|
item.target = self
|
|
}
|
|
|
|
statusItem.menu = menu
|
|
statusItem.button?.toolTip = "Capswitch"
|
|
}
|
|
|
|
private func refreshController() {
|
|
let now = Date()
|
|
|
|
if !inputController.accessibilityTrusted,
|
|
inputController.isTapEnabled {
|
|
inputController.stop()
|
|
}
|
|
|
|
inputController.repairEventTapIfNeeded()
|
|
|
|
if inputController.accessibilityTrusted,
|
|
!inputController.isPaused,
|
|
!inputController.isRunning,
|
|
now >= nextStartAttempt {
|
|
nextStartAttempt = now.addingTimeInterval(5)
|
|
|
|
if inputController.isTapEnabled {
|
|
inputController.repairMappingIfNeeded()
|
|
} else {
|
|
do {
|
|
try inputController.start()
|
|
} catch {
|
|
inputController.lastError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
if now.timeIntervalSince(lastDeepRefresh) >= 10 {
|
|
lastDeepRefresh = now
|
|
inputController.refreshInputSources()
|
|
if inputController.isRunning {
|
|
inputController.repairMappingIfNeeded()
|
|
}
|
|
}
|
|
|
|
update()
|
|
}
|
|
|
|
private func update() {
|
|
let name = inputController.currentInputSourceName
|
|
statusItem.button?.title = inputController.isRunning
|
|
? inputController.currentInputSourceShortName
|
|
: "Caps!"
|
|
statusItem.button?.toolTip = "Capswitch — \(name)"
|
|
|
|
currentLayoutItem.title = "Current layout: \(name)"
|
|
diagnosticsItem.title = inputController.diagnosticsSummary
|
|
diagnosticsItem.isEnabled = false
|
|
|
|
tapStatusItem.title = "Event tap: \(inputController.isTapEnabled ? "Enabled" : "Inactive")"
|
|
tapStatusItem.isEnabled = false
|
|
|
|
hidMappingItem.title = inputController.hidMappingConfigured
|
|
? "Native HID: \(inputController.mappingStatus)"
|
|
: "Native HID inactive: \(inputController.mappingStatus)"
|
|
hidMappingItem.isEnabled = false
|
|
reapplyMappingItem.isEnabled = inputController.isTapEnabled
|
|
|
|
errorItem.isHidden = inputController.lastError == nil
|
|
errorItem.title = "Error: \(inputController.lastError ?? "")"
|
|
errorItem.toolTip = inputController.lastError
|
|
errorItem.isEnabled = false
|
|
|
|
pauseItem.title = inputController.isPaused
|
|
? "Resume switching (restore F19)"
|
|
: "Pause switching (restore Caps)"
|
|
pauseItem.state = inputController.isPaused ? .on : .off
|
|
|
|
permissionItem.title = inputController.accessibilityTrusted
|
|
? "Accessibility: Granted"
|
|
: "Grant Accessibility permission…"
|
|
|
|
inputMonitoringItem.title = inputController.inputMonitoringTrusted
|
|
? "Input Monitoring: Granted"
|
|
: "Input Monitoring: Not granted (may be needed)…"
|
|
|
|
systemSwitchItem.title =
|
|
"System Caps input switch: \(inputController.nativeCapsSwitchState)"
|
|
systemSwitchItem.toolTip =
|
|
"Keyboard → Text Input → Edit → disable 'Use Caps Lock to switch input sources'."
|
|
|
|
installItem.title = lifecycle.isInstalled
|
|
? "Installed in ~/Applications"
|
|
: "Install to ~/Applications…"
|
|
installItem.isEnabled = !lifecycle.isInstalled
|
|
|
|
launchAtLoginItem.title = lifecycle.loginItemTitle
|
|
launchAtLoginItem.isEnabled = lifecycle.isInstalled
|
|
switch lifecycle.loginItemStatus {
|
|
case .enabled:
|
|
launchAtLoginItem.state = .on
|
|
case .requiresApproval:
|
|
launchAtLoginItem.state = .mixed
|
|
default:
|
|
launchAtLoginItem.state = .off
|
|
}
|
|
loginSettingsItem.isHidden = lifecycle.loginItemStatus != .requiresApproval
|
|
|
|
for item in [
|
|
currentLayoutItem,
|
|
permissionItem,
|
|
inputMonitoringItem,
|
|
systemSwitchItem,
|
|
pauseItem,
|
|
] {
|
|
item.target = self
|
|
}
|
|
}
|
|
|
|
func menuWillOpen(_ menu: NSMenu) {
|
|
refreshController()
|
|
}
|
|
|
|
@objc private func didWake(_ notification: Notification) {
|
|
inputController.repairMappingIfNeeded()
|
|
refreshController()
|
|
}
|
|
|
|
@objc private func togglePaused() {
|
|
inputController.setPaused(!inputController.isPaused)
|
|
nextStartAttempt = .distantPast
|
|
update()
|
|
}
|
|
|
|
@objc private func openAccessibilitySettings() {
|
|
inputController.openAccessibilitySettings()
|
|
}
|
|
|
|
@objc private func openInputMonitoringSettings() {
|
|
inputController.requestInputMonitoringPermission()
|
|
}
|
|
|
|
@objc private func openKeyboardSettings() {
|
|
inputController.openKeyboardSettings()
|
|
}
|
|
|
|
@objc private func reapplyMapping() {
|
|
inputController.reapplyHIDMapping()
|
|
}
|
|
|
|
@objc private func installApplication() {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Install Capswitch?"
|
|
alert.informativeText =
|
|
"Copy this version to ~/Applications/Capswitch.app and launch it from there. " +
|
|
"No administrator privileges are required."
|
|
alert.addButton(withTitle: "Install and Launch")
|
|
alert.addButton(withTitle: "Cancel")
|
|
guard alert.runModal() == .alertFirstButtonReturn else {
|
|
return
|
|
}
|
|
|
|
let installedURL: URL
|
|
do {
|
|
installedURL = try lifecycle.install()
|
|
} catch {
|
|
showError("Installation failed", message: error.localizedDescription)
|
|
return
|
|
}
|
|
|
|
do {
|
|
try lifecycle.restartInstalledCopy(at: installedURL)
|
|
NSApp.terminate(nil)
|
|
} catch {
|
|
showError(
|
|
"Installed, but could not relaunch",
|
|
message: "Capswitch was installed to ~/Applications. " +
|
|
error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc private func restartApplication() {
|
|
do {
|
|
try lifecycle.restartCurrentCopy()
|
|
NSApp.terminate(nil)
|
|
} catch {
|
|
showError("Restart failed", message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
@objc private func toggleLaunchAtLogin() {
|
|
do {
|
|
try lifecycle.toggleLaunchAtLogin()
|
|
} catch {
|
|
showError("Launch at Login", message: error.localizedDescription)
|
|
}
|
|
update()
|
|
}
|
|
|
|
@objc private func openLoginItemsSettings() {
|
|
SMAppService.openSystemSettingsLoginItems()
|
|
}
|
|
|
|
private func showError(_ title: String, message: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
|
|
@objc private func quit() {
|
|
do {
|
|
try lifecycle.stopForQuit()
|
|
NSApp.terminate(nil)
|
|
} catch {
|
|
showError("Caps Lock could not be restored", message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|