사용자 도구

사이트 도구


project:peekwindow

파이썬을 이용하여 다른 윈도우 엿보기

이번에는 파이썬과 pywin32 확장을 이용하여 내 프로그램의 윈도우 상황을 '엿보는' 예제를 작성해 보겠습니다.
'엿보기'라고 하니 뭔가 나쁜 짓을 하는 느낌이 듭니다. 하지만 사용자는 자기 프로그램에 어떤 프로그램이 어떻게 돌아가는지 알 권리가 있으므로 딱히 나쁜 짓이라고 하기 어렵습니다. 사실 '엿본다'라고 하지만 '감시'라는 표현이 더 어울리긴 합니다. 하지만 감시 보다는 엿보기 쪽이 왠지 더 장난스럽고 신나는 짓(응?) 같으므로 계속 엿보기라고 표현하도록 하지요.

배경 설명

우선 배경 지식부터 간단히 살펴 보겠습니다. 현재 거의 모든 컴퓨터는 멀티태스킹으로 돌아간다는 사실은 알고 계시지요? 음악을 들으면서, 글을 쓰고, 웹서핑을 동시에 수행합니다. 요즘은 동영상까지 시청하면서 웹서핑을 합니다. 이렇게 컴퓨터는 동시에 여러 작업을 수행하는 기능을 갖추고 있습니다.

윈도우와 프로세스, 그리고 스레드

운영체제가 어떻게 이렇게 다양한 작업을 관리하는지에 대해 설명하려면 아예 운영체제 이론 시간이 되어 버리므로 생략하겠습니다. 완벽히 들어맞는 명제는 아닙니다만, 쉽게 이렇게 생각하면 됩니다. 하나의 태스크(작업)는 하나의 '프로세스'에 대응됩니다. 아까는 작업을 동시에 처리한다고 했으나, 사실 컴퓨터는 여러 가지를 정말 '동시에' 처리하지 못합니다. 한 가지 작업을 매우 짧은 시간 수행할 뿐입니다. 짧은 시간에 연속적으로 여러 일을 처리하니 여러 가지 일을 동시에 수행하는 것처럼 보이는 겁니다. 영화나 만화에서 분신술을 쓰는 닌자처럼 말이죠. 단, 최근 멀티 코어 CPU가 나오면서 여러 작업을 진짜 동시에 처리하기도 합니다. 하지만 원론적으로 이렇게 알아두시면 됩니다.

그러면 정말 내 컴퓨터에 많은 프로세스들이 작업중이란 사실을 확인해 보도록 하지요. 윈도우의 경우 Ctrl+Shift+Esc를 누르거나 윈도우+R키를 누르고 'taskmgr'을 입력해 보세요. '작업 관리자'가 나올 겁니다. 현재 내 컴퓨터에서 내 눈에 보이거나, 내 눈에 보이지는 않지만 역시 컴퓨터에서 실행중인 여러 프로세스의 목록이 보일 것입니다. 얼마만큼 CPU를 사용하는지, 얼마만큼 메모리를 사용하고 있는지가 모두 보고되고 있습니다. 좀 더 자세한 정보들을 알고 싶다면 ProcessExplorer를 이용해 보셔도 됩니다.

아무튼 이렇게 운영체제 안에서는 여러 프로세스들이 각자의 일을 정해진대로 묵묵히 수행하고 있습니다. 아예 운영체제 자체가 여러 프로세스로 구성되어 있습니다. 사실 운영체제 내부에서는 절대로 멈추어서는 안되는 몇몇 중요한 프로세스들이 있습니다. 왠만한 프로세스들이 말썽을 일으키면 그냥 에러 메시지를 보는 정도로 그치지만, 그런 중요한 프로세스들이 말썽을 일으키면 흔히 말하는 '공포의 블루 스크린'을 만나게 되겠죠.

지금 여러분들이 보고 있는 웹브라우저 또한 하나의 프로세스입니다. 그리고 또 하나 중요한 사실이 있습니다. 웹브라우저라는 프로세스 안에는 다시 여러 작은 작업들이 동시에 돌아가고 있는 중이란 것입니다. 웹페이지를 보여주고, 필요한 파일을 다운로드 받고, 서버와 통신을 하는 등… 눈에 보이지 않지만 사용자에게 웹페이지를 보여주기 위한 중요한 작업들이 숨겨진 채로 바삐 실행되고 있을 것입니다. 이렇게 한 프로세스 안에서 동시다발로 이뤄지는 작업을 일컬어 '스레드(thread)'라고 합니다.

스레드라는 존재를 염두에 두고 사용중인 웹브라우저를 다시 한 번 살펴보세요. 정말 여러 가지 일들이 동시에 처리되고 있는지 확인되시나요? 페이지가 열리는 동안 북마크를 편집할 수도 있고(인터넷이 충분히 느리다면 하실 수 있습니다), 탭을 여러 개 열어 여러 웹페이지를 동시에 접속하고, 웹페이지를 열람하면서도 파일 다운로드를 받습니다. 이런 일들이 동시다발적으로 발생할 수 있는 것은 모두 스레드의 덕분입니다.

