diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index f76893b..e15423b 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ @@ -38,6 +38,9 @@ C48D0C9625CC80B100CC7490 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48D0C9525CC80B100CC7490 /* HeaderView.swift */; }; C48D0C9A25CC888B00CC7490 /* HeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C48D0C9925CC888B00CC7490 /* HeaderView.xib */; }; C48D0CA325CC992000CC7490 /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48D0CA225CC992000CC7490 /* StatsView.swift */; }; + C4998F0626175E7200B2526E /* HotKey in Frameworks */ = {isa = PBXBuildFile; productRef = C4998F0526175E7200B2526E /* HotKey */; }; + C4998F0A2617633900B2526E /* PrefsWC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4998F092617633900B2526E /* PrefsWC.swift */; }; + C4998F0B2617633900B2526E /* PrefsWC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4998F092617633900B2526E /* PrefsWC.swift */; }; C49EAB46259FC305007F6C3B /* Paths.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAB45259FC305007F6C3B /* Paths.swift */; }; C4ACA38F25C754C100060C66 /* PhpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4ACA38E25C754C100060C66 /* PhpExtension.swift */; }; C4D8016622B1584700C6DA1B /* Startup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D8016522B1584700C6DA1B /* Startup.swift */; }; @@ -113,6 +116,7 @@ C48D0C9525CC80B100CC7490 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; C48D0C9925CC888B00CC7490 /* HeaderView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = HeaderView.xib; sourceTree = ""; }; C48D0CA225CC992000CC7490 /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = ""; }; + C4998F092617633900B2526E /* PrefsWC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsWC.swift; sourceTree = ""; }; C49EAB45259FC305007F6C3B /* Paths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paths.swift; sourceTree = ""; }; C4ACA38E25C754C100060C66 /* PhpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpExtension.swift; sourceTree = ""; }; C4D8016522B1584700C6DA1B /* Startup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Startup.swift; sourceTree = ""; }; @@ -135,6 +139,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C4998F0626175E7200B2526E /* HotKey in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -151,6 +156,7 @@ 5420395726135DB800FB00FA /* Preferences */ = { isa = PBXGroup; children = ( + C4998F092617633900B2526E /* PrefsWC.swift */, 5420395826135DC100FB00FA /* PrefsVC.swift */, 5420395E2613607600FB00FA /* Preferences.swift */, ); @@ -305,6 +311,9 @@ dependencies = ( ); name = "PHP Monitor"; + packageProductDependencies = ( + C4998F0526175E7200B2526E /* HotKey */, + ); productName = phpmon; productReference = C41C1B3322B0097F00E7CF16 /* PHP Monitor.app */; productType = "com.apple.product-type.application"; @@ -355,6 +364,9 @@ Base, ); mainGroup = C41C1B2A22B0097F00E7CF16; + packageReferences = ( + C4998F0426175E7200B2526E /* XCRemoteSwiftPackageReference "HotKey" */, + ); productRefGroup = C41C1B3422B0097F00E7CF16 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -399,6 +411,7 @@ files = ( C4ACA38F25C754C100060C66 /* PhpExtension.swift in Sources */, C4D8016622B1584700C6DA1B /* Startup.swift in Sources */, + C4998F0A2617633900B2526E /* PrefsWC.swift in Sources */, C4F8C0A422D4F12C002EFE61 /* DateExtension.swift in Sources */, 5420395926135DC100FB00FA /* PrefsVC.swift in Sources */, C41C1B4722B009A400E7CF16 /* Shell.swift in Sources */, @@ -442,6 +455,7 @@ C481F79726164A78004FBCFF /* PrefsVC.swift in Sources */, C4F7809C25D80344000DBC97 /* CommandTest.swift in Sources */, C4F780BA25D80B62000DBC97 /* AppDelegate.swift in Sources */, + C4998F0B2617633900B2526E /* PrefsWC.swift in Sources */, C4F780A225D804AA000DBC97 /* Paths.swift in Sources */, C4F780BD25D80B65000DBC97 /* Constants.swift in Sources */, C4F780C325D80B75000DBC97 /* HeaderView.swift in Sources */, @@ -605,7 +619,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = 8M54J5J787; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = phpmon/Info.plist; @@ -613,7 +627,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 3.3; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -629,7 +643,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = 8M54J5J787; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = phpmon/Info.plist; @@ -637,7 +651,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 3.3; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = com.nicoverbruggen.phpmon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -718,6 +732,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C4998F0426175E7200B2526E /* XCRemoteSwiftPackageReference "HotKey" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/soffes/HotKey"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.1.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C4998F0526175E7200B2526E /* HotKey */ = { + isa = XCSwiftPackageProductDependency; + package = C4998F0426175E7200B2526E /* XCRemoteSwiftPackageReference "HotKey" */; + productName = HotKey; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = C41C1B2B22B0097F00E7CF16 /* Project object */; } diff --git a/phpmon/Domain/Core/App.swift b/phpmon/Domain/Core/App.swift index fcebe1a..97f28a9 100644 --- a/phpmon/Domain/Core/App.swift +++ b/phpmon/Domain/Core/App.swift @@ -4,12 +4,18 @@ // // Copyright © 2021 Nico Verbruggen. All rights reserved. // + import Cocoa +import HotKey class App { static let shared = App() + init() { + loadGlobalHotkey() + } + static var phpInstall: PhpInstallation? { return App.shared.currentInstall } @@ -66,4 +72,42 @@ class App { */ var brewPhpVersion: String = "8.0" + /** + The shortcut the user has requested. + */ + var shortcutHotkey: HotKey? = nil { + didSet { + self.setupGlobalHotkeyListener() + } + } + + // MARK: - Methods + + private func loadGlobalHotkey() { + let hotkey = Preferences.preferences[.globalHotkey] as! String? + if hotkey == nil { + return + } + + let keybindPref = GlobalKeybindPreference.fromJson(hotkey!) + + if (keybindPref != nil) { + self.shortcutHotkey = HotKey(keyCombo: KeyCombo( + carbonKeyCode: keybindPref!.keyCode, + carbonModifiers: keybindPref!.carbonFlags + )) + } else { + self.shortcutHotkey = nil + } + } + + private func setupGlobalHotkeyListener() { + guard let hotKey = self.shortcutHotkey else { + return + } + hotKey.keyDownHandler = { + MainMenu.shared.statusItem.button?.performClick(nil) + } + } + } diff --git a/phpmon/Domain/Core/Base.lproj/Main.storyboard b/phpmon/Domain/Core/Base.lproj/Main.storyboard index 40cc006..26545c4 100644 --- a/phpmon/Domain/Core/Base.lproj/Main.storyboard +++ b/phpmon/Domain/Core/Base.lproj/Main.storyboard @@ -58,7 +58,7 @@ - + @@ -85,7 +85,7 @@ - + - + - + + + + + + + + + + + @@ -120,24 +151,33 @@ + + + + + + + + + - + diff --git a/phpmon/Domain/Menu/MainMenu.swift b/phpmon/Domain/Menu/MainMenu.swift index 1515add..a7ee16d 100644 --- a/phpmon/Domain/Menu/MainMenu.swift +++ b/phpmon/Domain/Menu/MainMenu.swift @@ -169,7 +169,7 @@ class MainMenu: NSObject, NSWindowDelegate { if (App.busy) { setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIcon"))!) } else { - if Preferences.preferences[.shouldDisplayDynamicIcon] == false { + if Preferences.preferences[.shouldDisplayDynamicIcon] as! Bool == false { // Static icon has been requested setStatusBar(image: NSImage(named: NSImage.Name("StatusBarIconStatic"))!) } else { diff --git a/phpmon/Domain/Preferences/Preferences.swift b/phpmon/Domain/Preferences/Preferences.swift index 63d3900..b91306a 100644 --- a/phpmon/Domain/Preferences/Preferences.swift +++ b/phpmon/Domain/Preferences/Preferences.swift @@ -10,6 +10,7 @@ import Foundation enum PreferenceName: String { case shouldDisplayDynamicIcon = "use_dynamic_icon" + case globalHotkey = "global_hotkey" } class Preferences { @@ -28,22 +29,27 @@ class Preferences { print("Saving first-time preferences!") } - static func retrieve() -> [PreferenceName: Bool] { + static func retrieve() -> [PreferenceName: Any] { Preferences.handleFirstTimeLaunch() return [ .shouldDisplayDynamicIcon: UserDefaults.standard.bool( - forKey: PreferenceName.shouldDisplayDynamicIcon.rawValue - ) + forKey: PreferenceName.shouldDisplayDynamicIcon.rawValue) as Any, + .globalHotkey: UserDefaults.standard.string( + forKey: PreferenceName.globalHotkey.rawValue) as Any ] } - static var preferences: [PreferenceName: Bool] { + static var preferences: [PreferenceName: Any?] { return Preferences.retrieve() } - static func update(_ preference: PreferenceName, value: Bool) { - UserDefaults.standard.setValue(value, forKey: preference.rawValue) + static func update(_ preference: PreferenceName, value: Any?) { + if (value == nil) { + UserDefaults.standard.removeObject(forKey: preference.rawValue) + } else { + UserDefaults.standard.setValue(value, forKey: preference.rawValue) + } UserDefaults.standard.synchronize() } diff --git a/phpmon/Domain/Preferences/PrefsVC.swift b/phpmon/Domain/Preferences/PrefsVC.swift index 1ff32bf..2b5e5d9 100644 --- a/phpmon/Domain/Preferences/PrefsVC.swift +++ b/phpmon/Domain/Preferences/PrefsVC.swift @@ -7,6 +7,8 @@ // import Cocoa +import HotKey +import Carbon class PrefsVC: NSViewController { @@ -14,38 +16,212 @@ class PrefsVC: NSViewController { @IBOutlet weak var labelDynamicIcon: NSTextField! @IBOutlet weak var buttonClose: NSButton! + @IBOutlet weak var buttonSetShortcut: NSButton! + @IBOutlet weak var buttonClearShortcut: NSButton! + @IBOutlet weak var labelShortcut: NSTextField! + + // MARK: - Variables + + var listening = false { + didSet { + if listening { + DispatchQueue.main.async { [weak self] in + self?.buttonSetShortcut.highlight(true) + self?.buttonSetShortcut.title = "prefs.shortcut_listening".localized + } + } else { + DispatchQueue.main.async { [weak self] in + self?.buttonSetShortcut.highlight(false) + if (App.shared.shortcutHotkey == nil) { + self?.buttonSetShortcut.title = "prefs.shortcut_set".localized + } + } + } + } + } + + // MARK: - Display + public static func show(delegate: NSWindowDelegate? = nil) { if (App.shared.windowController == nil) { - let vc = NSStoryboard(name: "Main", bundle: nil).instantiateController(withIdentifier: "preferences") as! PrefsVC + let vc = NSStoryboard(name: "Main", bundle: nil) + .instantiateController(withIdentifier: "preferences") as! PrefsVC let window = NSWindow(contentViewController: vc) + window.title = "prefs.title".localized window.delegate = delegate window.styleMask = [.titled, .closable] - App.shared.windowController = NSWindowController(window: window) + + App.shared.windowController = PrefsWC(window: window) } + App.shared.windowController!.showWindow(self) NSApp.activate(ignoringOtherApps: true) } + // MARK: - Lifecycle + + override func viewWillAppear() { + // Load localization + buttonDynamicIcon.title = "prefs.dynamic_icon_title".localized + labelDynamicIcon.stringValue = "prefs.dynamic_icon_desc".localized + buttonClose.title = "prefs.close".localized + labelShortcut.stringValue = "prefs.shortcut_desc".localized + buttonSetShortcut.title = "prefs.shortcut_set".localized + buttonClearShortcut.title = "prefs.shortcut_clear".localized + + let prefs = Preferences.preferences + + // Load dynamic icon + self.buttonDynamicIcon.state = (prefs[.shouldDisplayDynamicIcon] as! Bool == true) ? .on : .off + + // Load global keybind initial state + let globalKeybind = GlobalKeybindPreference.fromJson(prefs[.globalHotkey] as! String?) + if (globalKeybind != nil) { + updateKeybindButton(globalKeybind!) + } + buttonClearShortcut.isEnabled = globalKeybind != nil + } + + // MARK: - Shortcut + // Adapted from: https://dev.to/mitchartemis/creating-a-global-configurable-shortcut-for-macos-apps-in-swift-25e9 + + func updateGlobalShortcut(_ event : NSEvent) { + self.listening = false + + if let characters = event.charactersIgnoringModifiers { + let newGlobalKeybind = GlobalKeybindPreference.init( + function: event.modifierFlags.contains(.function), + control: event.modifierFlags.contains(.control), + command: event.modifierFlags.contains(.command), + shift: event.modifierFlags.contains(.shift), + option: event.modifierFlags.contains(.option), + capsLock: event.modifierFlags.contains(.capsLock), + carbonFlags: event.modifierFlags.carbonFlags, + characters: characters, + keyCode: UInt32(event.keyCode) + ) + + Preferences.update(.globalHotkey, value: newGlobalKeybind.toJson()) + + updateKeybindButton(newGlobalKeybind) + buttonClearShortcut.isEnabled = true + + App.shared.shortcutHotkey = HotKey( + keyCombo: KeyCombo( + carbonKeyCode: UInt32(event.keyCode), + carbonModifiers: event.modifierFlags.carbonFlags + ) + ) + } + } + + @IBAction func register(_ sender: Any) { + unregister(nil) + listening = true + view.window?.makeFirstResponder(nil) + } + + @IBAction func unregister(_ sender: Any?) { + listening = false + App.shared.shortcutHotkey = nil + buttonSetShortcut.title = "" + + Preferences.update(.globalHotkey, value: nil) + } + + func updateClearButton(_ globalKeybindPreference: GlobalKeybindPreference?) { + if globalKeybindPreference != nil { + buttonClearShortcut.isEnabled = true + } else { + buttonClearShortcut.isEnabled = false + } + } + + func updateKeybindButton(_ globalKeybindPreference: GlobalKeybindPreference) { + buttonSetShortcut.title = globalKeybindPreference.description + } + + // MARK: - Actions + @IBAction func toggledDynamicIcon(_ sender: Any) { Preferences.update(.shouldDisplayDynamicIcon, value: buttonDynamicIcon.state == .on) MainMenu.shared.refreshIcon() } - override func viewWillAppear() { - buttonDynamicIcon.title = "prefs.dynamic_icon_title".localized - labelDynamicIcon.stringValue = "prefs.dynamic_icon_desc".localized - buttonClose.title = "prefs.close".localized - - let prefs = Preferences.preferences - self.buttonDynamicIcon.state = (prefs[.shouldDisplayDynamicIcon] == true) ? .on : .off - } - @IBAction func pressed(_ sender: Any) { self.view.window?.windowController?.close() } + // MARK: - Deinitialization + deinit { print("VC deallocated") } } + +struct GlobalKeybindPreference: Codable, CustomStringConvertible { + + // MARK: - Internal variables + + let function : Bool + let control : Bool + let command : Bool + let shift : Bool + let option : Bool + let capsLock : Bool + let carbonFlags : UInt32 + let characters : String? + let keyCode : UInt32 + + // MARK: - How the keybind is display in Preferences + + var description: String { + var stringBuilder = "" + if self.function { + stringBuilder += "Fn" + } + if self.control { + stringBuilder += "⌃" + } + if self.option { + stringBuilder += "⌥" + } + if self.command { + stringBuilder += "⌘" + } + if self.shift { + stringBuilder += "⇧" + } + if self.capsLock { + stringBuilder += "⇪" + } + if let characters = self.characters { + stringBuilder += characters.uppercased() + } + return "\(stringBuilder)" + } + + // MARK: - Persisting data to UserDefaults (as JSON) + + public func toJson() -> String { + let jsonData = try! JSONEncoder().encode(self) + return String(data: jsonData, encoding: .utf8)! + } + + public static func fromJson(_ string: String?) -> GlobalKeybindPreference? { + if string == nil { + return nil + } + + if let jsonData = string!.data(using: .utf8) { + let decoder = JSONDecoder() + do { + return try decoder.decode(GlobalKeybindPreference.self, from: jsonData) + } catch { + return nil + } + } + return nil + } +} diff --git a/phpmon/Domain/Preferences/PrefsWC.swift b/phpmon/Domain/Preferences/PrefsWC.swift new file mode 100644 index 0000000..da2d31e --- /dev/null +++ b/phpmon/Domain/Preferences/PrefsWC.swift @@ -0,0 +1,26 @@ +// +// PrefsWC.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 02/04/2021. +// Copyright © 2021 Nico Verbruggen. All rights reserved. +// + +import Cocoa + +class PrefsWC: NSWindowController { + + override func windowDidLoad() { + super.windowDidLoad() + } + + override func keyDown(with event: NSEvent) { + super.keyDown(with: event) + if let vc = self.contentViewController as? PrefsVC { + if vc.listening { + vc.updateGlobalShortcut(event) + } + } + } + +} diff --git a/phpmon/Localizable.strings b/phpmon/Localizable.strings index 6943ec6..26061a4 100644 --- a/phpmon/Localizable.strings +++ b/phpmon/Localizable.strings @@ -49,6 +49,10 @@ "prefs.close" = "Close"; "prefs.dynamic_icon_title" = "Show a dynamic icon in the menu bar"; "prefs.dynamic_icon_desc" = "If you uncheck this box, the truck icon will always be visible.\nIf checked, it will display the major version number of the currently linked PHP version."; +"prefs.shortcut_set" = "Set global shortcut"; +"prefs.shortcut_listening" = ""; +"prefs.shortcut_clear" = "Clear"; +"prefs.shortcut_desc" = "If a shortcut combination is set up, you can toggle PHP Monitor\nwherever you are by pressing the key combination you chose."; // NOTIFICATIONS