"""Backend do Windows (F5 do PLANO_5.2.md), em ctypes e PowerShell, sem dependência nova. Testado no CI do Windows; o que depende de uma pessoa na frente (seleção, notificação) só no notebook do Enzo. O que ainda falta aqui (acessibilidade UIA, janelas, bandeja) levanta NaoSuportado com a fase, pelo __getattr__.""" import ctypes import os import subprocess import time from pathlib import Path from . import NaoSuportado CF_UNICODETEXT = 13 GMEM_MOVEABLE = 0x0002 CREATE_NO_WINDOW = 0x08000000 # Ctrl+C num console interrompe o programa que roda nele: lá a seleção não é lida CONSOLES = ("ConsoleWindowClass", "CASCADIA_HOSTING_WINDOW_CLASS", "mintty", "VirtualConsoleClass", "PuTTY") APP = Path(__file__).resolve().parent.parent def _dlls(): from ctypes import wintypes user32 = ctypes.WinDLL("user32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) user32.OpenClipboard.argtypes = [wintypes.HWND] user32.GetClipboardData.restype = wintypes.HANDLE user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE] user32.SetClipboardData.restype = wintypes.HANDLE user32.GetForegroundWindow.restype = wintypes.HWND user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] kernel32.GlobalAlloc.restype = wintypes.HGLOBAL kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] kernel32.GlobalLock.restype = ctypes.c_void_p kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] return user32, kernel32 def abrir(alvo): os.startfile(str(alvo)) # noqa: S606 (só existe no Windows) def teclas_de_colar(): return ("ctrl", "v") # ------------------------------------------------------------ # Área de transferência # ------------------------------------------------------------ def _abrir_area(user32): for _ in range(20): # outro programa pode estar com ela aberta por um instante if user32.OpenClipboard(None): return time.sleep(0.03) raise OSError("a área de transferência está em uso por outro programa") def ler_area_de_transferencia(): user32, kernel32 = _dlls() _abrir_area(user32) try: dado = user32.GetClipboardData(CF_UNICODETEXT) if not dado: return "" ponteiro = kernel32.GlobalLock(dado) try: return ctypes.wstring_at(ponteiro) if ponteiro else "" finally: kernel32.GlobalUnlock(dado) finally: user32.CloseClipboard() def escrever_area_de_transferencia(texto): buffer = ctypes.create_unicode_buffer(str(texto)) user32, kernel32 = _dlls() _abrir_area(user32) try: user32.EmptyClipboard() memoria = kernel32.GlobalAlloc(GMEM_MOVEABLE, ctypes.sizeof(buffer)) ponteiro = kernel32.GlobalLock(memoria) ctypes.memmove(ponteiro, buffer, ctypes.sizeof(buffer)) kernel32.GlobalUnlock(memoria) if not user32.SetClipboardData(CF_UNICODETEXT, memoria): raise OSError(f"SetClipboardData falhou ({ctypes.get_last_error()})") finally: user32.CloseClipboard() # ------------------------------------------------------------ # Seleção: Ctrl+C simulado, devolvendo o que a pessoa tinha copiado # ------------------------------------------------------------ def _classe_da_janela_ativa(user32): nome = ctypes.create_unicode_buffer(256) user32.GetClassNameW(user32.GetForegroundWindow(), nome, 256) return nome.value def _esperar_soltar_teclas(user32, limite=1.0): """O atalho (Ctrl+Alt+R) ainda está apertado quando o openTARS acorda: Ctrl+C com o Alt no dedo vira Ctrl+Alt+C.""" fim = time.time() + limite teclas = (0x10, 0x11, 0x12, 0x5B, 0x5C) # Shift, Ctrl, Alt, Win esquerda e direita while time.time() < fim and any(user32.GetAsyncKeyState(k) & 0x8000 for k in teclas): time.sleep(0.02) _ARQUIVOS = [] # os arquivos da última cópia simulada (Explorer), lidos antes de devolver a área de transferência def _arquivos_na_area(user32): """Os caminhos que o Explorer pôs na área de transferência (CF_HDROP), ou [].""" from ctypes import wintypes shell32 = ctypes.WinDLL("shell32") shell32.DragQueryFileW.argtypes = [wintypes.HANDLE, wintypes.UINT, wintypes.LPWSTR, wintypes.UINT] shell32.DragQueryFileW.restype = wintypes.UINT if not user32.IsClipboardFormatAvailable(15) or not user32.OpenClipboard(None): # 15 = CF_HDROP return [] try: lista = user32.GetClipboardData(15) saida = [] for i in range(shell32.DragQueryFileW(lista, 0xFFFFFFFF, None, 0) if lista else 0): tamanho = shell32.DragQueryFileW(lista, i, None, 0) + 1 nome = ctypes.create_unicode_buffer(tamanho) shell32.DragQueryFileW(lista, i, nome, tamanho) saida.append(nome.value) return saida finally: user32.CloseClipboard() def arquivos_selecionados(): lista = list(_ARQUIVOS) _ARQUIVOS.clear() return lista def ler_selecao(): """shortcut: só texto volta para a área de transferência (imagem copiada antes se perde); trocar por salvar todos os formatos se alguém reclamar. Se o que estava selecionado eram arquivos (Explorer), eles ficam em _ARQUIVOS.""" user32, _kernel32 = _dlls() if _classe_da_janela_ativa(user32) in CONSOLES: return "" _esperar_soltar_teclas(user32) try: antes = ler_area_de_transferencia() except OSError: antes = None numero = user32.GetClipboardSequenceNumber() for tecla, solta in ((0x11, 0), (0x43, 0), (0x43, 2), (0x11, 2)): # Ctrl desce, C desce, C sobe, Ctrl sobe user32.keybd_event(tecla, 0, solta, 0) fim = time.time() + 0.6 while time.time() < fim and user32.GetClipboardSequenceNumber() == numero: time.sleep(0.03) if user32.GetClipboardSequenceNumber() == numero: return "" # nada copiado: não havia seleção (ou era campo de senha, que não deixa copiar) time.sleep(0.03) _ARQUIVOS[:] = _arquivos_na_area(user32) texto = "" if _ARQUIVOS else ler_area_de_transferencia() if antes is not None: escrever_area_de_transferencia(antes) return texto # ------------------------------------------------------------ # Lixeira, notificação, início automático # ------------------------------------------------------------ def lixeira(caminho): from ctypes import wintypes class SHFILEOPSTRUCTW(ctypes.Structure): _fields_ = [("hwnd", wintypes.HWND), ("wFunc", wintypes.UINT), ("pFrom", wintypes.LPCWSTR), ("pTo", wintypes.LPCWSTR), ("fFlags", ctypes.c_ushort), ("fAnyOperationsAborted", wintypes.BOOL), ("hNameMappings", ctypes.c_void_p), ("lpszProgressTitle", wintypes.LPCWSTR)] alvo = os.path.abspath(str(caminho)) if not os.path.lexists(alvo): raise OSError(f"não existe: {alvo}") # FO_DELETE com FOF_ALLOWUNDO = Lixeira; sem perguntar, sem janela de progresso, sem caixa de erro operacao = SHFILEOPSTRUCTW(wFunc=3, pFrom=alvo + "\0", fFlags=0x40 | 0x10 | 0x4 | 0x400) codigo = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(operacao)) if codigo or operacao.fAnyOperationsAborted: raise OSError(f"não consegui mandar para a Lixeira (código {codigo})") _TOAST = ( "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;" "$m = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(" "[Windows.UI.Notifications.ToastTemplateType]::ToastText02);" "$t = $m.GetElementsByTagName('text');" "$t.Item(0).AppendChild($m.CreateTextNode($env:TARS_TITULO)) > $null;" "$t.Item(1).AppendChild($m.CreateTextNode($env:TARS_TEXTO)) > $null;" "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(" "'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe')" ".Show([Windows.UI.Notifications.ToastNotification]::new($m))" ) def notificar(titulo, texto): # o texto vai por variável de ambiente: nada do que a pessoa (ou um site) escreveu vira comando do PowerShell subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-Command", _TOAST], env=dict(os.environ, TARS_TITULO=str(titulo), TARS_TEXTO=str(texto)), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW) def arquivo_autostart(nome): base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") return base / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{nome}.lnk" def autostart(nome, titulo, comando, ligado): atalho = arquivo_autostart(nome) if not ligado: atalho.unlink(missing_ok=True) return False atalho.parent.mkdir(parents=True, exist_ok=True) roteiro = ("$a = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TARS_LNK); $a.TargetPath = $env:TARS_ALVO;" "$a.Arguments = $env:TARS_ARGS; $a.WorkingDirectory = $env:TARS_PASTA; $a.Description = $env:TARS_TITULO;" "$a.IconLocation = $env:TARS_ICONE; $a.WindowStyle = 7; $a.Save()") subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", roteiro], capture_output=True, timeout=60, check=True, creationflags=CREATE_NO_WINDOW, env=dict(os.environ, TARS_LNK=str(atalho), TARS_ALVO=str(comando[0]), TARS_TITULO=str(titulo), TARS_ARGS=subprocess.list2cmdline([str(c) for c in comando[1:]]), TARS_PASTA=str(APP), TARS_ICONE=str(APP / "empacotamento" / "windows" / "opentars.ico") + ",0")) return atalho.exists() # ------------------------------------------------------------ # Janelas (ctypes): as de aplicativo, como na barra de tarefas # ------------------------------------------------------------ def _api_janelas(): from ctypes import wintypes user32 = ctypes.WinDLL("user32", use_last_error=True) user32.EnumWindows.argtypes = [ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM), wintypes.LPARAM] user32.EnumChildWindows.argtypes = [wintypes.HWND, ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM), wintypes.LPARAM] for nome in ("GetWindowTextW", "GetClassNameW"): getattr(user32, nome).argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] for nome in ("IsWindowVisible", "IsIconic", "GetWindowTextLengthW", "SetForegroundWindow", "IsZoomed"): getattr(user32, nome).argtypes = [wintypes.HWND] user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT] user32.GetWindow.restype = wintypes.HWND user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int] user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] user32.GetForegroundWindow.restype = wintypes.HWND user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] user32.MoveWindow.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.BOOL] return user32, wintypes def _camuflada(hwnd): """Apps da loja deixam janelas "camufladas" (existem, não aparecem): DWMWA_CLOAKED.""" from ctypes import wintypes valor = wintypes.DWORD() try: ctypes.windll.dwmapi.DwmGetWindowAttribute(wintypes.HWND(hwnd), 14, ctypes.byref(valor), ctypes.sizeof(valor)) except OSError: return False return bool(valor.value) def _nome_do_processo(pid): try: import psutil return psutil.Process(pid).name().rsplit(".", 1)[0] except Exception: # noqa: BLE001 (processo que acabou, sem permissão) return "" def _processo_do_quadro(user32, wintypes, hwnd, pid): """Apps da loja (Calculadora, Configurações) moram dentro do ApplicationFrameHost: o app é o dono da janela filha.""" outros = [] @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) def filho(h, _): dono = wintypes.DWORD() user32.GetWindowThreadProcessId(h, ctypes.byref(dono)) if dono.value != pid: outros.append(dono.value) return False return True user32.EnumChildWindows(hwnd, filho, 0) return outros[0] if outros else pid def janelas(): user32, wintypes = _api_janelas() achadas = [] @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) def cada(hwnd, _): if not user32.IsWindowVisible(hwnd) or user32.GetWindow(hwnd, 4) or _camuflada(hwnd): # 4 = GW_OWNER return True if user32.GetWindowLongW(hwnd, -20) & 0x80: # WS_EX_TOOLWINDOW return True tamanho = user32.GetWindowTextLengthW(hwnd) titulo = ctypes.create_unicode_buffer(tamanho + 1) user32.GetWindowTextW(hwnd, titulo, tamanho + 1) if not titulo.value: return True classe = ctypes.create_unicode_buffer(256) user32.GetClassNameW(hwnd, classe, 256) pid = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) if classe.value == "ApplicationFrameWindow": pid = wintypes.DWORD(_processo_do_quadro(user32, wintypes, hwnd, pid.value)) caixa = wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(caixa)) achadas.append({"id": int(hwnd), "titulo": titulo.value, "classe": f"{classe.value} {_nome_do_processo(pid.value)}", "pid": pid.value or None, "x": caixa.left, "y": caixa.top, "largura": caixa.right - caixa.left, "altura": caixa.bottom - caixa.top, "minimizada": bool(user32.IsIconic(hwnd))}) return True user32.EnumWindows(cada, 0) return list(reversed(achadas)) # o EnumWindows vai da frente para trás; o app espera da mais antiga à mais nova def janela_ativa(): user32, _ = _api_janelas() return int(user32.GetForegroundWindow() or 0) or None def focar_janela(id_janela): user32, _ = _api_janelas() if user32.IsIconic(id_janela): user32.ShowWindow(id_janela, 9) # SW_RESTORE # o Windows só deixa trazer para a frente quem acabou de receber uma tecla: um Alt solto destrava (truque conhecido) user32.keybd_event(0x12, 0, 0, 0) user32.keybd_event(0x12, 0, 2, 0) if not user32.SetForegroundWindow(id_janela): raise OSError("o Windows não deixou trazer a janela para a frente") def fechar_janela(id_janela): user32, _ = _api_janelas() user32.PostMessageW(id_janela, 0x0010, 0, 0) # WM_CLOSE: como clicar no X (o app pode perguntar se salva) def mover_janela(id_janela, x=None, y=None, largura=None, altura=None, maximizar=None): user32, wintypes = _api_janelas() if maximizar is not None: user32.ShowWindow(id_janela, 3 if maximizar else 9) # SW_MAXIMIZE / SW_RESTORE return if user32.IsZoomed(id_janela): user32.ShowWindow(id_janela, 9) caixa = wintypes.RECT() user32.GetWindowRect(id_janela, ctypes.byref(caixa)) user32.MoveWindow(id_janela, caixa.left if x is None else int(x), caixa.top if y is None else int(y), (caixa.right - caixa.left) if largura is None else int(largura), (caixa.bottom - caixa.top) if altura is None else int(altura), True) def area_util(): from ctypes import wintypes caixa = wintypes.RECT() ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(caixa), 0) # SPI_GETWORKAREA: sem a barra de tarefas return caixa.left, caixa.top, caixa.right - caixa.left, caixa.bottom - caixa.top # ------------------------------------------------------------ # Programas: o Menu Iniciar inteiro (inclusive os apps da loja, como a Calculadora), com o nome no idioma do Windows # ------------------------------------------------------------ _PROGRAMAS = {"lista": None, "quando": 0.0} SISTEMA = (("Calculator", ["calculadora", "calc"], "calc.exe"), ("Notepad", ["bloco de notas", "editor de texto", "text editor", "notepad"], "notepad.exe"), ("Paint", ["paint", "mspaint"], "mspaint.exe"), ("File Explorer", ["explorador de arquivos", "arquivos", "files", "explorer"], "explorer.exe"), ("Command Prompt", ["prompt de comando", "terminal", "cmd"], "cmd.exe"), ("Task Manager", ["gerenciador de tarefas", "taskmgr"], "taskmgr.exe")) def programas(): """[{'nome', 'outros_nomes', 'comando', 'id'}]: o Get-StartApps (5 min de cache) e os programas do próprio Windows.""" if _PROGRAMAS["lista"] is not None and time.time() - _PROGRAMAS["quando"] < 300: return _PROGRAMAS["lista"] lista = [] try: r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-StartApps | ConvertTo-Json -Compress"], capture_output=True, timeout=60, creationflags=CREATE_NO_WINDOW) import json dados = json.loads(r.stdout.decode("utf-8", "replace") or "[]") for item in dados if isinstance(dados, list) else [dados]: if item.get("Name") and item.get("AppID"): lista.append({"nome": item["Name"], "outros_nomes": [], "id": item["AppID"], "comando": ["explorer.exe", "shell:AppsFolder\\" + item["AppID"]]}) except (OSError, subprocess.SubprocessError, ValueError): pass pasta = Path(os.environ.get("SystemRoot") or "C:\\Windows") / "System32" for nome, outros, exe in SISTEMA: if (pasta / exe).exists() or exe == "explorer.exe": lista.append({"nome": nome, "outros_nomes": outros, "id": exe, "comando": [exe]}) _PROGRAMAS.update(lista=lista, quando=time.time()) return lista def __getattr__(funcao): """O que ainda não existe aqui levanta NaoSuportado com a fase, em vez de falhar em silêncio.""" if funcao.startswith("__"): raise AttributeError(funcao) def falta(*_args, **_kwargs): raise NaoSuportado(f"{funcao} ainda não existe no windows: entra na fase F5 da 5.2") return falta