윈도우의 컨트롤

윈도우 창에는 프로그램이 사용자에게 요구하는 사항들을 합리적으로 입력 받기 위한 여러 장치들이 있습니다. 간단히 말해 버튼, 콤보 상자, 리스트 상자, 입력 박스 등등 … 이런 것들을 '컨트롤(control)'이라고 부릅니다. 이 컨트롤들이 프로그램 안에서 살아 숨쉬는 듯 제각각 동작하기 위해서는 당연히 CPU가 각각의 컨트롤에 대해 처리를 해 주어야 합니다. 상식적으로도 이들이 스레드로 처리되어야 하겠지요?

이제서야 우리가 하고 싶은 일에 대해 좀 더 구체적인 설명을 할 수 있을 것 같습니다. 아까는 “내 프로그램의 윈도우 상황을 엿본다”고 하였습니다. 이를 지금까지 설명한 내용에 근거하여 다시 이야기하자면 이렇게 되겠네요.

  • 내 윈도우(운영체제)에 돌아가는 프로세스 중, 원하는 프로세스에 접근한다.
  • 프로세스 내부에 있는 여러 스레드 중에서, 컨트롤에 해당하는 스레드를 찾는다.
  • 컨트롤(스레드)의 정보를 얻어낸다.

사실 이런 일을 해 주는 유틸리티는 이미 널리고 널려 있습니다. 가장 유명한 것으로는 Spy++를 들 수 있겠네요. Spy++를 실행하면 내 컴퓨터에 돌아가는 윈도우의 정보를 낱낱이 볼 수 있습니다. 정말 낱낱이요. 다음에 설명할 메시지 이벤트까지 다 확인할 수 있습니다. 조금 설명이 어려워 일반적으로 접근하기 까다로웠을 뿐, 거의 모든 프로그램들은 이렇게 낱낱이 정보가 공개된 채 돌아가고 있는 것이었습니다.

우리의 목적은 Spy++와 비슷하지만, 그저 훨씬 장난감 같은 녀석을 파이썬을 이용해 한 번 짜 보는 것입니다. 그냥, 장난으로요 ^^

윈도우의 작동 방식

혹시라도 Win32API를 공부해 보셨다든지, 운영체제에 대해 호기심을 가졌던 분들이라면 'Event-driven'란 말은 한 번쯤 들어보셨으리라 생각합니다. 우선 이 Event-driven에 대해 설명을 하고자 합니다.

프로그램들은 일반적으로 사용자가 특별한 명령을 내리기 전까지는 가만히 대기하고 있지요? 몇날 며칠이건 입력이 없으면 무조건 대기합니다. 미리 약속되지 않은 이상 자기 멋대로 먼저 동작하는 일은 절대로 없습니다. 반드시 사용자가 무언가 지시해야 비로소 다음으로 진행됩니다.

사용자가 컴퓨터에게 무언가를 한 일(키보드를 누른다, 마우스를 이동시킨다)을 일컬어 '이벤트'라고 합니다. 이벤트의 종류는 굉장히 많습니다. 사용자가 마우스를 움직이는 것, 왼쪽 버튼을 누른 것, 왼쪽 버튼을 뗀 것, 키보드의 키 하나를 누른 것, 키보드의 키 하나를 뗀 것… 이런 세세한 모든 일들이 '이벤트'로서 운영체제에 전달됩니다. 운영체제는 이러한 이벤트에 기반해 정해진 작업들을 수행합니다. 그리고 프로그래머는 이러한 이벤트가 전달될 때 어떤 동작을 할 지를 정하고 그것을 프로그래밍합니다.

한 이벤트가 발생하면 그 이벤트에 대해 운영체제는 '메시지'를 생성해줍니다. 이벤트의 종류가 세세하고 많이 나눠져 있으므로 이 메시지는 매우 빠르게 생성되고, 운영체제 안에 쌓이게 됩니다. 운영체제가 메시지를 위해 이용하는 창고를 일컬어 '메시지 큐(queue)'라고 합니다. 여기에 메시지는 순서대로 차곡차곡 쌓이고, 순서대로 처리됩니다.

예를 들어 버튼을 클릭한다면, 버튼 클릭에 대한 이벤트가 발생합니다. 버튼 클릭에 대한 메시지가 생성되고, 이것은 메시지 큐에 답깁니다. 메시지 큐에 메시지는 미리 약속된 프로그램이 받아 정해진 동작을 수행합니다.

쓰고 나니 설명이 매우 많이 복잡해졌습니다. 저는 여기까지 설명하도록 하겠습니다.보다 자세한 설명은 '메시지 전달 과정'등을 검색하거나 윈도우 프로그래밍 서적을 참고하도록 하세요.

