Files
karuo-ai/01_卡资(金)/金仓_存储备份/脚本/karuo_handy_clipboard_wash.py
2026-07-19 00:21:46 +08:00

190 lines
5.6 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())