반응형

바쁜 분은 이것만

  1. CGWindowListCopyWindowInfo에 onScreenOnly를 주고 layer 0만 남기면 보이는 앱 창만 남아요
  2. swift 파일 하나를 swift winlist.swift로 바로 돌려 JSON을 찍고, 파이썬은 그걸 읽기만
  3. kCGWindowNumber가 screencapture -l에 넣는 창 번호, 창 이름은 화면 기록 권한이 있어야 채워짐

이전 글 screencapture 정리에서 -l 창번호로 창 하나만 찍는 걸 봤는데, 그 번호를 어디서 얻는지는 미뤄 뒀어요. 맥에서 창 목록을 주는 함수는 CGWindowListCopyWindowInfo입니다. 파이썬에서 바로 부르려면 pyobjc 같은 패키지가 필요한데, swift 스크립트 한 파일이면 설치 없이 됩니다(Xcode나 Command Line Tools가 있으면 swift 명령이 들어 있어요).

1. swift 스크립트: 창 목록을 JSON으로

import CoreGraphics
import Foundation

// 화면에 보이는 창만, 바탕화면 요소는 빼고
let opts: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let list = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] ?? []

var rows: [[String: Any]] = []
for w in list {
    let layer = w[kCGWindowLayer as String] as? Int ?? -1
    if layer != 0 { continue }            // 0 = 일반 앱 창 (메뉴 막대·Dock 등은 다른 층)
    let b = w[kCGWindowBounds as String] as? [String: Any] ?? [:]
    rows.append([
        "owner": w[kCGWindowOwnerName as String] as? String ?? "",
        "id": w[kCGWindowNumber as String] as? Int ?? 0,
        "pid": w[kCGWindowOwnerPID as String] as? Int ?? 0,
        "name": w[kCGWindowName as String] as? String ?? "",
        "x": b["X"] as? Int ?? 0, "y": b["Y"] as? Int ?? 0,
        "w": b["Width"] as? Int ?? 0, "h": b["Height"] as? Int ?? 0,
    ])
}
// 권한 상태는 stderr로 (JSON 출력과 섞이지 않게)
FileHandle.standardError.write("화면 기록 권한: \(CGPreflightScreenCaptureAccess())\n".data(using: .utf8)!)
let data = try! JSONSerialization.data(withJSONObject: rows, options: [.sortedKeys])
print(String(data: data, encoding: .utf8)!)
  • 키는 kCGWindowOwnerName(앱 이름), kCGWindowNumber(창 번호), kCGWindowBounds(위치·크기), kCGWindowName(창 제목) 네 개면 충분해요.
  • 키 상수는 CFString이라 딕셔너리에서 꺼낼 때 as String을 붙입니다.
  • swift 파일.swift는 컴파일하고 바로 실행해서, 이 맥에서는 한 번에 3초쯤 걸렸어요. 자주 부를 거면 swiftc winlist.swift -o winlist로 한 번 빌드해 두면 빨라요.

한 줄만 꺼내 보면 이런 모양이에요(창 제목은 가렸습니다).

{"h": 1410, "id": 3878, "name": "<제목 생략>", "owner": "Google Chrome", "pid": 66145, "w": 3440, "x": -908, "y": -1410}

x, y가 음수인 건 주 화면 위쪽·왼쪽에 둔 보조 모니터에 있는 창이라서예요. 좌표는 주 화면 왼쪽 위가 0,0입니다.

2. onScreenOnly, layer 0을 빼면 몇 개나 나올까

옵션만 바꿔 개수를 세 봤어요.

import CoreGraphics
func count(_ o: CGWindowListOption) -> (Int, Int) {
    let l = CGWindowListCopyWindowInfo(o, kCGNullWindowID) as? [[String: Any]] ?? []
    let layer0 = l.filter { ($0[kCGWindowLayer as String] as? Int) == 0 }
    return (l.count, layer0.count)
}
print("optionAll       전체/layer0:", count(.optionAll))
print("onScreenOnly    전체/layer0:", count(.optionOnScreenOnly))
print("+excludeDesktop 전체/layer0:", count([.optionOnScreenOnly, .excludeDesktopElements]))
$ swift count.swift
optionAll       전체/layer0: (193, 131)
onScreenOnly    전체/layer0: (42, 7)
+excludeDesktop 전체/layer0: (34, 7)

optionAll은 최소화된 창, 다른 데스크톱(Space)의 창, 앱이 숨겨 둔 창까지 다 줘서 193개나 나왔어요. 화면에 보이는 것만 받아도 42개인데, 메뉴 막대·Dock·상태 아이콘 같은 것이 섞여 있어서 layer 0로 거르면 실제 앱 창 7개만 남았습니다.

3. 파이썬에서 읽고, 창 번호로 바로 캡처