요점은 운영체제의 메시지 큐에 사용자가 입력한 이벤트에 대한 적절한 메시지가 담기고, 그것을 처리하는 것이 반복되면서 프로그램이 진행된다는 것입니다. 여기서 조금만, 조금만 더 심사숙고해보도록 하지요.

  • 원인과 결과로 생각하면,
    • 사용자의 이벤트(원인) → 운영체제에서 메시지 생성(결과)
    • 운영체제의 메시지 전달(원인) → 프로그램 동작(결과)
  • 그렇다면,
    • 사용자의 이벤트를 기반으로 하지 않고도 유사한 메시지를 생성할 수 있을까?
    • 유사하게 메시지가 생성된다면, 그것은 프로그램에서 같은 결과를 만들어낼까?

설명이 많이 길어지므로 결론만 적고 끝내도록 하겠습니다. 네, 됩니다. 그래서 우리가 프로그래밍을 하지요. :)

프로그래밍하기

이제 파이썬을 기반으로 프로그래밍을 시작하도록 하겠습니다. 우선 pywin32라는 파이썬 확장 기능이 필요합니다. 파이썬은 원래 플랫폼 독립적인 언어입니다. 우리 목표인 '윈도우 엿보기'의 타겟인 윈도우란 녀석은 마이크로소프트 윈도우 플랫폼에 종속된 존재입니다. 그러므로 이러한 확장이 별도로 존재합니다. 홈페이지에서 다운로드 받아 설치합니다. 설치 등의 세세한 설명은 생략하겠습니다. 문서는 이 곳에서 열람할 수 있습니다.

실행중인 윈도우를 열거하기

enumwindows.py
# -*- coding:cp949 -*-
# 모든 최상위 수준의 윈도우를 나열합니다.
import win32gui
 
def EnumWindowsHandler(hwnd, extra):
	wintext = win32gui.GetWindowText(hwnd)
	print "%08X: %s" % (hwnd, wintext)
 
if __name__ == '__main__':
	win32gui.EnumWindows(EnumWindowsHandler, None)

Spy++에서 출력된 결과(Spy→Windows를 선택)와 동일한지 확인해보세요. 동일해야 합니다 :)

여기서 한 가지 반드시 짚고 넘어가야 할 중요한 사항이 있습니다. 바로 'hwnd'의 값입니다. hwnd는 'window handle'을 줄여서 말하는 것입니다. 핸들은 단순한 숫자이며 모든 윈도우의 자원들, 이를테면 프로세스, 스레드 등등은 '핸들(handle)'로 식별됩니다. 우리는 자원에 직접적으로는 접근하지 못하도록 되어 있습니다. 즉 자원을 할당받고, 조작하며, 반납하는 과정은 모두 핸들이라는 일종의 꼬리표를 통해 이뤄집니다. 각 윈도우들은 프로세스, 혹은 스레드이므로 윈도우들 또한 핸들을 통해 식별되고 관리됩니다.

타겟 윈도우의 모든 컨트롤들을 찾아내기

우선 타겟이 되는 윈도우를 정해야겠지요? 저는 일례로 TeraCopy 2.7을 동작시켜 보겠습니다. TeraCopy는 아래 그림과 같은 UI를 가지고 있습니다. 개인적으로 대량의 파일을 복사할 때 유용하게 사용합니다.하지만 때때로 복사되는 파일 목록을 따로 저장하고 싶은데, 그것이 되지 않아 매우 불편하게 생각하고 있었습니다. Teracopy의 리스트 컨트롤에서 정보를 추출할 수 있다면 좋겠습니다. 만약 유료 버전에서만 되는 특수 기능이었다면 돈을 버는 셈이 되겠네요 :)

윈도우 상단의 문자열에서 'TeraCopy'라는 문자열이 찍혀 있는 것이 보입니다. 우리는 이것으로 TeraCopy의 최상위 윈도우를 찾아낼 수 있을 것입니다. 그리고 TeraCopy 최상위 윈도우 아래 생성된 다른 윈도우의 존재도 파해쳐 보도록 하겠습니다. 먼저 답안지인 Spy++를 통해 우선 확인을 해 보도록 하겠습니다.

enumchildwindows.py
# -*- coding:cp949 -*-
# TeraCopy 내부의 모든 윈도우 객체를 나열합니다.
import win32gui
import pywintypes
import sys
 
# 부모 윈도우의 핸들을 검사합니다.
class WindowFinder:
	def __init__(self, windowname):
		try:
			win32gui.EnumWindows(self.__EnumWindowsHandler, windowname)		
		except pywintypes.error as e:
			# 발생된 예외 중 e[0]가 0이면 callback이 멈춘 정상 케이스
			if e[0] == 0: pass		
 
	def __EnumWindowsHandler(self, hwnd, extra):
		wintext = win32gui.GetWindowText(hwnd)
		if wintext.find(extra) != -1:
			self.__hwnd = hwnd
			return pywintypes.FALSE # FALSE는 예외를 발생시킵니다.	
 
	def GetHwnd(self):
		return self.__hwnd
 
	__hwnd = 0
 
# 자식 윈도우의 핸들 리스트를 검사합니다.
class ChildWindowFinder:
	def __init__(self, parentwnd):
		try:
			win32gui.EnumChildWindows(parentwnd, self.__EnumChildWindowsHandler, None)		
		except pywintypes.error as e:
			if e[0] == 0: pass		
 
	def __EnumChildWindowsHandler(self, hwnd, extra):
		self.__childwnds.append(hwnd)
 
	def GetChildrenList(self):
		return self.__childwnds
 
	__childwnds = []
 
