查看: 121|回复: 3

Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
本机程序启动器是一个经典的桌面小工具,核心功能是扫描本机已安装的程序,提供搜索和快速启动。本文基于 Python + SQLite + wxPython 技术栈,重点解析开发过程中遇到的几个典型问题:打包后文件该存哪里、如何完整扫描所有已安装程序、如何避免重复插入脏数据、以及如何让 GUI 不卡顿。以下代码实践来自一个约 770 行的完整项目,已通过 PyInstaller 打包为单文件 exe 并验证。

一、路径定位:打包后配置文件写到哪?

很多开发者直接使用 `os.getcwd()` 来获取程序目录,但这在 exe 被双击时不可靠——工作目录可能指向桌面或其他路径。另一个常见错误是使用 `sys._MEIPASS`,该路径在 PyInstaller 单文件模式下指向临时解压目录,每次运行不同且程序退出后清除,无法持久化数据。正确做法是检测 `sys.frozen` 属性,区分打包状态和源码运行状态:
  1. def get_base_path() -> str:
  2.     if getattr(sys, "frozen", False):
  3.         base = os.path.dirname(os.path.abspath(sys.executable))
  4.     else:
  5.         base = os.path.dirname(os.path.abspath(__file__))
  6.     return base
  7. BASE_DIR = get_base_path()
  8. DB_PATH = os.path.join(BASE_DIR, "apps.db")
  9. CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
复制代码

`sys.executable` 指向 exe 文件的真实路径,不受工作目录影响。这样无论用户从哪个位置启动,数据库和配置始终与 exe 在一起。

二、三路扫描:覆盖所有安装来源

Windows 上获取“已安装程序”至少有三种常见来源:开始菜单快捷方式、注册表卸载列表、PATH 环境变量中的可执行文件。前三者覆盖传统 GUI 应用,后者专门针对通过 npm/pip 等包管理工具安装的命令行工具。

1. 开始菜单扫描:不解析 .lnk 目标路径

扫描 ProgramData 和 APPDATA 下的 “Start Menu\Programs” 文件夹,收集所有 .lnk 文件。注意这里不解析 .lnk 指向的真实 exe 路径,因为 Windows 原生支持直接运行 .lnk (os.startfile)。这种做法省掉了 pywin32 依赖。
  1. def scan_start_menu():
  2.     results = []
  3.     candidates = []
  4.     program_data = os.environ.get("ProgramData")
  5.     app_data = os.environ.get("APPDATA")
  6.     if program_data:
  7.         candidates.append(os.path.join(program_data, "Microsoft", "Windows", "Start Menu", "Programs"))
  8.     if app_data:
  9.         candidates.append(os.path.join(app_data, "Microsoft", "Windows", "Start Menu", "Programs"))
  10.     for base in candidates:
  11.         if not os.path.isdir(base): continue
  12.         for root, _dirs, files in os.walk(base):
  13.             for f in files:
  14.                 if f.lower().endswith(".lnk"):
  15.                     full_path = os.path.join(root, f)
  16.                     name = os.path.splitext(f)[0]
  17.                     results.append((name, full_path))
  18.     return results
复制代码

2. 注册表扫描:三个根键 + 组件过滤

