macOS Catalina 上如何检测屏幕录制设置

30

如何可靠地检测用户是否启用了此API?

CGWindowListCreateImage即使屏幕录制API被禁用也会返回有效对象。有多种组合可能(kCGWindowListOptionIncludingWindow, kCGWindowListOptionOnScreenBelowWindow),只有一些组合会返回NULL。

- (CGImageRef)createScreenshotImage
{
    NSWindow *window = [[self view] window];
    NSRect rect = [window frame];

    rect.origin.y = NSHeight([[window screen] frame]) - NSMaxY([window frame]);
    CGImageRef screenshot = CGWindowListCreateImage(
                                                    rect,
                                                    kCGWindowListOptionIncludingWindow,
                                                    //kCGWindowListOptionOnScreenBelowWindow,
                                                    0,//(CGWindowID)[window windowNumber],
                                                    kCGWindowImageBoundsIgnoreFraming);//kCGWindowImageDefault
    return screenshot;
}
唯一可靠的方法是通过CGDisplayStreamCreate,但这种方法存在风险,因为苹果每年都会更改隐私设置。
   - (BOOL)canRecordScreen
    {
        if (@available(macOS 10.15, *)) {
            CGDisplayStreamRef stream = CGDisplayStreamCreate(CGMainDisplayID(), 1, 1, kCVPixelFormatType_32BGRA, nil, ^(CGDisplayStreamFrameStatus status, uint64_t displayTime, IOSurfaceRef frameSurface, CGDisplayStreamUpdateRef updateRef) {
                ;
            });
            BOOL canRecord = stream != NULL;
            if (stream) { 
              CFRelease(stream); 
            }
            return canRecord;
        } else {
            return YES;
        }
    }

用户如何禁用屏幕录制API? - TheNextman
4
或许不是很清楚。在Catalina系统中有一个新的隐私开关。使用API会触发一个隐私窗口,用户有两个选择:1)拒绝,2)打开系统设置手动启用。没有允许按钮。 - Marek H
谢谢,很抱歉我无法回答你的问题,但是这是个好知识 :) - TheNextman
@MarekH 我们能否绕过/抑制这个隐私窗口。 - Sangram Shivankar
macOS 11 的 API 有任何更新吗? - Jimmy
@Jimmy 是的。使用CGRequestScreenCaptureAccess()函数。 - Marek H
9个回答

30
所有这里提出的解决方案都有某种缺陷。问题的根源在于,您对窗口(通过窗口列表中的名称)拥有知晓权限与您对窗口的进程所有者(例如WindowServer和Dock)拥有知晓权限之间没有关联。您查看屏幕上的像素的权限是两个稀疏信息集的组合。
以下是截至macOS 10.15.1的所有情况的一种启发式方法:
BOOL canRecordScreen = YES;
if (@available(macOS 10.15, *)) {
    canRecordScreen = NO;
    NSRunningApplication *runningApplication = NSRunningApplication.currentApplication;
    NSNumber *ourProcessIdentifier = [NSNumber numberWithInteger:runningApplication.processIdentifier];

    CFArrayRef windowList = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
    NSUInteger numberOfWindows = CFArrayGetCount(windowList);
    for (int index = 0; index < numberOfWindows; index++) {
        // get information for each window
        NSDictionary *windowInfo = (NSDictionary *)CFArrayGetValueAtIndex(windowList, index);
        NSString *windowName = windowInfo[(id)kCGWindowName];
        NSNumber *processIdentifier = windowInfo[(id)kCGWindowOwnerPID];

        // don't check windows owned by this process
        if (! [processIdentifier isEqual:ourProcessIdentifier]) {
            // get process information for each window
            pid_t pid = processIdentifier.intValue;
            NSRunningApplication *windowRunningApplication = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
            if (! windowRunningApplication) {
                // ignore processes we don't have access to, such as WindowServer, which manages the windows named "Menubar" and "Backstop Menubar"
            }
            else {
                NSString *windowExecutableName = windowRunningApplication.executableURL.lastPathComponent;
                if (windowName) {
                    if ([windowExecutableName isEqual:@"Dock"]) {
                        // ignore the Dock, which provides the desktop picture
                    }
                    else {
                        canRecordScreen = YES;
                        break;
                    }
                }
            }
        }
    }
    CFRelease(windowList);
}

如果未设置canRecordScreen,则需要弹出某种对话框,警告用户他们只能看到菜单栏、桌面图片和应用程序自身窗口。以下是我们在我们的应用程序xScope如何呈现它
是的,我仍然对这些保护措施没有考虑到可用性而感到不满。

