File size: 12,862 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
"""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