ProjectOpenTARS4's picture
Mirror of openTARS 5.2 Ranger (revision 2026110101)
2695555 verified
Raw History Blame Contribute Delete
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