파이썬은 swift를 subprocess로 돌려 JSON만 읽어요. 테스트로 크롬에 example.com 창을 하나 띄워 (100, 100) 위치에 800x600으로 맞춰 두고, 그 창을 위치·크기로 찾아 screencapture -l로 넘겼습니다. 글에 나오는 출력은 크롬 창만 남기고, 제목은 <제목 생략>으로 바꿔 찍게 했어요.

import json, os, subprocess, sys
from collections import Counter
from pathlib import Path

HERE = Path(__file__).parent
SHOW = {"Finder", "Terminal", "Google Chrome"}   # 글에 보여줄 앱만

def windows():
    r = subprocess.run(["swift", str(HERE / "winlist.swift")],
                       capture_output=True, text=True, check=True)
    print(r.stderr.strip())
    return json.loads(r.stdout)

rows = windows()
print("layer 0 창:", len(rows), "개")
for r in rows:
    if r["owner"] not in SHOW:
        continue
    name = "<제목 생략>" if r["name"] else "(빈 값)"
    print(f'{r["owner"]:14} id={r["id"]:<6} {r["x"]},{r["y"]} {r["w"]}x{r["h"]}  name={name}')

# 테스트용으로 띄운 창(100,100 / 800x600)을 찾아 그 창만 캡처
target = next(r for r in rows
              if r["owner"] == "Google Chrome" and (r["x"], r["y"], r["w"], r["h"]) == (100, 100, 800, 600))
out = Path(os.environ.get("CAPDIR", "/tmp/capdemo")) / "win.png"
subprocess.run(["screencapture", "-x", "-o", "-l", str(target["id"]), str(out)], check=True)
print("캡처:", target["id"], out.stat().st_size, "바이트")
print(subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(out)],
                     capture_output=True, text=True).stdout.split("\n", 1)[1].strip())
out.unlink()
$ .venv/bin/python p1.py
화면 기록 권한: true
layer 0 창: 7 개
Google Chrome  id=4388   100,100 800x600  name=<제목 생략>
Google Chrome  id=3878   -908,-1410 3440x1410  name=<제목 생략>
캡처: 4388 103376 바이트
pixelWidth: 1600
  pixelHeight: 1200
  • 창 번호 4388을 그대로 -l에 넣으니 그 창만 찍혔어요. 800x600 포인트 창이라 Retina에서 1600x1200 픽셀입니다.
  • 같은 앱 창이 여러 개면 앱 이름만으로는 못 고르니, 위치·크기나 창 제목으로 고르면 돼요.
  • 창 번호는 창을 닫았다 열면 바뀌므로 저장해 두지 말고 찍기 직전에 다시 찾는 게 안전해요.
  • 참고로 크롬 AppleScript의 창 id(9자리)와 이 창 번호(4388)는 서로 다른 값이에요.

4. 창 이름(kCGWindowName)이 비어 있을 때

Apple 문서 기준으로 macOS 10.15부터는 화면 기록 권한이 없는 프로그램이 목록을 받으면 다른 앱 창의 kCGWindowName이 빠져서 와요. 앱 이름·창 번호·위치는 권한 없이도 나옵니다. 스크립트에 넣어 둔 CGPreflightScreenCaptureAccess()가 지금 권한이 있는지를 true/false로 알려 줘요.

  • 이 글의 맥은 true라 이름이 채워져 나왔고, 권한이 없는 상태는 직접 확인하지 못했어요.
  • 권한이 있어도 앱이 제목을 안 붙인 창은 이름이 비어 있었어요. 이번 목록에서도 layer 0 창 7개 중 일부는 빈 값이었습니다. 그러니 이름이 비었다고 무조건 권한 문제는 아니에요.
  • 권한은 시스템 설정 → 개인정보 보호 및 보안 → 화면 및 시스템 오디오 녹음에서 스크립트를 실행하는 앱(터미널 등)에 켜 줍니다.

창 제목에는 열어 둔 문서 이름이나 웹 페이지 제목이 그대로 들어 있어요. 로그로 남기거나 어딘가에 올릴 때는 이 글처럼 이름은 가리고 앱 이름·번호만 쓰는 걸 권합니다.

정리

항목 내용
함수 CGWindowListCopyWindowInfo(opts, kCGNullWindowID)
옵션 .optionOnScreenOnly + .excludeDesktopElements (193개 → 34개)
거르기 kCGWindowLayer == 0 → 앱 창 7개
쓰는 키 kCGWindowOwnerName, kCGWindowNumber, kCGWindowBounds, kCGWindowName
파이썬 subprocess.run(["swift", "winlist.swift"]) → json.loads
캡처로 넘기기 screencapture -x -o -l 창번호 파일.png
창 이름이 빔 화면 기록 권한 확인(CGPreflightScreenCaptureAccess), 제목 없는 창일 수도

참고: Apple Developer Documentation — CGWindowListCopyWindowInfo, Required Window List Keys / Optional Window List Keys, CGPreflightScreenCaptureAccess / Python 공식 문서 — subprocess, json

반응형

+ Recent posts