feat: allow to run the app without screen-recording permissions

closes #1082
This commit is contained in:
lwouis committed 2024-10-17 22:28:34 +02:00
1 parent 19f59b207f
commit 129d061434
9 files changed
+190 -97

No files matched your search

+30 -12
View File
@@ -55,6 +55,9 @@
/* No comment provided by engineer. */
"AltTab is currently set to show Applications. This setting is only available when AltTab is set to show Windows." = "AltTab is currently set to show Applications. This setting is only available when AltTab is set to show Windows.";
/* Menubar callout */
"AltTab is running without Screen Recording permissions. Thumbnails won’t show." = "AltTab is running without Screen Recording permissions. Thumbnails won’t show.";
/* No comment provided by engineer. */
"AltTab needs some permissions" = "AltTab needs some permissions";
@@ -187,6 +190,9 @@
/* No comment provided by engineer. */
"General" = "General";
/* Menubar callout button */
"Grant permission" = "Grant permission";
/* No comment provided by engineer. */
"Hide" = "Hide";
@@ -226,9 +232,18 @@
/* No comment provided by engineer. */
"Ignore shortcuts when active" = "Ignore shortcuts when active";
/* No comment provided by engineer. */
"Language" = "Language";
/* No comment provided by engineer. */
"Language Change" = "Language Change";
/* No comment provided by engineer. */
"Large" = "Large";
/* No comment provided by engineer. */
"Later" = "Later";
/* No comment provided by engineer. */
"Latest releases" = "Latest releases";
@@ -310,6 +325,9 @@
/* No comment provided by engineer. */
"Reset preferences and restart…" = "Reset preferences and restart…";
/* No comment provided by engineer. */
"Restart Now" = "Restart Now";
/* No comment provided by engineer. */
"Screen including menu bar" = "Screen including menu bar";
@@ -427,6 +445,9 @@
/* No comment provided by engineer. */
"Size" = "Size";
/* No comment provided by engineer. */
"Skipped" = "Skipped";
/* No comment provided by engineer. */
"Small" = "Small";
@@ -454,6 +475,12 @@
/* No comment provided by engineer. */
"System" = "System";
/* No comment provided by engineer. */
"System Default" = "System Default";
/* No comment provided by engineer. */
"The application needs to restart to apply the language change." = "The application needs to restart to apply the language change.";
/* No comment provided by engineer. */
"Theme" = "Theme";
@@ -481,6 +508,9 @@
/* No comment provided by engineer. */
"Updates policy" = "Updates policy";
/* No comment provided by engineer. */
"Use the app without this permission. Thumbnails won’t show." = "Use the app without this permission. Thumbnails won’t show.";
/* No comment provided by engineer. */
"Version" = "Version";
@@ -525,15 +555,3 @@
/* No comment provided by engineer. */
"You didn’t write your email, thus can’t receive any response." = "You didn’t write your email, thus can’t receive any response.";
"Language" = "Language";
"System Default" = "System Default";
"Language Change" = "Language Change";
"The application needs to restart to apply the language change." = "The application needs to restart to apply the language change.";
"Restart Now" = "Restart Now";
"Later" = "Later";
+2
View File
@@ -101,6 +101,7 @@ class Preferences {
"hideWindowlessApps": "false",
"hideThumbnails": "false",
"previewFocusedWindow": "false",
"screenRecordingPermissionSkipped": "false",
]
// system preferences
@@ -137,6 +138,7 @@ class Preferences {
static var startAtLogin: Bool { defaults.bool("startAtLogin") }
static var blacklist: [BlacklistEntry] { jsonDecode([BlacklistEntry].self, defaults.string("blacklist")) }
static var previewFocusedWindow: Bool { defaults.bool("previewFocusedWindow") }
static var screenRecordingPermissionSkipped: Bool { defaults.bool("screenRecordingPermissionSkipped") }
// macro values
static var appearanceStyle: AppearanceStylePreference { defaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases) }
+62 -41
View File
@@ -2,20 +2,23 @@ import Cocoa
// macOS has some privacy restrictions. The user needs to grant certain permissions, app by app, in System Preferences > Security & Privacy
class SystemPermissions {
static var timer: Timer!
static var preStartupPermissionsPassed = false
static var timerPermissionsToUpdatePermissionsWindow: Timer?
static var timerPermissionsRemovedWhileAltTabIsRunning: Timer?
static func accessibilityIsGranted() -> Bool {
static func accessibilityIsGranted() -> PermissionStatus {
if #available(macOS 10.9, *) {
return AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeRetainedValue(): false] as CFDictionary)
return AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeRetainedValue(): false] as CFDictionary) ? .granted : .notGranted
}
return true
return .granted
}
static func screenRecordingIsGranted() -> Bool {
static func screenRecordingIsGranted() -> PermissionStatus {
if #available(macOS 10.15, *) {
return screenRecordingIsGranted_()
return screenRecordingIsGranted_() ? .granted :
(Preferences.screenRecordingPermissionSkipped ? .skipped : .notGranted)
}
return true
return .granted
}
// workaround: public API CGPreflightScreenCaptureAccess and private API SLSRequestScreenCaptureAccess exist, but
@@ -33,65 +36,83 @@ class SystemPermissions {
) != nil
}
static func observePermissionsPostStartup() {
static func observePermissionsRemovedWhileAltTabIsRunning() {
var counter = 0
timer = Timer(timeInterval: 5, repeats: true, block: { _ in
if !accessibilityIsGranted() {
App.app.restart()
}
if !screenRecordingIsGranted() {
// permission check may yield a false negative during wake-up
// we restart after 2 negative checks
if counter >= 2 {
timerPermissionsRemovedWhileAltTabIsRunning = Timer(timeInterval: 5, repeats: true, block: { _ in
DispatchQueue.main.async {
let accessibility = accessibilityIsGranted()
let screenRecording = screenRecordingIsGranted()
logger.d(accessibility, screenRecording, preStartupPermissionsPassed)
Menubar.permissionCalloutMenuItems?.forEach {
$0.isHidden = screenRecording != .skipped
}
if accessibility == .notGranted {
App.app.restart()
}
if screenRecording == .notGranted {
// permission check may yield a false negative during wake-up
// we restart after 2 negative checks
if counter >= 2 {
App.app.restart()
} else {
counter += 1
}
} else {
counter += 1
counter = 0
}
} else {
counter = 0
}
})
timer.tolerance = 4.9
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timer, .defaultMode)
timerPermissionsRemovedWhileAltTabIsRunning!.tolerance = 4.9
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timerPermissionsRemovedWhileAltTabIsRunning!, .defaultMode)
}
static func observePermissionsPreStartup(_ startupBlock: @escaping () -> Void) {
if #available(macOS 10.15, *) {
static func observePermissionsToUpdatePermissionsWindow(_ startupBlock: @escaping () -> Void) {
if #available(macOS 10.15, *), !preStartupPermissionsPassed {
// this call triggers the permission prompt, however it's the only way to force the app to be listed with a checkbox
SLSRequestScreenCaptureAccess()
}
timer = Timer(timeInterval: 0.1, repeats: true) { _ in
let accessibility = accessibilityIsGranted()
let screenRecording = screenRecordingIsGranted()
timerPermissionsToUpdatePermissionsWindow = Timer(timeInterval: 0.1, repeats: true) { _ in
DispatchQueue.main.async {
if accessibility && screenRecording {
timer.invalidate()
App.app.permissionsWindow?.close()
startupBlock()
} else {
if accessibility != App.app.permissionsWindow.accessibilityView.isPermissionGranted {
App.app.permissionsWindow.accessibilityView.updatePermissionStatus(accessibility)
let accessibility = accessibilityIsGranted()
let screenRecording = screenRecordingIsGranted()
logger.d(accessibility, screenRecording, preStartupPermissionsPassed)
Menubar.permissionCalloutMenuItems?.forEach {
$0.isHidden = screenRecording != .skipped
}
if accessibility != App.app.permissionsWindow?.accessibilityView?.permissionStatus {
App.app.permissionsWindow?.accessibilityView.updatePermissionStatus(accessibility)
}
if #available(macOS 10.15, *), screenRecording != App.app.permissionsWindow?.screenRecordingView?.permissionStatus {
App.app.permissionsWindow?.screenRecordingView?.updatePermissionStatus(screenRecording)
}
if !preStartupPermissionsPassed {
if accessibility != .notGranted && screenRecording != .notGranted {
preStartupPermissionsPassed = true
App.app.permissionsWindow?.close()
startupBlock()
}
if #available(macOS 10.15, *), screenRecording != App.app.permissionsWindow.screenRecordingView.isPermissionGranted {
App.app.permissionsWindow.screenRecordingView.updatePermissionStatus(screenRecording)
} else {
if accessibility == .notGranted || screenRecording == .notGranted {
App.app.restart()
}
}
}
}
timer.tolerance = 0.09
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timer, .defaultMode)
timerPermissionsToUpdatePermissionsWindow!.tolerance = 0.09
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timerPermissionsToUpdatePermissionsWindow!, .defaultMode)
}
static func ensurePermissionsAreGranted(_ continueAppStartup: @escaping () -> Void) {
let startupBlock = {
observePermissionsPostStartup()
observePermissionsRemovedWhileAltTabIsRunning()
continueAppStartup()
}
if accessibilityIsGranted() && screenRecordingIsGranted() {
if accessibilityIsGranted() != .notGranted && screenRecordingIsGranted() != .notGranted {
preStartupPermissionsPassed = true
startupBlock()
} else {
App.app.permissionsWindow.show()
observePermissionsPreStartup(startupBlock)
App.app.permissionsWindow.show(startupBlock)
}
}
}
+5 -5
View File
@@ -18,7 +18,6 @@ class App: AppCenterApplication, NSApplicationDelegate {
static let repository = "https://github.com/lwouis/alt-tab-macos"
static let website = "https://alt-tab-macos.netlify.app"
static var app: App!
var menubar: Menubar!
var thumbnailsPanel: ThumbnailsPanel!
var previewPanel: PreviewPanel!
var preferencesWindow: PreferencesWindow!
@@ -59,14 +58,14 @@ class App: AppCenterApplication, NSApplicationDelegate {
PFMoveToApplicationsFolderIfNecessary()
#endif
AXUIElement.setGlobalTimeout()
Preferences.initialize()
BackgroundWork.startSystemPermissionThread()
permissionsWindow = PermissionsWindow()
SystemPermissions.ensurePermissionsAreGranted { [weak self] in
guard let self = self else { return }
BackgroundWork.start()
Preferences.initialize()
Appearance.update()
self.menubar = Menubar()
Menubar.initialize()
self.loadMainMenuXib()
self.thumbnailsPanel = ThumbnailsPanel()
self.previewPanel = PreviewPanel()
@@ -80,7 +79,7 @@ class App: AppCenterApplication, NSApplicationDelegate {
DispatchQueue.main.async { () -> () in Windows.sortByLevel() }
self.preloadWindows()
#if DEBUG
self.showPreferencesWindow()
// self.showPreferencesWindow()
#endif
}
}
@@ -117,6 +116,7 @@ class App: AppCenterApplication, NSApplicationDelegate {
func restart() {
// we use -n to open a new instance, to avoid calling applicationShouldHandleReopen
// we use Bundle.main.bundlePath in case of multiple AltTab versions on the machine
printStackTrace()
Process.launchedProcess(launchPath: "/usr/bin/open", arguments: ["-n", Bundle.main.bundlePath])
App.shared.terminate(self)
}
@@ -180,7 +180,7 @@ class App: AppCenterApplication, NSApplicationDelegate {
}
@objc func checkPermissions(_ sender: NSMenuItem) {
permissionsWindow.show()
permissionsWindow.show({})
}
@objc func supportProject() {
+38 -10
View File
@@ -1,14 +1,21 @@
import Cocoa
class Menubar {
var statusItem: NSStatusItem!
var menu: NSMenu!
static var statusItem: NSStatusItem!
static var menu: NSMenu!
static var permissionCalloutMenuItems: [NSMenuItem]?
init() {
static func initialize() {
menu = NSMenu()
menu.title = App.name // perf: prevent going through expensive code-path within appkit
let permissionCalloutMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
permissionCalloutMenuItem.view = PermissionCallout()
let calloutSeparator = NSMenuItem.separator()
permissionCalloutMenuItems = [permissionCalloutMenuItem, calloutSeparator]
menu.addItem(permissionCalloutMenuItem)
menu.addItem(calloutSeparator)
menu.addItem(
withTitle: String(format: NSLocalizedString("About %@", comment: "Menubar option. %@ is AltTab"), App.name),
withTitle: String(format: NSLocalizedString("About %@", comment: "Menubar option. %@ is AltTab"), App.name),
action: #selector(App.app.showAboutTab),
keyEquivalent: "")
menu.addItem(NSMenuItem.separator())
@@ -48,16 +55,16 @@ class Menubar {
menubarIconCallback(nil)
}
@objc func statusItemOnClick() {
@objc static func statusItemOnClick() {
// NSApp.currentEvent == nil if the icon is "clicked" through VoiceOver
if let type = NSApp.currentEvent?.type, type != .leftMouseDown {
App.app.showUi()
} else {
statusItem.popUpMenu(App.app.menubar.menu)
statusItem.popUpMenu(Menubar.menu)
}
}
func menubarIconCallback(_: NSControl?) {
static func menubarIconCallback(_: NSControl?) {
if Preferences.menubarIcon == .hidden {
statusItem.isVisible = false
} else {
@@ -65,7 +72,7 @@ class Menubar {
}
}
private func loadPreferredIcon() {
static private func loadPreferredIcon() {
let i = imageIndexFromPreference()
let image = NSImage(named: "menubar-" + i)!
image.isTemplate = i == "3" ? false : true
@@ -74,7 +81,7 @@ class Menubar {
statusItem.button!.imageScaling = .scaleProportionallyUpOrDown
}
private func imageIndexFromPreference() -> String {
static private func imageIndexFromPreference() -> String {
switch Preferences.menubarIcon {
case .outlined: return "1"
case .filled: return "2"
@@ -82,4 +89,25 @@ class Menubar {
default: return "4"
}
}
}
}
class PermissionCallout: StackView {
convenience init() {
let label = NSTextField(wrappingLabelWithString: NSLocalizedString("AltTab is running without Screen Recording permissions. Thumbnails won’t show.", comment: "Menubar callout"))
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.preferredMaxLayoutWidth = 250
label.isSelectable = false
label.widthAnchor.constraint(equalToConstant: 250).isActive = true
let button = NSButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.attributedTitle = NSAttributedString(string: NSLocalizedString("Grant permission", comment: "Menubar callout button"), attributes: [NSAttributedString.Key.foregroundColor: NSColor.white])
button.onAction = { _ in
Preferences.remove("screenRecordingPermissionSkipped")
App.app.restart()
}
self.init([label, button], .vertical, true, top: 8, right: 15, bottom: 10, left: 15)
wantsLayer = true
layer!.backgroundColor = NSColor.purple.cgColor
}
}
+32 -15
View File
@@ -3,11 +3,12 @@ import Cocoa
class PermissionView: StackView {
static let greenColor = NSColor(srgbRed: 0.38, green: 0.75, blue: 0.33, alpha: 0.2)
static let redColor = NSColor(srgbRed: 0.90, green: 0.35, blue: 0.32, alpha: 0.2)
static let yellowColor = NSColor(srgbRed: 0.83, green: 0.66, blue: 0.07, alpha: 0.2)
var status: NSTextField!
var isPermissionGranted = false
var permissionStatus = PermissionStatus.notGranted
convenience init(_ iconName: String, _ title: String, _ justification: String, _ buttonText: String, _ buttonUrl: String, _ checkFunction: @escaping () -> Bool) {
convenience init(_ iconName: String, _ title: String, _ justification: String, _ buttonText: String, _ buttonUrl: String, _ checkFunction: @escaping () -> PermissionStatus, _ skipCheckbox: NSView? = nil) {
let icon = NSImageView(image: NSImage.initCopy(iconName))
icon.translatesAutoresizingMaskIntoConstraints = false
icon.fit()
@@ -29,25 +30,41 @@ class PermissionView: StackView {
let buttonStack = NSStackView(views: [button, status])
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonStack.alignment = .centerY
self.init([titleStack, justification, buttonStack], .vertical, top: GridView.interPadding, right: GridView.interPadding, bottom: GridView.interPadding, left: GridView.interPadding)
var views = [titleStack, justification, buttonStack]
if let skipCheckbox = skipCheckbox {
views.append(skipCheckbox)
}
self.init(views, .vertical, top: GridView.interPadding, right: GridView.interPadding, bottom: GridView.interPadding, left: GridView.interPadding)
self.status = status
wantsLayer = true
layer!.cornerRadius = GridView.interPadding / 2
updatePermissionStatus(checkFunction())
}
func updatePermissionStatus(_ isPermissionGranted: Bool) {
self.isPermissionGranted = isPermissionGranted
if isPermissionGranted {
let color = PermissionView.greenColor
status.stringValue = "● " + NSLocalizedString("Allowed", comment: "")
status.textColor = color.withAlphaComponent(1)
layer!.backgroundColor = color.cgColor
} else {
let color = PermissionView.redColor
status.stringValue = "● " + NSLocalizedString("Not allowed", comment: "")
status.textColor = color.withAlphaComponent(1)
layer!.backgroundColor = color.cgColor
func updatePermissionStatus(_ permissionStatus: PermissionStatus) {
self.permissionStatus = permissionStatus
var color: NSColor
var label: String
switch permissionStatus {
case .granted:
color = PermissionView.greenColor
label = NSLocalizedString("Allowed", comment: "")
case .notGranted:
color = PermissionView.redColor
label = NSLocalizedString("Not allowed", comment: "")
case .skipped:
color = PermissionView.yellowColor
label = NSLocalizedString("Skipped", comment: "")
}
status.stringValue = "● " + label
status.textColor = color.withAlphaComponent(1)
layer!.backgroundColor = color.cgColor
}
}
enum PermissionStatus {
case granted
case notGranted
case skipped
}
@@ -11,7 +11,7 @@ class PermissionsWindow: NSWindow, NSWindowDelegate {
setupView()
}
func show() {
func show(_ startupBlock: @escaping () -> Void) {
accessibilityView.updatePermissionStatus(SystemPermissions.accessibilityIsGranted())
if #available(macOS 10.15, *) {
screenRecordingView.updatePermissionStatus(SystemPermissions.screenRecordingIsGranted())
@@ -19,17 +19,24 @@ class PermissionsWindow: NSWindow, NSWindowDelegate {
center()
App.shared.activate(ignoringOtherApps: true)
makeKeyAndOrderFront(nil)
SystemPermissions.observePermissionsToUpdatePermissionsWindow(startupBlock)
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
if !SystemPermissions.accessibilityIsGranted() || !SystemPermissions.screenRecordingIsGranted() {
logger.e("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.",
"Please authorize and re-launch.",
"See https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx",
separator: "\n")
App.shared.terminate(self)
func windowWillClose(_ notification: Notification) {
logger.d("PermissionsWindow windowWillClose", SystemPermissions.preStartupPermissionsPassed)
if !SystemPermissions.preStartupPermissionsPassed {
if SystemPermissions.accessibilityIsGranted() == .notGranted || SystemPermissions.screenRecordingIsGranted() == .notGranted {
logger.e("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.",
"Please authorize and re-launch.",
"See https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx",
separator: "\n")
App.shared.terminate(self)
}
} else {
SystemPermissions.timerPermissionsToUpdatePermissionsWindow?.invalidate()
}
return true
}
private func setupWindow() {
@@ -66,7 +73,8 @@ class PermissionsWindow: NSWindow, NSWindowDelegate {
NSLocalizedString("This permission is needed to show screenshots and titles of open windows", comment: ""),
NSLocalizedString("Open Screen Recording Preferences…", comment: ""),
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
SystemPermissions.screenRecordingIsGranted
SystemPermissions.screenRecordingIsGranted,
StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Use the app without this permission. Thumbnails won’t show.", comment: ""), "screenRecordingPermissionSkipped", labelPosition: .right))
)
rows.append([screenRecordingView])
}
@@ -107,7 +107,6 @@ class LabelAndControl: NSObject {
return views
}
// periphery:ignore
static func makeLabelWithCheckbox(_ labelText: String, _ rawName: String, extraAction: ActionClosure? = nil, labelPosition: LabelPosition = .leftWithSeparator) -> [NSView] {
let checkbox = NSButton(checkboxWithTitle: labelPosition == .right ? labelText : " ", target: nil, action: nil)
checkbox.translatesAutoresizingMaskIntoConstraints = false
@@ -7,7 +7,7 @@ class GeneralTab {
let startAtLogin = TableGroupView.Row(leftTitle: NSLocalizedString("Start at login", comment: ""),
rightViews: [LabelAndControl.makeSwitch("startAtLogin", extraAction: startAtLoginCallback)])
let menubarIcon = TableGroupView.Row(leftTitle: NSLocalizedString("Menubar icon", comment: ""),
rightViews: [LabelAndControl.makeDropdown("menubarIcon", MenubarIconPreference.allCases, extraAction: App.app.menubar.menubarIconCallback)])
rightViews: [LabelAndControl.makeDropdown("menubarIcon", MenubarIconPreference.allCases, extraAction: Menubar.menubarIconCallback)])
let language = TableGroupView.Row(leftTitle: NSLocalizedString("Language", comment: ""),
rightViews: [LabelAndControl.makeDropdown("language", LanguagePreference.allCases, extraAction: setLanguageCallback)])
let resetPreferences = NSButton(title: NSLocalizedString("Reset preferences and restart…", comment: ""), target: self, action: #selector(GeneralTab.resetPreferences))
@@ -40,8 +40,8 @@ class GeneralTab {
}
private static func enableDraggingOffMenubarIcon(_ menubarIconDropdown: NSPopUpButton) {
App.app.menubar.statusItem.behavior = .removalAllowed
menubarIsVisibleObserver = App.app.menubar.statusItem.observe(\.isVisible, options: [.old, .new]) { _, change in
Menubar.statusItem.behavior = .removalAllowed
menubarIsVisibleObserver = Menubar.statusItem.observe(\.isVisible, options: [.old, .new]) { _, change in
if change.oldValue == true && change.newValue == false {
let hiddenIndex = Int(MenubarIconPreference.hidden.rawValue)!
menubarIconDropdown.selectItem(at: hiddenIndex)