# windowname을 가진 윈도우의 모든 자식 윈도우 리스트를 얻어낸다.
def GetChildWindows(windowname):
 
	# TeraCopy의 window handle을 검사한다.
	teracopyhwnd = WindowFinder('TeraCopy').GetHwnd()
 
	# Teracopy의 모든 child window handle을 검색한다.
	childrenlist = ChildWindowFinder(teracopyhwnd).GetChildrenList()
 
	return teracopyhwnd, childrenlist
 
# main 입니다.
def main(argv):
	hwnd, childwnds = GetChildWindows('TeraCopy')
	print "%X %s" % (hwnd, win32gui.GetWindowText(hwnd))
 
	print "HWND     CtlrID\tClass\tWindow Text"
	print "==========================================="
 
	for child in childwnds:
		ctrl_id  = win32gui.GetDlgCtrlID(child)
		wnd_clas = win32gui.GetClassName(child)
		wnd_text = win32gui.GetWindowText(child)		
		print "%08X %6d\t%s\t%s" % (child, ctrl_id, wnd_clas, wnd_text)
 
	return 0
 
if __name__ == '__main__':
	sys.exit(main(sys.argv))
 

코드의 결과입니다.

4A08B4 [일시중지됨] 복사: 17.2%  (32 MB/s) - TeraCopy
HWND     CtlrID Class   Window Text
===========================================
00250844    777 Static  4851892|0|1|D:\temp\
00220418     23 Static  IMG_0115.JPG
0014089C     22 Static  D:\temp\
0015072C    132 Static  항상 묻기
001601BC      2 Static  시스템 종료
00150102     77 Static  ??Static
00220348      2 Static  ??Static
002C0DAE      2 Static  ??Static
000E0730      2 Static  Task Manager
00270806     24 Button  간단하게
00160944     20 Button  계속
00100918     26 Button  건너뛰기
00200874     21 Button  취소
002702EE     18 Static
00190296     19 Static
001108DC      2 Static  www.teracopy.com
001D0DC6      0 SysListView32
00170310      0 SysHeader32
00210878    102 Button  지우기
00140868    101 Button  검증
001508B8    103 Button  삭제
000906EE     30 Button  메뉴
00090708     27 ComboBox

ListView의 내용을 추출해내기

앞서 컨트롤들을 열거한 것만으로도 버튼(Button) 컨트롤과 라벨(Static) 컨트롤의 내용을 가져올 수 있었습니다. 그러나 제가 원한 '복사할 파일의 목록'을 가져올 수는 없었네요. 이것은 리스트 뷰(ListView)라는 컨트롤에 따로 저장되어 있기 때문입니다.

바로 아래와 두 줄이 리스트 뷰 컨트롤의 존재를 보여주고 있습니다.

001D0DC6      0 SysListView32
00170310      0 SysHeader32

SysListView32에 리스트뷰가, SysHeader에는 머릿말이 저장되어 있을 것이라는 감이 옵니다.

한편 내용을 추출하기 위해서는 리스트뷰에 대한 약간의 사전 지식과 배경 지식이 있어야 합니다. Platform SDK에서는 list, MFC에서는 CListCtrl이라는 클래스로 제공이 되고 있습니다. MSDN을 뒤지면 리스트뷰를 어떻게 다룰 수 있는지에 대해 상세히 나와 있지만, 이 부분은 상당히 복잡한 설명을 필요로 하므로 기회가 되면 별도의 장에서 다루고, 여기서는 결과인 함수 코드만 소개하도록 하겠습니다.

ListViewItems.py
# -*- coding:cp949 -*-
import win32gui
import win32api
import commctrl
import struct
import ctypes
 
from win32con import PAGE_READWRITE, MEM_COMMIT, MEM_RESERVE, MEM_RELEASE, PROCESS_ALL_ACCESS
 
GetWindowThreadProcessId = ctypes.windll.user32.GetWindowThreadProcessId
VirtualAllocEx           = ctypes.windll.kernel32.VirtualAllocEx
VirtualFreeEx            = ctypes.windll.kernel32.VirtualFreeEx
OpenProcess              = ctypes.windll.kernel32.OpenProcess
WriteProcessMemory       = ctypes.windll.kernel32.WriteProcessMemory
ReadProcessMemory        = ctypes.windll.kernel32.ReadProcessMemory
 