6
非常好用!Swift 5 版本:https://gist.github.com/soffes/da6ea98be4f56bc7b8e75079a5224b37 - Sam Soffes
3
设备语言不是英文的情况下,这个方案是否有效我们不知道。看起来是个好方案,但"Dock"这个词让我有些担心...不过我猜可执行文件名应该是一样的。 - Max Chuquimia
6
@max-chuquimia,是的,当设备未设置为英语时也可以工作。Craig使用的是lastPathComponent而不是localizedName,因此名称是真正的可执行文件,而不是其本地化名称。当我为我的应用程序(Default Folder X)编写类似的代码时,我明确测试了这一点。 - Jon Gotow
2
如果没有打开的窗口,这个代码能正常工作吗?显然,这是一个边缘情况,系统中还有许多其他的窗口存在,但我只是好奇当一个应用程序被安装在干净的macOS上并运行此检查时,这是否是一个万无一失的解决方案。是否存在这样一种情况:根本没有窗口可以精确地确定这个问题? - Ian Bytchek
2
不幸的是,尽管有API文档,但CGRequestScreenCaptureAccess和CGPreflightScreenCaptureAccess在Catalina中没有实现,如果在10.15中使用,将会崩溃。 - Peter N Lewis
显示剩余4条评论

23

苹果提供直接的低级API来检查访问和授予权限。不需要使用棘手的解决方法。

/* Checks whether the current process already has screen capture access */
@available(macOS 10.15, *)
public func CGPreflightScreenCaptureAccess() -> Bool
使用上述函数来检查屏幕截图访问权限。如果没有访问权限,请使用下面的函数提示用户授权访问。
/* Requests event listening access if absent, potentially prompting */
@available(macOS 10.15, *)
public func CGRequestScreenCaptureAccess() -> Bool

来自文档的截图


2
有趣的是,社区对这个API如此之久都不知情。 - Jimmy
7
这似乎在Big Sur上运行良好,但在Catalina(至少是10.15.7版本)中会崩溃。 - Jordan H
2
只有在未在主队列上调用时,它才会崩溃。这对我很有效:dispatch_sync(dispatch_get_main_queue(), ^{ CGRequestScreenCaptureAccess(); }); - Jezevec
1
@Jezevec 太天真了,刚刚收到了300个崩溃报告,全部都是针对10.15版本的,而10.16/11.0版本却没有一个。这个API在10.15上不存在。我会提交问题报告。 - Marek H
1
请注意,API 无法在应用程序运行时检测更改。也就是说,在用户授予权限后,必须重新启动应用程序才能成功检查。谢谢。 - VoodooBoot
显示剩余3条评论

4

@marek-h发布了一个很好的例子,可以在不显示隐私警报的情况下检测屏幕录制设置。 另外,@jordan-h提到,当应用程序通过beginSheetModalForWindow显示警报时,此解决方案无法工作。

我发现SystemUIServer进程总是创建一些窗口,它们的名称为:AppleVolumeExtra、AppleClockExtra、AppleBluetoothExtra…

在隐私首选项中启用屏幕录制之前,我们无法获取这些窗口的名称。当我们至少能够获取其中一个名称时,那就意味着用户已经启用了屏幕录制。

因此,我们可以检查(由SystemUIServer进程创建的)窗口的名称来检测屏幕录制首选项,在macOS Catalina上运行良好。

#include <AppKit/AppKit.h>
#include <libproc.h>

bool isScreenRecordingEnabled()
{
    if (@available(macos 10.15, *)) {
        bool bRet = false;
        CFArrayRef list = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
        if (list) {
            int n = (int)(CFArrayGetCount(list));
            for (int i = 0; i < n; i++) {
                NSDictionary* info = (NSDictionary*)(CFArrayGetValueAtIndex(list, (CFIndex)i));
                NSString* name = info[(id)kCGWindowName];
                NSNumber* pid = info[(id)kCGWindowOwnerPID];
                if (pid != nil && name != nil) {
                    int nPid = [pid intValue];
                    char path[PROC_PIDPATHINFO_MAXSIZE+1];
                    int lenPath = proc_pidpath(nPid, path, PROC_PIDPATHINFO_MAXSIZE);
                    if (lenPath > 0) {
                        path[lenPath] = 0;
                        if (strcmp(path, "/System/Library/CoreServices/SystemUIServer.app/Contents/MacOS/SystemUIServer") == 0) {
                            bRet = true;
                            break;
                        }
                    }
                }
            }
            CFRelease(list);
        }
        return bRet;
    } else {
        return true;
    }
}

