권한 팝업 하나가 설치를 끝낸다
데스크톱 펫을 만들면서 다른 앱의 창 위치가 필요했다. 캐릭터가 창틀 위에 올라서고 걸어다니려면 화면에 어떤 창이 어디 있는지 알아야 한다.
문제는 macOS에서 이걸 잘못 요청하면 화면 녹화 권한 팝업이 뜬다는 것이다. 캐릭터 하나 띄우는 프로그램이 “이 앱이 화면을 녹화하려 합니다”를 띄우면, 대부분은 거기서 지운다.
결론부터 쓰면 이렇다. 창의 위치와 크기는 권한 없이 읽힌다. 권한이 필요한 건 창 제목이다. 제목을 안 읽으면 팝업도 없다.
실제로 재봤다
이 맥에서 확인한 값이다. CGWindowListCopyWindowInfo로 화면에 있는 창을 전부 가져와, 필드별로 몇 개나 채워져 있는지 셌다.
창 52개
kCGWindowBounds 52
kCGWindowOwnerPID 52
kCGWindowOwnerName 52
kCGWindowName(제목) 1
위치·크기(Bounds), 소유 프로세스(OwnerPID), 앱 이름(OwnerName)은 52개 전부 읽혔다. 제목만 1개다.
그 1개도 확인해 봤더니 사용자 앱이 아니라 Window Server가 소유한 창이었다. 즉 일반 앱 창의 제목은 하나도 안 읽혔다.
화면 녹화 권한을 준 적이 없는 상태에서 나온 결과다. 권한이 필요한 필드와 아닌 필드가 이렇게 갈린다.
왜 제목만 다른가
애플이 창 제목을 화면 내용과 같은 급으로 본다. 제목에는 열어 둔 파일 이름, 보고 있는 문서, 접속한 사이트가 그대로 들어간다. 무제.txt - 메모장과 2026년_연봉협상.xlsx - Excel은 노출되는 정보의 무게가 다르다.
반면 창이 화면 어디에 얼마만 한 크기로 있는지는 그 자체로 개인정보가 되기 어렵다. 그래서 앞은 막고 뒤는 열어 뒀다.
캐릭터가 창틀 위를 걸으려면 뒤만 있으면 된다. 어떤 앱의 창인지도 알 필요가 없다.
그래서 이렇게 부른다
copy_window_info(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
kCGNullWindowID,
)
OnScreenOnly는 지금 화면에 보이는 창만, ExcludeDesktopElements는 바탕화면 아이콘 같은 것을 뺀다.
여기서 꺼내 쓰는 건 세 가지다.
| 키 | 무엇 | 쓰는 이유 |
|---|---|---|
kCGWindowBounds |
위치와 크기 | 발판 후보 |
kCGWindowLayer |
창 층위 | 일반 창(0)만 고른다 |
kCGWindowOwnerPID |
소유 프로세스 | 자기 창을 발판으로 삼지 않으려고 |
kCGWindowName은 안 읽는다. 이 한 줄을 안 쓰는 것이 권한 팝업을 없앤다.
마지막 항목이 없으면 캐릭터가 자기가 들어 있는 창 위에 올라서려고 한다. 자기 발밑을 밟는 셈이라 위치가 진동한다.
Windows는 다른 API, 같은 원칙
Windows에는 화면 녹화 권한이라는 개념이 없다. 대신 다른 함정이 있다.
EnumWindows 열려 있는 창을 훑는다
IsWindowVisible 보이는 것만 남긴다
DwmGetWindowAttribute 실제 테두리를 얻는다
GetWindowRect를 쓰면 안 된다. 이 함수는 창 주변의 보이지 않는 그림자 여백까지 포함한 사각형을 준다. 그대로 발판으로 쓰면 캐릭터가 창 위가 아니라 허공에 서 있는 것처럼 보인다.
DwmGetWindowAttribute에 DWMWA_EXTENDED_FRAME_BOUNDS(값 9)를 넘기면 눈에 보이는 테두리를 준다.
거기에 두 가지를 더 걸러야 한다.
| 거를 것 | 무엇 | 왜 |
|---|---|---|
DWMWA_CLOAKED (14) |
숨겨진 창 | 다른 가상 데스크톱의 창이 발판이 된다 |
WS_EX_TOOLWINDOW |
도구 창 | 툴바·팝업이 발판이 된다 |
가상 데스크톱을 쓰는 사람에게는 첫 번째가 특히 중요하다. 걸러내지 않으면 지금 화면에 없는 창 위에 캐릭터가 올라선다.
좌표계를 한 번은 맞춰야 한다
Windows에서 얻는 값은 물리 픽셀이다. 프론트엔드가 쓰는 건 논리 좌표다. 고해상도 모니터에서는 두 값이 다르다.
그래서 캐릭터 발이 있는 모니터의 DPI로 변환한다. 화면마다 배율이 다를 수 있으니 아무 모니터 기준으로 나누면 어긋난다.
macOS는 처음부터 논리 좌표로 준다. 이 변환이 없다.
창이라고 다 발판은 아니다
위치를 읽었다고 끝이 아니다. 밟을 수 있는 것만 남긴다. 실제로 쓰는 값이다.
| 기준 | 값 | 이유 |
|---|---|---|
| 최소 발판 너비 | 200 | 캐릭터가 설 폭이 안 되면 발판이 아니다 |
| 화면 위쪽 여유 | 270 | 이보다 위면 캐릭터 창이 화면 밖으로 나간다 |
| 디스플레이 인접 허용 | 2 | 이 거리 안이면 두 화면이 붙어 있다고 본다 |
| 가장자리 통로 최소 겹침 | 200 | 지나갈 폭은 있어야 넘어간다 |
두 번째가 직접 겪은 것이다. 캐릭터 창은 발 위치를 기준으로 배치되는데, 발판이 화면 꼭대기에 너무 가까우면 창 윗부분이 화면 밖으로 밀린다. 그래서 화면 상단 270px 안쪽의 창 윗변은 발판에서 뺐다. 그런 창은 캐릭터가 밟지 않고 지나쳐 내려간다.
같이 나오는 질문
창 제목이 꼭 필요하면
그때는 권한을 요청하는 수밖에 없다. 다만 요청 시점을 고르는 게 낫다. 실행하자마자 묻지 말고, 제목이 필요한 기능을 사용자가 켤 때 묻는다.
권한을 왜 필요로 하는지 화면에서 먼저 설명하고 시스템 팝업을 띄우는 것도 차이가 크다. 맥락 없이 뜬 팝업은 대체로 거부된다.
`kCGWindowOwnerName`도 권한이 필요한가
아니다. 실측에서 52개 전부 읽혔다. 앱 이름은 제목과 달리 열어 둔 파일이나 문서 내용을 드러내지 않는다.
이 프로젝트는 진단용 코드에서만 쓰고 있다. 어떤 앱의 창이 어떻게 잡히는지 확인할 때다. 캐릭터를 움직이는 데는 필요 없다.
접근성 권한으로 하면 안 되나
AXUIElement로 창 정보를 얻는 방법도 있다. 다만 접근성 권한도 팝업이 뜨고, 사용자가 보기에는 화면 녹화만큼이나 무거운 요청이다. 위치만 필요하다면 권한 없는 쪽이 낫다.
이 방법으로 못 하는 것
창의 z 순서를 정확히 알기 어렵고, 어떤 창이 지금 활성인지도 알 수 없다. 위치와 크기만 얻는 대신 그만큼을 포기한다. 캐릭터를 창틀에 올려놓는 데는 부족하지 않았다.
다음에 볼 건 음성 인식 쪽이다. 조용한 방에서 마이크를 켜 두면 Whisper가 아무도 하지 않은 말을 받아 적는다. 그게 어떤 형태로 나오고 어떻게 걸러내는지 정리한다.