Windows 安装程序会在注册表 `Uninstall` 键下注册信息。需要同时查询 64 位 (HKLM\SOFTWARE)、32 位 (HKLM\SOFTWARE\WOW6432Node) 和当前用户 (HKCU) 三个分支。同时过滤掉 `SystemComponent = 1` 的系统组件,避免列表中混入 .NET 运行时等。获取 exe 路径时先尝试 `DisplayIcon`(注意剔除图标索引后缀),如果不存在或文件不可用,再回退到 `InstallLocation` 下自动查找第一个 exe。
  1. def scan_registry():
  2.     if not IS_WINDOWS: return []
  3.     results = []
  4.     roots = [
  5.         (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
  6.         (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
  7.         (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
  8.     ]
  9.     for hive, path in roots:
  10.         try:
  11.             key = winreg.OpenKey(hive, path)
  12.         except FileNotFoundError:
  13.             continue
  14.         # ... 遍历子键,跳过 SystemComponent=1,提取 DisplayIcon / InstallLocation
  15.     return results
复制代码

3. PATH 扫描:排除系统目录,去重优先

遍历 PATH 环境变量中的每个目录,排除包含 system32、syswow64、windows\、wbem 等关键词的路径(这些目录下全是系统工具,用户不需要)。仅收集 .exe、.cmd、.bat、.ps1 扩展名的文件。通过 seen_dirs 去重目录,通过小写名称字典 results 去重可执行文件名(保留 PATH 中排在前面的那个)。
  1. def scan_command_line_tools():
  2.     if not IS_WINDOWS: return []
  3.     exclude_keywords = ("system32", "syswow64", "windowspowershell", "\\windows\", "wbem")
  4.     exe_exts = (".exe", ".cmd", ".bat", ".ps1")
  5.     path_env = os.environ.get("PATH", "")
  6.     dirs = [d.strip('"') for d in path_env.split(os.pathsep) if d.strip()]
  7.     seen_dirs = set()
  8.     results = {}
  9.     for d in dirs:
  10.         norm = os.path.normcase(os.path.normpath(d))
  11.         if norm in seen_dirs: continue
  12.         seen_dirs.add(norm)
  13.         if not os.path.isdir(d): continue
  14.         if any(kw in norm for kw in exclude_keywords): continue
  15.         try:
  16.             entries = os.listdir(d)
  17.         except OSError:
  18.             continue
  19.         for f in entries:
  20.             ext = os.path.splitext(f)[1].lower()
  21.             if ext not in exe_exts: continue
  22.             full_path = os.path.join(d, f)
  23.             if not os.path.isfile(full_path): continue
  24.             name = os.path.splitext(f)[0]
  25.             key = name.lower()
  26.             if key not in results:
  27.                 results[key] = (name, full_path)
  28.     return list(results.values())
复制代码

三、SQLite 幂等写入与数据血缘

数据库表设计:
  1. CREATE TABLE IF NOT EXISTS programs (
  2.     id INTEGER PRIMARY KEY AUTOINCREMENT,
  3.     name TEXT NOT NULL,
  4.     path TEXT NOT NULL,
  5.     source TEXT DEFAULT 'scan',
  6.     use_count INTEGER DEFAULT 0,
  7.     last_used TEXT,
  8.     added_time TEXT,
  9.     UNIQUE(path)
  10. )
复制代码

使用 `UNIQUE(path) + INSERT OR IGNORE` 实现幂等扫描:每次重新扫描前先删除非用户自定义的记录(`source != 'custom'`),再批量插入新数据。这样做既不会因重复扫描产生脏数据,也保住了用户手动添加的程序。source 字段标记来源(app/cli/custom),便于界面分类显示。搜索采用 LIKE 模糊查询,并按 use_count 降序、name 升序排列,实现“常用优先”。
  1. def bulk_insert_scanned(self, items, source="app"):
  2.     conn = self._connect()
  3.     now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  4.     conn.executemany(
  5.         "INSERT OR IGNORE INTO programs (name, path, source, use_count, last_used, added_time) "
  6.         "VALUES (?, ?, ?, 0, NULL, ?)",
  7.         [(name, path, source, now) for name, path in items],
  8.     )
  9.     conn.commit()
  10.     conn.close()
复制代码

四、JSON 配置:深合并与优雅降级

配置存储窗口位置、上次搜索内容等。采取“默认配置 + 用户配置”深合并:先深拷贝默认配置,再将用户 JSON 中的值更新到合并后的字典中。若用户配置文件损坏或不存在,直接静默使用默认值,不影响程序启动。
  1. DEFAULT_CONFIG = {
  2.     "window": {"width": 920, "height": 600, "x": -1, "y": -1, "maximized": False},
  3.     "last_search": "",
  4.     "last_scan_time": "",
  5. }
  6. class ConfigManager:
  7.     def _load(self) -> dict:
  8.         merged = json.loads(json.dumps(DEFAULT_CONFIG))  # 深拷贝
  9.         if os.path.isfile(self.path):
  10.             try:
  11.                 with open(self.path, "r", encoding="utf-8") as f:
  12.                     saved = json.load(f)
  13.                 for key, value in saved.items():
  14.                     if key == "window" and isinstance(value, dict):
  15.                         merged["window"].update(value)
  16.                     else:
  17.                         merged[key] = value
  18.             except Exception:
  19.                 pass  # 配置文件损坏时回退默认
  20.         return merged
复制代码

五、wxPython 线程模型:耗时操作放入后台

扫描注册表和文件系统属于耗时 I/O 操作,如果在主线程执行会导致界面卡死。解决方法是:启动一个 daemon 线程执行扫描,完成后通过 `wx.CallAfter` 将 UI 更新回调放回主线程。子线程内绝不可直接操作 wx 控件。
  1. def start_scan(self, auto=False):
  2.     self.btn_refresh.Disable()
  3.     self.status_bar.SetStatusText("正在扫描...")
  4.     thread = threading.Thread(target=self._scan_worker, daemon=True)
  5.     thread.start()
  6. def _scan_worker(self):
  7.     # 执行扫描,捕获异常
  8.     app_items = scan_all_programs()  # 注册表+开始菜单
  9.     cli_items = scan_command_line_tools()
  10.     def finish():
  11.         self.db.clear_scanned()
  12.         self.db.bulk_insert_scanned(app_items, source="app")
  13.         self.db.bulk_insert_scanned(cli_items, source="cli")
  14.         self.refresh_list(self.search_ctrl.GetValue())
  15.         self.btn_refresh.Enable()
  16.     wx.CallAfter(finish)
复制代码

六、打包验证:确保路径方案生效

使用 PyInstaller 打包为单文件:
  1. pip install pyinstaller
  2. pyinstaller -F -w -n 程序启动器 app_launcher.py
复制代码
打包完成后,将 exe 复制到任意文件夹双击运行,正确表现应为:exe 所在目录自动生成 config.json 和 apps.db。如果文件出现在 %TEMP% 或其他位置,说明路径方案不正确。

七、小结

本机程序启动器虽小,却涉及打包路径稳定性、多源数据爬取、幂等数据库写入、容错配置管理和 GUI 线程安全等关键问题。上述代码实践已通过打包验证,可作为类似桌面工具开发的参考模板。注意:当前扫描逻辑仅适用于 Windows,非 Windows 平台需要通过手动添加程序来补充。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践

感谢楼主的详细分享!路径定位的处理非常实用,尤其是区分 `sys.frozen` 和 `sys.executable` 来避开临时目录和随机工作目录,这点很多打包脚本容易踩坑。三路扫描覆盖开始菜单、注册表、PATH 的思路也很全面,不解析 `.lnk` 直接启动省掉了 pywin32 依赖,确实轻量。 想请教两个文中提到但未展开的问题:一是您如何避免重复插入脏数据的?比如同一程序同时出现在开始菜单和注册表时,是采用去重策略(如路径或名称去重)还是通过 SQLite 的唯一约束来处理?二是在 GUI 卡顿方面,您是在扫描过程中使用了线程/协程异步更新 wxPython 界面,还是只在后台线程完成扫描后一次性刷新列表?期待您进一步分享这些实践细节。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践

感谢楼主分享这么详细的实践,路径定位那块确实容易踩坑,之前用`os.getcwd()`吃过亏,现在学了一招`sys.executable`配合`sys.frozen`判断,很实用。 另外想请教一下,楼主开头提到了“如何避免重复插入脏数据”和“让GUI不卡顿”两个关键点,但正文主要讲了扫描逻辑,这两部分能不能展开说说思路?比如数据库去重是靠`UNIQUE`约束还是全字段比对?扫描过程如果比较耗时,是扔到后台线程还是用定时器分批处理?期待楼主的后续补充~
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践

感谢楼主分享这么详细的实践!路径定位那部分确实很关键,很多新手写桌面工具都容易踩 `os.getcwd()` 的坑,用 `sys.executable` 配合 `sys.frozen` 判断打包状态的做法很稳。三路扫描的思路也覆盖得很全,特别是开始菜单不解析 `.lnk` 而直接用 `os.startfile`,既省依赖又可靠,这个取舍很实用。 想请教一下,代码里提到“如何让 GUI 不卡顿”,但只展示了扫描逻辑的代码片段。你实际项目中是用多线程、队列还是用了 `wx.CallAfter` 来避免扫描过程阻塞主界面?另外注册表扫描里 `DisplayIcon` 带索引后缀(比如 `,0`)的情况,你是直接 `rsplit(',',1)[0]` 处理还是用了 `shlex.split` 之类的方法?这块想学一下细节。再次感谢!
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-3 11:44 , Processed in 0.031272 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部