当应用程序通过beginSheetModalForWindow显示警报时,这也是有效的。 - Fred Zhang
1
请在您的答案中添加解释,以使其更清晰。 - mukund patel
嗨Fred,你能否详细解释一下为什么这样会有效吗?如果有例子的话会更好。你的解决方案没有计算屏幕上的窗口数量。它也不能用作复制粘贴因为有一些未知的常数如sl_true。 - Marek H
1
嗨Marek,我更新了帖子,加入了更多的描述和示例代码。 - Fred Zhang
我不明白。我们正在为特定应用程序请求“屏幕录制权限”...我们关心SystemUIServer窗口的名称吗? - Motti Shneor
显示剩余2条评论

1
截至MacOS 10.15.7,获取可见窗口的窗口名称的启发式方法(从而知道我们是否具有屏幕捕获权限)并不总是有效。有时我们只是找不到可以查询的有效窗口,并会错误地推断我们没有权限。
然而,我发现了另一种直接查询(使用sqlite)Apple TCC数据库的方法 - 权限持久化的模型。屏幕录制权限可在“系统级别”TCC数据库中找到(位于/Library/Application Support/com.apple.TCC/TCC.db)。如果您使用sqlite打开数据库,并查询:SELECT allowed FROM access WHERE client="com.myCompany.myApp" AND service="kTCCServiceScreenCapture",您将得到答案。
与其他答案相比,有两个缺点:
  • 要打开此TCC.db数据库,您的应用程序必须具有“完全磁盘访问”权限。它不需要以'root'特权运行,即使您没有“完全磁盘访问”权限,特权也无济于事。
  • 运行时间约为15毫秒,比查询窗口列表慢。

优点是,它直接查询实际对象,不依赖于任何窗口或进程存在于查询时间。

以下是一些示例代码:

NSString *client = @"com.myCompany.myApp";
sqlite3 *tccDb = NULL;
sqlite3_stmt *statement = NULL;

NSString *pathToSystemTCCDB = @"/Library/Application Support/com.apple.TCC/TCC.db";
const char *pathToDBFile = [pathToSystemTCCDB fileSystemRepresentation];
if (sqlite3_open(pathToDBFile, &tccDb) != SQLITE_OK)
   return nil;
    
const char *query = [[NSString stringWithFormat: @"SELECT allowed FROM access WHERE client=\"%@\" AND service=\"kTCCServiceScreenCapture\"",client] UTF8String];
if (sqlite3_prepare_v2(tccDb, query , -1, &statement, nil) != SQLITE_OK)
   return nil;
    
BOOL allowed = NO;
while (sqlite3_step(statement) == SQLITE_ROW)
    allowed |= (sqlite3_column_int(statement, 0) == 1);

if (statement)
    sqlite3_finalize(statement);

if (tccDb)
    sqlite3_close(tccDb);

return @(allowed);

}


在 macOS Monterey 中,访问表中不再有 kTCCServiceScreenCapture。你知道如何解决这个问题吗? - praveenbharatsagar
我只需要打开表格,并查找属性的新名称...我猜你也可以这样做。任何SQLite查看器都可以。当然,这在任何地方都没有记录 - 我会添加第三个“缺点”,即苹果可能随时更改此内容,而API则存活更长时间。 - Motti Shneor

1

我不知道是否有专门用于获取屏幕录制权限状态的API。除了创建CGDisplayStream并检查是否为空之外,macOS安全性的进展 WWDC演示还提到,除非获得许可,否则来自CGWindowListCopyWindowInfo() API的某些元数据将不会被返回。因此,像这样的东西似乎可以工作,尽管它具有依赖该函数实现细节的相同问题:

private func canRecordScreen() -> Bool {
    guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: AnyObject]] else { return false }
    return windows.allSatisfy({ window in
        let windowName = window[kCGWindowName as String] as? String
        return windowName != nil
    })
}

1
Swift代码让我感到愉悦。虽然有点神秘,但非常简洁!不过有一个问题:有些窗口可能没有名称!- 但你需要“allSatisfy”,即使单个窗口没有名称也会返回false。这不是一个bug吗?也许只要获得一个名称就足以知道你是否拥有屏幕捕获权限了? - Motti Shneor

1
截至11月19日,chockenberry 给出了正确答案。
正如 @onelittlefish 指出的那样,如果用户没有在隐私面板中启用屏幕录制访问权限,则会省略 kCGWindowName。此方法也不会触发隐私警报。
- (BOOL)canRecordScreen
{
    if (@available(macOS 10.15, *)) {
        CFArrayRef windowList = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
        NSUInteger numberOfWindows = CFArrayGetCount(windowList);
        NSUInteger numberOfWindowsWithName = 0;
        for (int idx = 0; idx < numberOfWindows; idx++) {
            NSDictionary *windowInfo = (NSDictionary *)CFArrayGetValueAtIndex(windowList, idx);
            NSString *windowName = windowInfo[(id)kCGWindowName];
            if (windowName) {
                numberOfWindowsWithName++;
            } else {
                //no kCGWindowName detected -> not enabled
                break; //breaking early, numberOfWindowsWithName not increased
            }

        }
        CFRelease(windowList);
        return numberOfWindows == numberOfWindowsWithName;
    }
    return YES;
}

