diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 05f82990..a34051a9 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA282BB16C1554595A968 /* Applications.swift */; }; D04BA2D8099594E1593EC8EF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA88EDDDD6CB3FBE71FC5 /* InfoPlist.strings */; }; D04BA2E64C59D96F6EB27D9D /* FeedbackWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA26C75F76C277653C932 /* FeedbackWindow.swift */; }; + D04BA2F0A1B2C3D4E5F60003 /* DebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2F0A1B2C3D4E5F60004 /* DebugWindow.swift */; }; D04BA328F7C85DD1A6C1C7ED /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAB163FDEE79F096A410A /* Localizable.strings */; }; D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA3B51D05213404938366 /* Localizable.strings */; }; D04BA3744F48116DF4252B19 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA02355EB28D639F854DF /* Localizable.strings */; }; @@ -604,6 +605,7 @@ D04BA2D022B64A876CCA6BB7 /* apple-touch-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "apple-touch-icon.png"; sourceTree = ""; }; D04BA2E723062A1DAC58618F /* setup_ci_pr.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = setup_ci_pr.sh; sourceTree = ""; }; D04BA2E8D5D5CC23456BC8F7 /* tr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = tr; path = Localizable.strings; sourceTree = ""; }; + D04BA2F0A1B2C3D4E5F60004 /* DebugWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugWindow.swift; sourceTree = ""; }; D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = ""; }; D04BA3632A2EB5C64A3FEE59 /* hu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = hu; path = InfoPlist.strings; sourceTree = ""; }; D04BA369B4D9FD6A13936F53 /* changelog-bare.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "changelog-bare.html"; sourceTree = ""; }; @@ -1471,6 +1473,7 @@ D04BABCD12CA72B06290C248 /* settings-window */, D04BAD905546AA93E5117B0A /* Menubar.swift */, D04BA26C75F76C277653C932 /* FeedbackWindow.swift */, + D04BA2F0A1B2C3D4E5F60004 /* DebugWindow.swift */, D04BA2CBF267F267225CA671 /* generic-components */, D04BA1C271F0DA573345B860 /* permission-window */, D04BAAF79737A0673502FEB5 /* AppCenterApplication.h */, @@ -2511,6 +2514,7 @@ D04BA8092885B40CE3527370 /* PoliciesTab.swift in Sources */, D04BAD451966B43720120D2E /* Menubar.swift in Sources */, D04BA2E64C59D96F6EB27D9D /* FeedbackWindow.swift in Sources */, + D04BA2F0A1B2C3D4E5F60003 /* DebugWindow.swift in Sources */, D04BA76A74267B1346D23687 /* GridView.swift in Sources */, D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */, D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */, diff --git a/resources/l10n/Localizable.strings b/resources/l10n/Localizable.strings index 433da53c..a55cda5b 100644 --- a/resources/l10n/Localizable.strings +++ b/resources/l10n/Localizable.strings @@ -172,6 +172,9 @@ /* No comment provided by engineer. */ "Dark" = "Dark"; +/* Menubar option */ +"Debug tools" = "Debug tools"; + /* No comment provided by engineer. */ "Disabled" = "Disabled"; diff --git a/resources/l10n/hu.lproj/Localizable.strings b/resources/l10n/hu.lproj/Localizable.strings index e6430af2..caca9f4a 100644 --- a/resources/l10n/hu.lproj/Localizable.strings +++ b/resources/l10n/hu.lproj/Localizable.strings @@ -1,5 +1,5 @@ "About" = "Az App-ról"; -"About %@" = "Körülbelül %@"; +"About %@" = "Az %@ról"; "Accessibility" = "Kisegítő lehetőségek"; "Acknowledgments" = "Köszönetnyílvánítás"; "Active app" = "Aktív alkalmazás"; @@ -126,7 +126,7 @@ "Preview selected window:" = "Választott ablak előnézete:"; "Preview the selected window." = "A kiválasztott ablak előnézete"; "Quit" = "Kilépés"; -"Quit %@" = "Kilépés %@"; +"Quit %@" = "Kilépés az %@ból"; "Quit app" = "Kilépés az alkalmazásból"; "Recently Created" = "Nemrég Elindított"; "Recently Created First" = "Mostanában megnyitott"; diff --git a/src/api-wrappers/Logger.swift b/src/api-wrappers/Logger.swift index 8ce2bb14..5f6c8f29 100644 --- a/src/api-wrappers/Logger.swift +++ b/src/api-wrappers/Logger.swift @@ -10,16 +10,31 @@ class Logger { static func initialize() { let console = ConsoleDestination() console.useTerminalColors = true - console.levelString.verbose = "VERB" - console.levelString.debug = "DEBG" - console.levelString.info = "INFO" - console.levelString.warning = "WARN" - console.levelString.error = "ERRO" - console.format = "$C$D\(longDateTimeFormat)$d $L$c $N.swift:$l $F $M" + configureDestination(console) + console.format = "$C$D\(shortDateTimeFormat)$d $L$c $N.swift:$l $F $M" console.minLevel = decideLevel() logger.addDestination(console) } + static func configureDestination(_ dest: BaseDestination) { + dest.levelString.verbose = "VERB" + dest.levelString.debug = "DEBG" + dest.levelString.info = "INFO" + dest.levelString.warning = "WARN" + dest.levelString.error = "ERRO" + dest.format = "$D\(shortDateTimeFormat)$d $L $N.swift:$l $F $M" + } + + @discardableResult + static func addDestination(_ dest: BaseDestination) -> Bool { + logger.addDestination(dest) + } + + @discardableResult + static func removeDestination(_ dest: BaseDestination) -> Bool { + logger.removeDestination(dest) + } + static func decideLevel() -> SwiftyBeaver.Level { if let level = (CommandLine.arguments.first { $0.starts(with: flag) })?.dropFirst(flag.count) { switch level { @@ -66,3 +81,26 @@ class Logger { } } } + +/// custom destination to display logs in the debug window +class DebugWindowDestination: BaseDestination { + var onNewEntry: ((SwiftyBeaver.Level, String) -> Void)? + + override var defaultHashValue: Int { return 2 } + + override init() { + super.init() + Logger.configureDestination(self) + minLevel = .debug + } + + override func send(_ level: SwiftyBeaver.Level, msg: String, thread: String, + file: String, function: String, line: Int, context: Any? = nil) -> String? { + let formattedString = super.send(level, msg: msg, thread: thread, + file: file, function: function, line: line, context: context) + guard let formatted = formattedString else { return nil } + let callback = onNewEntry + DispatchQueue.main.async { callback?(level, formatted) } + return formattedString + } +} diff --git a/src/logic/WindowDiscriminator.swift b/src/logic/WindowDiscriminator.swift index 64bd9c18..14fa794e 100644 --- a/src/logic/WindowDiscriminator.swift +++ b/src/logic/WindowDiscriminator.swift @@ -5,7 +5,7 @@ class WindowDiscriminator { // Minimized windows or windows of a hidden app have subrole "AXDialog" // Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow" // Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked) - // Logger.error { (app.bundleIdentifier, level, title, subrole, role, size) } + Logger.debug { (app.debugId, wid, level, title, subrole, role, size) } return wid != 0 // Finder's file copy dialogs are wide but < 100 height (see https://github.com/lwouis/alt-tab-macos/issues/1466) // Sonoma introduced a bug: a caps-lock & language indicators shows as a small window. diff --git a/src/ui/App.swift b/src/ui/App.swift index 6d18cfb6..40ab5aaa 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -21,13 +21,14 @@ class App: AppCenterApplication { var isTerminating = false var thumbnailsPanel: TilesPanel! var previewPanel: PreviewPanel! - var settingsWindow: SettingsWindow! - var aboutWindow: AboutWindow! - var permissionsWindow: PermissionsWindow? var appIsBeingUsed = false var shortcutIndex = 0 var forceDoNothingOnRelease = false + var settingsWindow: SettingsWindow! + var aboutWindow: AboutWindow! + var permissionsWindow: PermissionsWindow? private var feedbackWindow: FeedbackWindow! + private var debugWindow: DebugWindow! private var isFirstSummon = true private var isVeryFirstSummon = true // periphery:ignore @@ -95,8 +96,9 @@ class App: AppCenterApplication { private func allSecondaryWindowsCanBecomeKey(_ canBecomeKey_: Bool) { settingsWindow?.canBecomeKey_ = canBecomeKey_ aboutWindow?.canBecomeKey_ = canBecomeKey_ - feedbackWindow?.canBecomeKey_ = canBecomeKey_ permissionsWindow?.canBecomeKey_ = canBecomeKey_ + feedbackWindow?.canBecomeKey_ = canBecomeKey_ + debugWindow?.canBecomeKey_ = canBecomeKey_ } func closeSelectedWindow() { @@ -161,6 +163,10 @@ class App: AppCenterApplication { showSecondaryWindow(getOrCreateFeedbackWindow()) } + @objc func showDebugWindow() { + showSecondaryWindow(getOrCreateDebugWindow()) + } + @objc func showSettingsWindow() { showSecondaryWindow(getOrCreateSettingsWindow()) if settingsWindow?.isVisible != true { @@ -206,6 +212,13 @@ class App: AppCenterApplication { return feedbackWindow } + private func getOrCreateDebugWindow() -> DebugWindow { + if debugWindow == nil { + debugWindow = DebugWindow() + } + return debugWindow + } + private func getOrCreatePermissionsWindow() -> PermissionsWindow { if permissionsWindow == nil { permissionsWindow = PermissionsWindow() diff --git a/src/ui/DebugWindow.swift b/src/ui/DebugWindow.swift new file mode 100644 index 00000000..0f60e978 --- /dev/null +++ b/src/ui/DebugWindow.swift @@ -0,0 +1,389 @@ +import Cocoa +import SwiftyBeaver + +class DebugWindow: NSPanel { + var canBecomeKey_ = true + override var canBecomeKey: Bool { canBecomeKey_ } + private var scrollView: NSScrollView! + private var textView: NSTextView! + private var filterControl: NSSegmentedControl! + private var selectedMinLevel: SwiftyBeaver.Level = .debug + private var isAutoScrolling = true + private var isPerformingAutoScroll = false + private var entries = [(SwiftyBeaver.Level, String)]() + private var destination: DebugWindowDestination? + private var inspectButton: NSButton! + private var inspectColumns: NSStackView! + private var inspectAppField: NSTextField! + private var inspectCgField: NSTextField! + private var inspectAxField: NSTextField! + private var inspectTimer: Timer? + private var inspectClickMonitor: Any? + private var isInspecting = false + private static let logFont = NSFont.userFixedPitchFont(ofSize: 11)! + private static let levels: [SwiftyBeaver.Level] = [.debug, .info, .warning, .error] + private static let defaultAttrs: [NSAttributedString.Key: Any] = [.font: logFont, .foregroundColor: NSColor.textColor] + private static let levelWords: [SwiftyBeaver.Level: String] = [.debug: "DEBG", .info: "INFO", .warning: "WARN", .error: "ERRO"] + + convenience init() { + self.init(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 750), + styleMask: [.titled, .utilityWindow, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + setupWindow() + setupView() + } + + private func setupWindow() { + title = NSLocalizedString("Debug tools", comment: "") + hidesOnDeactivate = false + isReleasedWhenClosed = false + minSize = NSSize(width: 400, height: 300) + } + + private static func colorForLevel(_ level: SwiftyBeaver.Level) -> NSColor { + switch level { + case .debug: return NSColor(calibratedRed: 0.0, green: 0.7, blue: 0.0, alpha: 1.0) + case .info: return NSColor(calibratedRed: 0.0, green: 0.5, blue: 0.85, alpha: 1.0) + case .warning: return NSColor(calibratedRed: 0.82, green: 0.62, blue: 0.0, alpha: 1.0) + case .error: return NSColor(calibratedRed: 0.85, green: 0.15, blue: 0.3, alpha: 1.0) + default: return .textColor + } + } + + private static func colorDot(_ color: NSColor) -> NSImage { + let size = NSSize(width: 8, height: 8) + let image = NSImage(size: size) + image.lockFocus() + color.setFill() + NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image + } + + private func setupView() { + // Inspect section + inspectButton = NSButton(title: "Inspect window", target: nil, action: nil) + inspectButton.translatesAutoresizingMaskIntoConstraints = false + inspectButton.bezelStyle = .rounded + inspectButton.onAction = { [weak self] _ in self?.toggleInspect() } + inspectAppField = Self.makeInspectColumn("App", ["Name", "BundleID", "PID"]) + inspectCgField = Self.makeInspectColumn("Window (CG API)", ["Title", "WID", "Level (kCGWindowLayer)", "Level (CGSGetWindowLevel)", "Size", "Position", "Alpha", "IsOnScreen"]) + inspectAxField = Self.makeInspectColumn("Window (AX API)", ["Title", "Role", "Subrole", "Minimized", "Fullscreen", "Size", "Position"]) + inspectColumns = NSStackView(views: [inspectAppField, inspectCgField, inspectAxField]) + inspectColumns.translatesAutoresizingMaskIntoConstraints = false + inspectColumns.orientation = .horizontal + inspectColumns.alignment = .top + inspectColumns.spacing = 16 + inspectColumns.distribution = .fillEqually + // Log header and filter + let filterLabel = NSTextField(labelWithString: "Log Level:") + filterLabel.translatesAutoresizingMaskIntoConstraints = false + filterControl = NSSegmentedControl(labels: ["Debug", "Info", "Warning", "Error"], + trackingMode: .selectOne, target: nil, action: nil) + filterControl.translatesAutoresizingMaskIntoConstraints = false + filterControl.selectedSegment = 0 + filterControl.onAction = { [weak self] _ in self?.filterChanged() } + for i in 0.. NSAttributedString { + let result = NSMutableAttributedString(string: text, attributes: Self.defaultAttrs) + guard let word = Self.levelWords[level], + let range = text.range(of: word) else { return result } + result.addAttribute(.foregroundColor, value: Self.colorForLevel(level), + range: NSRange(range, in: text)) + return result + } + + private func rebuildText() { + let filtered = entries.filter { $0.0.rawValue >= selectedMinLevel.rawValue } + let result = NSMutableAttributedString() + for (i, entry) in filtered.enumerated() { + if i > 0 { result.append(NSAttributedString(string: "\n", attributes: Self.defaultAttrs)) } + result.append(attributedLine(entry.1, entry.0)) + } + textView.textStorage?.setAttributedString(result) + textView.needsDisplay = true + if isAutoScrolling { + scrollToBottom() + } + } + + private func appendEntry(_ level: SwiftyBeaver.Level, _ message: String) { + entries.append((level, message)) + guard level.rawValue >= selectedMinLevel.rawValue else { return } + let prefix = textView.string.isEmpty ? "" : "\n" + textView.textStorage?.append(attributedLine(prefix + message, level)) + if isAutoScrolling { + scrollToBottom() + } + } + + private func scrollToBottom() { + textView.layoutManager?.ensureLayout(for: textView.textContainer!) + guard let documentView = scrollView.documentView else { return } + let clipView = scrollView.contentView + let maxY = documentView.frame.height - clipView.bounds.height + guard maxY > 0 else { return } + isPerformingAutoScroll = true + clipView.setBoundsOrigin(NSPoint(x: clipView.bounds.origin.x, y: maxY)) + isPerformingAutoScroll = false + } + + @objc private func scrollViewDidScroll(_ notification: Notification) { + guard !isPerformingAutoScroll, + let documentView = scrollView.documentView else { return } + let clipView = scrollView.contentView + let contentHeight = documentView.frame.height + let scrollOffset = clipView.bounds.origin.y + clipView.bounds.height + isAutoScrolling = scrollOffset >= contentHeight - 1 + } + + override func makeKeyAndOrderFront(_ sender: Any?) { + startListening() + super.makeKeyAndOrderFront(sender) + } + + override func close() { + stopInspecting() + stopListening() + clearInspectData() + entries.removeAll() + textView.textStorage?.setAttributedString(NSAttributedString()) + selectedMinLevel = .debug + filterControl.selectedSegment = 0 + isAutoScrolling = true + hideAppIfLastWindowIsClosed() + super.close() + } + + private func toggleInspect() { + if isInspecting { stopInspecting() } else { startInspecting() } + } + + private func startInspecting() { + isInspecting = true + inspectButton.title = "Stop inspecting" + inspectTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.updateInspectData() + } + inspectClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in + self?.stopInspecting() + } + } + + private func stopInspecting() { + guard isInspecting else { return } + isInspecting = false + inspectButton.title = "Inspect window" + inspectTimer?.invalidate() + inspectTimer = nil + if let monitor = inspectClickMonitor { + NSEvent.removeMonitor(monitor) + inspectClickMonitor = nil + } + } + + private func updateInspectData() { + let mouseLocation = NSEvent.mouseLocation + guard let mainScreen = NSScreen.screens.first else { return } + let cgMousePoint = CGPoint(x: mouseLocation.x, y: mainScreen.frame.height - mouseLocation.y) + let myWid = CGWindowID(windowNumber) + guard let found = CGWindow.windows(.optionOnScreenOnly).first(where: { win in + guard let wid = win.id(), wid != myWid, + let bounds = win.bounds() else { return false } + return CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.width, height: bounds.height) + .contains(cgMousePoint) + }) else { + clearInspectData() + return + } + let wid = (found[kCGWindowNumber] as? Int).map { CGWindowID($0) } + let pid = found.ownerPID() + // App column + var appName = "-" + var appBundleId = "-" + let pidStr = pid.map { String($0) } ?? "-" + if let pid { + let app = NSRunningApplication(processIdentifier: pid) + appName = app?.localizedName ?? "-" + appBundleId = app?.bundleIdentifier ?? "-" + } + inspectAppField.stringValue = Self.formatColumn("App", [ + ("Name", appName), ("BundleID", appBundleId), ("PID", pidStr), + ]) + // CG column + let cgTitle = found.title() ?? "-" + let widStr = wid.map { "\($0)" } ?? "-" + let cgLayer = found.layer().map { "\($0)" } ?? "-" + let cgsLevel = wid.map { "\($0.level())" } ?? "-" + let bounds = found.bounds() + let cgSize = bounds.map { "\(Int($0.width))x\(Int($0.height))" } ?? "-" + let cgPos = bounds.map { "\(Int($0.origin.x)),\(Int($0.origin.y))" } ?? "-" + let alpha = (found[kCGWindowAlpha] as? Double).map { "\($0)" } ?? "-" + let isOnScreen = (found[kCGWindowIsOnscreen] as? Bool).map { "\($0)" } ?? "-" + inspectCgField.stringValue = Self.formatColumn("Window (CG API)", [ + ("Title", cgTitle), ("WID", widStr), + ("Level (kCGWindowLayer)", cgLayer), ("Level (CGSGetWindowLevel)", cgsLevel), + ("Size", cgSize), ("Position", cgPos), ("Alpha", alpha), ("IsOnScreen", isOnScreen), + ]) + // AX column + var axTitle = "-" + var axRole = "-" + var axSubrole = "-" + var axMinimized = "-" + var axFullscreen = "-" + var axSize = "-" + var axPosition = "-" + if let pid, let wid { + let axApp = AXUIElementCreateApplication(pid) + if let axWindows = try? axApp.attributes([kAXWindowsAttribute]).windows { + for axWin in axWindows { + if let axWid = try? axWin.cgWindowId(), axWid == wid { + let attrs = try? axWin.attributes([kAXTitleAttribute, kAXRoleAttribute, kAXSubroleAttribute, + kAXMinimizedAttribute, kAXFullscreenAttribute, + kAXSizeAttribute, kAXPositionAttribute]) + axTitle = attrs?.title ?? "-" + axRole = attrs?.role ?? "-" + axSubrole = attrs?.subrole ?? "-" + axMinimized = attrs?.isMinimized.map { "\($0)" } ?? "-" + axFullscreen = attrs?.isFullscreen.map { "\($0)" } ?? "-" + if let s = attrs?.size { axSize = "\(Int(s.width))x\(Int(s.height))" } + if let p = attrs?.position { axPosition = "\(Int(p.x)),\(Int(p.y))" } + break + } + } + } + } + inspectAxField.stringValue = Self.formatColumn("Window (AX API)", [ + ("Title", axTitle), ("Role", axRole), ("Subrole", axSubrole), + ("Minimized", axMinimized), ("Fullscreen", axFullscreen), + ("Size", axSize), ("Position", axPosition), + ]) + } + + private func clearInspectData() { + inspectAppField.stringValue = Self.formatColumn("App", [("Name", "-"), ("BundleID", "-"), ("PID", "-")]) + inspectCgField.stringValue = Self.formatColumn("Window (CG API)", [ + ("Title", "-"), ("WID", "-"), ("Level (kCGWindowLayer)", "-"), ("Level (CGSGetWindowLevel)", "-"), + ("Size", "-"), ("Position", "-"), ("Alpha", "-"), ("IsOnScreen", "-"), + ]) + inspectAxField.stringValue = Self.formatColumn("Window (AX API)", [ + ("Title", "-"), ("Role", "-"), ("Subrole", "-"), ("Minimized", "-"), ("Fullscreen", "-"), ("Size", "-"), ("Position", "-"), + ]) + } + + private static func makeInspectColumn(_ title: String, _ labels: [String]) -> NSTextField { + let field = NSTextField(labelWithString: formatColumn(title, labels.map { ($0, "-") })) + field.translatesAutoresizingMaskIntoConstraints = false + field.font = logFont + field.isSelectable = true + field.maximumNumberOfLines = 0 + field.cell?.lineBreakMode = .byTruncatingTail + field.cell?.truncatesLastVisibleLine = true + field.setContentHuggingPriority(.defaultLow, for: .horizontal) + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + private static func formatColumn(_ title: String, _ rows: [(String, String)]) -> String { + return "\(title)\n" + rows.map { "• \($0.0): \($0.1)" }.joined(separator: "\n") + } +} diff --git a/src/ui/Menubar.swift b/src/ui/Menubar.swift index 14026f56..3d04c540 100644 --- a/src/ui/Menubar.swift +++ b/src/ui/Menubar.swift @@ -29,6 +29,7 @@ class Menubar { addMenuItem(NSLocalizedString("Check permissions…", comment: "Menubar option"), #selector(App.app.checkPermissions), "", "hand.raised") menu.addItem(NSMenuItem.separator()) addMenuItem(String(format: NSLocalizedString("About %@", comment: "Menubar option. %@ is AltTab"), App.name), #selector(App.app.showAboutWindow), "", "info.circle") + addMenuItem(NSLocalizedString("Debug tools", comment: "Menubar option"), #selector(App.app.showDebugWindow), "", "scope") addMenuItem(NSLocalizedString("Send feedback…", comment: "Menubar option"), #selector(App.app.showFeedbackPanel), "", "text.bubble") addMenuItem(NSLocalizedString("Support this project", comment: "Menubar option"), App.supportProjectAction, "", "heart.fill", .red) menu.addItem(NSMenuItem.separator())