def GetListViewItems(hwnd, column_index=0):
	# Allocate virtual memory inside target process
	pid   = ctypes.create_string_buffer(4)
	p_pid = ctypes.addressof(pid)
 
	GetWindowThreadProcessId(hwnd, p_pid) # process owning the given hwnd
	hProcHnd = OpenProcess(PROCESS_ALL_ACCESS, False, struct.unpack("i",pid)[0])
	pLVI    = VirtualAllocEx(hProcHnd, 0, 4096, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE)
	pBuffer = VirtualAllocEx(hProcHnd, 0, 4096, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE)
 
	# Prepare an LVITEM record and write it to target process memory
	lvitem_str    = struct.pack('iiiiiiiii', *[0,0,column_index,0,0,pBuffer,4096,0,0])
	lvitem_buffer = ctypes.create_string_buffer(lvitem_str)
	copied = ctypes.create_string_buffer(4)
	p_copied = ctypes.addressof(copied)
	WriteProcessMemory(hProcHnd, pLVI, ctypes.addressof(lvitem_buffer), ctypes.sizeof(lvitem_buffer), p_copied)
 
	# iterate items in the SysListView32 control
	num_items = win32gui.SendMessage(hwnd, commctrl.LVM_GETITEMCOUNT)
 
	item_texts = []
	for item_index in range(num_items):
		win32gui.SendMessage(hwnd, commctrl.LVM_GETITEMTEXT, item_index, pLVI)
		target_buff = ctypes.create_string_buffer(4096)
		ReadProcessMemory(hProcHnd, pBuffer, ctypes.addressof(target_buff), 4096, p_copied)
		item_texts.append(target_buff.value)
 
	VirtualFreeEx(hProcHnd, pBuffer, 0, MEM_RELEASE)
	VirtualFreeEx(hProcHnd, pLVI, 0, MEM_RELEASE)
	win32api.CloseHandle(hProcHnd)
	return item_texts

그럼 이전의 enumwindowtext.py는 아래와 같이 변경해 봅시다. 'enumwindowtext2.py'와 'ListViewItems.py'는 같은 경로에 둡니다.

enumwindowtext2.py
# -*- coding:cp949 -*-
# TeraCopy 내부의 모든 윈도우 객체를 나열합니다.
import win32gui
import commctrl
import pywintypes
import struct, array
import sys
from ListViewItems import GetListViewItems
 
# 부모 윈도우의 핸들을 검사합니다.
class WindowFinder:
	def __init__(self, windowname):
		try:
			win32gui.EnumWindows(self.__EnumWindowsHandler, windowname)		
		except pywintypes.error as e:
			# 발생된 예외 중 e[0]가 0이면 callback이 멈춘 정상 케이스
			if e[0] == 0: pass		
 
	def __EnumWindowsHandler(self, hwnd, extra):
		wintext = win32gui.GetWindowText(hwnd)
		if wintext.find(extra) != -1:
			self.__hwnd = hwnd
			return pywintypes.FALSE # FALSE는 예외를 발생시킵니다.	
 
	def GetHwnd(self):
		return self.__hwnd
 
	__hwnd = 0
 
# 자식 윈도우의 핸들 리스트를 검사합니다.
class ChildWindowFinder:
	def __init__(self, parentwnd):
		try:
			win32gui.EnumChildWindows(parentwnd, self.__EnumChildWindowsHandler, None)		
		except pywintypes.error as e:
			if e[0] == 0: pass		
 
	def __EnumChildWindowsHandler(self, hwnd, extra):
		self.__childwnds.append(hwnd)
 
	def GetChildrenList(self):
		return self.__childwnds
 
	__childwnds = []
 
# windowname을 가진 윈도우의 모든 자식 윈도우 리스트를 얻어낸다.
def GetChildWindows(windowname):
 
	# TeraCopy의 window handle을 검사한다.
	teracopyhwnd = WindowFinder('TeraCopy').GetHwnd()
 
	# Teracopy의 모든 child window handle을 검색한다.
	childrenlist = ChildWindowFinder(teracopyhwnd).GetChildrenList()
 
	return teracopyhwnd, childrenlist
 
# main 입니다.
def main(argv):
	hwnd, childwnds = GetChildWindows('TeraCopy')
	listviewCtrl    = 0
 
	# TeraCopy의 윈도우 핸들
	print "%X %s" % (hwnd, win32gui.GetWindowText(hwnd))
 
	# TeraCopy 자식 윈도우의 핸들
	print "HWND     CtlrID\tClass\tWindow Text"
	print "==========================================="
 
	for child in childwnds:
		ctrl_id  = win32gui.GetDlgCtrlID(child)
		wnd_clas = win32gui.GetClassName(child)
		wnd_text = win32gui.GetWindowText(child)		
		print "%08X %6d\t%s\t%s" % (child, ctrl_id, wnd_clas, wnd_text)
 
		if wnd_clas == 'SysListView32':
			listviewCtrl = child
 
	itemlist = GetListViewItems(listviewCtrl)
 
	for itm in itemlist:
		print itm
 
	return 0
 
if __name__ == '__main__':
	sys.exit(main(sys.argv))
 

실행하면 TeraCopy의 파일 목록(1열)이 출력됩니다. GetListViewItems의 두 번째 인자는 생략되어 있지만, 명시하면 각 열의 목록을 얻어낼 수 있습니다.

타겟 윈도우에 특정 메시지 전달하기

위 예제 코드에서 이 함수를 눈여겨 보시기 바랍니다.

win32gui.SendMessage( ... )

