【车机应用管理器 GUI:一款高效的 Android 车机应用与系统命令管理工具】
在日常车机应用开发与测试中,我们经常需要频繁启动 App、执行系统命令,甚至批量操作多个应用。为了简化这一流程,我开发了一款 车机应用管理器 GUI 工具,它集成了应用管理、批量操作、ADB 执行、日志记录等强大功能,让车机测试和使用变得轻松高效。下面带你深入了解它的亮点功能和使用方法。
主要功能概览
1. 完整应用与命令管理
-
支持 App 和 系统命令 两种类型。
-
应用信息包括:
- 包名
- Activity
- 应用名称
- 分类(如视频、媒体、系统工具)
-
系统命令支持:
- 任意 shell 命令
- 命令名称
- 分类标注
-
所有数据保存为 apps_data.json,不存在时会自动生成默认数据,保证工具开箱即可用。
2. 直观的 GUI 操作界面
-
日志窗口:实时显示操作日志,可清空,便于追踪执行过程。
-
操作列表:TreeView 显示所有应用和命令,支持按名称、包名、命令、分类筛选。
-
双击启动:双击任意条目即可启动 App 或执行命令,同时显示启动状态。
-
右键/按钮操作:
- 批量新增、批量修改、批量删除
- 单项编辑、修改或启动
- 导入/导出 JSON 数据
3. 批量启动与间隔设置
- 支持按筛选结果 批量启动 App 或执行命令。
- 可设置 启动间隔(秒,可为小数,例如 2.5 秒)。
- 每项启动后显示倒计时,便于观察和调试。
- 支持 停止按钮中断批量操作。
4. ADB 设备管理
-
等待设备连接:
- 使用
adb wait-for-device
,后台线程检测设备是否在线。 - 支持超时处理(默认 300 秒)。
- 使用
-
自动尝试执行
adb root
(如果设备支持)。 -
命令执行可靠:
adb shell am start -n package/activity
- 任意 shell 命令
- 支持超时、错误捕获与日志输出
5. 日志记录与错误追踪
- 日志文件:
d01hw_open_app_gui.log
,记录所有操作与结果。 - GUI 日志窗口实时显示,便于调试和确认状态。
- 支持 清空日志 功能,避免信息累积过多。
6. 批量操作与智能校验
-
批量新增:一次性添加多条 App 或命令,支持文本框输入多行。
-
批量修改:对选中项进行一次性修改,自动按原类型解析。
-
数据校验:
- 检查必填字段
- 自动跳过格式错误或空字段,防止程序异常
-
操作完成后自动刷新列表,并保存至 JSON 文件。
7. 友好的用户体验
-
支持 小数间隔设置,启动间隔灵活。
-
双线程设计:
- 主线程负责 GUI 响应
- 后台线程处理批量启动或等待设备,避免界面卡顿
-
UI 布局合理:
- 日志、列表、按钮、筛选器、进度条一目了然
- 分类、筛选和搜索支持快速定位目标应用或命令
-
错误提示与日志记录保证操作安全可靠
使用示例
-
等待设备连接
点击 “等待设备连接” 按钮,后台线程自动等待车机设备连接并执行adb root
(如果支持)。 -
批量启动应用
- 在筛选框中输入关键字筛选需要启动的应用。
- 点击 “启动全部(按当前筛选)”,工具将按间隔依次启动应用,并在日志中显示状态。
- 可随时点击 “停止运行” 中断批量操作。
-
单项操作
- 双击应用或命令条目弹出操作窗口。
- 可选择 启动 或 修改。
- 修改窗口提供完整字段编辑,支持即时保存。
-
批量新增/导入
- 点击 “批量新增”,输入多行数据,每行对应一条 App 或命令。
- 支持直接导入 JSON 文件,快速添加大量条目。
技术亮点
- 线程安全:所有批量操作和单项操作均在后台线程执行,不阻塞界面。
- 日志 + GUI 输出双重记录:既可查看文件日志,也可在 GUI 实时追踪。
- 可扩展性强:新增类型、字段或操作方式只需修改 JSON 数据与启动逻辑即可。
- 容错能力强:格式错误、必填字段缺失、ADB 异常均有提示和日志记录。
总结
这款 车机应用管理器 GUI 工具,将原本繁琐的 App 启动、命令执行、批量操作整合为一个 可视化、可操作、可记录 的统一平台。无论是测试人员还是开发者,都能通过它大幅提升效率,减少重复操作,同时保证操作的安全和可追溯性。
源代码:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
车机应用管理器(支持 app 与 system command)
保存文件:apps_data.json
日志文件:d01hw_open_app_gui.log
"""
import os
import sys
import json
import time
import threading
import subprocess
import logging
import tempfile
import shutil
import tkinter as tk
from tkinter import ttk, scrolledtext, simpledialog, messagebox, filedialog# ---------------- configuration ----------------
APP_DATA_FILE = "apps_data.json"
LOG_FILE = "d01hw_open_app_gui.log"
ADB_TIMEOUT = 15 # seconds for single adb command timeout
DEFAULT_WAIT_SECONDS_AFTER_START = 5 # default interval between items (seconds)# ensure utf-8 console on Windows (best-effort)
if sys.platform.startswith("win"):try:os.system("chcp 65001 > nul")except Exception:pass# ---------------- logging ----------------
logger = logging.getLogger("AppLauncher")
logger.setLevel(logging.INFO)
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")# file handler
fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
fh.setFormatter(fmt)
logger.addHandler(fh)class GUITextHandler(logging.Handler):"""Logging handler that writes to a scrolledtext widget."""def __init__(self, text_widget):super().__init__()self.text_widget = text_widgetdef emit(self, record):try:msg = self.format(record)# must modify the widget in main thread; use after if called from other threaddef append():try:self.text_widget.config(state='normal')self.text_widget.insert(tk.END, msg + "\n")self.text_widget.see(tk.END)self.text_widget.config(state='disabled')except Exception:passtry:# if called from mainloop thread, .after will still workself.text_widget.after(0, append)except Exception:append()except Exception:# avoid raising during loggingpass# ---------------- utilities ----------------
def safe_write_json(path, obj):"""Atomic write JSON: write to temp then move."""d = os.path.dirname(os.path.abspath(path)) or "."fd, tmp = tempfile.mkstemp(dir=d, prefix=".tmp_appdata_", text=True)try:with os.fdopen(fd, "w", encoding="utf-8") as f:json.dump(obj, f, ensure_ascii=False, indent=4)shutil.move(tmp, path)except Exception:try:os.remove(tmp)except Exception:passraisedef run_adb_start(package, activity, timeout=ADB_TIMEOUT):"""Run adb shell am start -n package/activity"""cmd = ["adb", "shell", "am", "start", "-n", f"{package}/{activity}"]try:res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)out = (res.stdout or "") + (res.stderr or "")success = (res.returncode == 0)return success, out.strip()except subprocess.TimeoutExpired:return False, f"timeout after {timeout}s"except FileNotFoundError:return False, "adb not found in PATH"except Exception as e:return False, str(e)def run_shell_command(command, timeout=ADB_TIMEOUT):"""Run arbitrary shell command (string). Return (success, output)."""try:res = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=timeout)out = (res.stdout or "") + (res.stderr or "")success = (res.returncode == 0)return success, out.strip()except subprocess.TimeoutExpired:return False, f"timeout after {timeout}s"except FileNotFoundError:return False, "command not found"except Exception as e:return False, str(e)# ---------------- main GUI ----------------
class AppLauncherGUI(tk.Tk):def __init__(self):super().__init__()self.title("车机应用管理器")self.geometry("1100x820")self.minsize(900, 600)# concurrency primitivesself.lock = threading.Lock()self.stop_flag = threading.Event()# runtime configself.launch_interval = DEFAULT_WAIT_SECONDS_AFTER_START # seconds (float allowed)# load dataself.apps_data = self._load_or_create_apps()# filtered indices: list of integer indices into apps_data currently shownself.filtered_indices = [i for i in range(len(self.apps_data))]# build UIself._build_widgets()# attach GUI loggingself._attach_logger()# initial refreshself.refresh_tree()logger.info("应用管理器已启动")# ---------- load/save ----------def _load_or_create_apps(self):if os.path.exists(APP_DATA_FILE):try:with open(APP_DATA_FILE, "r", encoding="utf-8") as f:data = json.load(f)if isinstance(data, list):for item in data:if not isinstance(item, dict):continueif "type" not in item:item["type"] = "app"return dataelse:logger.warning("apps_data.json 不是数组 -> 重新创建默认数据")except Exception as e:logger.exception("读取 apps_data.json 错误,将创建默认数据: %s", e)# default listdefault = [{"type": "app", "package": "com.arcvideo.car.iqy.video", "activity": ".SplashActivity", "name": "爱奇艺", "category": "视频"},{"type": "app", "package": "com.bytedance.byteautoservice3", "activity": "com.bytedance.auto.lite.apps.framework.ui.MainActivity", "name": "车鱼视听", "category": "视频"},{"type": "app", "package": "com.sohu.automotive", "activity": ".home.MainActivity", "name": "搜狐视频", "category": "视频"},{"type": "app", "package": "com.autolink.music", "activity": "com.autolink.app.module.main.activity.MainActivity", "name": "媒体中心", "category": "媒体"},{"type": "command", "command": "adb shell service call statusbar 1", "name": "负一屏", "category": "系统"}]try:safe_write_json(APP_DATA_FILE, default)except Exception:logger.exception("创建默认 apps_data.json 失败")return defaultdef _save_apps(self):try:safe_write_json(APP_DATA_FILE, self.apps_data)except Exception:logger.exception("保存 apps_data.json 失败")try:messagebox.showerror("保存失败", "保存 apps_data.json 时出错,详见日志")except Exception:pass# ---------- UI build ----------def _build_widgets(self):# top log boxself.log_text = scrolledtext.ScrolledText(self, height=12, state='disabled', wrap=tk.WORD)self.log_text.pack(fill=tk.BOTH, padx=8, pady=6)# button rowbtn_frame = ttk.Frame(self)btn_frame.pack(fill=tk.X, padx=8, pady=4)ttk.Button(btn_frame, text="等待设备连接", command=self.wait_for_device_thread).pack(side=tk.LEFT, padx=4)self.btn_launch_all = ttk.Button(btn_frame, text="启动全部(按当前筛选)", command=self.launch_all_apps_thread)self.btn_launch_all.pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="停止运行", command=self.stop_running).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="批量新增", command=self.add_apps_bulk_dialog).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="批量修改选中", command=self.bulk_edit_selected_dialog).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="批量删除选中", command=self.batch_delete_apps).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="导入 JSON", command=self.import_apps).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="导出 JSON", command=self.export_apps).pack(side=tk.LEFT, padx=4)ttk.Button(btn_frame, text="清空日志", command=self.clear_log).pack(side=tk.RIGHT, padx=4)# interval settinginterval_frame = ttk.Frame(self)interval_frame.pack(fill=tk.X, padx=8, pady=4)ttk.Label(interval_frame, text="每个项启动间隔 (秒):").pack(side=tk.LEFT)self.interval_var = tk.StringVar(value=str(self.launch_interval))interval_entry = ttk.Entry(interval_frame, textvariable=self.interval_var, width=8)interval_entry.pack(side=tk.LEFT, padx=6)ttk.Button(interval_frame, text="设置间隔", command=self.set_interval).pack(side=tk.LEFT, padx=4)ttk.Label(interval_frame, text="(支持小数,如 2.5)", foreground="gray").pack(side=tk.LEFT, padx=6)# filter rowfilter_frame = ttk.Frame(self)filter_frame.pack(fill=tk.X, padx=8, pady=6)ttk.Label(filter_frame, text="筛选 (名称 / 包名 / 命令 / 分类):").pack(side=tk.LEFT)self.filter_entry = ttk.Entry(filter_frame)self.filter_entry.pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)self.filter_entry.bind("<KeyRelease>", lambda e: self.apply_filter())# treecolumns = ("Type", "Identifier", "Action", "Category")self.tree = ttk.Treeview(self, columns=columns, show="headings", selectmode="extended")for col in columns:self.tree.heading(col, text=col)# set widthsself.tree.column("Type", width=80, anchor=tk.W)self.tree.column("Identifier", width=420, anchor=tk.W)self.tree.column("Action", width=300, anchor=tk.W)self.tree.column("Category", width=120, anchor=tk.W)self.tree.pack(fill=tk.BOTH, padx=8, pady=6, expand=True)self.tree.bind("<Double-1>", self.on_app_double_click)# progress bar and statusself.progress = ttk.Progressbar(self, orient=tk.HORIZONTAL, length=900, mode="determinate")self.progress.pack(fill=tk.X, padx=8, pady=6)self.status_var = tk.StringVar(value="准备就绪")ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W).pack(fill=tk.X, padx=8, pady=(0, 8))def _attach_logger(self):gui_handler = GUITextHandler(self.log_text)gui_handler.setFormatter(fmt)logger.addHandler(gui_handler)# ---------- clear log ----------def clear_log(self):try:self.log_text.config(state='normal')self.log_text.delete(1.0, tk.END)self.log_text.config(state='disabled')logger.info("日志已清空")except Exception:logger.exception("清空日志出错")# ---------- set interval ----------def set_interval(self):try:val = float(self.interval_var.get())if val < 0:raise ValueError("间隔不能为负")self.launch_interval = vallogger.info("启动间隔设置为 %.2f 秒", self.launch_interval)except Exception:messagebox.showerror("错误", "请输入有效的非负数字(支持小数)")self.interval_var.set(str(self.launch_interval))# ---------- refresh / filter ----------def refresh_tree(self):for iid in self.tree.get_children():self.tree.delete(iid)if not hasattr(self, 'filtered_indices') or not self.filter_entry.get().strip():self.filtered_indices = [i for i in range(len(self.apps_data))]for idx in self.filtered_indices:item = self.apps_data[idx]t = item.get("type", "app")if t == "app":identifier = item.get("package", "")action = item.get("activity", "")else:identifier = item.get("command", "")action = item.get("name", "")display_action = item.get("name", "") if item.get("type") == "command" else f"{item.get('name','')} ({item.get('activity','')})"self.tree.insert("", tk.END, iid=str(idx), values=(t, identifier, display_action, item.get("category", "")))def apply_filter(self):key = self.filter_entry.get().strip().lower()if not key:self.filtered_indices = [i for i in range(len(self.apps_data))]else:res = []for i, a in enumerate(self.apps_data):if ((a.get("name") or "").lower().find(key) != -1) \or ((a.get("package") or "").lower().find(key) != -1) \or ((a.get("command") or "").lower().find(key) != -1) \or ((a.get("category") or "").lower().find(key) != -1):res.append(i)self.filtered_indices = resself.refresh_tree()# ---------- device helpers ----------def wait_for_device(self):self._set_busy(True, "等待设备连接...")logger.info("执行 adb wait-for-device(最多 300s)")try:# wait-for-device will block until device connected; set timeout to avoid infinite blocksubprocess.run(["adb", "wait-for-device"], timeout=300)logger.info("设备已连接,尝试 adb root")try:subprocess.run(["adb", "root"], timeout=10)logger.info("adb root 已执行(如果支持)")except Exception:logger.info("adb root 未执行或不支持")except subprocess.TimeoutExpired:logger.error("等待设备超时(300s)")except FileNotFoundError:logger.error("adb 未找到,请检查 PATH")except Exception as e:logger.exception("等待设备出错: %s", e)finally:self._set_busy(False, "设备准备完成")def wait_for_device_thread(self):threading.Thread(target=self.wait_for_device, daemon=True).start()# ---------- launch logic ----------def launch_item(self, item):"""Generic launch for item (dict). Returns True/False for success."""if self.stop_flag.is_set():logger.info("检测到停止信号,取消启动")return Falsetyp = item.get("type", "app")try:if typ == "app":pkg = item.get("package", "")act = item.get("activity", "")if not pkg or not act:logger.error("无效 app 项,缺少包名或 Activity")return Falseok, out = run_adb_start(pkg, act)if ok:logger.info("应用启动命令已发出: %s", item.get("name", pkg))# wait with per-second countdowninterval = self.launch_interval# if interval < 1 we still wait 0 seconds (no loop)if interval >= 1:remaining = int(interval)# if interval is float and has fractional part, wait integer seconds then fractionalfrac = interval - remainingfor sec in range(remaining, 0, -1):if self.stop_flag.is_set():logger.info("等待期间检测到停止信号,停止等待")return Truelogger.info("等待 %d 秒...", sec)time.sleep(1)if frac > 0 and not self.stop_flag.is_set():# last fractional sleep (no log)time.sleep(frac)else:# interval < 1: just sleep fractionalif interval > 0:time.sleep(interval)return Trueelse:logger.error("启动失败: %s -> %s", item.get("name", pkg), out)return Falseelse:cmd = item.get("command", "")if not cmd:logger.error("无效 command 项,命令为空")return Falseok, out = run_shell_command(cmd)if ok:logger.info("系统命令执行成功: %s", item.get("name", cmd))# same wait/countdown behavior as aboveinterval = self.launch_intervalif interval >= 1:remaining = int(interval)frac = interval - remainingfor sec in range(remaining, 0, -1):if self.stop_flag.is_set():logger.info("等待期间检测到停止信号,停止等待")return Truelogger.info("等待 %d 秒...", sec)time.sleep(1)if frac > 0 and not self.stop_flag.is_set():time.sleep(frac)else:if interval > 0:time.sleep(interval)return Trueelse:logger.error("系统命令执行失败: %s -> %s", item.get("name", cmd), out)return Falseexcept Exception as e:logger.exception("启动项执行出错: %s", e)return Falsedef _set_busy(self, busy: bool, status_text: str = ""):try:self.btn_launch_all["state"] = tk.DISABLED if busy else tk.NORMALexcept Exception:passself.status_var.set(status_text or ("忙碌中" if busy else "准备就绪"))try:self.update_idletasks()except Exception:passdef launch_all(self):# freeze the list at startwith self.lock:to_launch = list(self.filtered_indices)self.stop_flag.clear()self._set_busy(True, "批量启动中... (可按 停止 取消)")total = len(to_launch)self.progress["maximum"] = total if total > 0 else 1self.progress["value"] = 0success = 0try:for i, idx in enumerate(to_launch):if self.stop_flag.is_set():logger.info("收到停止信号,终止批量启动")break# read current item (may have been edited)with self.lock:if idx < 0 or idx >= len(self.apps_data):logger.warning("索引越界, 跳过: %s", idx)self.progress["value"] = i + 1continueitem = self.apps_data[idx]ok = self.launch_item(item)if ok:success += 1# update progresstry:self.progress["value"] = i + 1except Exception:passlogger.info("批量启动结束: 成功 %d / %d", success, total)except Exception:logger.exception("批量启动出错")finally:try:self.progress["value"] = 0except Exception:passself._set_busy(False, "批量启动完成")self.stop_flag.clear()def launch_all_apps_thread(self):threading.Thread(target=self.launch_all, daemon=True).start()def stop_running(self):self.stop_flag.set()logger.info("停止信号已发送")self.status_var.set("停止信号已发送")# ---------- single-item UI (double click) ----------def on_app_double_click(self, event):iid = self.tree.focus()if not iid:returntry:idx = int(iid)except Exception:returnif idx < 0 or idx >= len(self.apps_data):returnitem = self.apps_data[idx]# modal window with three buttons: 启动 / 修改 / 取消win = tk.Toplevel(self)win.title(f"操作 - {item.get('name', '')}")win.geometry("620x220")win.transient(self)win.grab_set()ttk.Label(win, text=f"名称: {item.get('name', '')}").pack(anchor=tk.W, padx=12, pady=6)typ = item.get("type", "app")ttk.Label(win, text=f"类型: {typ}").pack(anchor=tk.W, padx=12)if typ == "app":ttk.Label(win, text=f"包名: {item.get('package','')}").pack(anchor=tk.W, padx=12)ttk.Label(win, text=f"Activity: {item.get('activity','')}").pack(anchor=tk.W, padx=12, pady=(0, 8))else:ttk.Label(win, text=f"命令: {item.get('command','')}").pack(anchor=tk.W, padx=12, pady=(0, 8))frame = ttk.Frame(win)frame.pack(pady=12)def on_start():win.destroy()# start single item in background so UI remains responsivethreading.Thread(target=lambda: (self.launch_item(item)), daemon=True).start()def on_modify():win.destroy()self._single_edit_dialog(idx)ttk.Button(frame, text="启动", command=on_start).pack(side=tk.LEFT, padx=8)ttk.Button(frame, text="修改", command=on_modify).pack(side=tk.LEFT, padx=8)ttk.Button(frame, text="取消", command=win.destroy).pack(side=tk.LEFT, padx=8)# ---------- single edit dialog ----------def _single_edit_dialog(self, index):if index < 0 or index >= len(self.apps_data):returnitem = self.apps_data[index]dlg = tk.Toplevel(self)dlg.title("修改条目")dlg.geometry("760x300")dlg.transient(self)dlg.grab_set()f = ttk.Frame(dlg, padding=8)f.pack(fill=tk.BOTH, expand=True)# type selectorttk.Label(f, text="类型:").grid(row=0, column=0, sticky=tk.W, pady=6)type_var = tk.StringVar(value=item.get("type", "app"))type_combo = ttk.Combobox(f, textvariable=type_var, values=["app", "command"], state="readonly", width=12)type_combo.grid(row=0, column=1, sticky=tk.W)# app fieldsttk.Label(f, text="包名:").grid(row=1, column=0, sticky=tk.W, pady=6)e_pkg = ttk.Entry(f, width=80)e_pkg.grid(row=1, column=1, sticky=tk.W, columnspan=2)e_pkg.insert(0, item.get("package", ""))ttk.Label(f, text="Activity:").grid(row=2, column=0, sticky=tk.W, pady=6)e_act = ttk.Entry(f, width=80)e_act.grid(row=2, column=1, sticky=tk.W, columnspan=2)e_act.insert(0, item.get("activity", ""))# command fieldttk.Label(f, text="命令:").grid(row=3, column=0, sticky=tk.W, pady=6)e_cmd = ttk.Entry(f, width=80)e_cmd.grid(row=3, column=1, sticky=tk.W, columnspan=2)e_cmd.insert(0, item.get("command", ""))ttk.Label(f, text="名称:").grid(row=4, column=0, sticky=tk.W, pady=6)e_name = ttk.Entry(f, width=80)e_name.grid(row=4, column=1, sticky=tk.W, columnspan=2)e_name.insert(0, item.get("name", ""))ttk.Label(f, text="分类:").grid(row=5, column=0, sticky=tk.W, pady=6)e_cat = ttk.Entry(f, width=80)e_cat.grid(row=5, column=1, sticky=tk.W, columnspan=2)e_cat.insert(0, item.get("category", ""))# show/hide field logicdef refresh_fields(*_):typ = type_var.get()if typ == "app":e_pkg.config(state=tk.NORMAL)e_act.config(state=tk.NORMAL)e_cmd.config(state=tk.DISABLED)else:e_pkg.config(state=tk.DISABLED)e_act.config(state=tk.DISABLED)e_cmd.config(state=tk.NORMAL)type_combo.bind("<<ComboboxSelected>>", refresh_fields)refresh_fields()def on_apply():typ = type_var.get()name = e_name.get().strip()cat = e_cat.get().strip() or "未分类"if typ == "app":pkg = e_pkg.get().strip()act = e_act.get().strip()if not (pkg and act and name):messagebox.showerror("错误", "包名 / Activity / 名称 为必填项")returnnew_item = {"type": "app", "package": pkg, "activity": act, "name": name, "category": cat}else:cmd = e_cmd.get().strip()if not (cmd and name):messagebox.showerror("错误", "命令 / 名称 为必填项")returnnew_item = {"type": "command", "command": cmd, "name": name, "category": cat}with self.lock:self.apps_data[index] = new_itemself._save_apps()logger.info("已修改条目: %s", name)self.apply_filter()dlg.destroy()btns = ttk.Frame(f)btns.grid(row=6, column=0, columnspan=3, pady=12)ttk.Button(btns, text="Apply", command=on_apply).pack(side=tk.LEFT, padx=8)ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.LEFT, padx=8)# ---------- bulk add dialog ----------def add_apps_bulk_dialog(self):dlg = tk.Toplevel(self)dlg.title("批量新增(一次性添加多行)")dlg.geometry("880x520")dlg.transient(self)dlg.grab_set()ttk.Label(dlg, text="选择类型(影响解析格式):").pack(anchor=tk.W, padx=10, pady=(8, 0))type_var = tk.StringVar(value="app")type_frame = ttk.Frame(dlg)type_frame.pack(anchor=tk.W, padx=10)ttk.Radiobutton(type_frame, text="应用 (格式:包名,Activity,名称,分类(可选))", variable=type_var, value="app").pack(anchor=tk.W)ttk.Radiobutton(type_frame, text="系统命令 (格式:命令,名称,分类(可选))", variable=type_var, value="command").pack(anchor=tk.W)ttk.Label(dlg, text="在下面文本框中每行一项,输入完成后点击 OK 一次性添加").pack(anchor=tk.W, padx=10, pady=(6, 0))sample_text = ("示例 (app): com.example.app,com.example.app.MainActivity,示例应用,工具\n""示例 (command): adb shell service call statusbar 1,负一屏,系统")ttk.Label(dlg, text=sample_text, foreground="gray").pack(anchor=tk.W, padx=10, pady=(0, 6))text = scrolledtext.ScrolledText(dlg, wrap=tk.WORD)text.pack(fill=tk.BOTH, expand=True, padx=10, pady=6)# pre-insert a blank sample lineif type_var.get() == "app":text.insert(tk.END, "com.example.app,com.example.app.MainActivity,示例应用,工具\n")else:text.insert(tk.END, "adb shell service call statusbar 1,负一屏,系统\n")def on_ok():typ = type_var.get()raw_lines = text.get(1.0, tk.END).splitlines()added = 0skipped = 0with self.lock:for line in raw_lines:line = line.strip()if not line:continueparts = [p.strip() for p in line.split(",")]if typ == "app":if len(parts) < 3:logger.warning("新增跳过(app 格式错误): %s", line)skipped += 1continuepkg, act, name = parts[0:3]cat = parts[3] if len(parts) > 3 and parts[3] else "未分类"if not (pkg and act and name):logger.warning("新增跳过(app 有空字段): %s", line)skipped += 1continueself.apps_data.append({"type": "app", "package": pkg, "activity": act, "name": name, "category": cat})added += 1else:if len(parts) < 2:logger.warning("新增跳过(command 格式错误): %s", line)skipped += 1continuecmd = parts[0]name = parts[1]cat = parts[2] if len(parts) > 2 and parts[2] else "未分类"if not (cmd and name):logger.warning("新增跳过(command 有空字段): %s", line)skipped += 1continueself.apps_data.append({"type": "command", "command": cmd, "name": name, "category": cat})added += 1try:self._save_apps()except Exception:logger.exception("保存 apps_data.json 失败")self.apply_filter()logger.info("批量新增完成:新增 %d,跳过 %d", added, skipped)dlg.destroy()btn_frame = ttk.Frame(dlg)btn_frame.pack(pady=8)ttk.Button(btn_frame, text="OK - 添加全部", command=on_ok).pack(side=tk.LEFT, padx=6)ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(side=tk.LEFT, padx=6)# ---------- bulk edit selected ----------def bulk_edit_selected_dialog(self):sel = self.tree.selection()if not sel:messagebox.showinfo("提示", "请先在列表中选中要批量修改的条目(支持多选)")returnindices = sorted([int(iid) for iid in sel])dlg = tk.Toplevel(self)dlg.title(f"批量修改 - {len(indices)} 项")dlg.geometry("920x560")dlg.transient(self)dlg.grab_set()ttk.Label(dlg, text="每行对应一个条目,按原顺序填写/修改。格式说明见上方(混合类型会按各自原类型解析)").pack(anchor=tk.W, padx=10, pady=6)text = scrolledtext.ScrolledText(dlg, wrap=tk.WORD)text.pack(fill=tk.BOTH, expand=True, padx=10, pady=6)for idx in indices:a = self.apps_data[idx]if a.get("type") == "app":line = f"{a.get('package','')},{a.get('activity','')},{a.get('name','')},{a.get('category','')}"else:line = f"{a.get('command','')},{a.get('name','')},{a.get('category','')}"text.insert(tk.END, line + "\n")def on_apply():raw = text.get(1.0, tk.END).splitlines()if len(raw) != len(indices):if not messagebox.askyesno("行数不匹配", f"编辑后的行数 {len(raw)} 与所选项 {len(indices)} 不一致,是否继续?多余/不足的行将被跳过/忽略。"):returnsucc = 0skipped = 0with self.lock:for i, line in enumerate(raw):if i >= len(indices):breakidx = indices[i]line = line.strip()if not line:skipped += 1continueparts = [p.strip() for p in line.split(",")]orig_type = self.apps_data[idx].get("type", "app")if orig_type == "app":if len(parts) < 3:logger.warning("批量修改跳过(app 格式错误): %s", line)skipped += 1continuepkg, act, name = parts[0:3]cat = parts[3] if len(parts) > 3 and parts[3] else "未分类"if not (pkg and act and name):logger.warning("批量修改跳过(app 空字段): %s", line)skipped += 1continueself.apps_data[idx] = {"type": "app", "package": pkg, "activity": act, "name": name, "category": cat}succ += 1else:if len(parts) < 2:logger.warning("批量修改跳过(command 格式错误): %s", line)skipped += 1continuecmd = parts[0]name = parts[1]cat = parts[2] if len(parts) > 2 and parts[2] else "未分类"if not (cmd and name):logger.warning("批量修改跳过(command 空字段): %s", line)skipped += 1continueself.apps_data[idx] = {"type": "command", "command": cmd, "name": name, "category": cat}succ += 1try:self._save_apps()except Exception:logger.exception("保存 apps_data.json 失败")self.apply_filter()logger.info("批量修改完成:成功 %d,跳过 %d", succ, skipped)dlg.destroy()btn_frame = ttk.Frame(dlg)btn_frame.pack(pady=8)ttk.Button(btn_frame, text="Apply", command=on_apply).pack(side=tk.LEFT, padx=6)ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(side=tk.LEFT, padx=6)# ---------- batch delete ----------def batch_delete_apps(self):sel = self.tree.selection()if not sel:returnindices = sorted([int(iid) for iid in sel], reverse=True)if not messagebox.askyesno("确认删除", f"确认删除 {len(indices)} 项?此操作不可撤销"):returnwith self.lock:for idx in indices:if 0 <= idx < len(self.apps_data):try:del self.apps_data[idx]except Exception:logger.exception("删除索引 %s 出错", idx)try:self._save_apps()except Exception:logger.exception("保存 apps_data.json 失败")self.apply_filter()logger.info("删除完成:%d 项", len(indices))# ---------- import/export ----------def import_apps(self):path = filedialog.askopenfilename(filetypes=[("JSON 文件", "*.json"), ("All files", "*.*")])if not path:returntry:with open(path, "r", encoding="utf-8") as f:data = json.load(f)if not isinstance(data, list):messagebox.showerror("格式错误", "导入的 JSON 应该是数组(应用列表)")returnadded = 0with self.lock:for a in data:if not isinstance(a, dict):continuetyp = a.get("type", "app")if typ == "app":pkg = (a.get("package") or "").strip()act = (a.get("activity") or "").strip()name = (a.get("name") or "").strip()cat = (a.get("category") or "未分类").strip()if not (pkg and act and name):continueself.apps_data.append({"type": "app", "package": pkg, "activity": act, "name": name, "category": cat})added += 1else:cmd = (a.get("command") or "").strip()name = (a.get("name") or "").strip()cat = (a.get("category") or "未分类").strip()if not (cmd and name):continueself.apps_data.append({"type": "command", "command": cmd, "name": name, "category": cat})added += 1self._save_apps()self.apply_filter()logger.info("导入完成:%d 项", added)except Exception as e:logger.exception("导入失败: %s", e)try:messagebox.showerror("导入失败", f"导入出错:{e}")except Exception:passdef export_apps(self):path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON 文件", "*.json")])if not path:returntry:with open(path, "w", encoding="utf-8") as f:json.dump(self.apps_data, f, ensure_ascii=False, indent=4)logger.info("导出完成: %s", path)except Exception as e:logger.exception("导出失败: %s", e)try:messagebox.showerror("导出失败", f"导出出错:{e}")except Exception:pass# ---------- exit handler ----------def on_closing(self):try:with self.lock:safe_write_json(APP_DATA_FILE, self.apps_data)except Exception:logger.exception("退出保存 apps_data.json 失败")try:self.destroy()except Exception:pass# ---------------- main ----------------
def main():app = AppLauncherGUI()app.protocol("WM_DELETE_WINDOW", app.on_closing)app.mainloop()if __name__ == "__main__":main()