190 lines
5.6 KiB
Python
Executable File
190 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Handy ⌘1 回廊洗字守护:监听系统剪贴板,若内容命中 ASR 纠错表任一 key,在粘贴前替换为洗后文。
|
||
(规避 Handy 0.8.3 启用 external_script 后配置回滚的问题)
|
||
|
||
用法:
|
||
python3 karuo_handy_clipboard_wash.py # 前台
|
||
python3 karuo_handy_clipboard_wash.py --install # 安装 LaunchAgent
|
||
python3 karuo_handy_clipboard_wash.py --uninstall
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import plistlib
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
_REPO = Path(__file__).resolve().parents[3]
|
||
_SCRIPTS = _REPO / "运营中枢" / "工作台" / "脚本"
|
||
if str(_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_SCRIPTS))
|
||
|
||
from apply_karuo_voice_corrections import ( # noqa: E402
|
||
apply_corrections_with_meta,
|
||
load_corrections,
|
||
)
|
||
|
||
LABEL = "com.karuo.handy-asr-wash"
|
||
PLIST_PATH = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
||
SCRIPT_PATH = Path(__file__).resolve()
|
||
LOG_DIR = Path.home() / "Library/Logs/karuo"
|
||
POLL_MS = 45
|
||
|
||
|
||
def _load_keys() -> list[str]:
|
||
corr = load_corrections()
|
||
return sorted((k for k in corr if k and len(k) >= 2), key=len, reverse=True)
|
||
|
||
|
||
def _needs_wash(text: str, keys: list[str]) -> bool:
|
||
if not text or not text.strip():
|
||
return False
|
||
return any(k in text for k in keys)
|
||
|
||
|
||
def _pasteboard_text() -> str | None:
|
||
try:
|
||
from AppKit import NSPasteboard # type: ignore
|
||
|
||
pb = NSPasteboard.generalPasteboard()
|
||
s = pb.stringForType_("public.utf8-plain-text")
|
||
if s is None:
|
||
s = pb.stringForType_("NSStringPboardType")
|
||
return s
|
||
except Exception:
|
||
r = subprocess.run(["pbpaste"], capture_output=True, text=True, timeout=1)
|
||
if r.returncode == 0:
|
||
return r.stdout
|
||
return None
|
||
|
||
|
||
def _set_pasteboard(text: str) -> bool:
|
||
try:
|
||
from AppKit import NSPasteboard # type: ignore
|
||
|
||
pb = NSPasteboard.generalPasteboard()
|
||
pb.clearContents()
|
||
return bool(pb.setString_forType_(text, "public.utf8-plain-text"))
|
||
except Exception:
|
||
p = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True)
|
||
p.communicate(text)
|
||
return p.returncode == 0
|
||
|
||
|
||
def run_daemon() -> None:
|
||
keys = _load_keys()
|
||
last_seen = ""
|
||
change_count = -1
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
log = LOG_DIR / "handy_clipboard_wash.log"
|
||
|
||
def note(msg: str) -> None:
|
||
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n"
|
||
try:
|
||
with open(log, "a", encoding="utf-8") as f:
|
||
f.write(line)
|
||
except OSError:
|
||
pass
|
||
|
||
note("daemon start")
|
||
|
||
while True:
|
||
try:
|
||
from AppKit import NSPasteboard # type: ignore
|
||
|
||
pb = NSPasteboard.generalPasteboard()
|
||
cc = pb.changeCount()
|
||
except Exception:
|
||
cc = None
|
||
|
||
text = _pasteboard_text()
|
||
if text is None:
|
||
time.sleep(POLL_MS / 1000.0)
|
||
continue
|
||
|
||
if cc is not None and cc == change_count:
|
||
time.sleep(POLL_MS / 1000.0)
|
||
continue
|
||
if cc is not None:
|
||
change_count = cc
|
||
|
||
if text == last_seen:
|
||
time.sleep(POLL_MS / 1000.0)
|
||
continue
|
||
|
||
if _needs_wash(text, keys):
|
||
meta = apply_corrections_with_meta(text)
|
||
corrected = meta["corrected"]
|
||
if corrected != text:
|
||
if _set_pasteboard(corrected):
|
||
note(f"washed ({len(meta['hits'])} hits)")
|
||
last_seen = corrected
|
||
time.sleep(POLL_MS / 1000.0)
|
||
continue
|
||
|
||
last_seen = text
|
||
time.sleep(POLL_MS / 1000.0)
|
||
|
||
|
||
def _gui_domain() -> str:
|
||
uid = subprocess.check_output(["id", "-u"], text=True).strip()
|
||
return f"gui/{uid}"
|
||
|
||
|
||
def install_agent() -> int:
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
plist = {
|
||
"Label": LABEL,
|
||
"ProgramArguments": [sys.executable, str(SCRIPT_PATH)],
|
||
"RunAtLoad": True,
|
||
"KeepAlive": True,
|
||
"StandardOutPath": str(LOG_DIR / "handy_clipboard_wash.out.log"),
|
||
"StandardErrorPath": str(LOG_DIR / "handy_clipboard_wash.err.log"),
|
||
"ProcessType": "Background",
|
||
}
|
||
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(PLIST_PATH, "wb") as f:
|
||
plistlib.dump(plist, f)
|
||
domain = _gui_domain()
|
||
subprocess.run(["launchctl", "bootout", domain, LABEL], capture_output=True)
|
||
r = subprocess.run(["launchctl", "bootstrap", domain, str(PLIST_PATH)], capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
# 旧版 macOS
|
||
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], capture_output=True)
|
||
r2 = subprocess.run(["launchctl", "load", str(PLIST_PATH)], capture_output=True, text=True)
|
||
if r2.returncode != 0:
|
||
print(r.stderr or r.stdout or r2.stderr, file=sys.stderr)
|
||
return 1
|
||
print(f"OK LaunchAgent {LABEL}")
|
||
return 0
|
||
|
||
|
||
def uninstall_agent() -> int:
|
||
domain = _gui_domain()
|
||
subprocess.run(["launchctl", "bootout", domain, LABEL], capture_output=True)
|
||
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], capture_output=True)
|
||
if PLIST_PATH.exists():
|
||
PLIST_PATH.unlink()
|
||
print(f"OK removed {LABEL}")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--install", action="store_true")
|
||
ap.add_argument("--uninstall", action="store_true")
|
||
args = ap.parse_args()
|
||
if args.install:
|
||
return install_agent()
|
||
if args.uninstall:
|
||
return uninstall_agent()
|
||
run_daemon()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|