이 함수가 메시지 큐에 특정 메시지를 전달하는 함수입니다. 메시지의 목록은 일일이 열거하기 힘듭니다. MSDN중 Windows Controls - Control Library의 메시지 맵의 부분을 상당히 참고해야 합니다. 이 함수를 이용해 버튼을 클릭하는 효과, 체크박스를 클릭하는 효과 등을 프로그램 명령으로 해낼 수 있습니다. 이는 여러분께 맡깁니다.

결론

윈도우는 멀티태스킹, 여러 프로세스를 동시에 관리하는 것이 가능합니다. 음악을 들으며 웹서핑이 가능한 이유는 멀티태스킹이 가능하기 때문입니다. 한편 프로세스의 내부에는 여러 스레드들이 동시에 작동할 수 있습니다. 프로세스와 각 스레드들은 '핸들'이라는 태그로 식별 및 접근이 가능합니다.

윈도우 운영체제는 메시지 전달 기반으로 동작합니다. 운영체제는 메시지 큐에 메시지를 저장하고 각 메시지를 프로그램에 전달합니다. 프로그램은 전달받은 메시지를 해석해 미리 메시지를 받았을 때 약속된 사항을 수행합니다. 메시지는 이벤트에 의해 발생합니다. 이벤트란 마우스를 움직이거나 키보드를 움직이는 등의 여러 '사건'들을 의미합니다.

한편 우리가 만일 각 프로세스의 핸들에 접근할 수 있고 어떤 메시지를 주고 받는지에 대한 정보를 안다면, 우리는 이벤트와 관계없이 프로그래밍을 통해 메시지를 생성하여 그 프로세스(스레드)를 제어할 수도 있다는 것을 보았습니다. 본 문서는 이러한 사실에 기반하여 어떤 다른 프로그램의 컨트롤이 가진 정보를 얻어오거나, 컨트롤에 (사용자의 이벤트 발생에 기반하지 않은) 메시지를 보내 동작시키는 법에 대해 설명하였습니다.

가벼운 마음으로 시작하였는데, 운영체제 구조 및 시스템 프로그래밍의 영역을 살짝 건드리게 되어 생각한 것보다 훨씬 어렵고 장황한 문서가 되었습니다. 질문은 언제든 이메일(cs.chwnam@gmail.com)로 보내주세요.

2015년 08월 09일 내용 추가

한 클리앙 회원분께서 이메일로 “리스트뷰 컨트롤 선택은 어떻게 하나요?”라는 질문을 보내 주셨습니다. 예전 파이썬을 막 시작할 때 코드를 잠시 보다가 개정도 할 겸, 답변도 드릴 겸 코드를 조금 수정하기로 맘 먹었습니다.

ListView Peek

타겟이 되는 리스트뷰에 적절히 원하는 이벤트를 날려주는 코드.

listview_peek.py
# -*- coding: utf-8 -*-
import win32gui
import win32api
import commctrl
import ctypes
from win32con import PAGE_READWRITE, MEM_COMMIT, MEM_RESERVE, MEM_RELEASE, PROCESS_ALL_ACCESS
 
GetWindowThreadProcessId = ctypes.windll.user32.GetWindowThreadProcessId
VirtualAllocEx = ctypes.windll.kernel32.VirtualAllocEx
VirtualFreeEx = ctypes.windll.kernel32.VirtualFreeEx
OpenProcess = ctypes.windll.kernel32.OpenProcess
WriteProcessMemory = ctypes.windll.kernel32.WriteProcessMemory
ReadProcessMemory = ctypes.windll.kernel32.ReadProcessMemory
 
 
class LVItem(ctypes.Structure):
    """
    LVITEM structure: https://msdn.microsoft.com/en-us/library/windows/desktop/bb774760%28v=vs.85%29.aspx
 
       UINT   mask
       int    iItem
       int    iSubItem
       UINT   state
       UINT   stateMask
       LPTSTR pszText
       int    cchTextMax
       int    iImage
       LPARAM lParam
 
    Total 9 items
    """
    _fields_ = [
        ("mask", ctypes.c_uint),
        ("iItem", ctypes.c_int),
        ("iSubItem", ctypes.c_int),
        ("state", ctypes.c_uint),
        ("stateMask", ctypes.c_uint),
        ("pszText", ctypes.c_char_p),
        ("cchTextMax", ctypes.c_int),
        ("iImage", ctypes.c_int),
        ("lParam", ctypes.c_ulong),
    ]
 
 
