Download app/tars_plataforma/macos.py from OpenTARS/openTARS: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/OpenTARS/openTARS/resolve/main/app/tars_plataforma/macos.py
- Command line
-
hf download hf://OpenTARS/openTARS/app/tars_plataforma/macos.py
-
curl -L -o macos.py https://huggingface.co/OpenTARS/openTARS/resolve/main/app/tars_plataforma/macos.py
12.9 kB
| """Backend do macOS (F6 do PLANO_5.2.md). Programas que já vêm no Mac (pbcopy, pbpaste, osascript, open, launchctl) e o | |
| pyobjc que o pyautogui já instala (Quartz, AppKit, Foundation). Testado no CI do macOS no que não pede permissão; a | |
| seleção, os cliques e os títulos das janelas pedem Acessibilidade e Gravação de Tela (Ajustes > Privacidade e Segurança).""" | |
| import os | |
| import plistlib | |
| import subprocess | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from . import NaoSuportado | |
| def _carregar_frameworks(): | |
| try: | |
| import AppKit # noqa: F401 | |
| import Quartz # noqa: F401 | |
| except Exception: # noqa: BLE001 (sem o pyobjc: cada função diz o que falta quando for chamada) | |
| pass | |
| # O primeiro import do Quartz (pyobjc) leva segundos: no CI do macOS passou de 15 s, e cada pedido do chat esperava por ele | |
| # ao montar a lista de janelas. Carrega já, em segundo plano; quem só quer contexto pergunta frameworks_prontos(). | |
| _CARREGANDO = threading.Thread(target=_carregar_frameworks, daemon=True, name="pyobjc") | |
| _CARREGANDO.start() | |
| def frameworks_prontos(): | |
| return not _CARREGANDO.is_alive() | |
| APP = Path(__file__).resolve().parent.parent | |
| def abrir(alvo): | |
| subprocess.Popen(["open", str(alvo)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) | |
| def teclas_de_colar(): | |
| return ("command", "v") | |
| # ------------------------------------------------------------ | |
| # Área de transferência e seleção | |
| # ------------------------------------------------------------ | |
| def ler_area_de_transferencia(): | |
| return subprocess.run(["pbpaste"], capture_output=True, timeout=10, check=True).stdout.decode("utf-8", "replace") | |
| def escrever_area_de_transferencia(texto): | |
| subprocess.run(["pbcopy"], input=str(texto).encode("utf-8"), capture_output=True, timeout=10, check=True) | |
| def _mudancas(): | |
| from AppKit import NSPasteboard | |
| return NSPasteboard.generalPasteboard().changeCount() | |
| def _apertar_cmd(tecla): | |
| """Cmd+tecla pelo Quartz (precisa da permissão de Acessibilidade).""" | |
| import Quartz | |
| codigos = {"c": 8, "v": 9} | |
| for desce in (True, False): | |
| evento = Quartz.CGEventCreateKeyboardEvent(None, codigos[tecla], desce) | |
| Quartz.CGEventSetFlags(evento, Quartz.kCGEventFlagMaskCommand) | |
| Quartz.CGEventPost(Quartz.kCGHIDEventTap, evento) | |
| def _esperar_soltar_teclas(limite=1.0): | |
| """O atalho ainda está apertado quando o openTARS acorda: Cmd+C com o Ctrl e o Option no dedo não copia.""" | |
| import Quartz | |
| fim = time.time() + limite | |
| while time.time() < fim and Quartz.CGEventSourceFlagsState(Quartz.kCGEventSourceStateHIDSystemState) & ( | |
| Quartz.kCGEventFlagMaskControl | Quartz.kCGEventFlagMaskAlternate | Quartz.kCGEventFlagMaskShift | |
| | Quartz.kCGEventFlagMaskCommand): | |
| time.sleep(0.02) | |
| _ARQUIVOS = [] # os arquivos da última cópia simulada (Finder), lidos antes de devolver a área de transferência | |
| def _arquivos_na_area(): | |
| from AppKit import NSPasteboard, NSPasteboardURLReadingFileURLsOnlyKey, NSURL | |
| urls = NSPasteboard.generalPasteboard().readObjectsForClasses_options_( | |
| [NSURL], {NSPasteboardURLReadingFileURLsOnlyKey: True}) or [] | |
| return [str(u.path()) for u in urls] | |
| def arquivos_selecionados(): | |
| lista = list(_ARQUIVOS) | |
| _ARQUIVOS.clear() | |
| return lista | |
| def ler_selecao(): | |
| """Cmd+C simulado e a área de transferência devolvida. shortcut: só texto volta (imagem copiada antes se perde). | |
| Se o que estava selecionado eram arquivos (Finder, que também põe os nomes como texto), eles ficam em _ARQUIVOS.""" | |
| _esperar_soltar_teclas() | |
| try: | |
| antes = ler_area_de_transferencia() | |
| except (OSError, subprocess.SubprocessError): | |
| antes = None | |
| numero = _mudancas() | |
| _apertar_cmd("c") | |
| fim = time.time() + 0.6 | |
| while time.time() < fim and _mudancas() == numero: | |
| time.sleep(0.03) | |
| if _mudancas() == numero: | |
| return "" # nada copiado: não havia seleção, era campo de senha ou falta a permissão | |
| _ARQUIVOS[:] = _arquivos_na_area() | |
| 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 Foundation import NSFileManager, NSURL | |
| alvo = os.path.abspath(str(caminho)) | |
| if not os.path.lexists(alvo): | |
| raise OSError(f"não existe: {alvo}") | |
| ok, _destino, erro = NSFileManager.defaultManager().trashItemAtURL_resultingItemURL_error_( | |
| NSURL.fileURLWithPath_(alvo), None, None) | |
| if not ok: | |
| raise OSError(f"não consegui mandar para a Lixeira: {erro}") | |
| def notificar(titulo, texto): | |
| # título e texto vão como argumentos do script, nunca dentro dele: nada do que foi escrito vira AppleScript | |
| roteiro = ["-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run"] | |
| subprocess.Popen(["osascript", *roteiro, str(titulo), str(texto)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| def arquivo_autostart(nome): | |
| return Path.home() / "Library" / "LaunchAgents" / f"{nome}.plist" | |
| def autostart(nome, titulo, comando, ligado): | |
| arquivo = arquivo_autostart(nome) | |
| if not ligado: | |
| subprocess.run(["launchctl", "unload", str(arquivo)], capture_output=True, timeout=30) | |
| arquivo.unlink(missing_ok=True) | |
| return False | |
| arquivo.parent.mkdir(parents=True, exist_ok=True) | |
| arquivo.write_bytes(plistlib.dumps({"Label": nome, "ProgramArguments": [str(c) for c in comando], "RunAtLoad": True, | |
| "WorkingDirectory": str(APP), "ProcessType": "Interactive"})) | |
| return arquivo.exists() | |
| # ------------------------------------------------------------ | |
| # Permissões (F3.5): Acessibilidade (clicar, digitar, colar, ler a seleção) e Gravação de Tela (ver a tela). O atalho pelo | |
| # Carbon não pede Monitoramento de Entrada, e o microfone o próprio macOS pede quando a voz liga. | |
| # ------------------------------------------------------------ | |
| AJUSTES = {"acessibilidade": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", | |
| "tela": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"} | |
| def permissoes(): | |
| """{"acessibilidade": concedida?, "tela": concedida?}""" | |
| import ApplicationServices as AX | |
| import Quartz | |
| return {"acessibilidade": bool(AX.AXIsProcessTrusted()), "tela": bool(Quartz.CGPreflightScreenCaptureAccess())} | |
| def pedir_permissao(qual): | |
| """Põe o openTARS na lista do macOS (sem isso ele nem aparece em Ajustes) e abre a página certa dos Ajustes.""" | |
| if qual == "acessibilidade": | |
| import ApplicationServices as AX | |
| AX.AXIsProcessTrustedWithOptions({AX.kAXTrustedCheckOptionPrompt: True}) | |
| else: | |
| import Quartz | |
| Quartz.CGRequestScreenCaptureAccess() | |
| abrir(AJUSTES[qual]) | |
| # ------------------------------------------------------------ | |
| # Janelas (Quartz; os títulos pedem a permissão de Gravação de Tela, sem ela fica o nome do app) | |
| # ------------------------------------------------------------ | |
| def janelas(): | |
| import Quartz | |
| opcoes = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements | |
| saida = [] | |
| for info in Quartz.CGWindowListCopyWindowInfo(opcoes, Quartz.kCGNullWindowID) or []: | |
| if info.get("kCGWindowLayer", 0) != 0: | |
| continue # barra de menus, Dock, menus abertos | |
| dono = info.get("kCGWindowOwnerName") or "" | |
| caixa = info.get("kCGWindowBounds") or {} | |
| saida.append({"id": int(info["kCGWindowNumber"]), "titulo": info.get("kCGWindowName") or dono, "classe": dono, | |
| "pid": int(info.get("kCGWindowOwnerPID") or 0) or None, "x": int(caixa.get("X", 0)), | |
| "y": int(caixa.get("Y", 0)), "largura": int(caixa.get("Width", 0)), | |
| "altura": int(caixa.get("Height", 0)), "minimizada": False}) | |
| return list(reversed(saida)) # o Quartz lista da frente para trás; o app espera da mais antiga à mais nova | |
| def janela_ativa(): | |
| from AppKit import NSWorkspace | |
| app = NSWorkspace.sharedWorkspace().frontmostApplication() | |
| pid = app.processIdentifier() if app else None | |
| da_frente = [j for j in reversed(janelas()) if j["pid"] == pid] | |
| return da_frente[0]["id"] if da_frente else None | |
| def _pid_da_janela(id_janela): | |
| return next((j["pid"] for j in janelas() if j["id"] == int(id_janela)), None) | |
| def focar_janela(id_janela): | |
| from AppKit import NSApplicationActivateIgnoringOtherApps, NSRunningApplication | |
| pid = _pid_da_janela(id_janela) | |
| app = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid) if pid else None | |
| if not app: | |
| raise OSError("janela não encontrada") | |
| app.activateWithOptions_(NSApplicationActivateIgnoringOtherApps) | |
| def _janela_ax(id_janela): | |
| """A janela pela acessibilidade (AX) do app dono, casando pela posição (o AX não sabe o número da janela do Quartz).""" | |
| import ApplicationServices as AX | |
| alvo = next((j for j in janelas() if j["id"] == int(id_janela)), None) | |
| if not alvo: | |
| raise OSError("janela não encontrada") | |
| app = AX.AXUIElementCreateApplication(alvo["pid"]) | |
| erro, lista = AX.AXUIElementCopyAttributeValue(app, "AXWindows", None) | |
| for janela in lista or []: | |
| erro, posicao = AX.AXUIElementCopyAttributeValue(janela, "AXPosition", None) | |
| if erro == 0: | |
| _ok, ponto = AX.AXValueGetValue(posicao, AX.kAXValueCGPointType, None) | |
| if abs(ponto.x - alvo["x"]) < 4 and abs(ponto.y - alvo["y"]) < 4: | |
| return janela | |
| if lista: | |
| return lista[0] | |
| raise OSError("sem acesso à janela: libere o openTARS em Acessibilidade (Ajustes > Privacidade e Segurança)") | |
| def fechar_janela(id_janela): | |
| import ApplicationServices as AX | |
| janela = _janela_ax(id_janela) | |
| erro, botao = AX.AXUIElementCopyAttributeValue(janela, "AXCloseButton", None) | |
| if erro or AX.AXUIElementPerformAction(botao, "AXPress"): | |
| raise OSError("não consegui fechar a janela") | |
| def mover_janela(id_janela, x=None, y=None, largura=None, altura=None, maximizar=None): | |
| import ApplicationServices as AX | |
| import Quartz | |
| janela = _janela_ax(id_janela) | |
| if maximizar: | |
| caixa = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID()) | |
| x, y, largura, altura = 0, 25, caixa.size.width, caixa.size.height - 25 # 25: a barra de menus | |
| if x is not None and y is not None: | |
| AX.AXUIElementSetAttributeValue(janela, "AXPosition", AX.AXValueCreate(AX.kAXValueCGPointType, Quartz.CGPoint(x, y))) | |
| if largura and altura: | |
| AX.AXUIElementSetAttributeValue(janela, "AXSize", | |
| AX.AXValueCreate(AX.kAXValueCGSizeType, Quartz.CGSize(largura, altura))) | |
| def area_util(): | |
| import Quartz | |
| caixa = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID()) | |
| return 0, 25, int(caixa.size.width), int(caixa.size.height) - 25 | |
| # ------------------------------------------------------------ | |
| # Programas: os .app das pastas de aplicativos, com o nome que o Finder mostra (traduzido) | |
| # ------------------------------------------------------------ | |
| PASTAS_APPS = ("/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities", | |
| str(Path.home() / "Applications")) | |
| def programas(): | |
| """[{'nome', 'outros_nomes', 'comando', 'id'}] dos apps instalados.""" | |
| try: | |
| from Foundation import NSFileManager | |
| exibido = NSFileManager.defaultManager().displayNameAtPath_ | |
| except ImportError: | |
| exibido = None | |
| saida, vistos = [], set() | |
| for pasta in PASTAS_APPS: | |
| for app in sorted(Path(pasta).glob("*.app")) if Path(pasta).is_dir() else []: | |
| if app.stem in vistos: | |
| continue | |
| vistos.add(app.stem) | |
| nome = str(exibido(str(app))) if exibido else app.stem | |
| nome = nome[:-4] if nome.endswith(".app") else nome | |
| saida.append({"nome": nome, "outros_nomes": [app.stem] if nome != app.stem else [], | |
| "comando": ["open", "-a", str(app)], "id": app.stem}) | |
| return saida | |
| 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 macos: entra na fase F6 da 5.2") | |
| return falta | |