133 lines
6.0 KiB
Python
133 lines
6.0 KiB
Python
"""Save the pairing QR displayed by the already-running WhatsApp Desktop app.
|
|||
|
|
|
||
|
|
No browser, debug port, process injection, restart, or logout. Requires Pillow
|
||
|
|
and zxing-cpp. The official Desktop app must be displaying its linking QR.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import ctypes as C
|
||
|
|
from ctypes import wintypes as W
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
import subprocess
|
||
|
|
import time
|
||
|
|
|
||
|
|
|
||
|
|
user = C.WinDLL('user32', use_last_error=True)
|
||
|
|
gdi = C.WinDLL('gdi32', use_last_error=True)
|
||
|
|
user.GetDC.argtypes = [W.HWND]; user.GetDC.restype = W.HDC
|
||
|
|
user.ReleaseDC.argtypes = [W.HWND, W.HDC]
|
||
|
|
user.GetWindowThreadProcessId.argtypes = [W.HWND, C.POINTER(W.DWORD)]
|
||
|
|
user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
|
||
|
|
user.PrintWindow.argtypes = [W.HWND, W.HDC, W.UINT]
|
||
|
|
user.IsWindowVisible.argtypes = [W.HWND]
|
||
|
|
gdi.CreateCompatibleDC.argtypes = [W.HDC]; gdi.CreateCompatibleDC.restype = W.HDC
|
||
|
|
gdi.CreateCompatibleBitmap.argtypes = [W.HDC, C.c_int, C.c_int]; gdi.CreateCompatibleBitmap.restype = W.HBITMAP
|
||
|
|
gdi.SelectObject.argtypes = [W.HDC, W.HGDIOBJ]; gdi.SelectObject.restype = W.HGDIOBJ
|
||
|
|
gdi.DeleteObject.argtypes = [W.HGDIOBJ]
|
||
|
|
gdi.DeleteDC.argtypes = [W.HDC]
|
||
|
|
|
||
|
|
class Header(C.Structure):
|
||
|
|
_fields_ = [('size', W.DWORD), ('width', W.LONG), ('height', W.LONG),
|
||
|
|
('planes', W.WORD), ('bits', W.WORD), ('compression', W.DWORD),
|
||
|
|
('image_size', W.DWORD), ('xppm', W.LONG), ('yppm', W.LONG),
|
||
|
|
('used', W.DWORD), ('important', W.DWORD)]
|
||
|
|
|
||
|
|
class Info(C.Structure):
|
||
|
|
_fields_ = [('header', Header), ('colors', W.DWORD * 3)]
|
||
|
|
|
||
|
|
gdi.GetDIBits.argtypes = [W.HDC, W.HBITMAP, W.UINT, W.UINT, C.c_void_p, C.POINTER(Info), W.UINT]
|
||
|
|
|
||
|
|
def desktop_windows():
|
||
|
|
query = "@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
|
||
|
|
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', query], capture_output=True, text=True, check=True)
|
||
|
|
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||
|
|
pids = set(data if isinstance(data, list) else [data])
|
||
|
|
windows = []
|
||
|
|
callback_type = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
|
||
|
|
@callback_type
|
||
|
|
def callback(hwnd, _):
|
||
|
|
pid = W.DWORD()
|
||
|
|
user.GetWindowThreadProcessId(hwnd, C.byref(pid))
|
||
|
|
if pid.value in pids and user.IsWindowVisible(hwnd):
|
||
|
|
windows.append((hwnd, pid.value))
|
||
|
|
return True
|
||
|
|
user.EnumWindows.argtypes = [callback_type, W.LPARAM]
|
||
|
|
user.EnumWindows(callback, 0)
|
||
|
|
return windows
|
||
|
|
|
||
|
|
def capture(hwnd):
|
||
|
|
from PIL import Image
|
||
|
|
rect = W.RECT()
|
||
|
|
if not user.GetWindowRect(hwnd, C.byref(rect)):
|
||
|
|
return None
|
||
|
|
width, height = rect.right - rect.left, rect.bottom - rect.top
|
||
|
|
if not (100 <= width <= 10000 and 100 <= height <= 10000):
|
||
|
|
return None
|
||
|
|
screen = user.GetDC(hwnd)
|
||
|
|
dc = gdi.CreateCompatibleDC(screen)
|
||
|
|
bitmap = gdi.CreateCompatibleBitmap(screen, width, height)
|
||
|
|
old = gdi.SelectObject(dc, bitmap)
|
||
|
|
try:
|
||
|
|
if not user.PrintWindow(hwnd, dc, 2):
|
||
|
|
return None
|
||
|
|
gdi.SelectObject(dc, old)
|
||
|
|
info = Info(Header(C.sizeof(Header), width, -height, 1, 32, 0, 0, 0, 0, 0, 0))
|
||
|
|
pixels = C.create_string_buffer(width * height * 4)
|
||
|
|
if gdi.GetDIBits(dc, bitmap, 0, height, pixels, C.byref(info), 0) != height:
|
||
|
|
return None
|
||
|
|
return Image.frombytes('RGB', (width, height), pixels.raw, 'raw', 'BGRX')
|
||
|
|
finally:
|
||
|
|
gdi.SelectObject(dc, old)
|
||
|
|
gdi.DeleteObject(bitmap)
|
||
|
|
gdi.DeleteDC(dc)
|
||
|
|
user.ReleaseDC(hwnd, screen)
|
||
|
|
|
||
|
|
def pairing_payload(text):
|
||
|
|
import base64
|
||
|
|
parts = text.split(',')
|
||
|
|
if len(parts) not in (5, 6):
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
return bool(parts[0]) and all(len(base64.b64decode(p, validate=True)) == 32 for p in parts[1:4]) and parts[4].isdigit()
|
||
|
|
except ValueError:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('--output', type=Path, default=Path.cwd() / 'whatsapp-desktop-qr.png')
|
||
|
|
parser.add_argument('--wait-seconds', type=int, default=0)
|
||
|
|
args = parser.parse_args(argv)
|
||
|
|
import zxingcpp
|
||
|
|
if not 0 <= args.wait_seconds <= 3600:
|
||
|
|
parser.error('--wait-seconds must be 0..3600')
|
||
|
|
deadline = time.monotonic() + args.wait_seconds
|
||
|
|
while True:
|
||
|
|
windows = desktop_windows()
|
||
|
|
for hwnd, pid in windows:
|
||
|
|
image = capture(hwnd)
|
||
|
|
if image is None:
|
||
|
|
continue
|
||
|
|
for code in zxingcpp.read_barcodes(image):
|
||
|
|
if code.format != zxingcpp.BarcodeFormat.QRCode or not pairing_payload(code.text):
|
||
|
|
continue
|
||
|
|
points = [code.position.top_left, code.position.top_right, code.position.bottom_left, code.position.bottom_right]
|
||
|
|
margin = 25
|
||
|
|
box = (max(0, min(p.x for p in points)-margin), max(0, min(p.y for p in points)-margin),
|
||
|
|
min(image.width, max(p.x for p in points)+margin), min(image.height, max(p.y for p in points)+margin))
|
||
|
|
cropped = image.crop(box)
|
||
|
|
if not any(c.text == code.text for c in zxingcpp.read_barcodes(cropped)):
|
||
|
|
continue
|
||
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
cropped.save(args.output, format='PNG')
|
||
|
|
print(json.dumps({'status': 'saved', 'source': 'running WhatsApp Desktop window', 'pid': pid,
|
||
|
|
'file': str(args.output.resolve()), 'payload_sha256': hashlib.sha256(code.text.encode()).hexdigest()}))
|
||
|
|
return 0
|
||
|
|
if time.monotonic() >= deadline:
|
||
|
|
print(json.dumps({'status': 'no_active_desktop_qr', 'windows_found': len(windows),
|
||
|
|
'note': 'Desktop must display its linking QR. A linked account has no login QR; GPU rendering or a minimized window can also prevent capture. No QR file was created or overwritten.'}))
|
||
|
|
return 2
|
||
|
|
time.sleep(2)
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
raise SystemExit(main())
|