class ListViewPeek(object):
 
    def __init__(self, hwnd):
        self.hwnd = hwnd
        self.pid = ctypes.c_uint()
 
        GetWindowThreadProcessId(self.hwnd, ctypes.addressof(self.pid))
        self.process_handle = OpenProcess(PROCESS_ALL_ACCESS, False, self.pid)
        self.p_lvi = VirtualAllocEx(self.process_handle, 0, 4096, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)
        self.p_buf = VirtualAllocEx(self.process_handle, 0, 4096, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)
 
    def __del__(self):
        VirtualFreeEx(self.process_handle, self.p_buf, 0, MEM_RELEASE)
        VirtualFreeEx(self.process_handle, self.p_lvi, 0, MEM_RELEASE)
        win32api.CloseHandle(self.process_handle)
 
    def get_item_count(self):
        return win32gui.SendMessage(self.hwnd, commctrl.LVM_GETITEMCOUNT)
 
    def get_list_items(self):
        init_list = [0, 0, 0, 0, 0, self.p_buf, 4096, 0, 0]
        self.init_p_lvi(init_list)
 
        num_items = self.get_item_count()
        items = []
        extraction_buffer = ctypes.create_string_buffer(4096)
 
        for i in xrange(num_items):
            win32gui.SendMessage(self.hwnd, commctrl.LVM_GETITEMTEXT, i, self.p_lvi)
            self.__read_from_buffer(self.p_buf, ctypes.addressof(extraction_buffer), ctypes.sizeof(extraction_buffer))
            items.append(extraction_buffer.value)
        return items
 
    def select_item(self, index, selected):
        """
        index:     zero-based item index. -1 for select all.
        selected:  set True/False to select/deselect.
        """
        lv_item_list = [
            commctrl.LVIF_STATE,
            0,
            0,
            commctrl.LVIS_SELECTED if selected else 0,
            commctrl.LVIS_SELECTED,
            0,
            0,
            0,
            0,
        ]
        self.init_p_lvi(lv_item_list)
        return win32gui.SendMessage(self.hwnd, commctrl.LVM_SETITEMSTATE, index, self.p_lvi)
 
    def init_p_lvi(self, init_list):
        lv_item_buf = LVItem(*init_list)
        return self.__write_to_buffer(self.p_lvi, ctypes.byref(lv_item_buf), ctypes.sizeof(lv_item_buf))
 
    def __write_to_buffer(self, source, destination, read_size):
        copied = ctypes.c_uint()
        p_copied = ctypes.addressof(copied)
        WriteProcessMemory(self.process_handle, source, destination, read_size, p_copied)
        return copied.value
 
    def __read_from_buffer(self, source, destination, read_size):
        copied = ctypes.c_uint()
        p_copied = ctypes.addressof(copied)
        ReadProcessMemory(self.process_handle, source, destination, read_size, p_copied)
        return copied.value

설명

이전 코드의 약간 손보아 리스트 뷰에 대해 딱 필요한 기능을 꺼내 쓸 수 있도록 고쳤습니다. 그리고 이전에 생략한 설명을 첨부하도록 할께요. 어차피 혹시 또 코드를 볼 때가 있다면 다시 빨리 기억하기 편할 테니까요.

우선 LVItem 클래스는 LVITEM 구조체를 다시 정의한 것입니다. ctypes 레퍼런스를 참조하면 됩니다. 이전에는 'structure.pack('iiiiiiiii', …. )'이런 식으로 알아보기 어렵게 되었죠.

ListViewPeek 클래스가 본 기능을 하는 녀석입니다. 생성 인자로 리스트뷰 컨트롤의 핸들을 넘겨 주도록 되어 있죠. 생성자에서는 핸들의 PID를 얻고, 해당 PID로부터 프로세스 핸들을 얻어냅니다.

VirtualAllocEx는 해당 프로세스에서 메모리 버퍼를 만듭니다. p_lvi는 LVITEM 구조체를 위한 영역으로 만들었고, p_buf는 LVITEM의 멤버 변수인 pszText를 위한 영역입니다. 사실 p_lvi가 4KiB까지 차지할 필요는 없지만, 그냥 놔 뒀습니다.

리스트의 각 아이템 텍스트를 가져오는 방법은 이렇습니다. win32gui.SendMessageLVM_GETITEMTEXT 이벤트를 던지면 됩니다. 이 때 lParam으로 던지는 LVITEM은 pszText, cchTextMax만 신경 쓰면 됩니다. 단, 데이터를 가져올 때 메모리에 마구 접근하면 안되므로 우리가 VirtualAllocEx를 사용해 별도로 마련한 버퍼에 프로그램이 가진 데이터를 야금야금 복사해 가면서 파이썬 코드로 불러들여야 합니다.

우선 제대로 이벤트를 던지면 p_buf에는 각 항목의 텍스트가 복사됩니다. WinAPI가 여기까지는 해 줍니다. 그 다음 __read_from_buffer() 메소드가 호출됩니다. 여기서 ReadProcessMemory() 함수를 이용해 p_buf의 메모리를 다시 파이썬 스크립트 프로세스 쪽의 메모리 공간, extraction_buffer로 재차 복사합니다. 다른 프로세스에서 데이터를 끌어 오는 거라 좀 번거롭죠. extraction_buffer의 값을 드디어 파이썬 리스트가 받아 저장합니다.

리스트의 각 항목을 선택하려면 LVM_SETITEMSTATE 이벤트를 던져 주어야 합니다. 이 때 LVITEM 구조체는 이렇게 되어 있어야 합니다.

  • mask: state를 변경하는 것이므로 LVIF_STATE 플래그를 줍니다.
  • state: LVIS_SELECTED 플래그를 주면 해당 항목이 선택된 상태가 됩니다. 0이면 선택되지 않습니다.
  • stateMask: LVIS_SELECTED 플래그에 대해 검사를 할 것이므로 여기도 LVIS_SELECTED 플래그를 줍니다.
  • 이외: 이외의 필드는 0으로 해도 무방합니다.