我发现这个方法在我的应用程序通过beginSheetModalForWindow弹出警告时效果很好。它可以检测到31个具有名称的窗口,但是当弹出警告后,只能检测到其中17个。您知道为什么会这样吗? - Jordan H
这也会在公共版本中发生。如果任何应用程序在启用屏幕录制时显示警报,则此选项返回“否”。:( - Jordan H
@JordanH 你可以回退到问题中提到的CGDisplayStreamCreate,它是其中之一的解决方案。然而,它会触发警报。 - Marek H

0
最优秀的答案并不完全正确,他遗漏了一些场景,比如共享状态。
我们可以在WWDC(https://developer.apple.com/videos/play/wwdc2019/701/?time=1007)中找到答案。
以下是WWDC的一些摘录: 窗口名称和共享状态不可用,除非用户已经预先批准应用程序进行屏幕录制。这是因为一些应用程序将敏感数据(例如帐户名称或更可能的网页URL)放在窗口名称中。
- (BOOL)ScreeningRecordPermissionCheck {
    if (@available(macOS 10.15, *)) {
        CFArrayRef windowList = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
        NSUInteger numberOfWindows = CFArrayGetCount(windowList);
        NSUInteger numberOfWindowsWithInfoGet = 0;
        for (int idx = 0; idx < numberOfWindows; idx++) {

            NSDictionary *windowInfo = (NSDictionary *)CFArrayGetValueAtIndex(windowList, idx);
            NSString *windowName = windowInfo[(id)kCGWindowName];
            NSNumber* sharingType = windowInfo[(id)kCGWindowSharingState];

            if (windowName || kCGWindowSharingNone != sharingType.intValue) {
                numberOfWindowsWithInfoGet++;
            } else {
                NSNumber* pid = windowInfo[(id)kCGWindowOwnerPID];
                NSString* appName = windowInfo[(id)kCGWindowOwnerName];
                NSLog(@"windowInfo get Fail pid:%lu appName:%@", pid.integerValue, appName);
            }
        }
        CFRelease(windowList);
        if (numberOfWindows == numberOfWindowsWithInfoGet) {
            return YES;
        } else {
            return NO;
        }
    }
    return YES;
}

那么对于没有名称且不是“共享”的窗口呢? - Motti Shneor
它可能在Catalina上运行正常,但在Monterey上却无法工作。 - Trident

-1

对我来说工作正常。 代码来源:https://gist.github.com/code4you2021/270859c71f90720d880ccb2474f4e7df

import Cocoa

struct ScreenRecordPermission {
    static var hasPermission: Bool {
        permissionCheck()
    }

    static func permissionCheck() -> Bool {
        if #available(macOS 10.15, *) {
            let runningApplication = NSRunningApplication.current
            let processIdentifier = runningApplication.processIdentifier
            guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID)
                as? [[String: AnyObject]],
                let _ = windows.first(where: { window -> Bool in
                    guard let windowProcessIdentifier = (window[kCGWindowOwnerPID as String] as? Int).flatMap(pid_t.init),
                          windowProcessIdentifier != processIdentifier,
                          let windowRunningApplication = NSRunningApplication(processIdentifier: windowProcessIdentifier),
                          windowRunningApplication.executableURL?.lastPathComponent != "Dock",
                          let _ = window[String(kCGWindowName)] as? String
                    else {
                        return false
                    }

                    return true
                })
            else {
                return false
            }
        }
        return true
    }

    static func requestPermission() {
        if #available(macOS 10.15, *) {
            CGWindowListCreateImage(CGRect(x: 0, y: 0, width: 1, height: 1), .optionOnScreenOnly, kCGNullWindowID, [])
        }
    }
}

# how to use
# print("hasPermission: ", ScreenRecordPermission.hasPermission)

它可能在Catalina上运行正常,但在Monterey上无法工作。 - Trident

-1
以上答案不正常工作。下面是正确答案。
private var canRecordScreen : Bool {
    guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: AnyObject]] else { return false }
    return windows.allSatisfy({ window in
        let windowName = window[kCGWindowName as String] as? String
        let isSharingEnabled = window[kCGWindowSharingState as String] as? Int
        return windowName != nil || isSharingEnabled == 1
    })
  }

1
你能解释一下为什么吗? - Ian Bytchek
API权限如何授予屏幕录制。在系统偏好设置中,我看不到“+”以添加应用程序,来授予“屏幕录制”。 - mica
这对我很有效。由于不安全指针,上面的答案很难转换为Swift,但这个可以。 - Jorge Silva

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接