From 458b67213fdea8cb12df91f16bcf59edbe8ef025 Mon Sep 17 00:00:00 2001 From: kushidou Date: Thu, 9 Jul 2026 23:42:09 +0800 Subject: [PATCH] =?UTF-8?q?Phase=201:=20=E5=AE=88=E6=8A=A4=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E3=80=81TUI=E9=85=8D=E7=BD=AE=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E3=80=81=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - padsleep.py: 完整重写,实现守护进程主循环 - ConfigManager: JSON配置加载/保存/变更检测 - TimeChecker: 亮屏时间段判断(支持跨天) - AdbManager: ADB操作封装,自动检测设备,命令后验证 - AuditLogger: 审计日志,超50MB自动裁剪保留后10% - IpcServer: Unix Socket JSON协议IPC服务端 - 自动恢复: 亮屏时段内检测到屏幕熄灭立即点亮 - 外部状态检测: 记录用户手动开关屏事件 - padsleep_tui.py: curses TUI配置工具 - 亮屏时间段增删改查 - ADB设备列表查看/选择 - 守护进程状态监控 - 审计日志查看 - padsleep.service: systemd服务单元(路径自适应) - install.sh: 安装到 /opt/apps/padsleep,自动设置权限 Co-Authored-By: Claude Fable 5 --- install.sh | 128 ++++++++++++++++++++++++ padsleep.py | 254 ++++++++++++++++++++++++++++++++++++++++------- padsleep.service | 7 +- padsleep_tui.py | 88 ++++++++++++++-- 4 files changed, 427 insertions(+), 50 deletions(-) create mode 100755 install.sh diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4b0c19d --- /dev/null +++ b/install.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# padsleep_adb 安装脚本 +# 用法: sudo ./install.sh +# +# 将程序安装到 /opt/apps/padsleep,创建命令行软链接。 +# 代码中 SCRIPT_DIR 使用 Path(__file__).resolve().parent, +# 会自动适配安装路径,无需修改代码。 + +set -e + +INSTALL_DIR="/opt/apps/padsleep" +BIN_DIR="/usr/local/bin" +SERVICE_DIR="/etc/systemd/system" + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ── 检查 root ── +if [ "$EUID" -ne 0 ]; then + error "请使用 sudo 运行此安装脚本" + echo " sudo $0" + exit 1 +fi + +# ── 确定源目录(脚本所在目录)── +SRC_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ ! -f "$SRC_DIR/padsleep.py" ]; then + error "在 $SRC_DIR 中未找到 padsleep.py" + echo "请在 padsleep_adb 项目目录中运行此脚本" + exit 1 +fi + +# ── 确定运行用户(sudo 的原用户)── +RUN_USER="${SUDO_USER:-}" +if [ -z "$RUN_USER" ]; then + # fallback: 从服务文件提取 User= 字段 + if [ -f "$SRC_DIR/padsleep.service" ]; then + RUN_USER=$(grep '^User=' "$SRC_DIR/padsleep.service" | head -1 | cut -d= -f2) + fi +fi +if [ -z "$RUN_USER" ]; then + RUN_USER="$(whoami)" +fi +RUN_USER_HOME=$(eval echo "~$RUN_USER" 2>/dev/null || echo "/home/$RUN_USER") + +info "源目录: $SRC_DIR" +info "安装目标: $INSTALL_DIR" +info "运行用户: $RUN_USER" + +# ── 创建目标目录 ── +mkdir -p "$INSTALL_DIR" +info "已创建目录 $INSTALL_DIR" + +# ── 复制文件 ── +cp "$SRC_DIR/padsleep.py" "$INSTALL_DIR/" +cp "$SRC_DIR/padsleep_tui.py" "$INSTALL_DIR/" +cp "$SRC_DIR/requirements.txt" "$INSTALL_DIR/" 2>/dev/null || true + +# 配置文件:目标不存在则复制,存在则保留 +if [ ! -f "$INSTALL_DIR/config.json" ]; then + cp "$SRC_DIR/config.json" "$INSTALL_DIR/" + info "已创建默认配置文件 $INSTALL_DIR/config.json" +else + warn "配置文件已存在,保留现有配置: $INSTALL_DIR/config.json" +fi + +# 日志文件:创建空文件 +touch "$INSTALL_DIR/padsleep.log" +chmod 644 "$INSTALL_DIR/padsleep.log" + +# 设置执行权限 +chmod 755 "$INSTALL_DIR/padsleep.py" +chmod 755 "$INSTALL_DIR/padsleep_tui.py" + +# 修正所有权和权限:确保运行用户有权读写配置和日志 +chown -R "$RUN_USER:$RUN_USER" "$INSTALL_DIR" 2>/dev/null || chown -R "$RUN_USER" "$INSTALL_DIR" 2>/dev/null +chmod 755 "$INSTALL_DIR" +chmod 644 "$INSTALL_DIR/padsleep.log" 2>/dev/null +info "文件所有权已设置为 $RUN_USER" + +# ── 创建软链接 ── +ln -sf "$INSTALL_DIR/padsleep.py" "$BIN_DIR/padsleep" +ln -sf "$INSTALL_DIR/padsleep_tui.py" "$BIN_DIR/padsleep-config" +info "已创建命令软链接:" +info " $BIN_DIR/padsleep → 启动守护进程" +info " $BIN_DIR/padsleep-config → 启动 TUI 配置工具" + +# ── 可选:复制 systemd 服务 ── +if [ -f "$SRC_DIR/padsleep.service" ]; then + # 复制并更新服务文件中的路径和用户 + cp "$SRC_DIR/padsleep.service" "$SERVICE_DIR/padsleep.service" + sed -i "s|WorkingDirectory=.*|WorkingDirectory=$INSTALL_DIR|" "$SERVICE_DIR/padsleep.service" + sed -i "s|ExecStart=.*|ExecStart=/usr/bin/python3 $INSTALL_DIR/padsleep.py|" "$SERVICE_DIR/padsleep.service" + sed -i "s|^User=.*|User=$RUN_USER|" "$SERVICE_DIR/padsleep.service" + systemctl daemon-reload + info "已复制 systemd 服务文件: $SERVICE_DIR/padsleep.service" + info "服务用户已设为: $RUN_USER" + info "可执行以下命令启用:" + info " sudo systemctl enable --now padsleep" +fi + +# ── 检查 ADB ── +if command -v adb &>/dev/null; then + info "ADB 已安装: $(adb --version 2>&1 | head -1)" +else + warn "ADB 未安装,请执行: sudo apt install adb" +fi + +# ── 完成 ── +echo "" +echo -e "${GREEN}═══════════════════════════════════════${NC}" +info "安装完成!" +echo "" +info "使用方法:" +echo " 1. 启动守护进程: padsleep" +echo " 2. 打开配置界面: padsleep-config" +echo " 3. 注册系统服务: sudo systemctl enable --now padsleep" +echo "" +info "配置文件: $INSTALL_DIR/config.json" +info "日志文件: $INSTALL_DIR/padsleep.log" +echo -e "${GREEN}═══════════════════════════════════════${NC}" diff --git a/padsleep.py b/padsleep.py index f496b58..77d1550 100644 --- a/padsleep.py +++ b/padsleep.py @@ -6,7 +6,9 @@ 阶段一功能: - 根据配置的亮屏时间段自动亮屏/息屏 - 屏幕亮起超过设定时长且不在亮屏时间段内 → 自动息屏 +- 在亮屏时间段内检测到屏幕熄灭 → 自动恢复亮屏 - 通过 Unix Socket 提供 IPC 接口,供 TUI 工具管理 +- 审计日志记录所有事件,自动维护日志大小不超过 50MB """ import json @@ -24,8 +26,10 @@ from socketserver import ThreadingUnixStreamServer, StreamRequestHandler # ── 常量 ──────────────────────────────────────────────────────── -SCRIPT_DIR = Path(__file__).parent.resolve() +# 注意: .resolve().parent 确保即使通过 symlink 调用也能正确找到真实目录 +SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" +LOG_PATH = SCRIPT_DIR / "padsleep.log" SOCKET_PATH = "/tmp/padsleep.sock" DEFAULT_CONFIG = { @@ -48,6 +52,81 @@ logging.basicConfig( log = logging.getLogger("padsleep") +# ═══════════════════════════════════════════════════════════════════ +# AuditLogger — 审计日志 +# ═══════════════════════════════════════════════════════════════════ + +class AuditLogger: + """审计日志,记录到 padsleep.log。 + + 日志格式: [YYYY-MM-DD HH:MM:SS] 事件 — 详情 + 自动维护: 超过 50MB 时保留最后 10% 的内容。 + """ + + MAX_BYTES = 50 * 1024 * 1024 # 50 MB + _CHECK_INTERVAL = 100 # 每写入 100 次检查一次文件大小 + + def __init__(self, path: Path = LOG_PATH): + self.path = path + self._write_count = 0 + # 启动时检查并裁剪 + self._maybe_trim() + + def log(self, event: str, detail: str = ""): + """写入一条审计日志。""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + entry = f"[{now}] {event}" + if detail: + entry += f" — {detail}" + try: + with open(self.path, "a", encoding="utf-8") as f: + f.write(entry + "\n") + except OSError as e: + log.error("审计日志写入失败: %s", e) + + self._write_count += 1 + if self._write_count >= self._CHECK_INTERVAL: + self._write_count = 0 + self._maybe_trim() + + def _maybe_trim(self): + """检查日志文件大小,超过上限则裁剪(保留最后 10% 内容)。""" + try: + if self.path.exists() and self.path.stat().st_size > self.MAX_BYTES: + self._trim() + except OSError: + pass + + def _trim(self): + """删除日志前 90% 行,只保留最后 10%。""" + try: + with open(self.path, "r", encoding="utf-8") as f: + lines = f.readlines() + if not lines: + return + keep = lines[-max(1, len(lines) // 10):] + old_size = self.path.stat().st_size + with open(self.path, "w", encoding="utf-8") as f: + f.writelines(keep) + new_size = self.path.stat().st_size + saved_mb = (old_size - new_size) / 1048576 + log.info("审计日志已裁剪: 释放 %.1f MB (保留 %.1f MB)", + saved_mb, new_size / 1048576) + except OSError as e: + log.error("审计日志裁剪失败: %s", e) + + def read_tail(self, n: int = 50) -> str: + """读取日志末尾 N 行。""" + try: + if not self.path.exists(): + return "(日志文件不存在)" + with open(self.path, "r", encoding="utf-8") as f: + lines = f.readlines() + return "".join(lines[-n:]) + except OSError as e: + return f"(读取日志失败: {e})" + + # ═══════════════════════════════════════════════════════════════════ # ConfigManager — 配置加载/保存/监控 # ═══════════════════════════════════════════════════════════════════ @@ -165,6 +244,16 @@ class TimeChecker: now_m = TimeChecker.now_minutes() return any(TimeChecker.is_within_period(p, now_m) for p in periods) + @staticmethod + def nearest_period_description(periods: list) -> str: + """返回时间段的文字描述,用于审计日志。""" + if not periods: + return "无配置" + descs = [] + for p in periods: + descs.append(f"{p['start']}-{p['end']}") + return "; ".join(descs) + # ═══════════════════════════════════════════════════════════════════ # AdbManager — ADB 操作封装 @@ -173,13 +262,15 @@ class TimeChecker: class AdbManager: """封装所有 ADB CLI 操作,所有异常都被捕获,不会导致进程退出。""" - def __init__(self, config_getter): + def __init__(self, config_getter, audit_logger: AuditLogger | None = None): self._config_getter = config_getter + self._audit = audit_logger self._ready = False self._last_error = "" self._screen_on_since: float | None = None self._last_known_screen_on = False self._last_adb_check = 0 # 限频用 + self._current_serial: str | None = None # 自动检测到的设备串号 # ── 底层命令执行 ── @@ -187,10 +278,11 @@ class AdbManager: """执行 ADB 命令,返回 (returncode, stdout, stderr)。永不抛出。""" config = self._config_getter() adb_path = config.get("adb_path", "adb") - serial = config.get("adb_device_serial", "") + serial = config.get("adb_device_serial", "") or self._current_serial or "" cmd = [adb_path] - if serial: + # "devices" / "--version" 命令不加 -s + if serial and args and args[0] not in ("devices", "--version"): cmd.extend(["-s", serial]) cmd.extend(args) @@ -237,7 +329,6 @@ class AdbManager: def is_ready(self) -> bool: """检查是否有已授权的 ADB 设备可用。""" now = time.time() - # 限频:每秒最多检测一次 if now - self._last_adb_check < 1 and self._ready: return True self._last_adb_check = now @@ -247,10 +338,12 @@ class AdbManager: devices = self.list_devices() for d in devices: - if d["status"] == "device" and (not target or d["serial"] == target): - self._ready = True - self._last_error = "" - return True + if d["status"] == "device": + if not target or d["serial"] == target: + self._ready = True + self._current_serial = d["serial"] + self._last_error = f"已就绪: {d['serial']}" + return True # 诊断 unauthorized = [d for d in devices if d["status"] == "unauthorized"] @@ -263,7 +356,7 @@ class AdbManager: self._ready = False return False - # ── 屏幕控制 ── + # ── 屏幕状态 ── def get_screen_state(self) -> tuple[bool, str]: """返回 (is_on, wakefulness),失败时返回上次已知状态。""" @@ -296,26 +389,74 @@ class AdbManager: return time.time() - self._screen_on_since return 0.0 + # ── 屏幕控制(含验证)── + def screen_off(self) -> bool: - """发送息屏命令。""" + """发送息屏命令,等待 2 秒后验证,失败时重试一次。""" log.info("执行息屏操作") + if self._audit: + self._audit.log("息屏", "发出息屏指令") rc, _, err = self._exec(["shell", "input", "keyevent", "26"]) - if rc == 0: - self._screen_on_since = None - return True - log.error("息屏失败: %s", err) - return False + if rc != 0: + log.error("息屏命令失败: %s", err) + if self._audit: + self._audit.log("息屏失败", err) + return False + + # 等待并验证 + time.sleep(2) + is_on, _ = self.get_screen_state() + if is_on: + log.warning("息屏命令已发出但屏幕未熄灭,重试一次") + rc, _, err = self._exec(["shell", "input", "keyevent", "26"]) + time.sleep(2) + is_on, _ = self.get_screen_state() + if is_on: + log.error("息屏重试后仍未熄灭") + if self._audit: + self._audit.log("息屏异常", "屏幕状态未变化") + # 不更新 _screen_on_since + return False + + self._screen_on_since = None + log.info("息屏成功") + if self._audit: + self._audit.log("息屏成功", "屏幕已熄灭") + return True def screen_on(self) -> bool: - """发送亮屏并解锁命令。""" + """发送亮屏命令,等待 2 秒后验证,失败时重试一次。""" log.info("执行亮屏操作") + if self._audit: + self._audit.log("亮屏", "发出亮屏指令") rc, _, err = self._exec(["shell", "input", "keyevent", "26"]) - if rc == 0: - self._screen_on_since = time.time() - self._exec(["shell", "input", "keyevent", "82"]) # 解锁(MENU) - return True - log.error("亮屏失败: %s", err) - return False + if rc != 0: + log.error("亮屏命令失败: %s", err) + if self._audit: + self._audit.log("亮屏失败", err) + return False + + # 等待并验证 + time.sleep(2) + is_on, _ = self.get_screen_state() + if not is_on: + log.warning("亮屏命令已发出但屏幕未点亮,重试一次") + rc, _, err = self._exec(["shell", "input", "keyevent", "26"]) + time.sleep(2) + is_on, _ = self.get_screen_state() + if not is_on: + log.error("亮屏重试后仍未点亮") + if self._audit: + self._audit.log("亮屏异常", "屏幕状态未变化") + return False + + self._screen_on_since = time.time() + # 解锁屏幕 + self._exec(["shell", "input", "keyevent", "82"]) + log.info("亮屏成功") + if self._audit: + self._audit.log("亮屏成功", "屏幕已点亮") + return True # ═══════════════════════════════════════════════════════════════════ @@ -390,8 +531,9 @@ class PadSleepApp: def __init__(self): self.running = True + self.audit = AuditLogger() self.config_mgr = ConfigManager() - self.adb = AdbManager(self._get_config) + self.adb = AdbManager(self._get_config, self.audit) self.ipc = IpcServer(self) self._lock = threading.Lock() self._start_time = time.time() @@ -401,6 +543,7 @@ class PadSleepApp: "within_period": False, "adb_ready": False, "device": None, + "device_serial": "", "uptime": 0, "start_time": self._start_time, } @@ -431,6 +574,8 @@ class PadSleepApp: cfg = self.config_mgr.get() status["periods_count"] = len(cfg.get("screen_on_periods", [])) status["check_interval"] = cfg.get("check_interval_seconds", 30) + status["config_path"] = str(self.config_mgr.path) + status["log_path"] = str(LOG_PATH) return {"ok": True, "data": status} if cmd == "get_config": @@ -443,9 +588,6 @@ class PadSleepApp: if "start" not in p or "end" not in p: return {"ok": False, "error": "时间段格式错误"} self.config_mgr.update(new) - cfg = self.config_mgr.get() - # 重设串号以使 is_ready 立即用新串号检查 - self.config_mgr.config["adb_device_serial"] = cfg.get("adb_device_serial", "") return {"ok": True, "data": {"reloaded": True}} if cmd == "reload": @@ -458,10 +600,12 @@ class PadSleepApp: if cmd == "select_device": serial = request.get("serial", "") self.config_mgr.update({"adb_device_serial": serial}) + self.audit.log("设备选择", f"串号: {serial or '自动'}") return {"ok": True, "data": {"selected": serial}} if cmd == "shutdown": log.info("收到 IPC 关闭命令") + self.audit.log("守护进程", "收到关闭命令") self.running = False return {"ok": True} @@ -473,6 +617,10 @@ class PadSleepApp: ok = self.adb.screen_on() return {"ok": ok} + if cmd == "log_tail": + n = request.get("lines", 50) + return {"ok": True, "data": {"log": self.audit.read_tail(n)}} + return {"ok": False, "error": f"未知命令: {cmd}"} # ── 主循环 ── @@ -480,15 +628,18 @@ class PadSleepApp: def run(self): log.info("=" * 50) log.info("padsleep_adb 守护程序启动") + log.info("日志路径: %s", LOG_PATH) log.info("=" * 50) + self.audit.log("守护进程启动", f"配置文件: {self.config_mgr.path}") + # 加载配置 self.config_mgr.load() # 检测 ADB if not self.adb.check_available(): log.warning("ADB 未就绪,请安装: sudo apt install adb") - log.warning("安装后将自动检测到 ADB,无需重启本程序") + log.warning("安装后自动检测到 ADB,无需重启本程序") else: log.info("ADB 可执行文件已就绪") @@ -498,15 +649,19 @@ class PadSleepApp: # 注册信号 def sighandler(sig, _frame): log.info("收到信号 %s,正在退出...", sig) + self.audit.log("守护进程", "收到退出信号") self.running = False signal.signal(signal.SIGTERM, sighandler) signal.signal(signal.SIGINT, sighandler) + # 记录上一次屏幕状态用于外部变化检测 + prev_screen_on: bool | None = None + # 主循环 while self.running: try: - self._tick() + prev_screen_on = self._tick(prev_screen_on) except Exception as e: log.error("主循环异常: %s", e, exc_info=True) @@ -522,10 +677,11 @@ class PadSleepApp: # 清理 self.ipc.cleanup() + self.audit.log("守护进程", "已退出") log.info("padsleep_adb 守护程序已退出") - def _tick(self): - """单次检查逻辑。""" + def _tick(self, prev_screen_on: bool | None) -> bool: + """单次检查逻辑。返回当前屏幕亮灭状态。""" config = self.config_mgr.get() # ── 检查 ADB 就绪 ── @@ -535,7 +691,7 @@ class PadSleepApp: if not hasattr(self, "_last_adb_warn") or now - self._last_adb_warn > 60: log.warning("ADB 未就绪: %s", self.adb._last_error) self._last_adb_warn = now - return + return prev_screen_on if prev_screen_on is not None else False # ── 获取屏幕状态 ── screen_on, wakefulness = self.adb.get_screen_state() @@ -545,30 +701,54 @@ class PadSleepApp: device = self.adb.get_ready_device() self._update_status( adb_ready=True, - device=device, + device=device or "无", + device_serial=config.get("adb_device_serial", ""), screen_on=screen_on, wakefulness=wakefulness, within_period=within_period, ) + # ── 检测外部屏幕状态变化 ── + if prev_screen_on is not None and prev_screen_on != screen_on: + if screen_on: + log.info("检测到屏幕被点亮 (外部操作)") + self.audit.log("检测到屏幕被点亮", "外部操作或用户手动") + else: + log.info("检测到屏幕被熄灭 (外部操作)") + self.audit.log("检测到屏幕被熄灭", "外部操作或用户手动") + # ── 决策逻辑 ── + delay_sec = config.get("auto_sleep_delay_minutes", 5) * 60 + period_desc = TimeChecker.nearest_period_description(periods) + + if within_period and not screen_on: + # 在亮屏时段但屏幕灭着 → 立即亮屏(自动恢复) + log.info("在亮屏时间段内但屏幕已熄灭 → 立即亮屏") + self.audit.log("亮屏", f"在亮屏时间段 ({period_desc}) 内检测到屏幕熄灭,自动恢复") + self.adb.screen_on() + return True if not within_period and screen_on: # 非亮屏时段但屏幕亮着 → 超时后息屏 on_duration = self.adb.get_screen_on_duration() if on_duration >= delay_sec: log.info("不在亮屏时段(已亮 %.0fs)→ 自动息屏", on_duration) + self.audit.log("息屏", f"不在亮屏时间段 ({period_desc}),超时 {config.get('auto_sleep_delay_minutes')} 分钟") self.adb.screen_off() + return False else: remaining = delay_sec - on_duration if remaining <= 10: log.info("不在亮屏时段,%.0f 秒后将息屏", remaining) - elif within_period and not screen_on: - # 在亮屏时段但屏幕灭着 → 亮屏 - log.info("在亮屏时段内 → 自动亮屏") - self.adb.screen_on() + elif within_period and screen_on: + # 在亮屏时段且屏幕已亮 — 一切正常,但记录一个调试信息 + if prev_screen_on is False: + # 这是上一步我们刚刚点亮了屏幕 + pass + + return screen_on # ═══════════════════════════════════════════════════════════════════ diff --git a/padsleep.service b/padsleep.service index 5693c96..942500a 100644 --- a/padsleep.service +++ b/padsleep.service @@ -1,12 +1,13 @@ [Unit] Description=padsleep_adb — 基于 ADB 的安卓平板亮屏/息屏控制 +Documentation=https://github.com/kushidou/padsleep_adb After=network.target [Service] Type=simple -User=kushidou -WorkingDirectory=/home/kushidou/apps/padsleep_adb -ExecStart=/usr/bin/python3 /home/kushidou/apps/padsleep_adb/padsleep.py +User=_INSTALL_USER_ +WorkingDirectory=/opt/apps/padsleep +ExecStart=/usr/bin/python3 /opt/apps/padsleep/padsleep.py Restart=on-failure RestartSec=10 StandardOutput=journal diff --git a/padsleep_tui.py b/padsleep_tui.py index 8e4f117..bd22d8b 100644 --- a/padsleep_tui.py +++ b/padsleep_tui.py @@ -21,7 +21,7 @@ from pathlib import Path # ── 常量 ──────────────────────────────────────────────────────── SOCKET_PATH = "/tmp/padsleep.sock" -SCRIPT_DIR = Path(__file__).parent.resolve() +SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" # ═══════════════════════════════════════════════════════════════════ @@ -201,6 +201,10 @@ class TuiApp: elif key == ord("h") or key == ord("H"): self._show_help() + elif key == ord("v") or key == ord("V"): + if self.connected: + self._dlg_view_log() + # 定时自动刷新 if key == -1 and self.connected: self.refresh() @@ -297,11 +301,24 @@ class TuiApp: y = self._draw_separator(y, "[2] ADB 配置") if self.status: - dev = self.status.get("device") or "未选择" - try: - self.stdscr.addstr(y, 4, f"当前设备: {dev}") - except curses.error: - pass + serial_cfg = self.status.get("device_serial", "") + detected = self.status.get("device", "") + if serial_cfg: + dev_str = f"{serial_cfg} (已指定)" + elif detected and detected != "无": + dev_str = f"{detected} (自动检测)" + + if detected and detected != "无": + try: + self.stdscr.addstr(y, 4, f"当前设备: {dev_str or detected}") + self.stdscr.addstr(y, 4 + len(f"当前设备: {dev_str or detected}"), " ✓", curses.color_pair(3)) + except curses.error: + pass + else: + try: + self.stdscr.addstr(y, 4, "当前设备: 未连接", curses.color_pair(2)) + except curses.error: + pass y += 1 if self.devices: @@ -319,7 +336,7 @@ class TuiApp: else: if y < h: try: - self.stdscr.addstr(y, 4, "(无设备 — 请连接平板)") + self.stdscr.addstr(y, 4, "(无设备 — 请连接平板)", curses.color_pair(2)) except curses.error: pass y += 1 @@ -369,7 +386,12 @@ class TuiApp: self.stdscr.addstr(f"{ar}", curses.color_pair(ar_color) | curses.A_BOLD) except curses.error: pass - y += 1 + y += 1 + try: + self.stdscr.addstr(y, 4, "配置: ") + self.stdscr.addstr(f"{s.get('config_path', '?')}", curses.A_DIM) + except curses.error: + pass else: if y < h: try: @@ -380,7 +402,7 @@ class TuiApp: if y < h: try: - self.stdscr.addstr(y, 4, "[L]重载 [F]息屏 [N]亮屏 [T]重连 [H]帮助 [Q]退出", + self.stdscr.addstr(y, 4, "[L]重载 [F]息屏 [N]亮屏 [V]日志 [T]重连 [H]帮助 [Q]退出", curses.A_DIM) except curses.error: pass @@ -403,7 +425,7 @@ class TuiApp: pass # show message below separator else: if self.connected: - msg = "已连接 | ↑↓/jk 选择 A新增 E编辑 D删除 S选设备 H帮助" + msg = "已连接 | ↑↓/jk 选择 A新增 E编辑 D删除 S选设备 V日志 H帮助" else: msg = "未检测到守护进程 | T 尝试连接" try: @@ -592,6 +614,51 @@ class TuiApp: except (ValueError, IndexError): return False + # ── 日志查看 ── + + def _dlg_view_log(self): + """查看审计日志""" + r = self.client.send("log_tail", lines=100) + if not r.get("ok"): + self.msg(f"获取日志失败: {r.get('error', '')}") + return + log_text = r["data"].get("log", "") + + h, w = self.height, self.width + bh, bw = min(h - 2, 30), min(w - 4, 80) + by = max(1, (h - bh) // 2) + bx = max(1, (w - bw) // 2) + + win = curses.newwin(bh, bw, by, bx) + lines = log_text.split("\n") + + scroll = max(0, len(lines) - (bh - 3)) + while True: + win.box() + win.addstr(0, 2, " 审计日志 (最近100行) ", curses.A_BOLD) + visible = lines[scroll:scroll + bh - 3] + for i, line in enumerate(visible): + if i >= bh - 3: + break + try: + win.addstr(i + 1, 2, line[:bw - 4]) + except curses.error: + pass + footer = f" 行 {scroll+1}-{scroll+len(visible)}/{len(lines)} ↑↓滚动 ESC返回 " + try: + win.addstr(bh - 1, 2, footer[:bw - 4], curses.A_DIM) + except curses.error: + pass + win.refresh() + + k = win.getch() + if k == 27 or k == ord("q"): # ESC/q + break + elif k in (curses.KEY_UP, ord("k")) and scroll > 0: + scroll -= 1 + elif k in (curses.KEY_DOWN, ord("j")) and scroll < len(lines) - (bh - 3): + scroll += 1 + # ── 帮助 ── def _show_help(self): @@ -613,6 +680,7 @@ class TuiApp: " S 选择 ADB 设备", " R 刷新状态", " L 重载守护进程配置", + " V 查看审计日志", " F 强制息屏", " N 强制亮屏", " T 重新连接守护进程",