그런데 TeraCopy는 ListView Control에서 LVM_SETITEMSTATE 이벤트를 제대로 수신하지 않습니다. ListView Control을 이용한 다른 앱을 이용해서 확인하기 바랍니다. 추정컨데 TeraCopy는 WM_CTL_COLORBTN 이벤트로 항목을 하이라이트하는 것 같습니다. 그래서 저는 예전에 제가 만들어 둔 SubRenamer를 이용하였습니다.

Control Picker

ListView Control를 찾아내고 조정하는 역할을 합니다.

control_picker.py
# -*- coding: utf-8
import win32gui
import pywintypes
from listview_peek import ListViewPeek
 
 
class ControlPicker:
    def __init__(self, parent_window_name):
        self.parent_window_handle = 0
        self.child_windows = []
 
        try:
            win32gui.EnumWindows(self.__enum_window_handler, parent_window_name)
        except pywintypes.error as e:
            if e[0] == 0:
                pass
 
        win32gui.EnumChildWindows(self.parent_window_handle, self.__enum_child_window_handler, None)
 
    def __enum_window_handler(self, window_handle, extra):
        window_text = win32gui.GetWindowText(window_handle)
        if window_text.find(extra) != -1:
            self.parent_window_handle = window_handle
            return pywintypes.FALSE  # stop enumerating
 
    def __enum_child_window_handler(self, window_handle, extra):
        self.child_windows.append(window_handle)
 
    def pick_control(self, target_class_name, target_control_id = None):
        for child_window in self.child_windows:
            window_class = win32gui.GetClassName(child_window)
            control_id = win32gui.GetDlgCtrlID(child_window)
 
            if window_class != target_class_name:
                continue
 
            if target_control_id:
                if target_control_id == control_id:
                    return child_window
            else:
                return child_window
 
 
if __name__ == '__main__':
    import sys
    import time
 
    picker = ControlPicker('SubRenamer')
    list_view_control = picker.pick_control('SysListView32', 0x3EC)
    if not list_view_control:
        print "SysListView32 not found!"
        sys.exit(1)
 
    peek = ListViewPeek(list_view_control)
 
    print "There are %d items in the ListView control!" % (peek.get_item_count(), )
    for x in peek.get_list_items():
        print x
    print
 
    if peek.get_item_count() < 2:
        print "Make sure Subrenamer has enough unmatched video lists!"
        sys.exit(1)
 
    pause = 5
    win32gui.SetActiveWindow(picker.parent_window_handle)
    win32gui.SetForegroundWindow(list_view_control)
    print "Target window is selected. %d seconds sleep..." % (pause, )
    time.sleep(pause)
 
    peek.select_item(-1, False)
    print "No item selected. %d seconds sleep..." % (pause, )
    time.sleep(pause)
 
    peek.select_item(0, True)
    print "The first item selected. %d seconds sleep..." % (pause, )
    time.sleep(pause)
 
    peek.select_item(-1, False)
    peek.select_item(1, True)
    print "The second item selected. %d seconds sleep..." % (pause, )
    time.sleep(pause)
 
    peek.select_item(-1, True)
    print "All selected. %d seconds sleep..." % (pause, )
    time.sleep(pause)
 
    peek.select_item(-1, False)
    print "None selected again."
    print "Done!"

설명

위 그림이 SubRenamer입니다. wxWidget을 이용하 C++ 버전으로 제작한 것인데 바이너리만 남고 소스가 어딨는지… 알 수 없네요. m( 보다시피 두 개의 ListView 컨트롤이 있습니다. 위쪽의 ID는 0x3EC, 아래쪽이 0x3ED입니다. Spy++로 확인 가능합니다.

이 프로그램은 동영상과 자막을 맞춰주는 프로그램입니다. 파이썬 버전으로도 문서를 만들었으니 참고하기 바랍니다. 이 프로그램은 간단하게 폴더 안에 같은 이름으로 mp4, avi, mkv 같은 동영상 확장자와 smi 확장자 한 쌍이 맞춰져 있는지를 확인합니다. 짝이 맞지 않으면 동영상은 위쪽 리스트에, 자막은 아래쪽 리스트에 표시됩니다. 실제 동영상이 아니어도 괜찮습니다. 0바이트짜리 파일을 만들어 적당히 video_001.mp4, video_002.mp4 이런 식으로 가짜 동영상 파일만 서너개 만든 후, 아래쪽의 'scan' 버튼만 눌러 보면 바로 결과를 확인할 수 있습니다.

control picker 스크립트도 subrenamer 프로그램을 상대로 제작했습니다. 스크립트를 실행하면 subrenamer 창을 찾고, 창을 가장 위로 올린 다음, 리스트 컨트롤의 항목을 선택하는 데모를 보여 줍니다.

project/peekwindow.txt · 마지막으로 수정됨: 2015/08/08 20:22 저자 changwoo

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki