diff --git a/.swiftlint.yml b/.swiftlint.yml index 2dc1ebb..a267c9b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -40,6 +40,7 @@ only_rules: - empty_xctest_method - enum_case_associated_values_count - explicit_init + - explicit_self - fallthrough - fatal_error_message - first_where @@ -155,7 +156,7 @@ identifier_name: error: 2 validates_start_with_lowercase: false deployment_target: - macOS_deployment_target: '11.0' + macOS_deployment_target: "11.0" custom_rules: swiftui_state_private: regex: '@(State|StateObject|ObservedObject|EnvironmentObject)\s+var' diff --git a/auto-clicker.xcodeproj/project.pbxproj b/auto-clicker.xcodeproj/project.pbxproj index 5d6ede4..f16c1c3 100644 --- a/auto-clicker.xcodeproj/project.pbxproj +++ b/auto-clicker.xcodeproj/project.pbxproj @@ -43,6 +43,9 @@ B5BCE80A287C343900B739AD /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B5BCE80C287C343900B739AD /* Localizable.strings */; }; B5BCE80D287C344200B739AD /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = B5BCE80F287C344200B739AD /* Localizable.stringsdict */; }; B5BCE811287C38ED00B739AD /* CGVector+DefaultsSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BCE810287C38ED00B739AD /* CGVector+DefaultsSerializable.swift */; }; + B5BF09402883230D008092D9 /* MenuBarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BF093F2883230D008092D9 /* MenuBarService.swift */; }; + B5BF094328832A4E008092D9 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BF094228832A4E008092D9 /* MenuBarView.swift */; }; + B5BF094528847346008092D9 /* KeyboardShortcuts+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BF094428847346008092D9 /* KeyboardShortcuts+Extensions.swift */; }; B5D603EF2883027D00655D2C /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D603EE2883027D00655D2C /* SettingsTabView.swift */; }; B5D603F12883035E00655D2C /* ContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D603F02883035E00655D2C /* ContainerView.swift */; }; B5D603F528830A3600655D2C /* SettingsTabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D603F428830A3600655D2C /* SettingsTabItemView.swift */; }; @@ -106,6 +109,9 @@ B5BCE80B287C343900B739AD /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; B5BCE80E287C344200B739AD /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "en-GB"; path = "en-GB.lproj/Localizable.stringsdict"; sourceTree = ""; }; B5BCE810287C38ED00B739AD /* CGVector+DefaultsSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGVector+DefaultsSerializable.swift"; sourceTree = ""; }; + B5BF093F2883230D008092D9 /* MenuBarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarService.swift; sourceTree = ""; }; + B5BF094228832A4E008092D9 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + B5BF094428847346008092D9 /* KeyboardShortcuts+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcuts+Extensions.swift"; sourceTree = ""; }; B5D603EE2883027D00655D2C /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = ""; }; B5D603F02883035E00655D2C /* ContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerView.swift; sourceTree = ""; }; B5D603F428830A3600655D2C /* SettingsTabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabItemView.swift; sourceTree = ""; }; @@ -162,6 +168,7 @@ B5E92B1B27F1BA5E00A7FC63 /* ThemeService.swift */, B5B6B46428033A0F00C779FD /* PermissionsService.swift */, B5E4213628057BA900C2CA2D /* LoggerService.swift */, + B5BF093F2883230D008092D9 /* MenuBarService.swift */, ); path = Services; sourceTree = ""; @@ -300,6 +307,7 @@ isa = PBXGroup; children = ( B510761227F4A34500BB1CDA /* Main */, + B5BF094128832A3F008092D9 /* Menu Bar */, B510761327F4A35400BB1CDA /* Settings */, B510763327FF7ECD00BB1CDA /* Libs */, ); @@ -333,6 +341,14 @@ path = Init; sourceTree = ""; }; + B5BF094128832A3F008092D9 /* Menu Bar */ = { + isa = PBXGroup; + children = ( + B5BF094228832A4E008092D9 /* MenuBarView.swift */, + ); + path = "Menu Bar"; + sourceTree = ""; + }; B5E6394E27CA4CB1008B111A /* Constants */ = { isa = PBXGroup; children = ( @@ -376,6 +392,7 @@ C4345BB42846056000365CF9 /* ProcessInfo+Extensions.swift */, B5BCE804287BFB5A00B739AD /* Color+ExpressibleByStringLiteral.swift */, B5BCE810287C38ED00B739AD /* CGVector+DefaultsSerializable.swift */, + B5BF094428847346008092D9 /* KeyboardShortcuts+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -521,6 +538,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B5BF094528847346008092D9 /* KeyboardShortcuts+Extensions.swift in Sources */, B5E6395027CA4CDD008B111A /* FieldConstants.swift in Sources */, B5F6A00F27F37440003CD730 /* MainView.swift in Sources */, B510762527F4BB7B00BB1CDA /* WindowSettingsTabView.swift in Sources */, @@ -567,6 +585,7 @@ B5E92B0E27F1036B00A7FC63 /* DurationModal.swift in Sources */, B5B0B2AF2882EE6E00462F11 /* ACWindow.swift in Sources */, B5BCE803287BF59900B739AD /* Colour.swift in Sources */, + B5BF09402883230D008092D9 /* MenuBarService.swift in Sources */, B5F5C60B28039AA40049B04D /* FormState.swift in Sources */, B510760F27F4A25400BB1CDA /* WindowStateService.swift in Sources */, B5E92B1C27F1BA5E00A7FC63 /* ThemeService.swift in Sources */, @@ -574,6 +593,7 @@ C4345BB52846056000365CF9 /* ProcessInfo+Extensions.swift in Sources */, B5E6395327CA62EB008B111A /* ThemedButtonStyle.swift in Sources */, B510762127F4BB4900BB1CDA /* AppearanceSettingsTabView.swift in Sources */, + B5BF094328832A4E008092D9 /* MenuBarView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/auto-clicker/Build Assets/Info.plist b/auto-clicker/Build Assets/Info.plist index 4b6790b..736104d 100644 --- a/auto-clicker/Build Assets/Info.plist +++ b/auto-clicker/Build Assets/Info.plist @@ -16,8 +16,21 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + auto-clicker + CFBundleURLSchemes + + auto-clicker + + + CFBundleVersion - cc1646c + f910f6f LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/auto-clicker/Constants/Defaults.swift b/auto-clicker/Constants/Defaults.swift index 337a679..6556d1a 100644 --- a/auto-clicker/Constants/Defaults.swift +++ b/auto-clicker/Constants/Defaults.swift @@ -10,9 +10,12 @@ import Cocoa import Defaults extension Defaults.Keys { + static let appShouldQuitOnClose = Key("app_should_quit_on_close", default: true) + static let windowShouldKeepOnTop = Key("window_should_keep_on_top", default: false) - static let appShouldQuitOnClose = Key("app_should_quit_on_close", default: true) + static let menuBarShowIcon = Key("menu_bar_show_icon", default: true) + static let menuBarHideDock = Key("menu_bar_hide_dock", default: false) static let appearanceSelectedTheme = Key("appearance_selected_theme", default: ThemeService()) diff --git a/auto-clicker/Extensions/KeyboardShortcuts+Extensions.swift b/auto-clicker/Extensions/KeyboardShortcuts+Extensions.swift new file mode 100644 index 0000000..526b4a2 --- /dev/null +++ b/auto-clicker/Extensions/KeyboardShortcuts+Extensions.swift @@ -0,0 +1,29 @@ +// +// KeyboardShortcuts+Extensions.swift +// auto-clicker +// +// Created by Ben Tindall on 17/07/2022. +// + +import Foundation +import KeyboardShortcuts + +extension KeyboardShortcuts.Shortcut { + /** + The string representation of the keyboard shortcut key only. + + ``` + print(Shortcut(.a, modifiers: [.command])) + //=> "A" + ``` + */ + public var descriptionKeyOnly: String { + // 'keyToCharacter' is inaccessible due to 'fileprivate' protection level + // :( +// modifiers.description + (keyToCharacter()?.uppercased() ?? "�") + + // Hacky due to the above fileprivate protection level of keyToCharacter() + // So just strip the modifier from the string to gain access to a string representation of just the key alone + self.description.replacingOccurrences(of: modifiers.description, with: "") + } +} diff --git a/auto-clicker/Init/AppDelegate.swift b/auto-clicker/Init/AppDelegate.swift index 8d5dc0a..8f9e48a 100644 --- a/auto-clicker/Init/AppDelegate.swift +++ b/auto-clicker/Init/AppDelegate.swift @@ -7,16 +7,21 @@ import Foundation import Cocoa +import Defaults + +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + func applicationWillFinishLaunching(_ notification: Notification) { + WindowStateService.refreshDockIconState() + } -final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - if let window = NSApplication.shared.mainWindow { - window.titlebarAppearsTransparent = true + NSApp.activate(ignoringOtherApps: true) - let customToolbar = NSToolbar() - customToolbar.showsBaselineSeparator = false - window.toolbar = customToolbar - } + // Hacky workaround in SwiftUI in order to have macOS persist the window size state + // https://stackoverflow.com/a/72558375/4494375 + NSApp.windows[0].delegate = self + + MenuBarService.refreshState() PermissionsService.acquireAccessibilityPrivileges() } @@ -28,4 +33,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillBecomeActive(_ notification: Notification) { WindowStateService.refreshKeepWindowOnTop() } + + func applicationDidHide(_ notification: Notification) { + if let hideOrShowMenuItem = MenuBarService.hideOrShowMenuItem { + hideOrShowMenuItem.title = NSLocalizedString("menu_bar_item_hide_show_show", comment: "Menu bar item show option") + + " " + + NSLocalizedString("menu_bar_item_hide_show_suffix", comment: "Menu bar item show/hide option suffix") + } + } + + func applicationDidUnhide(_ notification: Notification) { + if let hideOrShowMenuItem = MenuBarService.hideOrShowMenuItem { + hideOrShowMenuItem.title = NSLocalizedString("menu_bar_item_hide_show_hide", comment: "Menu bar item hide option") + + " " + + NSLocalizedString("menu_bar_item_hide_show_suffix", comment: "Menu bar item show/hide option suffix") + } + } + + // Hacky workaround in SwiftUI in order to have macOS persist the window size state + // https://stackoverflow.com/a/72558375/4494375 + func windowShouldClose(_ sender: NSWindow) -> Bool { + if !Defaults[.appShouldQuitOnClose] { + NSApp.hide(nil) + return false + } + + return true + } } diff --git a/auto-clicker/Init/AutoClickerApp.swift b/auto-clicker/Init/AutoClickerApp.swift index d412956..ab02db7 100644 --- a/auto-clicker/Init/AutoClickerApp.swift +++ b/auto-clicker/Init/AutoClickerApp.swift @@ -20,10 +20,10 @@ struct AutoClickerApp: App { WindowGroup { ACWindow() - .frame(minWidth: WindowStateService.minWidth, - maxWidth: WindowStateService.minWidth * WindowStateService.maxDimensionMultiplier, - minHeight: WindowStateService.minHeight, - maxHeight: WindowStateService.minHeight) + .frame(minWidth: WindowStateService.mainWindowMinWidth, + maxWidth: WindowStateService.mainWindowMinWidth * WindowStateService.mainWindowMaxDimensionMultiplier, + minHeight: WindowStateService.mainWindowMinHeight, + maxHeight: WindowStateService.mainWindowMinHeight) } .windowStyle(.hiddenTitleBar) .commands { diff --git a/auto-clicker/Localisation/en-GB.lproj/Localizable.strings b/auto-clicker/Localisation/en-GB.lproj/Localizable.strings index 6905b92..cbf41f5 100644 --- a/auto-clicker/Localisation/en-GB.lproj/Localizable.strings +++ b/auto-clicker/Localisation/en-GB.lproj/Localizable.strings @@ -36,22 +36,38 @@ "settings_window" = "Window"; "settings_appearance" = "Appearance"; -"settings_general_app_should_quit_on_close_title" = "State"; +"settings_general_app_should_quit_on_close_title" = "Lifecycle"; "settings_general_app_should_quit_on_close" = "Quit app on main window close"; "settings_general_app_should_quit_on_close_help" = "When the main window of the application is closed, instead of keeping the app running in the background (macOS default behaviour), quit the app."; +"settings_general_menu_bar_show_icon_title" = "Menu Bar"; +"settings_general_menu_bar_show_icon" = "Show menu bar icon"; +"settings_general_menu_bar_show_icon_help" = "Always show an icon in the macOS menu bar where the app and quick access functionality can be accessed."; + +"settings_general_menu_bar_hide_dock" = "Hide dock icon"; +"settings_general_menu_bar_hide_dock_help" = "Instead of the app running from the dock, the app will instead run from the menu bar."; + "settings_keyboard_shortcuts_title" = "Global"; "settings_keyboard_shortcuts_start" = "Start auto clicker"; "settings_keyboard_shortcuts_stop" = "Stop auto clicker"; "settings_keyboard_shortcuts_help" = "Global shortcuts to start and stop the auto click functionality."; "settings_window_stay_ontop_title" = "Visibility"; -"settings_window_stay_ontop" = "Keep the main window ontop"; +"settings_window_stay_ontop" = "Always keep the main window on top"; "settings_window_stay_ontop_help" = "When you click on another window, this defines whether the window should dissapear behind the other windows (macOS default behaviour), or stay on top of all other windows."; "help_commands_request_a_feature" = "Request a feature..."; "help_commands_report_a_bug" = "Report a problem..."; +"menu_bar_item_start" = "Start now"; +"menu_bar_item_stop" = "Stop now"; +"menu_bar_item_hide_show_show" = "Show"; +"menu_bar_item_hide_show_hide" = "Hide"; +"menu_bar_item_hide_show_suffix" = "app"; +"menu_bar_item_preferences" = "Preferences..."; +"menu_bar_item_about" = "About..."; +"menu_bar_item_quit" = "Quit"; + "colour_black" = "Black"; "colour_blue" = "Blue"; "colour_brown" = "Brown"; diff --git a/auto-clicker/Observable Objects/AutoClickSimulator.swift b/auto-clicker/Observable Objects/AutoClickSimulator.swift index 89c8679..80515a0 100644 --- a/auto-clicker/Observable Objects/AutoClickSimulator.swift +++ b/auto-clicker/Observable Objects/AutoClickSimulator.swift @@ -11,6 +11,9 @@ import SwiftUI import Defaults final class AutoClickSimulator: ObservableObject { + static var shared: AutoClickSimulator = .init() + private init() {} + @Published var isAutoClicking = false @Published var remainingInterations: Int = 0 @@ -31,6 +34,14 @@ final class AutoClickSimulator: ObservableObject { func start() { self.isAutoClicking = true + if let startMenuItem = MenuBarService.startMenuItem { + startMenuItem.isEnabled = false + } + + if let stopMenuItem = MenuBarService.stopMenuItem { + stopMenuItem.isEnabled = true + } + self.activity = ProcessInfo.processInfo.beginActivity(.autoClicking) self.duration = Defaults[.autoClickerState].pressIntervalDuration @@ -53,6 +64,14 @@ final class AutoClickSimulator: ObservableObject { func stop() { self.isAutoClicking = false + if let startMenuItem = MenuBarService.startMenuItem { + startMenuItem.isEnabled = true + } + + if let stopMenuItem = MenuBarService.stopMenuItem { + stopMenuItem.isEnabled = false + } + self.activity?.cancel() self.activity = nil diff --git a/auto-clicker/Observable Objects/DelayTimer.swift b/auto-clicker/Observable Objects/DelayTimer.swift index 1d1515f..d2e411a 100644 --- a/auto-clicker/Observable Objects/DelayTimer.swift +++ b/auto-clicker/Observable Objects/DelayTimer.swift @@ -10,6 +10,9 @@ import Defaults import Combine final class DelayTimer: ObservableObject { + static var shared: DelayTimer = .init() + private init() {} + private static let defaultCountdownText: String = "-" @Published var isCountingDown = false @@ -25,6 +28,14 @@ final class DelayTimer: ObservableObject { self.onFinish = onFinish + if let startMenuItem = MenuBarService.startMenuItem { + startMenuItem.isEnabled = false + } + + if let stopMenuItem = MenuBarService.stopMenuItem { + stopMenuItem.isEnabled = true + } + if delayInSeconds > 0 { self.remainingDelaySeconds = delayInSeconds self.isCountingDown = true diff --git a/auto-clicker/Services/LoggerService.swift b/auto-clicker/Services/LoggerService.swift index e567569..4f7e5cc 100644 --- a/auto-clicker/Services/LoggerService.swift +++ b/auto-clicker/Services/LoggerService.swift @@ -10,15 +10,11 @@ import SwiftUI final class LoggerService { private static func log(file: String, function: String, _ lines: [String]) { #if DEBUG - NSLog("---") - - NSLog("Caller: \(file) ~ \(function)") + NSLog(">~ Who: \(file) ~ \(function)") for line in lines { - NSLog(line) + NSLog(">~ What: \(line)") } - - NSLog("---") #endif } diff --git a/auto-clicker/Services/MenuBarService.swift b/auto-clicker/Services/MenuBarService.swift new file mode 100644 index 0000000..645e499 --- /dev/null +++ b/auto-clicker/Services/MenuBarService.swift @@ -0,0 +1,257 @@ +// +// MenuBarService.swift +// auto-clicker +// +// Created by Ben Tindall on 16/07/2022. +// + +import Foundation +import Cocoa +import Defaults +import SwiftUI +import KeyboardShortcuts + +// Good references: +// - https://khorbushko.github.io/article/2021/04/30/minimal-macOS-menu-bar-extra%27s-app-with-SwiftUI.html +// - https://github.com/kyan/kyan_bar/blob/master/KyanBar/MainMenu.swift +// - https://github.com/AnaghSharma/Ambar-SwiftUI/blob/master/Ambar/AppDelegate.swift +// - https://github.com/AnaghSharma/Ambar-SwiftUI/blob/master/Ambar/Helpers/StatusBarController.swift +// - https://stackoverflow.com/q/64816192/4494375 +// - Annoyingly in Ventura/iOS 16 you can now just use the SwiftUI native MenuBarExtras method like WindowGroup and Settings in +// the App, so in AutoClickerApp! + +final class MenuBarService { + static var statusBar: NSStatusBar? + static var statusBarItem: NSStatusItem? + static var statusBarPopover: NSPopover? + + static var startMenuItem: NSMenuItem? + static var stopMenuItem: NSMenuItem? + static var hideOrShowMenuItem: NSMenuItem? + static var preferencesMenuItem: NSMenuItem? + static var aboutMenuItem: NSMenuItem? + static var quitMenuItem: NSMenuItem? + + static func create() { + self.statusBar = NSStatusBar.system + self.statusBarItem = self.statusBar!.statusItem(withLength: NSStatusItem.variableLength) + self.statusBarPopover = NSPopover() + + if let statusBarButton = self.statusBarItem!.button { + statusBarButton.image = NSImage(systemSymbolName: "cursorarrow.click.badge.clock", accessibilityDescription: "auto clicker") + statusBarButton.action = #selector(togglePopover(sender:)) + statusBarButton.target = self + } + +// Styling just didn't really work, this would work well for a Menu Bar app, but not for just simple clickable Menu Items... +// self.statusBarPopover!.contentSize = NSSize(width: WindowStateService.menuBarWidth, height: WindowStateService.menuBarHeight) +// self.statusBarPopover!.behavior = .transient +// self.statusBarPopover!.contentViewController = NSHostingController(rootView: MenuBarView()) + + self.statusBarItem!.menu = self.buildMenu() + + // Set default state of disabled if the application is waiting for permissions trust + if !PermissionsService.shared.isTrusted { + self.disableAllMenuBarItems() + } + } + + private static func buildMenu() -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + + self.startMenuItem = NSMenuItem( + title: NSLocalizedString("menu_bar_item_start", comment: "Menu bar item start option"), + action: #selector(menuActionStart), + keyEquivalent: KeyboardShortcuts.Name.pressStartButton.shortcut!.descriptionKeyOnly.lowercased() + ) + self.startMenuItem!.target = self + menu.addItem(self.startMenuItem!) + + self.stopMenuItem = NSMenuItem( + title: NSLocalizedString("menu_bar_item_stop", comment: "Menu bar item stop option"), + action: #selector(menuActionStop), + keyEquivalent: KeyboardShortcuts.Name.pressStopButton.shortcut!.descriptionKeyOnly.lowercased() + ) + self.stopMenuItem!.isEnabled = false + self.stopMenuItem!.target = self + menu.addItem(self.stopMenuItem!) + + menu.addItem(NSMenuItem.separator()) + + self.hideOrShowMenuItem = NSMenuItem( + title: (NSApp.isHidden + ? NSLocalizedString("menu_bar_item_hide_show_show", comment: "Menu bar item show option") + : NSLocalizedString("menu_bar_item_hide_show_hide", comment: "Menu bar item hide option")) + + " " + NSLocalizedString("menu_bar_item_hide_show_suffix", comment: "Menu bar item show/hide option suffix"), + action: #selector(menuActionHideOrShow), + keyEquivalent: "h" + ) + self.hideOrShowMenuItem!.target = self + menu.addItem(self.hideOrShowMenuItem!) + + menu.addItem(NSMenuItem.separator()) + + self.preferencesMenuItem = NSMenuItem( + title: NSLocalizedString("menu_bar_item_preferences", comment: "Menu bar item preferences option"), + action: #selector(self.menuActionPreferences), + keyEquivalent: "," + ) + self.preferencesMenuItem!.target = self + menu.addItem(self.preferencesMenuItem!) + + self.aboutMenuItem = NSMenuItem( + title: NSLocalizedString("menu_bar_item_about", comment: "Menu bar item about option"), + action: #selector(self.menuActionAbout), + keyEquivalent: "" + ) + self.aboutMenuItem!.target = self + menu.addItem(self.aboutMenuItem!) + + menu.addItem(NSMenuItem.separator()) + + self.quitMenuItem = NSMenuItem( + title: NSLocalizedString("menu_bar_item_quit", comment: "Menu bar item quit option"), + action: #selector(menuActionQuit), + keyEquivalent: "q" + ) + self.quitMenuItem!.target = self + menu.addItem(self.quitMenuItem!) + + return menu + } + + static func destroy() { + self.statusBar = nil + self.statusBarItem = nil + self.statusBarPopover = nil + } + + static func toggle(_ isEnabled: Bool) { + if isEnabled { + self.create() + } else { + self.destroy() + } + } + + static func refreshState() { + self.toggle(Defaults[.menuBarShowIcon]) + } + + @objc static func togglePopover(sender: AnyObject) { + if self.statusBarPopover!.isShown { + self.hidePopover(sender) + } else { + self.showPopover(sender) + } + } + + static func showPopover(_ sender: AnyObject) { + if let statusBarButton = self.statusBarItem!.button { + self.statusBarPopover!.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: .minY) + } + } + + static func hidePopover(_ sender: AnyObject) { + self.statusBarPopover!.performClose(sender) + } + + static func disableAllMenuBarItems() { + if let startMenuItem = self.startMenuItem { + startMenuItem.isEnabled = false + } + + if let stopMenuItem = self.stopMenuItem { + stopMenuItem.isEnabled = false + } + + if let hideOrShowMenuItem = self.hideOrShowMenuItem { + hideOrShowMenuItem.isEnabled = false + } + + if let preferencesMenuItem = self.preferencesMenuItem { + preferencesMenuItem.isEnabled = false + } + + // Intentional, the about option should always be enabled + if let aboutMenuItem = self.aboutMenuItem { + aboutMenuItem.isEnabled = true + } + + // Intentional, the quit option should always be enabled + if let quitMenuItem = self.quitMenuItem { + quitMenuItem.isEnabled = true + } + } + + static func enableAllMenuBarItems() { + if let startMenuItem = self.startMenuItem { + startMenuItem.isEnabled = true + } + + // Intentional, the default state of stop should be disabled as nothing is running + if let stopMenuItem = self.stopMenuItem { + stopMenuItem.isEnabled = false + } + + if let hideOrShowMenuItem = self.hideOrShowMenuItem { + hideOrShowMenuItem.isEnabled = true + } + + if let preferencesMenuItem = self.preferencesMenuItem { + preferencesMenuItem.isEnabled = true + } + + if let aboutMenuItem = self.aboutMenuItem { + aboutMenuItem.isEnabled = true + } + + if let quitMenuItem = self.quitMenuItem { + quitMenuItem.isEnabled = true + } + } + + @objc static func menuActionStart(sender: NSMenuItem) { + AutoClickSimulator.shared.start() + } + + @objc static func menuActionStop(sender: NSMenuItem) { + AutoClickSimulator.shared.stop() + } + + @objc static func menuActionHideOrShow(sender: NSMenuItem) { + if NSApp.isHidden { + NSApp.activate(ignoringOtherApps: true) + NSApp.unhide(sender) + } else { + NSApp.hide(sender) + } + } + + @objc static func menuActionPreferences(sender: NSMenuItem) { + NSApp.activate(ignoringOtherApps: true) + + // https://stackoverflow.com/questions/65355696/how-to-programatically-open-settings-window-in-a-macos-swiftui-app + if #available(macOS 13, *) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } else { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } + + @objc static func menuActionAbout(sender: NSMenuItem) { + NSApp.activate(ignoringOtherApps: true) + + // This isn't documented anywhere SEO'able ~ meaning I ended up trawling menu bar apps I had installed to see if they had + // this functionality and then checking the GitHub repo's powering them to see how they did it. + // Turns out Rectangle have this implemented, so I scoured their repo to see how they did it: + // https://github.com/rxhanson/Rectangle/blob/master/Rectangle/AppDelegate.swift#L204 + // Undocumented Apple methods strike again! + NSApp.orderFrontStandardAboutPanel(sender) + } + + @objc static func menuActionQuit(sender: NSMenuItem) { + NSApp.terminate(self) + } +} diff --git a/auto-clicker/Services/PermissionsService.swift b/auto-clicker/Services/PermissionsService.swift index 8d41ca6..d941c5c 100644 --- a/auto-clicker/Services/PermissionsService.swift +++ b/auto-clicker/Services/PermissionsService.swift @@ -19,14 +19,19 @@ import Cocoa final class PermissionsService: ObservableObject { @Published var isTrusted: Bool = AXIsProcessTrusted() - func pollAccessibilityPrivileges() { + static var shared: PermissionsService = .init() + private init() {} + + func pollAccessibilityPrivileges(onTrusted: @escaping () -> Void) { LoggerService.permissionTrustedState() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.isTrusted = AXIsProcessTrusted() if !self.isTrusted { - self.pollAccessibilityPrivileges() + self.pollAccessibilityPrivileges(onTrusted: onTrusted) + } else { + onTrusted() } } } diff --git a/auto-clicker/Services/WindowStateService.swift b/auto-clicker/Services/WindowStateService.swift index 731cb1a..c423fe2 100644 --- a/auto-clicker/Services/WindowStateService.swift +++ b/auto-clicker/Services/WindowStateService.swift @@ -10,18 +10,18 @@ import Cocoa import Defaults struct WindowStateService { - static let minWidth: CGFloat = 550 - static let minHeight: CGFloat = 430 - - static let maxDimensionMultiplier: CGFloat = 1.3 + static let mainWindowMinWidth: CGFloat = 550 + static let mainWindowMinHeight: CGFloat = 430 + static let mainWindowMaxDimensionMultiplier: CGFloat = 1.3 static let settingsMinWidth: CGFloat = 500 - static let settingsMinHeight: CGFloat = 200 - static var settingsWidthSide: CGFloat { WindowStateService.settingsMinWidth / 5 } + static let menuBarWidth: CGFloat = 150 + static let menuBarHeight: CGFloat = 500 + static func toggleKeepWindowOnTop(_ keepOnTop: Bool) { // This is somewhat finiky... I originally used NSApplication.shared.mainWindow as it contained the primary window // but this has problems when using the preferences window as when the preferences window is open it becomes the @@ -46,4 +46,14 @@ struct WindowStateService { static func shouldExitOnClose() -> Bool { Defaults[.appShouldQuitOnClose] } + + static func toggleDockIcon(showIcon: Bool) -> Bool { + showIcon + ? NSApp.setActivationPolicy(NSApplication.ActivationPolicy.regular) + : NSApp.setActivationPolicy(NSApplication.ActivationPolicy.accessory) + } + + static func refreshDockIconState() { + _ = self.toggleDockIcon(showIcon: !Defaults[.menuBarHideDock]) + } } diff --git a/auto-clicker/Views/Main/ACWindow.swift b/auto-clicker/Views/Main/ACWindow.swift index 686a796..b4b13d7 100644 --- a/auto-clicker/Views/Main/ACWindow.swift +++ b/auto-clicker/Views/Main/ACWindow.swift @@ -11,7 +11,7 @@ import Defaults struct ACWindow: View { @Default(.appearanceSelectedTheme) private var activeTheme - @StateObject private var permissionsService = PermissionsService() + @StateObject private var permissionsService = PermissionsService.shared var body: some View { ZStack { @@ -23,6 +23,10 @@ struct ACWindow: View { PermissionsView() } } - .onAppear(perform: self.permissionsService.pollAccessibilityPrivileges) + .onAppear { + self.permissionsService.pollAccessibilityPrivileges(onTrusted: { + MenuBarService.enableAllMenuBarItems() + }) + } } } diff --git a/auto-clicker/Views/Main/MainView.swift b/auto-clicker/Views/Main/MainView.swift index cb46c9c..9b2ebf6 100644 --- a/auto-clicker/Views/Main/MainView.swift +++ b/auto-clicker/Views/Main/MainView.swift @@ -14,11 +14,19 @@ struct MainView: View { @Default(.appearanceSelectedTheme) private var activeTheme @Default(.autoClickerState) private var formState - @StateObject private var autoClickSimulator = AutoClickSimulator() - @StateObject private var delayTimer = DelayTimer() + @StateObject private var autoClickSimulator = AutoClickSimulator.shared + @StateObject private var delayTimer = DelayTimer.shared @State private var showThemeName = false + var hasStarted: Bool { + self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown + } + + var hasStopped: Bool { + !self.autoClickSimulator.isAutoClicking + } + var estNextClickAt: Date { .init(timeInterval: self.formState.pressIntervalDuration.asTimeInterval(interval: self.formState.pressInterval), since: .init()) @@ -73,10 +81,10 @@ struct MainView: View { min: MIN_PRESS_INTERVAL, max: MAX_PRESS_INTERVAL, number: self.$formState.pressInterval) - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) DurationSelector(selectedDuration: self.$formState.pressIntervalDuration) - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) Text("main_window_comma", comment: "Main window comma") } @@ -85,13 +93,13 @@ struct MainView: View { Text("main_window_press", comment: "Main window 'press'") PressKeyListener() - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) DynamicWidthNumberField(text: "", min: MIN_PRESS_AMOUNT, max: MAX_PRESS_AMOUNT, number: self.$formState.pressAmount) - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) Text(self.formState.pressAmount == 1 ? "main_window_time" : "main_window_times", comment: "Main window 'time(s)'") + Text("main_window_comma", comment: "Main window comma") } @@ -103,7 +111,7 @@ struct MainView: View { min: MIN_REPEAT_AMOUNT, max: MAX_REPEAT_AMOUNT, number: self.$formState.repeatAmount) - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) Text(self.formState.repeatAmount == 1 ? "main_window_time" : "main_window_times", comment: "Main window 'time(s)'") + Text("main_window_full_stop", comment: "Main window full stop") } @@ -115,7 +123,7 @@ struct MainView: View { min: MIN_START_DELAY, max: MAX_START_DELAY, number: self.$formState.startDelay) - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) Text(self.formState.startDelay == 1 ? "main_window_second" : "main_window_seconds", comment: "Main window 'second(s)'") + Text("main_window_before_starting", comment: "Main window 'before starting'") + Text("main_window_full_stop", comment: "Main window full stop") } @@ -129,13 +137,13 @@ struct MainView: View { HStack { VStack { Button(action: self.start) { - if self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown { + if self.hasStarted { Text(self.delayTimer.countdownText).kerning(1) } else { Text("main_window_start_btn", comment: "Main window start button").kerning(1) } } - .disabled(self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown) + .disabled(self.hasStarted) .buttonStyle(ThemedButtonStyle()) KeyboardShortcutHint(shortcut: KeyboardShortcuts.Name.pressStartButton.shortcut!) @@ -145,7 +153,7 @@ struct MainView: View { Button(action: self.stop) { Text("main_window_stop_btn", comment: "Main window stop button").kerning(1) } - .disabled(!self.autoClickSimulator.isAutoClicking) + .disabled(self.hasStopped) .buttonStyle(ThemedButtonStyle()) KeyboardShortcutHint(shortcut: KeyboardShortcuts.Name.pressStopButton.shortcut!) @@ -163,29 +171,17 @@ struct MainView: View { VStack { HStack { -// Spacer() -// -// StatBox(title: "Next press at", -// value: "2010-06-07 10:00:00.000") -// -// Spacer() -// -// StatBox(title: "Final press at", -// value: "2010-06-07 12:00:00.000") -// -// Spacer() - Spacer() StatBox(title: "main_window_stat_box_next_press_at", - value: self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown + value: self.hasStarted ? self.autoClickSimulator.nextClickAt.asString(inFormat: "yyyy-MM-dd HH:mm:ss.SSS") : self.estNextClickAt.asString(inFormat: "yyyy-MM-dd HH:mm:ss.SSS")) Spacer() StatBox(title: "main_window_stat_box_final_press_at", - value: self.autoClickSimulator.isAutoClicking || self.delayTimer.isCountingDown + value: self.hasStarted ? self.autoClickSimulator.finalClickAt.asString(inFormat: "yyyy-MM-dd HH:mm:ss.SSS") : self.estFinalClickAt.asString(inFormat: "yyyy-MM-dd HH:mm:ss.SSS")) diff --git a/auto-clicker/Views/Menu Bar/MenuBarView.swift b/auto-clicker/Views/Menu Bar/MenuBarView.swift new file mode 100644 index 0000000..77e8409 --- /dev/null +++ b/auto-clicker/Views/Menu Bar/MenuBarView.swift @@ -0,0 +1,25 @@ +// +// MenuBarView.swift +// auto-clicker +// +// Created by Ben Tindall on 16/07/2022. +// + +import SwiftUI + +struct MenuBarView: View { + var body: some View { + VStack { + Button( + action: { + print("Button 1") + }, + label: { + Text("Button 1") + } + ) + .buttonStyle(.link) + } + .padding() + } +} diff --git a/auto-clicker/Views/Settings/SettingsTabItemView.swift b/auto-clicker/Views/Settings/SettingsTabItemView.swift index 216208e..6a17f45 100644 --- a/auto-clicker/Views/Settings/SettingsTabItemView.swift +++ b/auto-clicker/Views/Settings/SettingsTabItemView.swift @@ -8,12 +8,12 @@ import SwiftUI struct SettingsTabItemView: View { - var title: LocalizedStringKey - var help: LocalizedStringKey + var title: LocalizedStringKey? + var help: LocalizedStringKey? var divider: Bool var content: () -> Content - init(title: LocalizedStringKey, help: LocalizedStringKey, divider: Bool = false, @ViewBuilder content: @escaping () -> Content) { + init(title: LocalizedStringKey? = nil, help: LocalizedStringKey? = nil, divider: Bool = false, @ViewBuilder content: @escaping () -> Content) { self.title = title self.help = help self.divider = divider @@ -23,16 +23,16 @@ struct SettingsTabItemView: View { var body: some View { VStack { HStack(alignment: .top) { - Text(self.title) + // Setting an empty string so we still get the frame size applied for layout uniformity + Text(self.title ?? "") .fontWeight(.bold) .frame(width: WindowStateService.settingsWidthSide, alignment: .trailing) VStack(alignment: .leading) { VStack(content: self.content) - // swiftlint:disable empty_string - if self.help != "" { - Text(self.help) + if let help = self.help { + Text(help) .font(.footnote) } } diff --git a/auto-clicker/Views/Settings/SettingsView.swift b/auto-clicker/Views/Settings/SettingsView.swift index 4e1c95a..99a8c70 100644 --- a/auto-clicker/Views/Settings/SettingsView.swift +++ b/auto-clicker/Views/Settings/SettingsView.swift @@ -29,7 +29,7 @@ struct SettingsView: View { Label("settings_general", systemImage: "gear") } .onAppear { - self.changeFrameHeight(WindowStateService.settingsMinHeight) + self.changeFrameHeight(300) } KeyboardShortcutsSettingsTabView() @@ -37,7 +37,7 @@ struct SettingsView: View { Label("settings_keyboard_shortcuts", systemImage: "keyboard") } .onAppear { - self.changeFrameHeight(WindowStateService.settingsMinHeight / 1.5) + self.changeFrameHeight(130) } WindowSettingsTabView() @@ -45,7 +45,7 @@ struct SettingsView: View { Label("settings_window", systemImage: "macwindow") } .onAppear { - self.changeFrameHeight(WindowStateService.settingsMinHeight / 2) + self.changeFrameHeight(110) } AppearanceSettingsTabView() @@ -53,7 +53,7 @@ struct SettingsView: View { Label("settings_appearance", systemImage: "paintpalette") } .onAppear { - self.changeFrameHeight(WindowStateService.settingsMinHeight) + self.changeFrameHeight(200) } } .frame(width: WindowStateService.settingsMinWidth, height: self.frameHeight) diff --git a/auto-clicker/Views/Settings/Tabs/GeneralSettingsTabView.swift b/auto-clicker/Views/Settings/Tabs/GeneralSettingsTabView.swift index 7313fb3..fafe024 100644 --- a/auto-clicker/Views/Settings/Tabs/GeneralSettingsTabView.swift +++ b/auto-clicker/Views/Settings/Tabs/GeneralSettingsTabView.swift @@ -12,6 +12,9 @@ import Defaults struct GeneralSettingsTabView: View { @StateObject private var zoop = Zoop() + @Default(.menuBarShowIcon) private var menuBarShowIcon + @Default(.appShouldQuitOnClose) private var appShouldQuitOnClose + var body: some View { SettingsTabView { SettingsTabItemView( @@ -25,6 +28,44 @@ struct GeneralSettingsTabView: View { ) } + SettingsTabItemView( + title: "settings_general_menu_bar_show_icon_title", + help: "settings_general_menu_bar_show_icon_help" + ) { + HStack { + Defaults.Toggle( + " " + String(format: NSLocalizedString("settings_general_menu_bar_show_icon", comment: "Icon in menu bar toggle")), + key: .menuBarShowIcon + ) + .onChange { isOn in + MenuBarService.toggle(isOn) + + // If the menu bar icon is turned off, enforce that the dock icon is restored + // otherwise the user can get stuck! + if !isOn { + Defaults[.menuBarHideDock] = false + WindowStateService.refreshDockIconState() + } + } + + Image(systemName: "cursorarrow.click.badge.clock") + } + } + + SettingsTabItemView( + help: "settings_general_menu_bar_hide_dock_help", + divider: true + ) { + Defaults.Toggle( + " " + String(format: NSLocalizedString("settings_general_menu_bar_hide_dock", comment: "Hide dock icon toggle")), + key: .menuBarHideDock + ) + .onChange { _ in + WindowStateService.refreshDockIconState() + } + .disabled(!self.menuBarShowIcon) + } + Spacer() HStack { diff --git a/auto-clicker/Views/Settings/Tabs/WindowSettingsTabView.swift b/auto-clicker/Views/Settings/Tabs/WindowSettingsTabView.swift index 0174beb..45a472d 100644 --- a/auto-clicker/Views/Settings/Tabs/WindowSettingsTabView.swift +++ b/auto-clicker/Views/Settings/Tabs/WindowSettingsTabView.swift @@ -16,7 +16,7 @@ struct WindowSettingsTabView: View { help: "settings_window_stay_ontop_help" ) { Defaults.Toggle( - " " + String(format: NSLocalizedString("settings_window_stay_ontop", comment: "Settings Window window should stay ontop toggle")), + " " + String(format: NSLocalizedString("settings_window_stay_ontop", comment: "Settings Window window should stay on top toggle")), key: .windowShouldKeepOnTop ) .onChange { isOn in