吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1932|回复: 33
收起左侧

[其他原创] 为了一瓶醋,又包了顿饺子。

  [复制链接]
hellovirus 发表于 2026-10-18 12:14
本帖最后由 hellovirus 于 2026-10-18 12:51 编辑

为了一瓶醋,包了一次饺子!
这段时间有点忙职称晋升的事,立个flag,等职称晋升后,增加个功能:
把MAC分组,可能按组唤醒并机,主要是针对办公室里有多台电脑,同事下班后,都不关机就走了,电脑上还有工作界面,不安全,所以想把办公室中所有的电脑MAC汇总一下,到时候运行的电脑可以选择其余的全部关机,或者有选择性的关机,注意保留当做打印机主机的电脑。
这条是给自己做的便签,以后来完成。


先把事情的起因说一下:
我们科室有两个办公室,
距离相对来说比较远。其中另外一台电脑上有打印机,我所在的办公室没有打印机,
这两台电脑在一个局域网中。我把打印机做了一个网络共享,这样的话我的电脑上也可以使用打印机正常打印。
但是出现了两个问题:
第1个问题:是连接打印机的电脑长时间不动,会进入休眠模式。这时候我要想打印,就要先过去那个办公室,然后晃晃鼠标,再回来,才能正常使用。
第2个问题:是打完以后,我把打印的东西拿过来,可能会在我电脑上看看有没有再次需要打印的,如果没有,再回到那边关电脑,经常出现这种没有意义的来回跑动。
为了解决这个来回跑动唤醒和关机的问题,我就想能不能写一个软件,能实现远程唤醒和关机,所以我就让AI帮我写一个小程序,
写的过程中,我突然想到有一个飞鸽传书,又叫IPMSG的程序,我就能不能实现ip message的正常功能,
在这个基础上,额外加一个远程关机和唤醒功能,让它实现可以聊天,可以自动传文件(我可以把文件传过去,到那边打印也可以,为了麻烦,这个可以直接自动接收,不用再点确定接收),还可以远程唤醒关机,于是就有了我现在的这个小程序。
ScreenShot_2026-10-18_121412_606.png

PyMsg Pro

基于 Python 实现的局域网飞鸽传书增强版,兼容 IPMsg 协议,局域网内聊天、文件传输、远程关机、网络唤醒工具。

✨ 功能简介

PyMsg Pro 是局域网即时通讯工具,对标经典飞鸽传书 (IPMsg),在原版协议基础上做大量增强,无需服务器,依靠 UDP 广播在局域网工作。

📨 聊天功能

  •     局域网点对点私聊,支持广播全员消息
  •     聊天消息本地持久化保存,消息交替背景色,字体大小可调
  •     新消息弹窗提醒;支持离开模式(在线但标记离开状态)
  •     用户列表自动维护,显示 用户名@主机名、IP、MAC、在线状态
  •     双击列表项复制名称 / IP / MAC,支持列表搜索过滤


📁 文件传输

  •     点对点发送文件 / 文件夹(文件夹自动 zip 压缩传输)
  •     断点续传,支持最大重试次数配置,多线程并发传输
  •     传输面板展示上传下载进度,支持打开文件目录、复制路径
  •     可设置自动接收文件到指定下载目录


🛠️ 远程控制


  • [color=var(--md-box-color-fg)]    远程关机:向在线客户端发送关机指令,对方弹窗倒计时,支持取消关机
  • [color=var(--md-box-color-fg)]    WOL 网络唤醒:输入 MAC 地址发送魔术包,唤醒局域网内已关机主机


🧩 增强特性(对比原版 IPMsg)

  •     心跳保活机制:60 秒无活动自动探测,解决客户端异常下线后列表残留僵尸节点
  •     MAC 地址查询协议,自动获取对端 MAC 地址,用于 WOL 唤醒
  •     IP 变更自动识别迁移用户条目,IP 变化不会丢失用户信息
  •     Windows 开机自启动(注册表优先,快捷方式降级兜底)
  •     日志轮转记录,聊天历史、传输记录本地持久化
  •     托盘后台运行,最小化到托盘,不占用任务栏


程序如下:
[Asm] 纯文本查看 复制代码
#!/usr/bin/env python3# -*- coding: utf-8 -*-
"""
PyMsg Pro - 飞鸽传书增强版 (完整修复版)
修复:
  1. 关机指令失效问题(移除有害的UDP去重逻辑)
  2. 本机也显示 用户名@主机名
  3. 新增 MAC 查询/回复协议,解决后上线客户端获取不到前面客户端 MAC 的问题
  4. 双击"用户名@主机名"列自动复制
  5. 去掉标题轮播,固定显示 PyMsg Pro - IP:端口
  6. ★ 新增 Peer 超时淘汰机制(60秒无活动自动移除),修复下线后仍显示在线的问题
  7. ★ 自启动改用 注册表优先+Startup快捷方式降级 双模式,彻底解决路径/权限/CLIXML问题
特性:
  - 相邻消息背景色交替 (米白/浅黄) + 前景色 5 色柔和循环
"""

import socket, threading, time, os, sys, platform, json, io, zipfile, uuid, subprocess, logging, random
from collections import deque
from logging.handlers import TimedRotatingFileHandler
import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog
from datetime import datetime
from PIL import Image, ImageDraw
import pystray

# ============================================================
# IPMsg 协议常量与工具函数
# ============================================================
IPMSG_VERSION, IPMSG_DEFAULT_PORT = 1, 2425
IPMSG_BR_ENTRY, IPMSG_BR_EXIT, IPMSG_ANSENTRY = 0x01, 0x02, 0x03
IPMSG_BR_ABSENCE = 0x04                 # ★新增:离开模式/昵称变更(IPMSG 标准命令)
IPMSG_SENDMSG, IPMSG_RECVMSG = 0x20, 0x21
IPMSG_GETFILEDATA = 0x60
IPMSG_GETDIRFILES = 0x62                 # ★修正:标准值 0x62(原 0x63 与官方客户端不通)
IPMSG_GETDIRFILES_LEGACY = 0x63          # 兼容旧版 PyMsg
IPMSG_SENDCHECKOPT, IPMSG_ABSENCEOPT = 0x100, 0x100
IPMSG_FILEATTACHOPT, IPMSG_CAPUTF8OPT = 0x200000, 0x1000000
IPMSG_DIALUPOPT = 0x10000                # 收不到广播的成员,需单独单播通知
IPMSG_FILE_REGULAR = 0x01
IPMSG_CUSTOM_SHUTDOWN, IPMSG_SHUTDOWN_CANCEL = 0x80, 0x81
IPMSG_PING, IPMSG_PONG, IPMSG_FILE_RECV_OK = 0x82, 0x83, 0x84
IPMSG_MAC_QUERY, IPMSG_MAC_REPLY = 0x85, 0x86
IPMSG_PYMSG_FLAG = 0x20000
IPMSG_HEARTBEAT_OPT = 0x40000            # ★新增:该 BR_ENTRY 只是心跳,收到无需回 ANSENTRY

# ============================================================
# ★ 在线用户(Peer)管理参数 —— 针对原版 IPMSG 硬伤的增强
#   硬伤:①无心跳->僵尸列表 ②无状态机->只有"在/不在" ③无离开态
#        ④UDP 丢包导致漏人/漏删 ⑤应答广播风暴
# ============================================================
HEARTBEAT_INTERVAL = 15      # 心跳周期(秒):周期广播 BR_ENTRY 当心跳
PEER_TIMEOUT = 45            # 超过该时长未收到任何报文 -> 转入"探测中"
PROBE_INTERVAL = 4           # 探测中时 PING 的间隔(秒)
PROBE_MAX_MISS = 3           # 连续探测无应答次数 -> 判定离线并移除
ANS_DELAY_MAX = 4.0          # ANSENTRY 随机延迟上限(秒),IPMSG 原版 0~4 秒,抑制风暴
PRESENCE_DEDUPE_WIN = 2.0    # BR_ENTRY/ANSENTRY 去重窗口(秒),防止重发包被重复处理
ENTRY_RETRY = 3              # 上线广播重发次数
ENTRY_RETRY_GAP = 0.4
EXIT_RETRY = 3               # 下线广播重发次数(最关键的报文,宁可多发)
EXIT_RETRY_GAP = 0.25
MAC_QUERY_MIN_GAP = 15       # 同一 peer 的 MAC 查询最小间隔(秒)


def get_command(cmd): return cmd & 0xFF


def get_flags(cmd): return cmd & 0xFFFFFF00


CLIENT_TYPE_UNKNOWN, CLIENT_TYPE_PYMSG, CLIENT_TYPE_IPMSG = "unknown", "pymsg", "ipmsg"


def get_data_dir():
    base = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
                        'PyMsgPro') if platform.system() == "Windows" else os.path.join(os.path.expanduser('~'),
                                                                                        '.pymsgpro')
    for d in ['', 'history', 'logs', 'temp']: os.makedirs(os.path.join(base, d), exist_ok=True)
    return base


DATA_DIR = get_data_dir()
CONFIG_FILE = os.path.join(DATA_DIR, 'config.json')
GLOBAL_HISTORY_FILE = os.path.join(DATA_DIR, 'global_chat.json')
TRANSFER_HISTORY_FILE = os.path.join(DATA_DIR, 'transfer_history.json')
LOG_DIR = os.path.join(DATA_DIR, 'logs')


class FileLogger:
    def __init__(self):
        os.makedirs(LOG_DIR, exist_ok=True)
        self.logger = logging.getLogger('PyMsgPro')
        self.logger.setLevel(logging.DEBUG)
        if not self.logger.handlers:
            handler = TimedRotatingFileHandler(os.path.join(LOG_DIR, 'pymsg.log'), when='midnight', interval=1,
                                               backupCount=30, encoding='utf-8')
            handler.setFormatter(logging.Formatter('[%(asctime)s] %(message)s', datefmt='%H:%M:%S'))
            self.logger.addHandler(handler)

    def info(self, msg): self.logger.info(msg)

    def debug(self, msg): self.logger.debug(msg)

    def warning(self, msg): self.logger.warning(msg)


class Config:
    def __init__(self):
        self.data = {
            'username': '', 'hostname': '', 'port': 9999,
            'download_dir': os.path.join(os.path.expanduser('~'), 'PyMsgDownloads'),
            'aliases': {}, 'mac_addresses': {}, 'auto_start': False, 'auto_download': False, 'accept_shutdown': True,
            'max_retry': 3, 'max_threads': 4,
            # ★ 在线用户管理相关配置
            'heartbeat_interval': HEARTBEAT_INTERVAL,   # 心跳周期(秒)
            'peer_timeout': PEER_TIMEOUT,               # 多久没消息就转"探测中"(秒)
            'absence_mode': False,                      # 离开模式
            'absence_msg': '暂时离开,稍后回复',
        }
        self.load()

    def load(self):
        try:
            if os.path.exists(CONFIG_FILE):
                with open(CONFIG_FILE, 'r', encoding='utf-8') as f: self.data.update(json.load(f))
        except:
            pass

    def save(self):
        try:
            with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=2)
        except:
            pass

    def get(self, k, d=None):
        return self.data.get(k, d)

    def set(self, k, v):
        self.data[k] = v; self.save()


def get_local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM);
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0];
        s.close();
        return ip
    except:
        return "127.0.0.1"


def get_hostname():
    try:
        return socket.gethostname()
    except:
        return "Unknown"


def get_username():
    try:
        return os.getlogin()
    except:
        return os.environ.get('USERNAME', os.environ.get('USER', 'User'))


def format_size(size):
    if size < 1024:
        return f"{size} B"
    elif size < 1024 ** 2:
        return f"{size / 1024:.1f} KB"
    elif size < 1024 ** 3:
        return f"{size / 1024 ** 2:.1f} MB"
    else:
        return f"{size / 1024 ** 3:.2f} GB"


def get_broadcast_addr(ip):
    p = ip.split('.');
    return f"{p[0]}.{p[1]}.{p[2]}.255" if len(p) == 4 else "255.255.255.255"


def get_subnet_broadcasts():
    b = set()
    try:
        b.add(get_broadcast_addr(get_local_ip()))
    except:
        pass
    b.add("255.255.255.255");
    return list(b)


def get_local_mac(target_ip=None):
    try:
        system = platform.system()
        if system == "Windows":
            out = subprocess.check_output(
                ["ipconfig", "/all"], encoding='gbk', errors='ignore',
                creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)
            )
            lines = out.splitlines()
            for i, line in enumerate(lines):
                if target_ip and target_ip in line:
                    for j in range(max(0, i - 20), min(len(lines), i + 20)):
                        if "Physical Address" in lines[j] or "物理地址" in lines[j]:
                            mac = lines[j].split(":", 1)[-1].strip()
                            mac = mac.replace('-', ':').upper()
                            if len(mac.replace(':', '')) == 12:
                                return mac
            for i, line in enumerate(lines):
                if "Physical Address" in line or "物理地址" in line:
                    mac = line.split(":", 1)[-1].strip().replace('-', ':').upper()
                    if len(mac.replace(':', '')) == 12 and mac != "00:00:00:00:00:00":
                        return mac
        else:
            try:
                out = subprocess.check_output(["ip", "link"], encoding='utf-8', errors='ignore')
            except Exception:
                out = subprocess.check_output(["ifconfig"], encoding='utf-8', errors='ignore')
            import re
            macs = re.findall(r'([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})', out)
            for m in macs:
                m = m.upper()
                if m != "00:00:00:00:00:00":
                    return m
    except Exception:
        pass

    try:
        m = uuid.getnode()
        if m:
            s = ':'.join(('%012X' % m)[i:i + 2] for i in range(0, 12, 2))
            if s != '00:00:00:00:00:00': return s
    except:
        pass
    return ""


def send_wol(mac, bcast="255.255.255.255", port=9):
    m = mac.replace(':', '').replace('-', '')
    if len(m) != 12: raise ValueError("Invalid MAC")
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM);
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.sendto(b'\xff' * 6 + bytes.fromhex(m) * 16, (bcast, port));
    sock.close()


# ============================================================
# ★ 自启动核心:注册表优先 + Startup快捷方式降级
# ============================================================
def _create_shortcut(lnk_path: str, target_path: str, arguments: str = "", working_dir: str = ""):
    """
    使用 WScript.Shell COM 对象创建 .lnk 快捷方式
    ★ 核心优势:完全不经过命令行解析,路径中的特殊字符/混合斜杠均安全
    """
    # 优先尝试 pywin32(最可靠)
    try:
        from win32com.client import Dispatch
        shell = Dispatch('WScript.Shell')
        sc = shell.CreateShortCut(lnk_path)
        sc.TargetPath = os.path.normpath(target_path)
        if arguments:
            sc.Arguments = arguments
        if working_dir:
            sc.WorkingDirectory = os.path.normpath(working_dir)
        sc.Description = "PyMsg Pro"
        sc.save()
        return True, ""
    except ImportError:
        pass
    except Exception as e:
        return False, f"pywin32创建失败: {e}"

    # ★ 兜底方案:无pywin32时,写入临时VBS脚本执行(比PowerShell更稳定)
    try:
        norm_target = os.path.normpath(target_path)
        norm_workdir = os.path.normpath(working_dir) if working_dir else ""
        # VBS中双引号用两个双引号转义
        vbs_target = norm_target.replace('"', '""')
        vbs_args = arguments.replace('"', '""')
        vbs_workdir = norm_workdir.replace('"', '""')
        vbs_lnk = lnk_path.replace('"', '""')

        vbs_content = (
            'Set ws = CreateObject("WScript.Shell")\n'
            f'Set sc = ws.CreateShortcut("{vbs_lnk}")\n'
            f'sc.TargetPath = "{vbs_target}"\n'
        )
        if vbs_args:
            vbs_content += f'sc.Arguments = "{vbs_args}"\n'
        if vbs_workdir:
            vbs_content += f'sc.WorkingDirectory = "{vbs_workdir}"\n'
        vbs_content += (
            'sc.Description = "PyMsg Pro"\n'
            'sc.Save\n'
        )

        tmp_vbs = os.path.join(os.environ.get('TEMP', '.'), '_pymsgpro_mklnk.vbs')
        with open(tmp_vbs, 'w', encoding='gbk') as f:
            f.write(vbs_content)

        result = subprocess.run(
            ['cscript', '//Nologo', tmp_vbs],
            capture_output=True, timeout=15,
            creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)
        )
        try:
            os.remove(tmp_vbs)
        except Exception:
            pass

        if result.returncode != 0 or not os.path.exists(lnk_path):
            err = result.stderr.decode('gbk', errors='ignore').strip()
            return False, f"VBS创建失败: {err or '快捷方式未生成'}"
        return True, ""
    except Exception as e:
        return False, f"VBS兜底失败: {e}"


def add_to_windows_startup(enable=True):
    """
    添加/移除开机自启动
    ★ 提供两种模式:注册表Run(推荐) + Startup文件夹快捷方式(兜底)
    """
    if platform.system() != "Windows":
        return False, "仅支持 Windows"

    try:
        import winreg
    except ImportError:
        return False, "无法导入 winreg 模块"

    appdata = os.environ.get('APPDATA', '')
    if not appdata:
        return False, "无法获取 APPDATA 路径"

    startup_dir = os.path.join(appdata, r'Microsoft\Windows\Start Menu\Programs\Startup')
    lnk_path = os.path.join(startup_dir, 'PyMsgPro.lnk')
    reg_name = "PyMsgPro"

    # 确定目标可执行路径(兼容打包exe和直接运行py)
    if getattr(sys, 'frozen', False):
        file_path = os.path.normpath(sys.executable)
        arguments = ""
        working_dir = os.path.dirname(file_path)
    else:
        file_path = os.path.normpath(sys.executable)
        script_path = os.path.normpath(os.path.abspath(sys.argv[0]))
        arguments = f'"{script_path}"'
        working_dir = os.path.dirname(script_path)

    if enable:
        # ★ 优先尝试注册表(参考代码推荐方式)
        reg_err = None
        try:
            run_key = winreg.OpenKey(
                winreg.HKEY_CURRENT_USER,
                r"Software\Microsoft\Windows\CurrentVersion\Run",
                0, winreg.KEY_SET_VALUE | winreg.KEY_READ
            )
            reg_value = f'"{file_path}"'
            if arguments:
                reg_value += f' {arguments}'
            winreg.SetValueEx(run_key, reg_name, 0, winreg.REG_SZ, reg_value)
            winreg.CloseKey(run_key)
            return True, "已添加到注册表 Run(推荐)"
        except Exception as e:
            reg_err = str(e)

        # ★ 注册表失败时,自动降级到Startup快捷方式
        os.makedirs(startup_dir, exist_ok=True)
        ok, lnk_err = _create_shortcut(lnk_path, file_path, arguments, working_dir)
        if ok:
            return True, f"注册表写入失败({reg_err}),已改用Startup快捷方式"
        return False, f"注册表和快捷方式均失败\n注册表: {reg_err}\n快捷方式: {lnk_err}"
    else:
        removed_any = False
        msgs = []
        # 移除注册表项
        try:
            run_key = winreg.OpenKey(
                winreg.HKEY_CURRENT_USER,
                r"Software\Microsoft\Windows\CurrentVersion\Run",
                0, winreg.KEY_SET_VALUE | winreg.KEY_READ
            )
            winreg.DeleteValue(run_key, reg_name)
            winreg.CloseKey(run_key)
            removed_any = True
            msgs.append("注册表项已移除")
        except FileNotFoundError:
            msgs.append("注册表项不存在")
        except Exception as e:
            msgs.append(f"注册表移除失败: {e}")
        # 移除快捷方式
        if os.path.exists(lnk_path):
            try:
                os.remove(lnk_path)
                removed_any = True
                msgs.append("快捷方式已移除")
            except Exception as e:
                msgs.append(f"快捷方式移除失败: {e}")
        else:
            msgs.append("快捷方式不存在")

        summary = ";".join(msgs)
        return removed_any or True, summary


# ============================================================
# 核心业务类
# ============================================================
class GlobalChatHistory:
    def __init__(self):
        self.messages = [];
        self.lock = threading.Lock();
        self._load()

    def _load(self):
        try:
            if os.path.exists(GLOBAL_HISTORY_FILE):
                with open(GLOBAL_HISTORY_FILE, 'r', encoding='utf-8') as f: self.messages = json.load(f)
        except:
            self.messages = []

    def _save(self):
        tmp = GLOBAL_HISTORY_FILE + ".tmp"
        try:
            with open(tmp, 'w', encoding='utf-8') as f:
                json.dump(self.messages, f, ensure_ascii=False, indent=1)
            os.replace(tmp, GLOBAL_HISTORY_FILE)
        except:
            pass

    def add(self, rec):
        with self.lock:
            self.messages.append(rec)
            if len(self.messages) > 10000: self.messages = self.messages[-8000:]
            self._save()
            return len(self.messages) - 1

    def get_all(self):
        with self.lock: return list(self.messages)

    def clear(self):
        with self.lock: self.messages = []; self._save()


class IPMsgPacket:
    def __init__(self, ver=1, pno=0, sender="", host="", cmd=0, add=""):
        self.ver = ver;
        self.pno = pno;
        self.sender = sender;
        self.host = host;
        self.cmd = cmd;
        self.add = add

    def encode(self):
        return f"{self.ver}:{self.pno}:{self.sender}:{self.host}:{self.cmd}:{self.add}"

    def bytes(self):
        return self.encode().encode('utf-8', errors='replace')

    @staticmethod
    def decode(data):
        try:
            t = data.decode('utf-8') if isinstance(data, bytes) else data
            if not t: t = data.decode('gbk', errors='ignore')
            p = t.split(':', 5)
            if len(p) < 5: return None
            pkt = IPMsgPacket()
            pkt.ver = int(p[0]) if p[0].isdigit() else 1
            pkt.pno = int(p[1]) if p[1].isdigit() else 0
            pkt.sender = p[2];
            pkt.host = p[3]
            pkt.cmd = int(p[4]) if p[4].isdigit() else 0
            pkt.add = p[5] if len(p) > 5 else ""
            return pkt
        except:
            return None


class FileInfo:
    def __init__(self, no=0, name="", size=0, mtime=0, type=1, content=""):
        self.no = no;
        self.name = name;
        self.size = size;
        self.mtime = mtime;
        self.type = type;
        self.content = content

    def encode(self):
        return f"{self.no:x}:{self.name}:{self.size:x}:{self.mtime:x}:{self.type:x}:{self.content}"

    @staticmethod
    def parse(add):
        files = []
        if not add: return files
        for part in add.split('\0'):
            if not part.strip(): continue
            try:
                f = part.split(':')
                if len(f) >= 5:
                    fi = FileInfo()
                    fi.no = int(f[0], 16);
                    fi.name = f[1]
                    fi.size = int(f[2], 16) if f[2] else 0
                    fi.mtime = int(f[3], 16) if f[3] else 0
                    fi.type = int(f[4], 16) if f[4] else 1
                    if len(f) > 5: fi.content = f[5]
                    files.append(fi)
            except:
                continue
        return files


class PeerInfo:
    """★ 在线用户条目:带状态机,而不是简单的"在/不在"

    状态流转:
        online ──(超时未收到报文)──&#9654; probing ──(PING 连续无应答)──&#9654; 移除(offline)
          ▲                            │
          └────────(收到任何报文/PONG)──┘
        online &#8644; absence(IPMSG_BR_ABSENCE:离开模式,仍在线)
    """
    ST_ONLINE, ST_ABSENCE, ST_PROBING = "online", "absence", "probing"
    STATE_TEXT = {ST_ONLINE: "在线", ST_ABSENCE: "离开", ST_PROBING: "掉线?"}

    def __init__(self, ip="", user="", host="", port=2425):
        self.ip = ip
        self.user = user
        self.host = host
        self.port = port
        self.alias = ""
        self.mac = ""
        self.is_local = False
        self.ctype = CLIENT_TYPE_UNKNOWN
        # ---- 状态 ----
        self.status = self.ST_ONLINE
        self.absence_msg = ""
        self.last_seen = time.time()      # 最后一次收到该 peer 的任何报文
        self.last_probe = 0.0             # 最后一次主动探测(PING)时间
        self.probe_missed = 0             # 连续探测未应答次数
        self.last_mac_query = 0.0         # MAC 查询节流
        self.first_seen = time.time()

    @property
    def uid(self):
        """身份键:用户名@主机名(IP 会变,这个不会)"""
        return f"{self.user}@{self.host}"

    def is_pymsg(self): return self.ctype == CLIENT_TYPE_PYMSG

    def touch(self):
        """收到该 peer 的任何报文 -> 刷新存活时间"""
        return self.mark_alive()

    def mark_alive(self):
        """刷新存活时间;若此前处于"掉线?"则恢复。返回状态是否发生变化"""
        self.last_seen = time.time()
        recovered = False
        if self.status == self.ST_PROBING:
            self.status = self.ST_ABSENCE if self.absence_msg else self.ST_ONLINE
            recovered = True
        self.probe_missed = 0
        return recovered

    def set_absence(self, on, msg=""):
        self.status = self.ST_ABSENCE if on else self.ST_ONLINE
        self.absence_msg = msg if on else ""
        self.last_seen = time.time()
        self.probe_missed = 0

    def state_text(self):
        if self.is_local: return "本机"
        return self.STATE_TEXT.get(self.status, "在线")

    def display_name(self):
        prefix = "&#127968;" if self.is_local else (
            "★" if self.ctype == CLIENT_TYPE_PYMSG else ("●" if self.ctype == CLIENT_TYPE_IPMSG else "○"))
        name = self.alias if self.alias else self.user
        base = f"{prefix} {name}@{self.host}" if self.host else f"{prefix} {name}"
        if self.is_local: return base
        if self.status == self.ST_ABSENCE:
            return base + " &#128164;"
        if self.status == self.ST_PROBING:
            return base + " &#9203;"
        return base


class TransferTask:
    PENDING, RUNNING, WAIT_ACK, DONE, DONE_UNCONFIRMED, FAILED, RETRYING = "等待中", "传输中", "等待确认", "完成", "完成 (未确认)", "失败", "重试中"

    def __init__(self, tid, ip, port, fi, path="", dir="receive"):
        self.tid = tid;
        self.ip = ip;
        self.port = port;
        self.fi = fi;
        self.path = path;
        self.dir = dir
        self.status = self.PENDING;
        self.prog = 0.0;
        self.recv = 0;
        self.speed = 0.0;
        self.retry = 0
        self.cancelled = False;
        self.start_time = 0;
        self.data_done = False;
        self.max_retry = 3
        self.error = ""

    def to_dict(self):
        return {'tid': self.tid, 'ip': self.ip, 'port': self.port, 'fi_no': self.fi.no, 'fi_name': self.fi.name,
                'fi_size': self.fi.size, 'path': self.path, 'dir': self.dir, 'status': self.status, 'prog': self.prog,
                'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

    @staticmethod
    def from_dict(d):
        fi = FileInfo(no=d.get('fi_no', 0), name=d.get('fi_name', ''), size=d.get('fi_size', 0))
        t = TransferTask(d['tid'], d['ip'], d.get('port', 2425), fi, d.get('path', ''), d.get('dir', 'receive'))
        t.status = d.get('status', TransferTask.DONE);
        t.prog = d.get('prog', 100.0)
        return t


class TransferQueue:
    def __init__(self, engine, config, app):
        self.engine = engine;
        self.config = config;
        self.app = app
        self.tasks = [];
        self.lock = threading.Lock();
        self.tid = 0
        self.running = True;
        self._load_history()
        threading.Thread(target=self._loop, daemon=True).start()
        threading.Thread(target=self._ack_loop, daemon=True).start()

    def _load_history(self):
        try:
            if os.path.exists(TRANSFER_HISTORY_FILE):
                with open(TRANSFER_HISTORY_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                for d in data:
                    t = TransferTask.from_dict(d);
                    self.tasks.append(t)
                    if t.tid > self.tid: self.tid = t.tid
        except:
            pass

    def _save_history(self):
        tmp = TRANSFER_HISTORY_FILE + ".tmp"
        try:
            with self.lock:
                data = [t.to_dict() for t in self.tasks]
            with open(tmp, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False, indent=1)
            os.replace(tmp, TRANSFER_HISTORY_FILE)
        except:
            pass

    def add_recv(self, ip, port, fi, path):
        with self.lock:
            self.tid += 1;
            t = TransferTask(self.tid, ip, port, fi, path, "receive")
            t.max_retry = self.config.get('max_retry', 3);
            self.tasks.append(t)
            self.app.root.after_idle(lambda t=t: self.app.ui_add_task(t))
        self._save_history();
        return t

    def add_send(self, ip, port, fi):
        with self.lock:
            self.tid += 1;
            t = TransferTask(self.tid, ip, port, fi, dir="send")
            self.tasks.append(t);
            self.app.root.after_idle(lambda t=t: self.app.ui_add_task(t))
        self._save_history();
        return t

    def get_send(self, fno):
        with self.lock:
            for t in self.tasks:
                if t.dir == "send" and t.fi.no == fno: return t
        return None

    def has_active(self):
        with self.lock: return any(
            t.status in (TransferTask.PENDING, TransferTask.RUNNING, TransferTask.RETRYING, TransferTask.WAIT_ACK) for t
            in self.tasks)

    def cancel_all(self):
        with self.lock:
            for t in self.tasks:
                if t.status in (TransferTask.PENDING, TransferTask.RUNNING, TransferTask.RETRYING,
                                TransferTask.WAIT_ACK):
                    t.cancelled = True;
                    t.status = TransferTask.FAILED
        self._save_history()

    def _loop(self):
        while self.running:
            t = self._next_recv()
            if t:
                self._do_recv(t)
            else:
                time.sleep(0.5)

    def _ack_loop(self):
        while self.running:
            time.sleep(5);
            now = time.time();
            changed = False
            with self.lock:
                for t in self.tasks:
                    if t.dir == "send" and t.status == TransferTask.WAIT_ACK and t.data_done and (
                            now - t.start_time) > 60:
                        t.status = TransferTask.DONE_UNCONFIRMED
                        self.app.root.after_idle(lambda t=t: self.app.ui_upd_task(t));
                        changed = True
            if changed: self._save_history()

    def _next_recv(self):
        with self.lock:
            if sum(1 for t in self.tasks if t.status == TransferTask.RUNNING and t.dir == "receive") >= self.config.get(
                'max_threads', 4): return None
            for t in self.tasks:
                if t.status == TransferTask.PENDING and t.dir == "receive": return t
        return None

    def _do_recv(self, t):
        t.status = TransferTask.RUNNING;
        t.start_time = time.time();
        self._notify(t)
        ok = False
        try:
            ok = self._download(t)
        except Exception as e:
            t.error = str(e)
        if t.cancelled: return
        if ok:
            t.status = TransferTask.DONE;
            t.prog = 100.0
            try:
                if os.path.exists(t.path + ".part"):
                    if os.path.exists(t.path): os.remove(t.path)
                    os.rename(t.path + ".part", t.path)
                if os.path.exists(t.path + ".meta"): os.remove(t.path + ".meta")
            except Exception as e:
                t.status = TransferTask.FAILED; ok = False
            if ok and self.engine:
                try:
                    self.engine.send_pkt(
                        IPMsgPacket(pno=self.engine.next_pno(), sender=self.engine.user, host=self.engine.host,
                                    cmd=IPMSG_FILE_RECV_OK | IPMSG_CAPUTF8OPT, add=str(t.fi.no)), t.ip, t.port)
                except:
                    pass
        else:
            if not t.cancelled:
                t.retry += 1
                if t.retry < t.max_retry:
                    t.status = TransferTask.RETRYING; self._notify(t); time.sleep(3); t.status = TransferTask.PENDING
                else:
                    t.status = TransferTask.FAILED
        self._notify(t);
        self._save_history()

    def _notify(self, t):
        try:
            self.app.root.after_idle(lambda t=t: self.app.ui_upd_task(t))
        except:
            pass

    def upd_send(self, t, sent, total):
        t.recv = sent;
        t.prog = (sent / total * 100) if total > 0 else 0
        if t.start_time == 0: t.start_time = time.time(); t.status = TransferTask.RUNNING
        t.speed = sent / (time.time() - t.start_time) if time.time() - t.start_time > 0 else 0
        self._notify(t)

    def mark_send_done(self, t):
        t.data_done = True; t.status = TransferTask.WAIT_ACK; t.prog = 100.0; self._notify(t); self._save_history()

    def confirm_send(self, fno):
        t = self.get_send(fno)
        if t and t.status in (TransferTask.WAIT_ACK, TransferTask.RUNNING):
            t.status = TransferTask.DONE;
            t.prog = 100.0;
            self._notify(t);
            self._save_history();
            return True
        return False

    def fail_send(self, t, err=""):
        if t.status not in (TransferTask.DONE, TransferTask.WAIT_ACK, TransferTask.DONE_UNCONFIRMED):
            t.status = TransferTask.FAILED;
            t.error = err
            self._notify(t);
            self._save_history()

    def _download(self, t):
        total = t.fi.size;
        offset = 0
        if os.path.exists(t.path + ".part") and os.path.exists(t.path + ".meta"):
            try:
                with open(t.path + ".meta") as f:
                    m = json.load(f)
                if m.get('no') == t.fi.no: offset = m.get('recv', 0)
            except:
                pass
        if offset >= total: return True
        try:
            if offset == 0:
                with open(t.path + ".part", 'wb') as f:
                    if total > 0: f.seek(total - 1); f.write(b'\0')
        except:
            return False
        return self._dl_single(t, offset, total)

    def _dl_single(self, t, offset, total):
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM);
            s.settimeout(60);
            s.connect((t.ip, t.port))
            s.sendall(IPMsgPacket(pno=self.engine.next_pno(), sender=self.engine.user, host=self.engine.host,
                                  cmd=IPMSG_GETFILEDATA, add=f"{t.fi.no:x}:{offset:x}").bytes())
            recv = offset;
            st = time.time()
            with open(t.path + ".part", 'r+b') as f:
                if offset > 0: f.seek(offset)
                while recv < total and not t.cancelled:
                    d = s.recv(min(1048576, total - recv))
                    if not d: break
                    f.write(d);
                    recv += len(d);
                    t.recv = recv;
                    t.prog = recv / total * 100
                    t.speed = recv / (time.time() - st) if time.time() - st > 0 else 0
                    self._notify(t)
            s.close();
            return recv >= total
        except:
            return False

    def cleanup(self):
        with self.lock:
            before = len(self.tasks)
            self.tasks = [t for t in self.tasks if
                          t.status not in (TransferTask.DONE, TransferTask.FAILED, TransferTask.DONE_UNCONFIRMED)]
            removed = before - len(self.tasks)
        if removed > 0: self._save_history(); self.app.root.after_idle(lambda: self.app._refresh_trans_ui())
        return removed


class Engine:
    PEER_TIMEOUT = PEER_TIMEOUT          # 兼容旧引用

    def __init__(self, app, cfg, file_logger):
        self.app = app
        self.cfg = cfg
        self.flog = file_logger
        self.user = cfg.get('username') or get_username()
        self.host = cfg.get('hostname') or get_hostname()
        self.port = cfg.get('port', 9999)
        self.ip = get_local_ip()
        self.bcasts = get_subnet_broadcasts()
        self.udp = None
        self.tcp = None
        self.running = False
        self.pno = int(time.time()) & 0xFFFFFFFF
        self.lock = threading.Lock()          # 保护 pno / pending
        self.peers_lock = threading.RLock()   # ★ 保护 peers 及其索引
        self.peers = {}
        self.uid_index = {}                   # ★ "user@host" -> ip(身份键,IP 变了也能认出来)
        self.mac_index = {}                   # ★ MAC -> ip
        self.pending = {}
        self.fcnt = 1
        self.mac = get_local_mac(self.ip)
        self.offline = False
        self.absence = bool(cfg.get('absence_mode', False))   # ★ 离开模式
        self.absence_msg = cfg.get('absence_msg', '暂时离开,稍后回复')
        # ★ presence 辅助结构
        self._my_pnos = deque(maxlen=256)     # 自己发出去的包编号(回环/多实例防护)
        self._recent = {}                     # (ip, cmd) -> 时间,presence 去重
        self._ans_pending = {}                # ip -> Timer,ANSENTRY 随机延迟队列
        self._last_heartbeat = 0.0
        self.hb_interval = float(cfg.get('heartbeat_interval', HEARTBEAT_INTERVAL) or HEARTBEAT_INTERVAL)
        self.peer_timeout = float(cfg.get('peer_timeout', PEER_TIMEOUT) or PEER_TIMEOUT)
        if self.mac:
            m = self.cfg.get('mac_addresses', {})
            m[self.ip] = self.mac
            self.cfg.set('mac_addresses', m)

    def next_pno(self):
        with self.lock: self.pno = (self.pno + 1) & 0xFFFFFFFF; return self.pno

    def send_pkt(self, pkt, addr, port=None):
        if not self.udp: return False
        # ★ 离线时仍允许发出 BR_EXIT(否则"下线"按钮广播不出去,别人只能等超时)
        if self.offline and get_command(pkt.cmd) != IPMSG_BR_EXIT: return False
        try:
            self._my_pnos.append(pkt.pno)
            self.udp.sendto(pkt.bytes(), (addr, port or self.port))
            return True
        except:
            return False

    def start(self):
        self.running = True;
        self.offline = False
        self.udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            self.udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        except:
            pass
        self.udp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        self.udp.bind(('0.0.0.0', self.port));
        self.udp.settimeout(1.0)
        self.tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.tcp.bind(('0.0.0.0', self.port));
        self.tcp.listen(20);
        self.tcp.settimeout(1.0)
        threading.Thread(target=self._udp_loop, daemon=True).start()
        threading.Thread(target=self._tcp_loop, daemon=True).start()
        threading.Thread(target=self._presence_loop, daemon=True).start()   # ★ 心跳+状态机
        lp = PeerInfo(self.ip, self.user, self.host, self.port)
        lp.mac = self.mac
        lp.is_local = True
        lp.ctype = CLIENT_TYPE_PYMSG
        lp.status = PeerInfo.ST_ABSENCE if self.absence else PeerInfo.ST_ONLINE
        with self.peers_lock:
            self.peers[self.ip] = lp
            self.uid_index[lp.uid] = self.ip
        self.app.root.after(0, lambda: self.app.ui_add_peer(lp))
        self.app.root.after(300, self.send_entry)
        self.app.root.after(1500, self._startup_resend)

    def _startup_resend(self):
        if self.running and not self.offline:
            self.send_entry()
            self.flog.info("已自动补发上线广播(UDP 不可靠,冗余重发)")

    def stop(self):
        """退出:先广播下线(重发多次),再停线程"""
        was_offline = self.offline
        self.offline = False            # ★ 允许把 BR_EXIT 发出去
        self.send_exit()
        self.running = False
        self.offline = was_offline

    def go_offline(self):
        """真·离线:不再参与网络"""
        self.send_exit(); self.offline = True
        with self.peers_lock:
            for ip, p in list(self.peers.items()):
                if not p.is_local:
                    self.peers.pop(ip, None)
                    self.uid_index.pop(p.uid, None)
                    self.app.root.after(0, lambda i=ip: self.app.ui_rem_peer(i))
        self.flog.info("已暂时下线(已广播 BR_EXIT 并清空列表)")

    def go_online(self):
        self.offline = False
        self.send_entry()
        self.flog.info("已重新上线(已广播 BR_ENTRY)")

    # ---------------------------------------------------------------
    # ★ 成员识别命令:上线 / 下线 / 应答 / 离开 / 心跳
    # ---------------------------------------------------------------
    def _bcast_repeat(self, pkt, times=1, gap=0.4, sync=False):
        """广播并冗余重发,弥补 UDP 无确认的丢包"""
        def _go():
            for i in range(max(1, times)):
                if not self.running and get_command(pkt.cmd) != IPMSG_BR_EXIT: break
                for b in self.bcasts: self.send_pkt(pkt, b)
                if i < times - 1: time.sleep(gap)

        if times <= 1 or sync:
            _go()      # ★ 下线通知同步发完,防止进程退出时线程被杀导致发不出去
        else:
            threading.Thread(target=_go, daemon=True).start()

    def send_entry(self, heartbeat=False, repeat=ENTRY_RETRY):
        """BR_ENTRY:上线报到 / 心跳保活"""
        cmd = IPMSG_BR_ENTRY | IPMSG_CAPUTF8OPT | IPMSG_PYMSG_FLAG
        if self.absence: cmd |= IPMSG_ABSENCEOPT
        if heartbeat: cmd |= IPMSG_HEARTBEAT_OPT      # ★ 心跳包:对方不必回 ANSENTRY
        add = self.absence_msg if self.absence else self.user
        p = IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=cmd, add=add)
        if heartbeat:
            # ★ 心跳只走广播、不重发、不单播:把在线维护成本压到 O(N)
            for b in self.bcasts: self.send_pkt(p, b)
        else:
            self._bcast_repeat(p, repeat, ENTRY_RETRY_GAP)
            # ★ 收不到广播的成员(跨网段/手工添加)单独单播一份
            self._unicast_known(p)

    def _unicast_known(self, pkt):
        """向已知 peer 单播一份,覆盖跨网段/广播不通的场景"""
        with self.peers_lock:
            targets = [(p.ip, p.port) for p in self.peers.values() if not p.is_local]
        for ip, port in targets: self.send_pkt(pkt, ip, port)

    def send_exit(self):
        """BR_EXIT:下线通知,同步重发多次(别人能否及时删除全靠它)"""
        p = IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=IPMSG_BR_EXIT, add="")
        self._bcast_repeat(p, EXIT_RETRY, EXIT_RETRY_GAP, sync=True)
        self._unicast_known(p)

    def send_ans(self, ip, port, delay=None):
        """ANSENTRY:★ 随机延迟应答(IPMSG 原版 0~4 秒),避免全网同时回包造成广播风暴"""
        with self.peers_lock:
            if ip in self._ans_pending: return       # 同一 peer 已有待发应答
        if delay is None: delay = random.uniform(0.05, ANS_DELAY_MAX)

        def _go():
            self._ans_pending.pop(ip, None)
            if not self.running or self.offline: return
            cmd = IPMSG_ANSENTRY | IPMSG_CAPUTF8OPT | IPMSG_PYMSG_FLAG
            if self.absence: cmd |= IPMSG_ABSENCEOPT
            self.send_pkt(IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                                      cmd=cmd, add=self.absence_msg if self.absence else self.user), ip, port)

        t = threading.Timer(delay, _go)
        t.daemon = True
        self._ans_pending[ip] = t
        t.start()

    def set_absence(self, on, msg=""):
        """★ 离开模式:IPMSG_BR_ABSENCE,仍在线但状态变更"""
        self.absence = bool(on)
        self.absence_msg = msg or (self.cfg.get('absence_msg', '暂时离开') if on else "")
        self.cfg.set('absence_mode', self.absence)
        if msg: self.cfg.set('absence_msg', msg)
        with self.peers_lock:
            lp = self.peers.get(self.ip)
        if lp:
            lp.set_absence(self.absence, self.absence_msg)
            self.app.root.after(0, lambda: self.app.ui_add_peer(lp))
        cmd = IPMSG_BR_ABSENCE | IPMSG_CAPUTF8OPT | IPMSG_PYMSG_FLAG
        if self.absence: cmd |= IPMSG_ABSENCEOPT
        p = IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                        cmd=cmd, add=self.absence_msg if self.absence else self.user)
        self._bcast_repeat(p, 2, 0.4)
        self._unicast_known(p)
        self.flog.info(f"状态切换为:{'离开' if self.absence else '在线'}")

    # ---------------------------------------------------------------
    # ★ Peer 列表维护:新增/更新/迁移/删除
    # ---------------------------------------------------------------
    def upsert_peer(self, ip, port, user, host, ctype=None, absence=None, absence_msg=None):
        """新增或更新 peer;★ 绝不用新对象整体覆盖,保住别名/MAC/计时器"""
        with self.peers_lock:
            uid = f"{user}@{host}"
            old_ip = self.uid_index.get(uid)
            p = self.peers.get(ip)
            if p is None and old_ip and old_ip != ip and old_ip in self.peers:
                # ★ IP 变了但人没变:迁移旧条目,保留别名与 MAC
                p = self.peers.pop(old_ip)
                p.ip = ip
                p.port = port or p.port
                self.peers[ip] = p
                self.app.root.after(0, lambda o=old_ip: self.app.ui_rem_peer(o))
                self.flog.info(f"{uid} IP 变更 {old_ip} -> {ip},条目已迁移")
            if p is None:
                p = PeerInfo(ip, user, host, port)
                p.alias = self.cfg.get('aliases', {}).get(ip, '')
                p.mac = self.cfg.get('mac_addresses', {}).get(ip, '')
                self.peers[ip] = p
                self.flog.info(f"新增在线用户 {uid} ({ip}:{port})")
            else:
                p.user = user
                p.host = host
            p.port = port or p.port
            if ctype is not None: p.ctype = ctype
            recovered = p.mark_alive()
            if absence is not None:
                p.set_absence(absence, absence_msg or "")
            elif p.status not in (PeerInfo.ST_ABSENCE, PeerInfo.ST_PROBING):
                p.status = PeerInfo.ST_ONLINE
            self.uid_index[uid] = ip
            if p.mac: self.mac_index[p.mac.upper()] = ip
            return p, recovered

    def del_peer(self, ip, reason=""):
        with self.peers_lock:
            p = self.peers.pop(ip, None)
            if p:
                self.uid_index.pop(p.uid, None)
                if p.mac and self.mac_index.get(p.mac.upper()) == ip:
                    self.mac_index.pop(p.mac.upper(), None)
        if p and not p.is_local:
            self.app.root.after(0, lambda: self.app.ui_rem_peer(ip))
            self.flog.info(f"移除 {p.uid}({ip}):{reason}")
        return p

    def _is_dup(self, ip, cmd):
        """★ presence 命令短窗口去重:重发的同命令不重复处理(消息类命令不参与)"""
        if cmd not in (IPMSG_BR_ENTRY, IPMSG_ANSENTRY, IPMSG_BR_ABSENCE): return False
        now = time.time()
        key = (ip, cmd)
        if now - self._recent.get(key, 0) < PRESENCE_DEDUPE_WIN: return True
        self._recent[key] = now
        return False

    def refresh_now(self):
        """手动刷新:重新报到 + 向全部已知 peer 探测,3 秒后做一次清理"""
        if self.offline:
            self.flog.info("当前处于离线状态,刷新已忽略")
            return
        self.send_entry(heartbeat=False, repeat=2)
        with self.peers_lock:
            targets = [(p.ip, p.port) for p in self.peers.values() if not p.is_local]
        for ip, port in targets: self.send_ping(ip, port)
        threading.Timer(3.0, self.sweep_peers).start()
        self.flog.info("手动刷新:已重新报到并向所有已知节点发送探测")

    def send_msg(self, ip, port, msg):
        p = IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                        cmd=IPMSG_SENDMSG | IPMSG_SENDCHECKOPT | IPMSG_CAPUTF8OPT, add=msg)
        self.send_pkt(p, ip, port);
        return p.pno

    def broadcast_msg(self, msg):
        p = IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=IPMSG_SENDMSG | IPMSG_CAPUTF8OPT,
                        add=msg)
        for b in self.bcasts: self.send_pkt(p, b)

    def send_recv(self, ip, port, pno):
        self.send_pkt(
            IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=IPMSG_RECVMSG, add=str(pno)), ip,
            port)

    def send_mac_query(self, ip, port):
        self.send_pkt(IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                                  cmd=IPMSG_MAC_QUERY | IPMSG_CAPUTF8OPT, add=""), ip, port)

    def send_mac_reply(self, ip, port):
        self.send_pkt(IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                                  cmd=IPMSG_MAC_REPLY | IPMSG_CAPUTF8OPT, add=self.mac), ip, port)

    def send_files(self, ip, port, msg, paths):
        fis = [];
        now = int(time.time())
        for path in paths:
            path = os.path.normpath(path)
            if os.path.isdir(path):
                buf = io.BytesIO()
                with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
                    for r, d, fs in os.walk(path):
                        for f in fs: zf.write(os.path.join(r, f),
                                              os.path.relpath(os.path.join(r, f), os.path.dirname(path)))
                data = buf.getvalue();
                no = self.fcnt;
                self.fcnt += 1
                with self.lock:
                    self.pending[no] = (path, True, data)
                fis.append(FileInfo(no, os.path.basename(path) + ".zip", len(data), now))
            elif os.path.isfile(path):
                no = self.fcnt;
                self.fcnt += 1
                with self.lock:
                    self.pending[no] = (path, False, None)
                fis.append(FileInfo(no, os.path.basename(path), os.path.getsize(path), int(os.path.getmtime(path))))
        if fis:
            fd = "".join(f.encode() + "\0" for f in fis)
            self.send_pkt(IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                                      cmd=IPMSG_SENDMSG | IPMSG_FILEATTACHOPT | IPMSG_SENDCHECKOPT | IPMSG_CAPUTF8OPT,
                                      add=msg + "\0" + fd), ip, port)
        return fis

    def send_shutdown(self, ip, port):
        self.send_pkt(IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host,
                                  cmd=IPMSG_CUSTOM_SHUTDOWN | IPMSG_CAPUTF8OPT, add=f"SHUTDOWN:30:{self.user}"), ip,
                      port)

    def send_ping(self, ip, port):
        self.send_pkt(
            IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=IPMSG_PING | IPMSG_CAPUTF8OPT,
                        add=str(int(time.time() * 1000))), ip, port)

    def send_pong(self, ip, port, ts):
        self.send_pkt(
            IPMsgPacket(pno=self.next_pno(), sender=self.user, host=self.host, cmd=IPMSG_PONG | IPMSG_CAPUTF8OPT,
                        add=ts), ip, port)

    # ---------------------------------------------------------------
    # ★ 心跳保活 + 三级下线判定(原版 IPMSG 最缺的一环)
    #    在线 --超时--> 探测中(PING) --连续无应答--> 移除
    # ---------------------------------------------------------------
    def _presence_loop(self):
        while self.running:
            try:
                time.sleep(1)
                if self.running and not self.offline:
                    self._tick()
            except Exception:
                continue

    def _tick(self):
        now = time.time()
        # 1) 周期心跳:广播 BR_ENTRY(带 HEARTBEAT 标记,对方不回 ANS,成本 O(N))
        if now - self._last_heartbeat >= self.hb_interval:
            self._last_heartbeat = now
            self.send_entry(heartbeat=True)
        # 2) IP 变化自动重播(IPMSG 的 /IPCHANGE_REFRESH 思路)
        self._check_ip_changed()
        # 3) 状态机巡检
        self.sweep_peers()

    def _check_ip_changed(self):
        cur = get_local_ip()
        if not cur or cur == self.ip: return
        old = self.ip
        with self.peers_lock:
            lp = self.peers.pop(old, None)
        if lp and lp.is_local: self.app.root.after(0, lambda: self.app.ui_rem_peer(old))
        self.ip = cur
        self.bcasts = get_subnet_broadcasts()
        self.mac = get_local_mac(cur) or self.mac
        if self.mac:
            m = self.cfg.get('mac_addresses', {})
            m[cur] = self.mac
            self.cfg.set('mac_addresses', m)
        lp = PeerInfo(cur, self.user, self.host, self.port)
        lp.mac = self.mac
        lp.is_local = True
        lp.ctype = CLIENT_TYPE_PYMSG
        with self.peers_lock:
            self.peers[cur] = lp
            self.uid_index[lp.uid] = cur
        self.app.root.after(0, lambda: self.app.ui_add_peer(lp))
        self.app.root.after(0, self.app._update_title)
        self.send_entry(repeat=ENTRY_RETRY)
        self.flog.info(f"检测到本机 IP 变更 {old} -> {cur},已重新广播上线")

    def sweep_peers(self):
        """巡检所有 peer:超时转探测,连续探测无应答才移除(避免误杀静默节点)"""
        now = time.time()
        with self.peers_lock:
            snapshot = [(p.ip, p) for p in self.peers.values() if not p.is_local]
        for ip, p in snapshot:
            idle = now - p.last_seen
            if idle <= self.peer_timeout:
                if p.status == PeerInfo.ST_PROBING:
                    p.status = PeerInfo.ST_ABSENCE if p.absence_msg else PeerInfo.ST_ONLINE
                    self.app.root.after(0, lambda pp=p: self.app.ui_add_peer(pp))
                continue
            # 超时 -> 进入探测态(UI 显示 &#9203;,不立即删除)
            if p.status != PeerInfo.ST_PROBING:
                p.status = PeerInfo.ST_PROBING
                p.probe_missed = 0
                p.last_probe = 0.0
                self.flog.info(f"{p.uid}({ip}) 已 {int(idle)}s 无报文,转入探测")
                self.app.root.after(0, lambda pp=p: self.app.ui_add_peer(pp))
            if now - p.last_probe >= PROBE_INTERVAL:
                p.last_probe = now
                p.probe_missed += 1
                self.send_ping(ip, p.port)
                if p.probe_missed >= PROBE_MAX_MISS:
                    self.del_peer(ip, f"连续 {p.probe_missed} 次探测无应答({int(idle)}s 无活动)")
                    try:
                        ts = datetime.now().strftime('%H:%M:%S')
                        self.app.root.after(0, lambda pp=p: self.app._append_to_chat(
                            {'type': 'sys', 'msg': f"○ {pp.user}({pp.ip}) 已离线", 'time': ts}))
                    except Exception:
                        pass

    def _udp_loop(self):
        while self.running:
            try:
                d, a = self.udp.recvfrom(65535)
                if d:
                    p = IPMsgPacket.decode(d)
                    if p: threading.Thread(target=self._handle, args=(p, a), daemon=True).start()
            except:
                continue

    def _handle(self, p, a):
        if self.offline: return
        cmd = get_command(p.cmd)
        flags = get_flags(p.cmd)
        ip, port = a
        if ip == self.ip: return
        if p.pno and p.pno in self._my_pnos: return   # ★ 自己发出去的包(回环/多实例)
        if self._is_dup(ip, cmd): return              # ★ presence 重发包去重

        # ★ 任何来自该 peer 的报文都算"还活着"
        with self.peers_lock:
            exist = self.peers.get(ip)
        if exist and exist.mark_alive():
            self.app.root.after(0, lambda: self.app.ui_add_peer(exist))

        if cmd == IPMSG_BR_ENTRY:
            ct = CLIENT_TYPE_PYMSG if flags & IPMSG_PYMSG_FLAG else CLIENT_TYPE_IPMSG
            absence = bool(flags & IPMSG_ABSENCEOPT)
            peer, _ = self.upsert_peer(ip, port, p.sender, p.host, ct, absence, p.add if absence else "")
            # ★ 心跳包不回 ANSENTRY:否则 N 台机器 × 每 15 秒 = 广播风暴
            if not (flags & IPMSG_HEARTBEAT_OPT):
                self.send_ans(ip, port)
            self._maybe_query_mac(peer)
            self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
        elif cmd == IPMSG_BR_EXIT:
            self.del_peer(ip, "收到下线通知 BR_EXIT")
        elif cmd == IPMSG_ANSENTRY:
            ct = CLIENT_TYPE_PYMSG if flags & IPMSG_PYMSG_FLAG else CLIENT_TYPE_IPMSG
            absence = bool(flags & IPMSG_ABSENCEOPT)
            peer, _ = self.upsert_peer(ip, port, p.sender, p.host, ct, absence, p.add if absence else "")
            self._maybe_query_mac(peer)
            self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
        elif cmd == IPMSG_BR_ABSENCE:
            # ★ 离开模式/昵称变更:只更新状态,不回 ANS(与 IPMSG 规范一致)
            ct = CLIENT_TYPE_PYMSG if flags & IPMSG_PYMSG_FLAG else CLIENT_TYPE_IPMSG
            absence = bool(flags & IPMSG_ABSENCEOPT)
            peer, _ = self.upsert_peer(ip, port, p.sender, p.host, ct, absence, p.add)
            self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
            self.flog.info(f"{peer.uid}({ip}) 状态变更为:{peer.state_text()}")
        elif cmd == IPMSG_MAC_QUERY:
            self.send_mac_reply(ip, port)
        elif cmd == IPMSG_MAC_REPLY:
            mac = p.add.strip().upper().replace('-', ':')
            if mac and len(mac.replace(':', '')) == 12:
                macs = self.cfg.get('mac_addresses', {})
                macs[ip] = mac
                self.cfg.set('mac_addresses', macs)
                with self.peers_lock:
                    peer = self.peers.get(ip)
                if peer:
                    peer.mac = mac
                    self.mac_index[mac] = ip
                    self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
                self.flog.info(f"已获取 {ip} 的 MAC: {mac}")
        elif cmd == IPMSG_SENDMSG:
            # ★ 陌生人发消息:先确认对方身份再加进列表(NOADDLISTOPT 时只回执不入库)
            with self.peers_lock:
                known = ip in self.peers
            if not known:
                ct = CLIENT_TYPE_PYMSG if flags & IPMSG_PYMSG_FLAG else CLIENT_TYPE_UNKNOWN
                peer, _ = self.upsert_peer(ip, port, p.sender, p.host, ct)
                self._maybe_query_mac(peer)
                self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
            if flags & IPMSG_SENDCHECKOPT: self.send_recv(ip, port, p.pno)
            if get_flags(p.cmd) & IPMSG_SENDCHECKOPT: self.send_recv(ip, port, p.pno)
            if get_flags(p.cmd) & IPMSG_FILEATTACHOPT:
                parts = p.add.split('\0', 1)
                msg = parts[0]
                fis = FileInfo.parse(parts[1] if len(parts) > 1 else "")
                if msg: self.app.root.after(0, lambda: self.app.recv_msg(ip, p.sender, msg))
                if fis: self.app.root.after(0,
                                            lambda fi=fis, s=p.sender, i=ip, pt=port: self.app.recv_files(i, s, fi, pt))
            else:
                if p.add: self.app.root.after(0, lambda: self.app.recv_msg(ip, p.sender, p.add))
        elif cmd == IPMSG_RECVMSG:
            self.flog.info(f"消息已送达 (#{p.add}) -> {ip}")
        elif cmd in (IPMSG_GETFILEDATA, IPMSG_GETDIRFILES, IPMSG_GETDIRFILES_LEGACY):
            parts = p.add.split(':')
            try:
                fno = int(parts[0], 16); off = int(parts[1], 16) if len(parts) > 1 else 0
            except:
                return
            threading.Thread(target=self._serve_tcp, args=(ip, port, fno, off), daemon=True).start()
        elif cmd == IPMSG_CUSTOM_SHUTDOWN:
            self.flog.info(f"收到关机指令 from {p.sender}({ip})")
            if self.cfg.get('accept_shutdown', True):
                self.app.root.after(0, lambda: self.app.show_shutdown(p.sender, ip))
        elif cmd == IPMSG_PING:
            self.send_pong(ip, port, p.add)
        elif cmd == IPMSG_PONG:
            # ★ 探测应答:peer 复活
            with self.peers_lock:
                peer = self.peers.get(ip)
            if peer and peer.mark_alive():
                self.app.root.after(0, lambda: self.app.ui_add_peer(peer))
            self.app.root.after(0, lambda p=p, i=ip: self.app.handle_pong(p, i))
        elif cmd == IPMSG_FILE_RECV_OK:
            try:
                fno = int(p.add.strip()); self.app.tq.confirm_send(fno)
            except:
                pass

    def _maybe_query_mac(self, peer):
        """MAC 补齐,带节流,避免每次心跳都问一遍"""
        if peer.is_local or peer.mac: return
        now = time.time()
        if now - peer.last_mac_query < MAC_QUERY_MIN_GAP: return
        peer.last_mac_query = now
        self.send_mac_query(peer.ip, peer.port)

    def _tcp_loop(self):
        while self.running:
            try:
                c, a = self.tcp.accept(); threading.Thread(target=self._handle_tcp, args=(c, a), daemon=True).start()
            except:
                continue

    def _handle_tcp(self, s, a):
        try:
            s.settimeout(30);
            d = s.recv(65535)
            if not d: s.close(); return
            p = IPMsgPacket.decode(d)
            if not p: s.close(); return
            if get_command(p.cmd) in (IPMSG_GETFILEDATA, IPMSG_GETDIRFILES, IPMSG_GETDIRFILES_LEGACY):
                parts = p.add.split(':')
                try:
                    fno = int(parts[0], 16); off = int(parts[1], 16) if len(parts) > 1 else 0
                except:
                    s.close(); return
                self._send_file(s, fno, off)
            else:
                s.close()
        except:
            try:
                s.close()
            except:
                pass

    def _send_file(self, s, fno, off=0):
        t = None
        try:
            if self.app.tq: t = self.app.tq.get_send(fno)
            with self.lock:
                if fno not in self.pending: s.close(); return
                path, is_dir, zdata = self.pending[fno]
            total = len(zdata) if (is_dir and zdata) else os.path.getsize(path)
            if is_dir and zdata:
                s.sendall(zdata[off:] if off > 0 else zdata)
            elif os.path.isfile(path):
                sent = off
                with open(path, 'rb') as f:
                    if off > 0: f.seek(off)
                    while True:
                        c = f.read(1048576)
                        if not c: break
                        s.sendall(c);
                        sent += len(c)
                        if t: self.app.tq.upd_send(t, sent, total)
            try:
                s.shutdown(socket.SHUT_WR)
            except:
                pass
            s.close()
            if t: self.app.tq.mark_send_done(t)
        except Exception as e:
            if t: self.app.tq.fail_send(t, str(e))
            try:
                s.close()
            except:
                pass

    def _serve_tcp(self, ip, port, fno, off):
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM);
            s.settimeout(30);
            s.connect((ip, port))
            self._send_file(s, fno, off)
        except:
            pass


# ============================================================
# GUI 主程序
# ============================================================
FIXED_WIDTH = 400
FIXED_HEIGHT = 550
RIGHT_PANEL_WIDTH = 75

CHAT_BG_ALTERNATE = ["#FAFAF5", "#FDF6E3"]
CHAT_COLOR_PALETTE = [
    "#3E5C76",
    "#5A7A5A",
    "#8B6F47",
    "#8B5A6B",
    "#6B5B95",
]
NUM_COLORS = len(CHAT_COLOR_PALETTE)


class App:
    def __init__(self):
        self.cfg = Config()
        self.hist = GlobalChatHistory()
        self.flog = FileLogger()
        self.engine = None;
        self.tq = None
        self.root = tk.Tk();
        self.root.withdraw()
        self.root.title("PyMsg Pro");
        self.root.resizable(False, False)
        self.root.geometry(f"{FIXED_WIDTH}x{FIXED_HEIGHT}")
        self.current_peer = None;
        self.is_topmost = False;
        self.is_offline = False
        self.chat_font_size = 10;
        self.shutdown_win = None;
        self.shutdown_timer = None

        self._color_index = 0

        self._build_ui();
        self._setup_tray();
        self._start_engine()

    def _create_icon(self):
        size = 64;
        img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
        draw = ImageDraw.Draw(img);
        margin = int(size * 0.15)
        draw.ellipse([margin, margin, size - margin, size - margin], fill=(160, 160, 160, 255))
        return img

    def _setup_tray(self):
        menu = pystray.Menu(pystray.MenuItem("显示主界面", self._show_window, default=True),
                            pystray.MenuItem("退出程序", self._quit_app))
        self.tray = pystray.Icon("PyMsgPro", self._create_icon(), "PyMsg Pro", menu)
        threading.Thread(target=self.tray.run, daemon=True).start()

    def _show_window(self, icon=None, item=None):
        self.root.after(0, self.root.deiconify); self.root.after(10, self.root.lift)

    def _hide_window(self):
        self.root.withdraw()

    def _quit_app(self, icon=None, item=None):
        if self.tray: self.tray.stop()
        self.root.after(0, self._on_close)

    def _start_engine(self):
        self.engine = Engine(self, self.cfg, self.flog)
        self.tq = TransferQueue(self.engine, self.cfg, self)
        self.engine.start()
        self.btn_absence.config(text="&#9728;在线" if self.engine.absence else "&#128164;离开")
        self._update_title()
        self._render_global_history()
        self._refresh_trans_ui()

    def _update_title(self):
        if self.engine:
            self.root.title(f"★ {self.engine.ip}:{self.engine.port} - PyMsg Pro")
        else:
            self.root.title("PyMsg Pro")

    def _apply_chat_tags(self):
        fs = self.chat_font_size
        fs_small = max(8, fs - 1)
        for i, fg in enumerate(CHAT_COLOR_PALETTE):
            bg = CHAT_BG_ALTERNATE[i % 2]
            self.chat.tag_config(f"sys_{i}", foreground=fg, background=bg,
                                 font=("Consolas", fs_small, "italic"))
            self.chat.tag_config(f"sent_{i}", foreground=fg, background=bg,
                                 font=("Consolas", fs, "bold"))
            self.chat.tag_config(f"recv_{i}", foreground=fg, background=bg,
                                 font=("Consolas", fs, "bold"))
            self.chat.tag_config(f"file_{i}", foreground=fg, background=bg,
                                 font=("Consolas", fs, "bold"))

    def _build_ui(self):
        self.root.protocol("WM_DELETE_WINDOW", self._hide_window)
        pad = {'padx': 4, 'pady': 2}

        r1 = ttk.Frame(self.root)
        r1.pack(fill=tk.X, **pad)
        ttk.Button(r1, text="&#9881;设置", command=self._show_settings, width=6).pack(side=tk.LEFT, padx=1)
        ttk.Button(r1, text="&#128260;刷新", command=self._refresh, width=6).pack(side=tk.LEFT, padx=1)
        self.btn_absence = ttk.Button(r1, text="&#128164;离开", command=self._toggle_absence, width=6)
        self.btn_absence.pack(side=tk.LEFT, padx=1)
        self.btn_offline = ttk.Button(r1, text="&#9208;下线", command=self._toggle_offline, width=6)
        self.btn_offline.pack(side=tk.LEFT, padx=1)
        self.btn_top = ttk.Button(r1, text="&#128204;置顶", command=self._toggle_top, width=6)
        self.btn_top.pack(side=tk.LEFT, padx=1)
        self.search_var = tk.StringVar()
        self.search_var.trace('w', lambda *a: self._filter())
        ttk.Entry(r1, textvariable=self.search_var, width=10).pack(side=tk.RIGHT, padx=(1, 1))
        ttk.Label(r1, text="&#128269;").pack(side=tk.RIGHT)

        r2 = ttk.Frame(self.root)
        r2.pack(fill=tk.BOTH, expand=True, **pad)
        cols = ("name", "ip", "mac", "status")
        self.tree = ttk.Treeview(r2, columns=cols, show="headings", height=3)
        self.tree.heading("name", text="用户名@主机名")
        self.tree.heading("ip", text="IP")
        self.tree.heading("mac", text="MAC")
        self.tree.heading("status", text="状态")
        self.tree.column("name", width=140)
        self.tree.column("ip", width=92)
        self.tree.column("mac", width=105)
        self.tree.column("status", width=42, anchor=tk.CENTER)
        sb = ttk.Scrollbar(r2, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.bind('<<TreeviewSelect>>', self._on_select)
        self.tree.bind('<Double-1>', self._on_tree_double_click)

        r3 = ttk.Frame(self.root)
        r3.pack(fill=tk.X, **pad)
        ttk.Button(r3, text="发送文件", command=self._send_file, width=8).pack(side=tk.LEFT, padx=2, expand=True,
                                                                               fill=tk.X)
        ttk.Button(r3, text="&#128421;关机指令", command=self._send_shut, width=8).pack(side=tk.LEFT, padx=2, expand=True,
                                                                                fill=tk.X)
        ttk.Button(r3, text="&#128268;网络唤醒", command=self._send_wol, width=8).pack(side=tk.LEFT, padx=2, expand=True,
                                                                               fill=tk.X)

        nb = ttk.Notebook(self.root)
        nb.pack(fill=tk.BOTH, expand=True, **pad)
        chat_tab = ttk.Frame(nb)
        nb.add(chat_tab, text=" 聊天")
        chat_tab.grid_rowconfigure(0, weight=1)
        chat_tab.grid_rowconfigure(1, weight=0)
        chat_tab.grid_columnconfigure(0, weight=1)

        self.chat = scrolledtext.ScrolledText(chat_tab, font=("Consolas", self.chat_font_size), wrap=tk.WORD,
                                              state=tk.DISABLED, bg=CHAT_BG_ALTERNATE[0])
        self.chat.grid(row=0, column=0, sticky="nsew", padx=2, pady=2)
        self._apply_chat_tags()

        bottom_frame = ttk.Frame(chat_tab)
        bottom_frame.grid(row=1, column=0, sticky="ew", padx=2, pady=(2, 4))
        bottom_frame.grid_columnconfigure(0, weight=1, minsize=0)
        bottom_frame.grid_columnconfigure(1, weight=0, minsize=RIGHT_PANEL_WIDTH)
        bottom_frame.grid_rowconfigure(0, weight=1)
        self.inp = tk.Text(bottom_frame, height=3, font=("Microsoft YaHei", 9), wrap=tk.WORD)
        self.inp.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
        self.inp.bind('<Control-Return>', lambda e: (self._send_msg(), "break"))
        right_panel = ttk.Frame(bottom_frame)
        right_panel.grid(row=0, column=1, sticky="nsew")
        ttk.Button(right_panel, text="发送", command=self._send_msg).pack(fill=tk.X, pady=(0, 3))
        tools_row = ttk.Frame(right_panel)
        tools_row.pack(fill=tk.X, side=tk.BOTTOM)
        ttk.Button(tools_row, text="&#10010;", command=lambda: self._change_font(1), width=2).pack(side=tk.LEFT, expand=True,
                                                                                            fill=tk.X)
        ttk.Button(tools_row, text="━", command=lambda: self._change_font(-1), width=2).pack(side=tk.LEFT, expand=True,
                                                                                             fill=tk.X)
        ttk.Button(tools_row, text="&#10008;", command=self._clear_chat, width=2).pack(side=tk.LEFT, expand=True, fill=tk.X)

        trans_tab = ttk.Frame(nb)
        nb.add(trans_tab, text="&#128202; 传输")
        tt = ttk.Frame(trans_tab)
        tt.pack(fill=tk.X)
        ttk.Button(tt, text="&#128465;清除已完成", command=self._clear_trans, width=12).pack(side=tk.LEFT, padx=2)
        tcols = ("dir", "file", "peer", "status", "prog")
        self.tr_tree = ttk.Treeview(trans_tab, columns=tcols, show="headings", height=6)
        self.tr_tree.heading("dir", text="方向")
        self.tr_tree.heading("file", text="文件")
        self.tr_tree.heading("peer", text="IP")
        self.tr_tree.heading("status", text="状态")
        self.tr_tree.heading("prog", text="进度")
        self.tr_tree.column("dir", width=35)
        self.tr_tree.column("file", width=110)
        self.tr_tree.column("peer", width=100)
        self.tr_tree.column("status", width=55)
        self.tr_tree.column("prog", width=50)
        self.tr_tree.pack(fill=tk.BOTH, expand=True)
        self.tr_tree.bind('<Button-3>', self._on_trans_right_click)
        self.nb = nb

    def _get_next_tag(self, msg_type):
        idx = self._color_index
        self._color_index = (self._color_index + 1) % NUM_COLORS
        return f"{msg_type}_{idx}"

    def _insert_single_msg(self, rec):
        ts = rec.get('time', '')
        msg_type = rec.get('type', 'sys')
        tag = self._get_next_tag(msg_type)

        if msg_type == 'sys':
            line = f"[{ts}] &#128226; {rec['msg']}"
        elif msg_type == 'file':
            line = f"[{ts}]  {rec['msg']}"
        elif msg_type == 'sent':
            line = f"[{ts}] 我 → {rec['msg']}"
        elif msg_type == 'recv':
            line = f"[{ts}] {rec.get('sender', '')} → {rec['msg']}"
        else:
            line = f"[{ts}] {rec['msg']}"

        self.chat.insert(tk.END, line + "\n", (tag,))

    def _on_trans_right_click(self, event):
        item = self.tr_tree.identify_row(event.y)
        if not item: return
        self.tr_tree.selection_set(item)
        task = None
        if self.tq:
            with self.tq.lock:
                for t in self.tq.tasks:
                    if str(t.tid) == str(item): task = t; break
        menu = tk.Menu(self.root, tearoff=0)
        if task and task.path:
            menu.add_command(label="&#128194; 打开文件所在目录", command=lambda: self._open_file_location(task.path))
            menu.add_command(label="&#128203; 复制文件路径", command=lambda: self._copy_to_clipboard(task.path))
            menu.add_separator()
        menu.add_command(label="&#8505;&#65039; 详情", command=lambda: self._show_task_detail(task))
        try:
            menu.tk_popup(event.x_root, event.y_root)
        finally:
            menu.grab_release()

    def _open_file_location(self, filepath):
        if not filepath: messagebox.showinfo("提示", "该记录无文件路径信息"); return
        if not os.path.exists(filepath):
            dl_dir = self.cfg.get('download_dir', os.path.join(os.path.expanduser('~'), 'PyMsgDownloads'))
            alt_path = os.path.join(dl_dir, os.path.basename(filepath))
            if os.path.exists(alt_path):
                filepath = alt_path
            else:
                messagebox.showwarning("提示", f"文件不存在:\n{filepath}\n\n可能已被移动或删除"); return
        try:
            system = platform.system()
            if system == "Windows":
                subprocess.Popen(['explorer', '/select,', os.path.normpath(filepath)])
            elif system == "Darwin":
                subprocess.Popen(['open', '-R', filepath])
            else:
                subprocess.Popen(['xdg-open', os.path.dirname(filepath)])
            self.flog.info(f"打开文件位置:{filepath}")
        except Exception as e:
            messagebox.showerror("错误", f"无法打开文件位置:\n{e}")

    def _copy_to_clipboard(self, text):
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.root.update()
        self.flog.info(f"已复制路径:{text}")

    def _show_task_detail(self, task):
        if not task: return
        info = (f"任务 ID: {task.tid}\n方向:{'发送' if task.dir == 'send' else '接收'}\n"
                f"文件名:{task.fi.name}\n大小:{format_size(task.fi.size)}\n"
                f"对方 IP: {task.ip}:{task.port}\n状态:{task.status}\n"
                f"进度:{task.prog:.1f}%\n文件路径:{task.path or '(发送任务无本地路径)'}")
        messagebox.showinfo("传输任务详情", info)

    def _refresh_trans_ui(self):
        for i in self.tr_tree.get_children(): self.tr_tree.delete(i)
        if self.tq:
            with self.tq.lock:
                for t in self.tq.tasks: self.ui_add_task(t)

    def _render_global_history(self):
        self.chat.config(state=tk.NORMAL)
        self.chat.delete('1.0', tk.END)
        self._color_index = 0
        msgs = self.hist.get_all()
        for rec in msgs:
            self._insert_single_msg(rec)
        self.chat.config(state=tk.DISABLED)
        self.chat.see(tk.END)

    def _on_tree_double_click(self, event):
        sel = self.tree.selection()
        if not sel: return
        ip = sel[0]
        peer = self.engine.peers.get(ip) if self.engine else None
        col_id = self.tree.identify_column(event.x)
        copy_text = ""
        label = ""

        if col_id == "#1":
            if peer:
                copy_text = peer.display_name()
            else:
                vals = self.tree.item(ip, "values")
                copy_text = vals[0] if vals else ""
            label = "名称"
        elif col_id == "#2":
            copy_text = ip
            label = "IP"
        elif col_id == "#3":
            if peer and peer.mac:
                copy_text = peer.mac
            else:
                copy_text = self.cfg.get('mac_addresses', {}).get(ip, "")
            if not copy_text:
                messagebox.showinfo("提示", f"该客户端 ({ip}) 暂无 MAC 地址信息")
                return
            label = "MAC"
            saved_macs = self.cfg.get('mac_addresses', {})
            if saved_macs.get(ip) != copy_text:
                saved_macs[ip] = copy_text
                self.cfg.set('mac_addresses', saved_macs)
            if peer:
                peer.mac = copy_text
                if self.tree.exists(ip):
                    self.tree.item(ip, values=(
                        peer.display_name(), peer.ip, copy_text, peer.state_text()))
        else:
            return

        if not copy_text:
            messagebox.showinfo("提示", "该项内容为空")
            return

        self.root.clipboard_clear()
        self.root.clipboard_append(copy_text)
        self.root.update()
        self.flog.info(f"已复制 {label}: {copy_text}")

    def _change_font(self, delta):
        self.chat_font_size = max(8, min(20, self.chat_font_size + delta))
        self.chat.config(font=("Consolas", self.chat_font_size))
        self._apply_chat_tags()

    def _toggle_top(self):
        self.is_topmost = not self.is_topmost
        self.root.attributes('-topmost', self.is_topmost)
        self.btn_top.config(text="&#128204;取消" if self.is_topmost else "&#128204;置顶")

    def _toggle_offline(self):
        self.is_offline = not self.is_offline
        if self.is_offline:
            self.engine.go_offline(); self.btn_offline.config(text="&#9654;上线")
            self.btn_absence.config(state=tk.DISABLED)
        else:
            self.engine.go_online(); self.btn_offline.config(text="&#9208;下线")
            self.btn_absence.config(state=tk.NORMAL)

    def _refresh(self):
        if self.engine:
            self.engine.refresh_now()
            self.flog.info("手动刷新:已重新报到并向所有已知节点探测")

    def _toggle_absence(self):
        """★ 离开模式(仍在线,只是状态变更)——与"下线"是两回事"""
        if not self.engine: return
        on = not self.engine.absence
        if on:
            msg = simpledialog.askstring("离开模式", "离开时的自动回复语:",
                                         initialvalue=self.cfg.get('absence_msg', '暂时离开,稍后回复'),
                                         parent=self.root)
            if msg is None: return
        else:
            msg = ""
        self.engine.set_absence(on, msg or "")
        self.btn_absence.config(text="&#9728;在线" if on else "&#128164;离开")
        ts = datetime.now().strftime('%H:%M:%S')
        self._append_to_chat({'type': 'sys', 'msg': f"状态切换:{'离开' if on else '在线'}", 'time': ts})

    def _filter(self):
        kw = self.search_var.get().strip().lower()
        self.tree.delete(*self.tree.get_children())
        if self.engine:
            with self.engine.peers_lock:
                peers = list(self.engine.peers.values())
            for p in peers:
                if kw and kw not in f"{p.display_name()} {p.ip} {p.mac}".lower(): continue
                self.tree.insert("", tk.END, iid=p.ip,
                                 values=(p.display_name(), p.ip, p.mac or "获取中...", p.state_text()))

    def _on_select(self, e):
        sel = self.tree.selection()
        if sel and self.engine and sel[0] in self.engine.peers:
            self.current_peer = self.engine.peers[sel[0]]
            self.flog.info(f"当前发送目标切换:{self.current_peer.user}({self.current_peer.ip})")

    def _get_peer(self):
        sel = self.tree.selection()
        if sel and self.engine and sel[0] in self.engine.peers: return self.engine.peers[sel[0]]
        return None

    def _append_to_chat(self, rec):
        self.hist.add(rec)
        self.chat.config(state=tk.NORMAL)
        self._insert_single_msg(rec)
        self.chat.update_idletasks()
        self.chat.config(state=tk.DISABLED)
        self.chat.see(tk.END)

    def _send_msg(self):
        if not self.engine: return
        msg = self.inp.get('1.0', tk.END).strip()
        if not msg: return
        self.inp.delete('1.0', tk.END)
        peer = self._get_peer()
        ts = datetime.now().strftime('%H:%M:%S')
        if peer and not peer.is_local:
            self.engine.send_msg(peer.ip, peer.port, msg)
            rec = {'type': 'sent', 'msg': f"→ {peer.user}({peer.ip}): {msg}", 'time': ts}
        else:
            self.engine.broadcast_msg(msg)
            rec = {'type': 'sent', 'msg': f"[广播] → 所有人:{msg}", 'time': ts}
        self._append_to_chat(rec)

    def _send_file(self):
        if not self.engine: return
        peer = self._get_peer()
        if not peer or peer.is_local: messagebox.showinfo("提示", "请选择一个远程客户端发送文件"); return
        if not peer.is_pymsg(): messagebox.showwarning("提示", "对方不是 PyMsg 客户端,不支持文件传输"); return
        paths = filedialog.askopenfilenames(title="选择文件")
        if not paths: return
        msg = simpledialog.askstring("附带消息", "附带消息 (可留空):", parent=self.root) or ""
        fis = self.engine.send_files(peer.ip, peer.port, msg, list(paths))
        if fis:
            for f in fis: self.tq.add_send(peer.ip, peer.port, f)
            ts = datetime.now().strftime('%H:%M:%S')
            file_names = ", ".join(f.name for f in fis)
            rec = {'type': 'file', 'msg': f"发送 {file_names} → {peer.user}({peer.ip})", 'time': ts}
            self._append_to_chat(rec)
            self.flog.info(f"发送 {len(fis)} 个文件给 {peer.user}({peer.ip}): {file_names}")
            self.nb.select(1)

    def _send_shut(self):
        peer = self._get_peer()
        if not peer or peer.is_local: messagebox.showinfo("提示", "请选择远程客户端"); return
        if messagebox.askyesno("确认", f"向 {peer.user}({peer.ip}) 发送关机指令?"):
            self.engine.send_shutdown(peer.ip, peer.port)
            ts = datetime.now().strftime('%H:%M:%S')
            rec = {'type': 'sys', 'msg': f"已向 {peer.user}({peer.ip}) 发送关机指令", 'time': ts}
            self._append_to_chat(rec)
            self.flog.info(f"已发送关机指令给 {peer.user}({peer.ip})")

    def _send_wol(self):
        self._show_wol_dialog()

    def _show_wol_dialog(self):
        win = tk.Toplevel(self.root)
        win.title(" WOL 远程唤醒")
        win.geometry("300x250")
        win.resizable(False, False)
        win.transient(self.root)
        win.grab_set()
        frame = ttk.Frame(win, padding=15)
        frame.pack(fill=tk.BOTH, expand=True)
        ttk.Label(frame, text=" WOL 远程唤醒", font=("Microsoft YaHei", 12, "bold")).pack(pady=5)
        ttk.Label(frame, text="MAC 地址:").pack(anchor=tk.W, pady=3)
        mac_var = tk.StringVar()
        ttk.Entry(frame, textvariable=mac_var, width=30, font=("Consolas", 11)).pack(fill=tk.X, pady=2)
        macs = self.cfg.get('mac_addresses', {})
        if macs:
            ttk.Label(frame, text="已保存的 MAC:").pack(anchor=tk.W, pady=(8, 2))
            display_list = [f"{ip} ({m})" for ip, m in macs.items()]
            mac_combo = ttk.Combobox(frame, values=display_list, width=35, state="readonly")
            mac_combo.pack(fill=tk.X)

            def on_mac_selected(event):
                selected = mac_combo.get()
                ip_part = selected.split(' ')[0] if selected else ""
                mac_var.set(macs.get(ip_part, ''))

            mac_combo.bind('<<ComboboxSelected>>', on_mac_selected)
        peer = self._get_peer()
        if peer and peer.ip in macs: mac_var.set(macs[peer.ip])

        def do_wol():
            mac = mac_var.get().strip().upper()
            if not mac: messagebox.showerror("错误", "请输入 MAC 地址", parent=win); return
            clean_mac = mac.replace(':', '').replace('-', '')
            if len(clean_mac) != 12 or not all(c in '0123456789ABCDEF' for c in clean_mac):
                messagebox.showerror("格式错误", f"MAC 地址格式无效:\n{mac}", parent=win)
                return
            normalized_mac = ':'.join(clean_mac[i:i + 2] for i in range(0, 12, 2))
            saved_macs = self.cfg.get('mac_addresses', {})
            target_ip = peer.ip if peer else None
            if target_ip:
                saved_macs[target_ip] = normalized_mac
                self.cfg.set('mac_addresses', saved_macs)
                peer.mac = normalized_mac
            if peer and self.tree.exists(peer.ip):
                self.tree.item(peer.ip, values=(
                    peer.display_name(), peer.ip, normalized_mac, peer.state_text()))
            try:
                for bcast in get_subnet_broadcasts(): send_wol(normalized_mac, bcast)
                ts = datetime.now().strftime('%H:%M:%S')
                rec = {'type': 'sys', 'msg': f"WOL 唤醒包已发送:{normalized_mac}", 'time': ts}
                self._append_to_chat(rec)
                self.flog.info(f"WOL 唤醒包已发送:{normalized_mac}")
                messagebox.showinfo("成功", "WOL 魔术包已发送!", parent=win)
                win.destroy()
            except Exception as e:
                messagebox.showerror("WOL 错误", str(e), parent=win)

        ttk.Button(frame, text="&#128268; 发送唤醒包", command=do_wol, width=18).pack(pady=25)

    def log(self, tag, msg):
        self.flog.info(f"[{tag}] {msg}")

    def _clear_chat(self):
        if messagebox.askyesno("确认", "确定要清空所有聊天记录吗?此操作不可恢复!"):
            self.hist.clear()
            self.chat.config(state=tk.NORMAL)
            self.chat.delete('1.0', tk.END)
            self.chat.config(state=tk.DISABLED)
            self._color_index = 0

    def _clear_trans(self):
        if self.tq:
            removed = self.tq.cleanup()
            if removed > 0:
                self.flog.info(f"已清除 {removed} 条已完成/失败的传输记录")
            else:
                messagebox.showinfo("提示", "没有可清除的记录")

    def recv_msg(self, ip, sender, msg):
        ts = datetime.now().strftime('%H:%M:%S')
        rec = {'type': 'recv', 'sender': sender, 'msg': f"← {sender}({ip}): {msg}", 'time': ts}
        self._append_to_chat(rec)
        self.root.after(0, lambda: self._notify(sender, msg, ip))

    def _notify(self, sender, msg, ip):
        n = tk.Toplevel(self.root)
        n.title(f"新消息 - {sender}")
        n.geometry("300x100")
        n.attributes('-topmost', True)
        ttk.Label(n, text=f"{sender}: {msg[:50]}", wraplength=280).pack(pady=10)
        ttk.Button(n, text="查看", command=lambda: [n.destroy(), self._show_window()]).pack()
        n.after(5000, n.destroy)

    def recv_files(self, ip, sender, fis, port):
        auto = self.cfg.get('auto_download', False)
        if auto:
            dl = self.cfg.get('download_dir', os.path.join(os.path.expanduser('~'), 'PyMsgDownloads'))
            os.makedirs(dl, exist_ok=True)
            for f in fis: self.tq.add_recv(ip, port, f, os.path.join(dl, f.name))
            ts = datetime.now().strftime('%H:%M:%S')
            file_names = ", ".join(f.name for f in fis)
            rec = {'type': 'file', 'msg': f"收到 {sender}({ip}) 的文件:{file_names}", 'time': ts}
            self._append_to_chat(rec)
            self.flog.info(f"自动接收 {len(fis)} 个文件来自 {sender}({ip}): {file_names}")
            self.nb.select(1)
        else:
            total = sum(f.size for f in fis)
            msg = f"来自 {sender} 的文件:\n" + "\n".join(
                [f"&#128196; {f.name} ({format_size(f.size)})" for f in fis]) + f"\n总计:{format_size(total)}\n\n是否接收?"
            if messagebox.askyesno("接收文件", msg):
                dl = self.cfg.get('download_dir', os.path.join(os.path.expanduser('~'), 'PyMsgDownloads'))
                os.makedirs(dl, exist_ok=True)
                for f in fis: self.tq.add_recv(ip, port, f, os.path.join(dl, f.name))
                ts = datetime.now().strftime('%H:%M:%S')
                file_names = ", ".join(f.name for f in fis)
                rec = {'type': 'file', 'msg': f"收到 {sender}({ip}) 的文件:{file_names}", 'time': ts}
                self._append_to_chat(rec)
                self.nb.select(1)

    def ui_add_peer(self, p):
        vals = (p.display_name(), p.ip, p.mac or "获取中...", p.state_text())
        if self.tree.exists(p.ip):
            self.tree.item(p.ip, values=vals)
        else:
            self.tree.insert("", tk.END, iid=p.ip, values=vals)
        # 搜索过滤生效时,新增条目需符合当前关键字
        kw = self.search_var.get().strip().lower()
        if kw and kw not in f"{p.display_name()} {p.ip} {p.mac} {p.state_text()}".lower():
            self.tree.detach(p.ip)

    def ui_rem_peer(self, ip):
        if self.tree.exists(ip): self.tree.delete(ip)

    def ui_add_task(self, t):
        d = "&#11014;发" if t.dir == "send" else "&#11015;收"
        if not self.tr_tree.exists(str(t.tid)):
            self.tr_tree.insert("", tk.END, iid=str(t.tid), values=(d, t.fi.name, t.ip, t.status, f"{t.prog:.1f}%"))

    def ui_upd_task(self, t):
        d = "&#11014;发" if t.dir == "send" else "&#11015;收"
        if self.tr_tree.exists(str(t.tid)):
            self.tr_tree.item(str(t.tid), values=(d, t.fi.name, t.ip, t.status, f"{t.prog:.1f}%"))
        else:
            self.ui_add_task(t)

    def handle_pong(self, p, ip):
        try:
            rtt = int(time.time() * 1000) - int(p.add); self.flog.info(f"Ping {ip} 延迟:{rtt}ms")
        except:
            self.flog.info(f"Ping {ip} 已响应")

    def show_shutdown(self, sender, ip):
        try:
            self.root.deiconify()
            self.root.lift()
        except:
            pass
        if self.shutdown_win and self.shutdown_win.winfo_exists(): return
        sw = tk.Toplevel(self.root)
        sw.title("&#9888; 远程关机")
        sw.geometry("300x150")
        sw.attributes('-topmost', True)
        self.shutdown_win = sw
        ttk.Label(sw, text=f"&#9888; {sender} 请求关机", font=("", 12, "bold"), foreground="red").pack(pady=10)
        cd_var = tk.StringVar(value="30 秒后关机")
        ttk.Label(sw, textvariable=cd_var).pack()
        rem = [30]

        def upd():
            rem[0] -= 1
            if rem[0] <= 0: self._do_shut(); return
            cd_var.set(f"{rem[0]}秒后关机")
            self.shutdown_timer = sw.after(1000, upd)

        def cancel():
            if self.shutdown_timer: sw.after_cancel(self.shutdown_timer)
            self.engine.send_pkt(IPMsgPacket(pno=self.engine.next_pno(), sender=self.engine.user, host=self.engine.host,
                                             cmd=IPMSG_SHUTDOWN_CANCEL, add=""), ip)
            cd_var.set("已取消")
            sw.after(1000, sw.destroy)

        bf = ttk.Frame(sw)
        bf.pack(pady=10)
        ttk.Button(bf, text="取消", command=cancel).pack(side=tk.LEFT, padx=10)
        ttk.Button(bf, text="立即关机", command=self._do_shut).pack(side=tk.LEFT, padx=10)
        self.shutdown_timer = sw.after(1000, upd)

    def _do_shut(self):
        if self.shutdown_timer and self.shutdown_win: self.shutdown_win.after_cancel(self.shutdown_timer)
        try:
            if platform.system() == "Windows":
                subprocess.Popen(["shutdown", "/s", "/t", "3"],
                                 creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
            else:
                subprocess.Popen(["shutdown", "-h", "+1"])
        except:
            pass
        if self.shutdown_win: self.shutdown_win.destroy()

    def _show_settings(self):
        win = tk.Toplevel(self.root)
        win.title("&#9881; 设置")
        win.geometry("330x400")
        win.transient(self.root)
        win.grab_set()
        win.resizable(False, False)
        f = ttk.Frame(win, padding=15)
        f.pack(fill=tk.BOTH, expand=True)
        ttk.Label(f, text="端口:").grid(row=0, column=0, sticky=tk.W, pady=4)
        port_var = tk.IntVar(value=self.cfg.get('port', 9999))
        ttk.Entry(f, textvariable=port_var, width=10).grid(row=0, column=1, sticky=tk.W, pady=4)
        auto_var = tk.BooleanVar(value=self.cfg.get('auto_download', False))
        ttk.Checkbutton(f, text="收到文件时自动下载到默认目录", variable=auto_var).grid(row=1, column=0, columnspan=2,
                                                                                        sticky=tk.W, pady=4)
        ttk.Label(f, text="下载目录:").grid(row=2, column=0, sticky=tk.W, pady=4)
        dl_var = tk.StringVar(value=self.cfg.get('download_dir', ''))
        dl_f = ttk.Frame(f)
        dl_f.grid(row=2, column=1, sticky=tk.W, pady=4)
        ttk.Entry(dl_f, textvariable=dl_var, width=20).pack(side=tk.LEFT)
        ttk.Button(dl_f, text="浏览", command=lambda: dl_var.set(filedialog.askdirectory() or dl_var.get()),
                   width=5).pack(side=tk.LEFT, padx=3)
        shut_var = tk.BooleanVar(value=self.cfg.get('accept_shutdown', True))
        ttk.Checkbutton(f, text="同意接受远程关机指令", variable=shut_var).grid(row=3, column=0, columnspan=2,
                                                                                sticky=tk.W, pady=4)
        auto_start_var = tk.BooleanVar(value=self.cfg.get('auto_start', False))
        ttk.Checkbutton(f, text="开机自动启动 (注册表优先+快捷方式兜底)", variable=auto_start_var).grid(row=4, column=0,
                                                                                                   columnspan=2,
                                                                                                   sticky=tk.W, pady=4)
        ttk.Label(f, text="传输线程数:").grid(row=5, column=0, sticky=tk.W, pady=4)
        thr_var = tk.IntVar(value=self.cfg.get('max_threads', 4))
        ttk.Spinbox(f, from_=1, to=8, textvariable=thr_var, width=5).grid(row=5, column=1, sticky=tk.W, pady=4)
        ttk.Label(f, text="失败最大重试次数:").grid(row=6, column=0, sticky=tk.W, pady=4)
        retry_var = tk.IntVar(value=self.cfg.get('max_retry', 3))
        ttk.Spinbox(f, from_=0, to=10, textvariable=retry_var, width=5).grid(row=6, column=1, sticky=tk.W, pady=4)
        ttk.Separator(f).grid(row=7, column=0, columnspan=2, sticky="ew", pady=6)
        ttk.Label(f, text="在线管理", font=("", 9, "bold")).grid(row=8, column=0, columnspan=2, sticky=tk.W)
        ttk.Label(f, text="心跳周期(秒):").grid(row=9, column=0, sticky=tk.W, pady=3)
        hb_var = tk.IntVar(value=self.cfg.get('heartbeat_interval', HEARTBEAT_INTERVAL))
        ttk.Spinbox(f, from_=5, to=120, textvariable=hb_var, width=5).grid(row=9, column=1, sticky=tk.W, pady=3)
        ttk.Label(f, text="判定超时(秒):").grid(row=10, column=0, sticky=tk.W, pady=3)
        to_var = tk.IntVar(value=self.cfg.get('peer_timeout', PEER_TIMEOUT))
        ttk.Spinbox(f, from_=20, to=600, textvariable=to_var, width=5).grid(row=10, column=1, sticky=tk.W, pady=3)
        ttk.Label(f, text="(超时≥3倍心跳,先探测再移除)", foreground="#888").grid(row=11, column=0, columnspan=2,
                                                                                     sticky=tk.W)

        def save():
            new_port = port_var.get()
            if new_port != self.cfg.get('port', 9999):
                self.cfg.set('port', new_port)
                if self.engine:
                    self.engine.stop()
                    self.engine = Engine(self, self.cfg, self.flog)
                    self.tq = TransferQueue(self.engine, self.cfg, self)
                    self.engine.start()
                    self._update_title()
                ts = datetime.now().strftime('%H:%M:%S')
                rec = {'type': 'sys', 'msg': f"端口已更改并重启网络:{new_port}", 'time': ts}
                self._append_to_chat(rec)
            self.cfg.set('auto_download', auto_var.get())
            self.cfg.set('download_dir', dl_var.get())
            self.cfg.set('accept_shutdown', shut_var.get())
            self.cfg.set('max_threads', thr_var.get())
            self.cfg.set('max_retry', retry_var.get())
            # ★ 在线管理参数热更新
            hb = max(5, hb_var.get())
            to = max(hb * 2, to_var.get())
            self.cfg.set('heartbeat_interval', hb)
            self.cfg.set('peer_timeout', to)
            if self.engine:
                self.engine.hb_interval = float(hb)
                self.engine.peer_timeout = float(to)
            if auto_start_var.get() != self.cfg.get('auto_start', False):
                ok, msg = add_to_windows_startup(auto_start_var.get())
                self.cfg.set('auto_start', auto_start_var.get())
                if ok:
                    messagebox.showinfo("自启动", msg)
                else:
                    messagebox.showwarning("自启动", msg)
            messagebox.showinfo("成功", "设置已保存")
            win.destroy()

        ttk.Button(f, text="&#128190; 保存并应用", command=save, width=15).grid(row=12, column=0, columnspan=2, pady=15)

    def _on_close(self):
        if self.tq and self.tq.has_active():
            if not messagebox.askyesno("传输中", "有文件正在传输,确定退出吗?"): return
        if self.tq: self.tq.cancel_all()
        if self.engine: self.engine.stop()
        self.root.destroy()
        os._exit(0)

    def run(self):
        self.root.mainloop()


def main(): App().run()


if __name__ == "__main__": main()

免费评分

参与人数 9吾爱币 +6 热心值 +7 收起 理由
laozhang4201 + 1 + 1 热心回复!
52Cooper + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
gxsky + 1 大佬,增强飞书下载地址在哪
快乐王子 + 1 + 1 用心讨论,共获提升!
xuemantian0304 + 1 谢谢@Thanks!
WinterIsC0ming + 1 用心讨论,共获提升!
pptx + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
liyitong + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
策士 + 1 谢谢@Thanks!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| hellovirus 发表于 2026-10-18 12:17
本帖最后由 hellovirus 于 2026-10-18 12:24 编辑

写好以后,想实现开机自动启动,所以在程序中加了自动启动功能,运行的时候发现,实现不了想要的功能,不管是注册表还是快捷方式的方法,都 实现不了,但是用另外一个软件却可以
我就想是不是能让AI写一个这样的程序看看怎么回事,
就有了以下这个程序:

ScreenShot_2026-10-18_122027_860.png

启动项管理器 qidong.py


Windows 全位置启动项扫描管理工具,支持多用户、深度扫描,图形化 GUI,可启用 / 禁用 / 导出备份启动项。

&#10024; 功能简介

一款增强版 Windows 开机自启动项管理工具,不止扫描当前用户,支持本机全部用户、系统级启动位置,可深度扫描注册表、计划任务、系统服务,提供图形界面,支持备份恢复、拖拽添加启动项。

&#128269; 扫描能力

  • [color=var(--md-box-color-fg)]常规扫描
    • 当前用户、所有用户注册表 Run 启动项
    • 当前用户、所有用户「启动文件夹」快捷方式
  • [color=var(--md-box-color-fg)]深度扫描(可选勾选)
    • RunOnce、策略 Run、Winlogon、AppInit_DLLs、BootExecute 等隐蔽注册表启动位置
    • 系统启动 / 用户登录触发的[color=var(--md-box-color-fg)]计划任务
    • 自动启动类型的[color=var(--md-box-color-fg)]Windows 系统服务
  • [color=var(--md-box-color-fg)]多用户支持
    • 自动枚举本机全部 Windows 账户
    • 对未登录离线用户自动挂载NTUSER.DAT,读取其他用户的注册表启动项


&#128736;&#65039; 管理操作

  • 表格展示:序号、状态、类型、名称、程序路径、启动位置、所属用户;区分启用 / 禁用,系统关键项标记只读锁定
  • [color=var(--md-box-color-fg)]启用 / 禁用
    • 注册表项:迁移到xxx_Disabled子项实现禁用,不直接删除
    • 启动文件夹快捷方式:重命名增加.disabled后缀
    • 计划任务:调用系统接口直接启用 / 禁用任务
    • 系统服务:修改服务启动类型,本地记录被禁用服务便于恢复
  • [color=var(--md-box-color-fg)]新增启动项
    • 拖拽 exe/bat 等程序到窗口直接新建启动项
    • 手动弹窗新建,支持选择写入:当前用户 / 所有用户 / 其他用户的注册表或启动文件夹
  • [color=var(--md-box-color-fg)]删除操作:支持删除选中项,支持一键删除全部(高危,有二次确认);系统关键项禁止删除
  • [color=var(--md-box-color-fg)]打开位置:一键跳转程序目录;计划任务直接打开任务计划管理器;服务直接打开服务管理器

&#128190; 备份与恢复

  • [color=var(--md-box-color-fg)]导出清单:把全部扫描到的启动项导出为JSON备份文件,保存完整位置、所属用户、原始命令
  • [color=var(--md-box-color-fg)]导入备份:从 JSON 备份恢复启动项;注册表、快捷方式可还原;计划任务、系统服务不自动重建,防止破坏系统

&#128272; 权限处理

  • 识别管理员权限,系统级、其他用户启动项修改需要管理员
  • 提供【管理员重启】按钮,一键提权重新运行程序
  • 离线用户注册表自动获取备份 / 还原特权,挂载 NTUSER.DAT。


把这个软件的功能 加入到上面的程序中,可能实现正常的自动启动了。
以下是代码:
[Asm] 纯文本查看 复制代码
# -*- coding: utf-8 -*-
"""
启动项管理器 —— 全位置扫描 / 深度扫描 / 多用户管理
by AYDQYY-hellovirus 52pojie

相比原版新增:
1. 扫描全部启动位置(当前用户 / 所有用户 / 其他用户 的注册表 Run 与启动文件夹)
2. 【深度扫描】复选项:RunOnce、策略 Run、Winlogon、AppInit_DLLs、BootExecute、
   计划任务、系统服务等深层自启动位置
3. 多用户支持:自动枚举 ProfileList 下所有用户,未登录用户自动挂载 NTUSER.DAT 读写
4. 所有条目均可启用/禁用/删除(系统关键项锁定为只读),并支持管理员提权
"""

import os
import re
import sys
import json
import shutil
import ctypes
import subprocess
import winreg
import pythoncom
import win32com.client
from tkinter import filedialog, font as tkfont
import ttkbootstrap as ttk
from ttkbootstrap.constants import *
from ttkbootstrap.dialogs import Messagebox, Querybox
from tkinterdnd2 import TkinterDnD, DND_FILES


# ============================== 常量 ==============================
DISABLED_SUFFIX = ".disabled"
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
RUNONCE_KEY = r"Software\Microsoft\Windows\CurrentVersion\RunOnce"
POLICIES_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run"
LEGACY_RUN_KEY = r"Software\Microsoft\Windows NT\CurrentVersion\Windows\Run"
WINLOGON_KEY = r"Software\Microsoft\Windows NT\CurrentVersion\Winlogon"
APPINIT_KEY = r"Software\Microsoft\Windows NT\CurrentVersion\Windows"
BOOTEXEC_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager"
SERVICES_KEY = r"SYSTEM\CurrentControlSet\Services"
WOW6432_RUN_KEY = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run"

WOW64 = getattr(winreg, "KEY_WOW64_64KEY", 0)
CREATE_NO_WINDOW = 0x08000000

HIVE_NAME = {
    winreg.HKEY_CURRENT_USER: "HKCU",
    winreg.HKEY_LOCAL_MACHINE: "HKLM",
    winreg.HKEY_USERS: "HKU",
}
HIVE_CONST = {v: k for k, v in HIVE_NAME.items()}

# 计划任务触发类型(TASK_TRIGGER_TYPE2):7=注册 8=启动 9=登录
TASK_TRIGGER_LABEL = {7: "注册时", 8: "系统启动时", 9: "用户登录时"}

# 服务启动类型
SERVICE_START_LABEL = {0: "引导启动", 1: "系统启动", 2: "自动启动", 4: "已禁用"}

STARTUP_FOLDER_TAIL = r"AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup"

# 记录本工具禁用过的服务,便于重新启用(仅本地记录)
DISABLED_SERVICES_FILE = ".disabled_services.json"


# ============================== 底层工具函数 ==============================
def is_admin():
    """判断是否已具备管理员权限"""
    try:
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def enable_privilege(priv_name):
    """启用指定特权(用于挂载离线用户注册表配置单元)"""
    try:
        TOKEN_QUERY = 0x0008
        TOKEN_ADJUST_PRIVILEGES = 0x0020
        SE_PRIVILEGE_ENABLED = 0x00000002

        class LUID(ctypes.Structure):
            _fields_ = [("LowPart", ctypes.c_uint32), ("HighPart", ctypes.c_int32)]

        class LUID_AND_ATTRIBUTES(ctypes.Structure):
            _fields_ = [("Luid", LUID), ("Attributes", ctypes.c_uint32)]

        class TOKEN_PRIVILEGES(ctypes.Structure):
            _fields_ = [("PrivilegeCount", ctypes.c_uint32),
                        ("Privileges", LUID_AND_ATTRIBUTES)]

        advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        token = ctypes.c_void_p()
        if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(),
                                         TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES,
                                         ctypes.byref(token)):
            return False
        luid = LUID()
        ok = advapi32.LookupPrivilegeValueW(None, priv_name, ctypes.byref(luid))
        if ok:
            tp = TOKEN_PRIVILEGES()
            tp.PrivilegeCount = 1
            tp.Privileges.Luid = luid
            tp.Privileges.Attributes = SE_PRIVILEGE_ENABLED
            ok = advapi32.AdjustTokenPrivileges(token, False, ctypes.byref(tp),
                                                ctypes.sizeof(tp), None, None)
        kernel32.CloseHandle(token)
        return bool(ok)
    except Exception:
        return False


def load_user_hive(sid, ntuser_path):
    """把离线用户的 NTUSER.DAT 挂载到 HKEY_USERS\\<sid>"""
    if not os.path.exists(ntuser_path):
        return False
    enable_privilege("SeRestorePrivilege")
    enable_privilege("SeBackupPrivilege")
    enable_privilege("SeTakeOwnershipPrivilege")
    try:
        winreg.LoadKey(winreg.HKEY_USERS, sid, ntuser_path)
        return True
    except Exception:
        return False


def unload_user_hive(sid):
    """卸载临时挂载的用户配置单元"""
    try:
        winreg.UnLoadKey(winreg.HKEY_USERS, sid)
        return True
    except Exception:
        return False


def resolve_indirect_string(text):
    """解析 @%SystemRoot%\\... 形式的间接字符串(多语言服务显示名)"""
    if not text or not str(text).startswith("@"):
        return text
    try:
        buf = ctypes.create_unicode_buffer(1024)
        ctypes.windll.shlwapi.SHLoadIndirectString(str(text), buf, ctypes.sizeof(buf), None)
        return buf.value or text
    except Exception:
        return text


def run_hidden(cmd):
    """静默执行命令行,失败抛异常"""
    res = subprocess.run(cmd, capture_output=True, text=True,
                         encoding="utf-8", errors="ignore",
                         creationflags=CREATE_NO_WINDOW)
    if res.returncode != 0:
        raise RuntimeError((res.stderr or res.stdout or "命令执行失败").strip()[:300])
    return res.stdout or ""


def enum_user_profiles():
    """枚举本机所有用户配置:返回 [{sid, name, profile, current}]"""
    users = []
    current_profile = os.path.expandvars(os.environ.get("USERPROFILE", "")).lower()
    current_name = (os.environ.get("USERNAME", "") or "").lower()
    try:
        key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
                             r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList",
                             0, winreg.KEY_READ | WOW64)
        i = 0
        while True:
            try:
                sid = winreg.EnumKey(key, i)
                i += 1
            except OSError:
                break
            if not sid.startswith("S-1-5-21"):
                continue
            try:
                sub = winreg.OpenKey(key, sid)
                try:
                    profile, _ = winreg.QueryValueEx(sub, "ProfileImagePath")
                except OSError:
                    profile = ""
                winreg.CloseKey(sub)
            except OSError:
                continue
            profile = os.path.expandvars(str(profile)) if profile else ""
            name = os.path.basename(profile.rstrip("\\/")) if profile else sid
            is_cur = (profile.lower() == current_profile) or (name.lower() == current_name and current_name)
            users.append({"sid": sid, "name": name, "profile": profile, "current": is_cur})
        winreg.CloseKey(key)
    except OSError:
        pass
    return users


def resolve_lnk(lnk_path):
    """解析快捷方式目标:返回 (target, arguments)"""
    try:
        pythoncom.CoInitialize()
        try:
            shell = win32com.client.Dispatch("WScript.Shell")
            sc = shell.CreateShortCut(lnk_path)
            return sc.Targetpath or "", sc.Arguments or ""
        finally:
            pythoncom.CoUninitialize()
    except Exception:
        return "", ""


class StartupManager:
    def __init__(self, root):
        self.root = root
        self.root.title("启动项管理器(全位置·深度扫描·多用户) by AYDQYY-hellovirus 52pojie")
        self.root.geometry("800x400")
        self.root.minsize(480, 400)

        self.root.drop_target_register(DND_FILES)
        self.root.dnd_bind('<<Drop>>', self.on_file_drop)

        self.style = ttk.Style(theme='cosmo')

        self.is_admin = is_admin()
        self.users_cache = None
        self.scan_notes = []
        self.disabled_services = self._load_disabled_services()

        main_frame = ttk.Frame(root, padding=10)
        main_frame.pack(fill=BOTH, expand=True)

        # ========== 顶部滚动提示 + 刷新按钮 ==========
        tip_frame = ttk.Frame(main_frame)
        tip_frame.pack(anchor="w", pady=(0, 6), fill=X)

        self.tip_text_list = [
            "&#128161; 将 exe 文件直接拖拽到本窗口即可添加启动项,可自由选择写入位置",
            "&#128269; 勾选【深度扫描】可扫描 RunOnce、策略Run、Winlogon、计划任务、系统服务等深层位置",
            "&#128101; 勾选【多用户】可管理本机全部账户的启动项,离线账户会自动挂载 NTUSER.DAT",
            "&#127760; 系统级(HKLM/ProgramData) 与多用户条目需要【管理员权限】才能修改",
            "&#128203; 支持多选表格条目(Ctrl / Shift),批量启用禁用或删除",
            "&#8596;&#65039; 表格底部有水平滚动条,Shift/Ctrl + 鼠标滚轮 可直接横向滚动查看长路径",
            "&#128190; 导出清单 可备份全部启动项(含位置与所属用户),导入备份 可一键还原到原位置",
            "&#128280; 双击行可切换启用/禁用:注册表项移入 *_Disabled,快捷方式改名加 .disabled",
            "&#9888;&#65039; 删除全部 为高危操作,删除前请先 导出清单备份",
            "&#128194; 选中条目后点击【打开位置】,快速定位程序或快捷方式所在目录",
            "&#10133; 新增启动项可指定写入【当前用户/所有用户/其他用户】的注册表或启动文件夹"
        ]
        self.current_tip_index = 0
        self.tip_label = ttk.Label(tip_frame, text=self.tip_text_list[0], bootstyle="info")
        self.tip_label.pack(side=LEFT)

        self.load_button = ttk.Button(
            tip_frame, text="刷新启动项", command=self.load_startup_items, bootstyle=PRIMARY
        )
        self.load_button.pack(side=RIGHT)

        # ========== 扫描选项(深度扫描 / 多用户 / 系统级) ==========
        opt_frame = ttk.Frame(main_frame)
        opt_frame.pack(fill=X, pady=(0, 6))

        self.var_deep = ttk.BooleanVar(value=False)
        self.var_multi = ttk.BooleanVar(value=True)
        self.var_system = ttk.BooleanVar(value=True)

        ttk.Checkbutton(opt_frame, text="&#128269; 深度扫描(RunOnce/策略/计划任务/服务等)",
                        variable=self.var_deep, bootstyle="round-toggle",
                        command=self.load_startup_items).pack(side=LEFT, padx=(0, 12))
        ttk.Checkbutton(opt_frame, text="&#128101; 包含其他用户",
                        variable=self.var_multi, bootstyle="round-toggle",
                        command=self.load_startup_items).pack(side=LEFT, padx=(0, 12))
        ttk.Checkbutton(opt_frame, text="&#127760; 系统级(HKLM / ProgramData)",
                        variable=self.var_system, bootstyle="round-toggle",
                        command=self.load_startup_items).pack(side=LEFT, padx=(0, 12))

        self.admin_label = ttk.Label(opt_frame, text="")
        self.admin_label.pack(side=RIGHT)
        self.admin_btn = ttk.Button(opt_frame, text="管理员重启", bootstyle=DANGER,
                                    command=self.restart_as_admin, width=10)
        if not self.is_admin:
            self.admin_btn.pack(side=RIGHT, padx=(0, 8))

        # ========== 表格(随窗口自适应) ==========
        table_frame = ttk.Frame(main_frame)
        table_frame.pack(fill=BOTH, expand=True)

        columns = ("idx", "status", "kind", "name", "path", "location", "owner")
        self.table = ttk.Treeview(
            table_frame, columns=columns, show="headings",
            selectmode="extended", height=12
        )
        self.table.heading("idx", text="序号")
        self.table.heading("status", text="状态")
        self.table.heading("kind", text="类型")
        self.table.heading("name", text="名称")
        self.table.heading("path", text="路径 / 命令")
        self.table.heading("location", text="启动位置")
        self.table.heading("owner", text="所属用户")

        self.table.column("idx", width=45, anchor=CENTER, stretch=False)
        self.table.column("status", width=50, anchor=CENTER, stretch=False)
        self.table.column("kind", width=70, anchor=CENTER, stretch=False)
        self.table.column("name", width=150, anchor=W, stretch=False)
        self.table.column("path", width=330, anchor=W, stretch=True)
        self.table.column("location", width=170, anchor=W, stretch=False)
        self.table.column("owner", width=110, anchor=W, stretch=False)

        self.table.tag_configure('disabled', foreground="#888888")
        self.table.tag_configure('readonly', foreground="#c97c00")

        # ---- 垂直 + 水平滚动条(grid 布局,水平条才能落在表格正下方)----
        vbar = ttk.Scrollbar(table_frame, orient=VERTICAL, command=self.table.yview)
        self.hbar = ttk.Scrollbar(table_frame, orient=HORIZONTAL, command=self.table.xview)
        self.table.configure(yscrollcommand=vbar.set, xscrollcommand=self.hbar.set)

        table_frame.rowconfigure(0, weight=1)
        table_frame.columnconfigure(0, weight=1)
        self.table.grid(row=0, column=0, sticky=NSEW)
        vbar.grid(row=0, column=1, sticky=NS)
        self.hbar.grid(row=1, column=0, columnspan=2, sticky=EW)

        # 双击切换启用/禁用
        self.table.bind("<Double-1>", lambda e: self.toggle_enable_item())
        # Shift + 滚轮 = 横向滚动;Ctrl + 滚轮 = 横向快速翻页
        self.table.bind("<Shift-MouseWheel>", self._on_shift_wheel)
        self.table.bind("<Control-MouseWheel>", self._on_shift_wheel)

        # ========== 底部按钮栏 ==========
        btn_frame = ttk.Frame(root, padding=6)
        btn_frame.pack(fill=X)

        self.add_button = ttk.Button(
            btn_frame, text="新增启动项", command=self.add_new_startup, bootstyle=SUCCESS
        )
        self.add_button.pack(side=LEFT, padx=6)

        self.export_btn = ttk.Button(
            btn_frame, text="导出清单", command=self.export_list, bootstyle=INFO
        )
        self.export_btn.pack(side=LEFT, padx=6)

        self.import_btn = ttk.Button(
            btn_frame, text="导入备份", command=self.import_json_backup, bootstyle=INFO
        )
        self.import_btn.pack(side=LEFT, padx=6)

        self.open_loc_btn = ttk.Button(btn_frame, text="打开位置", command=self.open_selected_path,
                                       bootstyle=WARNING)
        self.open_loc_btn.pack(side=LEFT, padx=6)

        self.enable_btn = ttk.Button(btn_frame, text="启用/禁用", command=self.toggle_enable_item,
                                     bootstyle=WARNING)
        self.enable_btn.pack(side=LEFT, padx=6)

        self.del_selected_btn = ttk.Button(btn_frame, text="删除选中项", command=self.batch_delete_selected,
                                           bootstyle=WARNING)
        self.del_selected_btn.pack(side=LEFT, padx=6)

        self.del_all_btn = ttk.Button(btn_frame, text="删除全部", command=self.delete_all_items,
                                      bootstyle=DANGER)
        self.del_all_btn.pack(side=LEFT, padx=6)

        # ========== 状态栏 ==========
        status_frame = ttk.Frame(root, padding=(10, 3))
        status_frame.pack(fill=X, side=BOTTOM)
        self.status_label = ttk.Label(status_frame, text="", bootstyle="secondary")
        self.status_label.pack(side=LEFT)

        self.item_list = []

        self.load_startup_items()
        self.roll_tip()

    # ============ 通用小工具 ============
    def _is_ok(self, res):
        """兼容不同 ttkbootstrap 版本 okcancel 的返回值"""
        return str(res).upper() in ("OK", "YES", "确定", "继续", "覆盖", "确认删除",
                                    "确认删除全部", "仍然继续")

    def _sanitize_name(self, name):
        """去掉 Windows 文件名非法字符"""
        return re.sub(r'[\\/:*?"<>|]', '_', name).strip()

    def _center_on_root(self, win):
        win.update_idletasks()
        x = self.root.winfo_x() + (self.root.winfo_width() - win.winfo_width()) // 2
        y = self.root.winfo_y() + (self.root.winfo_height() - win.winfo_height()) // 2
        win.geometry(f"+{max(x, 0)}+{max(y, 0)}")

    def roll_tip(self):
        self.current_tip_index += 1
        if self.current_tip_index >= len(self.tip_text_list):
            self.current_tip_index = 0
        self.tip_label.config(text=self.tip_text_list[self.current_tip_index])
        self.root.after(4000, self.roll_tip)

    def restart_as_admin(self):
        """以管理员权限重启本程序"""
        if self.is_admin:
            Messagebox.show_info("当前已经是管理员权限。", "提示", parent=self.root)
            return
        res = Messagebox.okcancel(
            message="将以管理员权限重新启动本程序,是否继续?",
            title="提权重启", parent=self.root
        )
        if not self._is_ok(res):
            return
        try:
            if getattr(sys, "frozen", False):
                exe, params = sys.executable, ""
            else:
                exe = sys.executable
                params = f'"{os.path.abspath(sys.argv[0])}"'
            ctypes.windll.shell32.ShellExecuteW(
                None, "runas", exe, params,
                os.path.dirname(os.path.abspath(sys.argv[0])), 1)
            self.root.destroy()
        except Exception as e:
            Messagebox.show_error(f"提权失败:{e}", "错误", parent=self.root)

    def _need_admin(self, what="此项"):
        """非管理员时提示一次,返回是否继续"""
        if self.is_admin:
            return True
        res = Messagebox.okcancel(
            message=f"当前不是管理员权限,{what}很可能失败。\n是否仍然尝试?\n"
                    f"(建议先点右上角【管理员重启】)",
            title="权限不足", oktext="仍然继续", canceltext="取消", parent=self.root
        )
        return self._is_ok(res)

    # ============ 路径 ============
    def _current_startup_dir(self):
        """当前用户启动文件夹(优先读注册表真实路径)"""
        try:
            key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
                                 r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders",
                                 0, winreg.KEY_READ)
            val, _ = winreg.QueryValueEx(key, "Startup")
            winreg.CloseKey(key)
            val = os.path.expandvars(str(val))
            if val and os.path.isdir(val):
                return val
        except OSError:
            pass
        return os.path.join(os.getenv('APPDATA', ''),
                            r'Microsoft\Windows\Start Menu\Programs\Startup')

    def _common_startup_dir(self):
        """所有用户启动文件夹"""
        try:
            key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
                                 r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
                                 0, winreg.KEY_READ | WOW64)
            val, _ = winreg.QueryValueEx(key, "Common Startup")
            winreg.CloseKey(key)
            val = os.path.expandvars(str(val))
            if val and os.path.isdir(val):
                return val
        except OSError:
            pass
        return os.path.join(os.getenv('ProgramData', r'C:\ProgramData'),
                            r'Microsoft\Windows\Start Menu\Programs\Startup')

    def _all_users(self):
        if self.users_cache is None:
            self.users_cache = enum_user_profiles()
        return self.users_cache

    # ---- 本工具禁用过的服务记录(便于再次启用) ----
    def _load_disabled_services(self):
        try:
            path = os.path.join(os.path.dirname(os.path.abspath(__file__)), DISABLED_SERVICES_FILE)
            if os.path.exists(path):
                with open(path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                if isinstance(data, list):
                    return set(data)
        except Exception:
            pass
        return set()

    def _save_disabled_services(self):
        try:
            path = os.path.join(os.path.dirname(os.path.abspath(__file__)), DISABLED_SERVICES_FILE)
            with open(path, "w", encoding="utf-8") as f:
                json.dump(sorted(self.disabled_services), f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    # ============ 注册表访问封装(支持 HKCU / HKLM / 其他用户) ============
    def _open_reg(self, hive_name, key, user=None, write=False):
        """打开注册表键,返回 (句柄, 是否临时挂载的用户配置单元)"""
        hive = HIVE_CONST.get(hive_name, winreg.HKEY_CURRENT_USER)
        access = winreg.KEY_ALL_ACCESS if write else winreg.KEY_READ
        if hive == winreg.HKEY_LOCAL_MACHINE:
            access |= WOW64
        if user:
            return self._open_user_key(user, key, access, write)
        try:
            return winreg.OpenKey(hive, key, 0, access), False
        except FileNotFoundError:
            if not write:
                return None, False
            try:
                return winreg.CreateKeyEx(hive, key, 0, access), False
            except OSError:
                return None, False
        except OSError:
            return None, False

    def _open_user_key(self, user, key, access, write=False):
        """打开 HKEY_USERS\\<sid>\\key,未加载则临时挂载 NTUSER.DAT"""
        sid = user.get("sid", "")
        full = sid + "\\" + key
        try:
            return winreg.OpenKey(winreg.HKEY_USERS, full, 0, access), False
        except OSError:
            pass
        ntuser = os.path.join(user.get("profile", ""), "NTUSER.DAT")
        if not load_user_hive(sid, ntuser):
            return None, False
        try:
            return winreg.OpenKey(winreg.HKEY_USERS, full, 0, access), True
        except FileNotFoundError:
            if not write:
                unload_user_hive(sid)
                return None, False
            try:
                return winreg.CreateKeyEx(winreg.HKEY_USERS, full, 0, access), True
            except OSError:
                unload_user_hive(sid)
                return None, False
        except OSError:
            unload_user_hive(sid)
            return None, False

    def _close_reg(self, hkey, unload=False, user=None):
        try:
            winreg.CloseKey(hkey)
        except Exception:
            pass
        if unload and user:
            unload_user_hive(user.get("sid", ""))

    @staticmethod
    def _enum_values(hkey):
        out = {}
        i = 0
        while True:
            try:
                name, value, _ = winreg.EnumValue(hkey, i)
                i += 1
            except OSError:
                break
            if isinstance(value, bytes):
                try:
                    value = value.decode("utf-16-le", errors="ignore").rstrip("\x00")
                except Exception:
                    value = str(value)
            elif isinstance(value, int):
                value = str(value)
            elif isinstance(value, tuple):  # REG_MULTI_SZ
                value = " | ".join(str(v) for v in value)
            out[name] = str(value)
        return out

    def _enum_values_ex(self, hive_name, key, user=None):
        hkey, unload = self._open_reg(hive_name, key, user, write=False)
        if hkey is None:
            return {}
        try:
            return self._enum_values(hkey)
        finally:
            self._close_reg(hkey, unload, user)

    def _set_value(self, hive_name, key, name, value, user=None):
        hkey, unload = self._open_reg(hive_name, key, user, write=True)
        if hkey is None:
            raise PermissionError("无法打开注册表键(可能权限不足或路径不存在)")
        try:
            winreg.SetValueEx(hkey, name, 0, winreg.REG_SZ, value)
        finally:
            self._close_reg(hkey, unload, user)

    def _del_value(self, hive_name, key, name, user=None):
        hkey, unload = self._open_reg(hive_name, key, user, write=True)
        if hkey is None:
            return False
        try:
            winreg.DeleteValue(hkey, name)
            return True
        except FileNotFoundError:
            return False
        finally:
            self._close_reg(hkey, unload, user)

    def create_shortcut(self, shortcut_path, target_exe):
        """创建 lnk 快捷方式"""
        if not os.path.exists(target_exe):
            raise FileNotFoundError(f"目标程序不存在:{target_exe}")
        pythoncom.CoInitialize()
        try:
            shell = win32com.client.Dispatch("WScript.Shell")
            shortcut = shell.CreateShortCut(shortcut_path)
            shortcut.Targetpath = target_exe
            shortcut.WorkingDirectory = os.path.dirname(target_exe)
            shortcut.save()
        finally:
            pythoncom.CoUninitialize()

    # ============ 扫描源定义 ============
    def _build_sources(self):
        """根据复选框生成要扫描的注册表位置与启动文件夹"""
        deep = self.var_deep.get()
        multi = self.var_multi.get()
        system = self.var_system.get()
        cur_name = os.environ.get("USERNAME") or "当前用户"

        def ok(src):
            return (not src.get("system")) or system

        reg_srcs = [
            dict(hive="HKCU", key=RUN_KEY, label="注册表 Run", owner=cur_name,
                 scope="user", toggle=True, loc_detail=r"HKCU\...\Run"),
        ]
        if system:
            reg_srcs += [
                dict(hive="HKLM", key=RUN_KEY, label="注册表 Run", owner="所有用户",
                     scope="system", toggle=True, loc_detail=r"HKLM\...\Run"),
                dict(hive="HKLM", key=WOW6432_RUN_KEY, label="注册表 Run(32位)", owner="所有用户",
                     scope="system", toggle=True, loc_detail=r"HKLM\WOW6432Node\...\Run"),
            ]
        if deep:
            reg_srcs += [
                dict(hive="HKCU", key=RUNONCE_KEY, label="注册表 RunOnce", owner=cur_name,
                     scope="user", toggle=False, loc_detail=r"HKCU\...\RunOnce"),
                dict(hive="HKCU", key=POLICIES_RUN_KEY, label="策略 Run", owner=cur_name,
                     scope="user", toggle=True, loc_detail=r"HKCU\...\Policies\Explorer\Run"),
                dict(hive="HKCU", key=LEGACY_RUN_KEY, label="Windows Run(旧)", owner=cur_name,
                     scope="user", toggle=True, loc_detail=r"HKCU\...\Windows\Run"),
                dict(hive="HKLM", key=RUNONCE_KEY, label="注册表 RunOnce", owner="所有用户",
                     scope="system", toggle=False, system=True, loc_detail=r"HKLM\...\RunOnce"),
                dict(hive="HKLM", key=POLICIES_RUN_KEY, label="策略 Run", owner="所有用户",
                     scope="system", toggle=True, system=True,
                     loc_detail=r"HKLM\...\Policies\Explorer\Run"),
                dict(hive="HKLM", key=WINLOGON_KEY, label="Winlogon 登录项", owner="所有用户",
                     scope="system", toggle=False, readonly=True, system=True,
                     only_values=["Userinit", "Shell", "AppSetup"],
                     loc_detail=r"HKLM\...\Winlogon"),
                dict(hive="HKLM", key=APPINIT_KEY, label="AppInit_DLLs", owner="所有用户",
                     scope="system", toggle=False, readonly=True, system=True,
                     only_values=["AppInit_DLLs"], loc_detail=r"HKLM\...\Windows"),
                dict(hive="HKLM", key=BOOTEXEC_KEY, label="开机执行", owner="所有用户",
                     scope="system", toggle=False, readonly=True, system=True,
                     only_values=["BootExecute", "SetupExecute"], loc_detail=r"HKLM\...\Session Manager"),
            ]
        if multi:
            for u in self._all_users():
                if u["current"]:
                    continue
                reg_srcs.append(dict(hive="HKU", key=RUN_KEY, label="注册表 Run", owner=u["name"],
                                     scope="other", toggle=True, user=u,
                                     loc_detail=r"HKU\%s\...\Run" % u["name"]))
                if deep:
                    reg_srcs.append(dict(hive="HKU", key=RUNONCE_KEY, label="注册表 RunOnce",
                                         owner=u["name"], scope="other", toggle=False, user=u,
                                         loc_detail=r"HKU\%s\...\RunOnce" % u["name"]))

        folders = [
            dict(path=self._current_startup_dir(), label="启动文件夹", owner=cur_name, scope="user"),
        ]
        if system:
            folders.append(dict(path=self._common_startup_dir(), label="启动文件夹",
                                owner="所有用户", scope="system"))
        if multi:
            for u in self._all_users():
                if u["current"]:
                    continue
                folders.append(dict(path=os.path.join(u["profile"], STARTUP_FOLDER_TAIL),
                                    label="启动文件夹", owner=u["name"], scope="other"))
            default_dir = os.path.join(os.environ.get("SystemDrive", "C:") + "\\", "Users", "Default")
            folders.append(dict(path=os.path.join(default_dir, STARTUP_FOLDER_TAIL),
                                label="启动文件夹(新用户模板)", owner="Default", scope="other"))

        return [s for s in reg_srcs if ok(s)], folders

    # ============ 扫描 ============
    def load_startup_items(self):
        self.load_button.config(text="扫描中...", state=DISABLED)
        try:
            self.root.update_idletasks()
        except Exception:
            pass
        try:
            self._do_scan()
        finally:
            self.load_button.config(text="刷新启动项", state=NORMAL)

    def _do_scan(self):
        self.table.delete(*self.table.get_children())
        self.item_list.clear()
        self.scan_notes.clear()
        self.users_cache = None  # 每次扫描重新枚举本机用户

        reg_srcs, folders = self._build_sources()
        for src in reg_srcs:
            self._scan_registry(src)
        for f in folders:
            self._scan_folder(f)
        if self.var_deep.get():
            self._scan_tasks()
            self._scan_services()

        order = {"user": 0, "system": 1, "other": 2}
        self.item_list.sort(key=lambda x: (order.get(x.get("scope"), 3),
                                           x.get("owner", ""),
                                           x.get("location", ""),
                                           x.get("name", "").lower()))

        for idx, item in enumerate(self.item_list):
            status_text = "启用" if item["enabled"] else "禁用"
            tags = []
            if not item["enabled"]:
                tags.append("disabled")
            if item.get("readonly"):
                tags.append("readonly")
            self.table.insert("", END, iid=str(idx),
                              values=(idx + 1, status_text, item["kind"], item["name"],
                                      item["path"], item["location"], item["owner"]),
                              tags=tags)

        self._auto_fit_columns()
        self._update_status()

    def _on_shift_wheel(self, event):
        """Shift / Ctrl + 滚轮:横向滚动表格"""
        delta = getattr(event, "delta", 0) or 0
        if not delta:
            return
        self.table.xview_scroll(-3 if delta > 0 else 3, "units")
        return "break"

    def _auto_fit_columns(self):
        """
        按内容自动调整列宽,让超长路径可以横向滚动看全。
        只扩不缩,且给 path 列设置 minwidth,窗口变窄时水平滚动条就会生效。
        """
        try:
            f = tkfont.nametofont("TkDefaultFont")
        except Exception:
            return
        # 每次扫描先回到基准宽度,避免"只增不减"导致列越来越宽
        base = {"name": 150, "path": 330, "location": 170}
        limits = {"name": (110, 240), "path": (280, 820), "location": (130, 220)}
        for col, (minw, maxw) in limits.items():
            longest = ""
            for item in self.item_list:
                v = str(item.get(col) or "")
                if len(v) > len(longest):
                    longest = v
            if not longest:
                w = base[col]
            else:
                w = max(minw, min(maxw, int(f.measure(longest)) + 34))
            self.table.column(col, width=w, minwidth=w)

    def _update_status(self):
        total = len(self.item_list)
        en = sum(1 for i in self.item_list if i["enabled"])
        dis = total - en
        kinds = {}
        for i in self.item_list:
            kinds[i["kind"]] = kinds.get(i["kind"], 0) + 1
        kind_txt = " · ".join(f"{k}{v}" for k, v in kinds.items()) or "无"
        txt = f"共 {total} 项|启用 {en}|禁用 {dis}|{kind_txt}"
        if self.scan_notes:
            txt += "|" + ";".join(self.scan_notes[:3])
        self.status_label.config(text=txt)

        if self.is_admin:
            self.admin_label.config(text="&#9989; 管理员权限", bootstyle=SUCCESS)
        else:
            self.admin_label.config(text="&#9888;&#65039; 非管理员:系统级/其他用户无法修改", bootstyle=DANGER)

    def _scan_registry(self, src):
        hive_name = src["hive"]
        key = src["key"]
        user = src.get("user")
        only = src.get("only_values")
        can_toggle = src.get("toggle", False)

        values = self._enum_values_ex(hive_name, key, user)
        disabled_values = self._enum_values_ex(hive_name, key + "_Disabled", user) if can_toggle else {}

        for name, value in values.items():
            if only and name not in only:
                continue
            if not value:
                continue
            self._add_reg_item(src, name, value, True)
        for name, value in disabled_values.items():
            self._add_reg_item(src, name, value, False)

    def _add_reg_item(self, src, name, value, enabled):
        path = self.extract_path_from_registry_value(value)
        self.item_list.append({
            "type": "Registry",
            "kind": "注册表",
            "name": name,
            "path": path,
            "raw_value": value,
            "enabled": enabled,
            "location": src["label"],
            "loc_detail": src.get("loc_detail", ""),
            "owner": src["owner"],
            "scope": src.get("scope", "user"),
            "hive": src["hive"],
            "key": src["key"],
            "sid": (src.get("user") or {}).get("sid"),
            "profile": (src.get("user") or {}).get("profile"),
            "toggle": src.get("toggle", False),
            "readonly": src.get("readonly", False),
        })

    def _scan_folder(self, f):
        d = f["path"]
        if not d or not os.path.isdir(d):
            return
        try:
            names = os.listdir(d)
        except Exception as e:
            self.scan_notes.append(f"{d} 无法访问({e})")
            return
        for filename in names:
            if filename.lower() == "desktop.ini":
                continue
            fp = os.path.join(d, filename)
            if not os.path.isfile(fp):
                continue
            is_disabled = filename.lower().endswith(DISABLED_SUFFIX)
            real = filename[:-len(DISABLED_SUFFIX)] if is_disabled else filename
            base, ext = os.path.splitext(real)
            target = ""
            if ext.lower() == ".lnk":
                target, _args = resolve_lnk(fp)
            display = target or fp
            self.item_list.append({
                "type": "File",
                "kind": "快捷方式",
                "name": base if ext.lower() == ".lnk" else real,
                "full_filename": filename,
                "folder": d,
                "path": display,
                "real_path": fp,
                "target": target,
                "ext": ext,
                "enabled": not is_disabled,
                "location": f["label"],
                "loc_detail": d,
                "owner": f["owner"],
                "scope": f.get("scope", "user"),
                "toggle": True,
                "readonly": False,
            })

    def _scan_tasks(self):
        """深度扫描:计划任务(启动/登录/注册触发)"""
        try:
            pythoncom.CoInitialize()
            try:
                svc = win32com.client.Dispatch("Schedule.Service")
                svc.Connect()
                found = []
                self._walk_task_folder(svc.GetFolder("\\"), found)
                self.item_list.extend(found)
            finally:
                pythoncom.CoUninitialize()
        except Exception as e:
            self.scan_notes.append(f"计划任务扫描失败({e})")

    def _walk_task_folder(self, folder, out):
        try:
            tasks = folder.GetTasks(1)  # 1 = 包含隐藏任务
        except Exception:
            tasks = []
        for t in tasks:
            try:
                types = set()
                try:
                    trig = t.Definition.Triggers
                    for i in range(1, trig.Count + 1):
                        types.add(trig.Item(i).Type)
                except Exception:
                    pass
                hit = types & set(TASK_TRIGGER_LABEL.keys())
                if not hit:
                    continue
                exe, args = "", ""
                try:
                    acts = t.Definition.Actions
                    if acts.Count:
                        a = acts.Item(1)
                        exe = getattr(a, "Path", "") or ""
                        args = getattr(a, "Arguments", "") or ""
                except Exception:
                    pass
                label = "、".join(TASK_TRIGGER_LABEL[x] for x in sorted(hit))
                out.append({
                    "type": "Task",
                    "kind": "计划任务",
                    "name": t.Name,
                    "path": exe or t.Path,
                    "raw_value": (exe + " " + args).strip(),
                    "enabled": bool(t.Enabled),
                    "location": "计划任务 · " + label,
                    "loc_detail": t.Path,
                    "owner": "系统",
                    "scope": "system",
                    "task_path": t.Path,
                    "toggle": True,
                    "readonly": False,
                })
            except Exception:
                continue
        try:
            subs = folder.GetFolders(0)
        except Exception:
            subs = []
        for sub in subs:
            self._walk_task_folder(sub, out)

    def _scan_services(self):
        """深度扫描:自动启动的 Win32 系统服务"""
        try:
            key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, SERVICES_KEY,
                                 0, winreg.KEY_READ | WOW64)
        except OSError:
            return
        i = 0
        while True:
            try:
                svc_name = winreg.EnumKey(key, i)
                i += 1
            except OSError:
                break
            try:
                sub = winreg.OpenKey(key, svc_name)
                try:
                    start, _ = winreg.QueryValueEx(sub, "Start")
                    typ, _ = winreg.QueryValueEx(sub, "Type")
                    image, _ = winreg.QueryValueEx(sub, "ImagePath")
                    try:
                        disp, _ = winreg.QueryValueEx(sub, "DisplayName")
                    except OSError:
                        disp = svc_name
                    winreg.CloseKey(sub)
                except OSError:
                    try:
                        winreg.CloseKey(sub)
                    except Exception:
                        pass
                    continue
            except OSError:
                continue
            if start not in (0, 1, 2, 4):
                continue
            if not (typ & 0x30):  # 跳过内核驱动
                continue
            # 仅显示自动启动的服务;被本工具禁用的服务也列出,方便重新启用
            if start == 4 and svc_name not in self.disabled_services:
                continue
            if start != 4:
                self.disabled_services.discard(svc_name)
            disp = resolve_indirect_string(str(disp))
            self.item_list.append({
                "type": "Service",
                "kind": "系统服务",
                "name": svc_name,
                "path": str(image),
                "raw_value": str(image),
                "enabled": start != 4,
                "location": "系统服务 · " + SERVICE_START_LABEL.get(start, str(start)),
                "loc_detail": r"HKLM\SYSTEM\CurrentControlSet\Services\%s" % svc_name,
                "owner": "系统",
                "scope": "system",
                "display_name": disp,
                # start 保存"重新启用时应恢复的启动类型"
                "start": (2 if start == 4 else start),
                "toggle": True,
                "readonly": False,
            })
        winreg.CloseKey(key)

    def extract_path_from_registry_value(self, value):
        value = str(value)
        quoted_path_match = re.search(r'"([^"]+)"', value)
        if quoted_path_match:
            path = quoted_path_match.group(1)
            if os.path.exists(path):
                return path

        exe_match = re.search(r'[a-zA-Z]:[\\/](?:[^\\/:* "<>|\r\n]+[\\/])*[^\\/:* "<>|\r\n]+\.(exe|bat|cmd|vbs)',
                              value, re.IGNORECASE)
        if exe_match:
            path = exe_match.group(0)
            if os.path.exists(path):
                return path

        if os.path.exists(value):
            return value

        parts = value.split()
        if parts:
            if os.path.exists(parts[0]):
                return parts[0]
            for part in parts:
                if re.search(r'\.(exe|bat|cmd|vbs)$', part, re.IGNORECASE) and os.path.exists(part):
                    return part
        return value

    # ============ 拖拽 / 新增 ============
    def on_file_drop(self, event):
        files = [f for f in self.root.tk.splitlist(event.data) if os.path.isfile(f)]
        if not files:
            return
        for fpath in files:
            self.create_startup_from_file(fpath)
        self.load_startup_items()

    def create_startup_from_file(self, file_path):
        default_name = os.path.splitext(os.path.basename(file_path))[0]
        name = Querybox.get_string(
            prompt=f"为这个启动项设置名称:\n文件路径:{file_path}",
            title="新增启动项",
            initialvalue=default_name,
            parent=self.root
        )
        if not name:
            return
        self._open_add_dialog(initial_name=name, initial_path=file_path)

    def _writable_locations(self):
        """新增启动项时可写入的位置列表"""
        locs = []
        cur_name = os.environ.get("USERNAME") or "当前用户"
        locs.append({"desc": f"注册表 Run · 当前用户({cur_name})", "kind": "reg",
                     "hive": "HKCU", "key": RUN_KEY, "owner": cur_name, "scope": "user"})
        locs.append({"desc": "启动文件夹 · 当前用户", "kind": "dir",
                     "path": self._current_startup_dir(), "owner": cur_name, "scope": "user"})
        locs.append({"desc": "注册表 Run · 所有用户(HKLM)", "kind": "reg",
                     "hive": "HKLM", "key": RUN_KEY, "owner": "所有用户", "scope": "system"})
        locs.append({"desc": "启动文件夹 · 所有用户(ProgramData)", "kind": "dir",
                     "path": self._common_startup_dir(), "owner": "所有用户", "scope": "system"})
        if self.var_multi.get():
            for u in self._all_users():
                if u["current"]:
                    continue
                locs.append({"desc": f"注册表 Run · {u['name']}", "kind": "reg",
                             "hive": "HKU", "key": RUN_KEY, "owner": u["name"],
                             "scope": "other", "user": u})
                locs.append({"desc": f"启动文件夹 · {u['name']}", "kind": "dir",
                             "path": os.path.join(u["profile"], STARTUP_FOLDER_TAIL),
                             "owner": u["name"], "scope": "other"})
        return locs

    def _open_add_dialog(self, initial_name="", initial_path=""):
        """新增启动项弹窗:名称 / 路径 / 目标位置(支持当前用户、所有用户、其他用户)"""
        win = ttk.Toplevel(self.root)
        win.title("新增启动项")
        win.geometry("620x260")
        win.transient(self.root)
        win.grab_set()
        win.resizable(False, False)

        var_name = ttk.StringVar(value=initial_name)
        var_path = ttk.StringVar(value=initial_path)
        locs = self._writable_locations()
        var_loc = ttk.StringVar(value=locs[0]["desc"])

        def browse_file():
            fp = filedialog.askopenfilename(
                title="选择程序文件",
                filetypes=[("可执行文件", "*.exe;*.bat;*.cmd;*.vbs"), ("所有文件", "*.*")],
                parent=win
            )
            if fp:
                var_path.set(fp)
                if not var_name.get().strip():
                    var_name.set(os.path.splitext(os.path.basename(fp))[0])

        def ok_click():
            name = var_name.get().strip()
            path = var_path.get().strip()
            if not name:
                Messagebox.show_warning("请填写启动项名称", "提示", parent=win)
                return
            if not path or not os.path.isfile(path):
                Messagebox.show_warning("请选择有效的程序文件", "提示", parent=win)
                return
            target = next((l for l in locs if l["desc"] == var_loc.get()), locs[0])
            win.destroy()
            self._do_add(name, path, target)

        container = ttk.Frame(win, padding=20)
        container.pack(fill=BOTH, expand=True)

        ttk.Label(container, text="启动项名称:").grid(row=0, column=0, sticky=W, pady=8)
        ttk.Entry(container, textvariable=var_name, width=45).grid(
            row=0, column=1, columnspan=2, sticky=WE, pady=8)

        ttk.Label(container, text="程序路径:").grid(row=1, column=0, sticky=W, pady=8)
        ttk.Entry(container, textvariable=var_path, width=32).grid(
            row=1, column=1, sticky=WE, pady=8)
        ttk.Button(container, text="浏览...", command=browse_file, bootstyle=INFO).grid(
            row=1, column=2, padx=(8, 0), pady=8)

        ttk.Label(container, text="写入位置:").grid(row=2, column=0, sticky=W, pady=8)
        cb = ttk.Combobox(container, textvariable=var_loc, values=[l["desc"] for l in locs],
                          state="readonly", width=42)
        cb.grid(row=2, column=1, columnspan=2, sticky=WE, pady=8)
        cb.current(0)

        btn_bar = ttk.Frame(container)
        btn_bar.grid(row=3, column=0, columnspan=3, pady=(18, 0))
        ttk.Button(btn_bar, text="确定", command=ok_click, bootstyle=SUCCESS, width=10).pack(side=LEFT, padx=10)
        ttk.Button(btn_bar, text="取消", command=win.destroy, bootstyle=DANGER, width=10).pack(side=LEFT, padx=10)

        container.columnconfigure(1, weight=1)
        self._center_on_root(win)
        win.wait_window()

    def _do_add(self, name, file_path, loc):
        name = self._sanitize_name(name)
        if not name:
            Messagebox.show_warning("名称包含非法字符,请重新输入", "提示", parent=self.root)
            return
        if loc.get("scope") != "user" and not self._need_admin("写入系统级/其他用户启动项"):
            return
        try:
            if loc["kind"] == "reg":
                self._set_value(loc["hive"], loc["key"], name, f'"{file_path}"', loc.get("user"))
                Messagebox.show_info(f"启动项【{name}】已写入 {loc['desc']}", "成功", parent=self.root)
            else:
                os.makedirs(loc["path"], exist_ok=True)
                lnk = os.path.join(loc["path"], name + ".lnk")
                self.create_shortcut(lnk, file_path)
                Messagebox.show_info(f"快捷方式【{name}.lnk】已写入 {loc['desc']}", "成功", parent=self.root)
            self.load_startup_items()
        except Exception as e:
            Messagebox.show_error(f"添加失败:{str(e)}", "错误", parent=self.root)

    def add_new_startup(self):
        self._open_add_dialog()

    # ============ 导出 / 导入 ============
    def export_list(self):
        save_path = filedialog.asksaveasfilename(
            parent=self.root,
            title="保存JSON备份文件",
            defaultextension="*.json",
            filetypes=[("JSON备份文件", "*.json"), ("所有文件", "*.*")]
        )
        if not save_path:
            return
        try:
            with open(save_path, "w", encoding="utf-8") as f:
                json.dump(self.item_list, f, ensure_ascii=False, indent=2, default=str)
            Messagebox.show_info(f"导出JSON成功!共 {len(self.item_list)} 项\n文件:{save_path}",
                                 "导出完成", parent=self.root)
        except Exception as e:
            Messagebox.show_error(f"导出失败:{str(e)}", "错误", parent=self.root)

    def import_json_backup(self):
        file_path = filedialog.askopenfilename(
            parent=self.root,
            title="选择JSON备份文件",
            filetypes=[("JSON备份文件", "*.json"), ("所有文件", "*.*")]
        )
        if not file_path:
            return
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                backup_data = json.load(f)
        except Exception as e:
            Messagebox.show_error(f"读取JSON失败:{str(e)}", "错误", parent=self.root)
            return

        if not isinstance(backup_data, list):
            Messagebox.show_error("JSON格式不正确!必须为本工具导出的备份", "错误", parent=self.root)
            return

        current_keys = {(i.get("owner"), i.get("name")) for i in self.item_list}
        success_count = 0
        skip_count = 0
        need_admin_asked = False

        for entry in backup_data:
            name = entry.get("name")
            item_type = entry.get("type")
            target_path = entry.get("path")
            enabled = entry.get("enabled", True)
            raw_value = entry.get("raw_value")
            owner = entry.get("owner", "")

            if not name or not item_type or not target_path:
                skip_count += 1
                continue

            if (owner, name) in current_keys:
                res = Messagebox.okcancel(
                    message=f"启动项【{name}】({owner})已存在!\n【确定】覆盖,【取消】跳过此项",
                    title="重名提示", oktext="覆盖", canceltext="跳过", parent=self.root
                )
                if not self._is_ok(res):
                    skip_count += 1
                    continue

            try:
                if item_type == "Registry":
                    hive = entry.get("hive", "HKCU")
                    key = entry.get("key", RUN_KEY)
                    user = None
                    if entry.get("sid"):
                        user = {"sid": entry["sid"],
                                "profile": entry.get("profile", ""),
                                "name": owner}
                        if not need_admin_asked and not self._need_admin(f"恢复用户【{owner}】的启动项"):
                            skip_count += 1
                            continue
                        need_admin_asked = True
                    elif hive == "HKLM" and not need_admin_asked:
                        if not self._need_admin("恢复系统级启动项"):
                            skip_count += 1
                            continue
                        need_admin_asked = True
                    val = raw_value if raw_value else f'"{target_path}"'
                    if enabled:
                        self._set_value(hive, key, name, val, user)
                        self._del_value(hive, key + "_Disabled", name, user)
                    else:
                        self._set_value(hive, key + "_Disabled", name, val, user)
                        self._del_value(hive, key, name, user)
                    success_count += 1

                elif item_type == "File":
                    folder = entry.get("folder") or self._current_startup_dir()
                    if folder != self._current_startup_dir() and not need_admin_asked:
                        if not self._need_admin(f"恢复【{owner}】启动文件夹项目"):
                            skip_count += 1
                            continue
                        need_admin_asked = True
                    os.makedirs(folder, exist_ok=True)
                    ext = entry.get("ext") or ".lnk"
                    lnk_name = name + ext
                    if not enabled:
                        lnk_name += DISABLED_SUFFIX
                    dest = os.path.join(folder, lnk_name)
                    src_file = entry.get("target") or target_path
                    if src_file.lower().endswith(".lnk") and os.path.exists(src_file):
                        # 兼容旧版备份:路径存的是快捷方式本身,需解析出真实目标
                        real_target, _a = resolve_lnk(src_file)
                        if real_target:
                            src_file = real_target
                    if ext.lower() == ".lnk":
                        self.create_shortcut(dest, src_file)
                    elif os.path.exists(src_file):
                        shutil.copy2(src_file, dest)
                    else:
                        self.create_shortcut(os.path.join(folder, name + ".lnk"), src_file)
                    success_count += 1

                else:
                    # 计划任务 / 系统服务不做自动重建,避免破坏系统配置
                    skip_count += 1
            except Exception as e:
                Messagebox.show_warning(f"【{name}】恢复失败:{str(e)}", "警告", parent=self.root)
                skip_count += 1

        self.load_startup_items()
        Messagebox.show_info(
            f"导入完成!\n成功恢复:{success_count} 项\n跳过/失败:{skip_count} 项"
            f"\n(计划任务与系统服务不自动重建)",
            "导入结果", parent=self.root
        )

    # ============ 选中项工具 ============
    def _selected_items(self):
        children = list(self.table.get_children())
        items = []
        for iid in self.table.selection():
            if iid in children:
                items.append(self.item_list[children.index(iid)])
        return items

    # ============ 启用 / 禁用 ============
    def toggle_enable_item(self):
        items = self._selected_items()
        if not items:
            Messagebox.show_warning("请先选中一行(按住Ctrl可多选)", "提示", parent=self.root)
            return

        success, fail = 0, 0
        errors = []
        need_admin_asked = False
        for item in items:
            if item.get("readonly"):
                fail += 1
                errors.append(f"{item['name']}: 系统关键项,已锁定不支持禁用")
                continue
            if item.get("scope") != "user" and not need_admin_asked:
                if not self._need_admin("修改系统级/其他用户启动项"):
                    return
                need_admin_asked = True
            try:
                self._toggle_one(item)
                success += 1
            except Exception as e:
                fail += 1
                errors.append(f"{item['name']}: {e}")

        self.load_startup_items()
        if fail:
            msg = f"切换完成!\n成功:{success} 项\n失败:{fail} 项\n\n" + "\n".join(errors[:6])
            Messagebox.show_warning(msg, "结果", parent=self.root)
        else:
            Messagebox.show_info(f"状态切换成功!共切换 {success} 项", "成功", parent=self.root)

    def _toggle_one(self, item):
        t = item["type"]
        if t == "File":
            folder = item["folder"]
            old_path = item["real_path"] or os.path.join(folder, item["full_filename"])
            if not os.path.exists(old_path):
                raise FileNotFoundError("对应的快捷方式文件不存在")
            old_name = os.path.basename(old_path)
            if item["enabled"]:
                new_name = old_name + DISABLED_SUFFIX
            else:
                new_name = (old_name[:-len(DISABLED_SUFFIX)]
                            if old_name.lower().endswith(DISABLED_SUFFIX) else old_name)
            os.rename(old_path, os.path.join(folder, new_name))

        elif t == "Registry":
            user = None
            if item.get("sid"):
                user = {"sid": item["sid"], "profile": item.get("profile", ""),
                        "name": item.get("owner", "")}
            raw = item.get("raw_value", "")
            key = item["key"]
            dis_key = key + "_Disabled"
            if item["enabled"]:
                self._set_value(item["hive"], dis_key, item["name"], raw, user)
                self._del_value(item["hive"], key, item["name"], user)
            else:
                self._set_value(item["hive"], key, item["name"], raw, user)
                self._del_value(item["hive"], dis_key, item["name"], user)

        elif t == "Task":
            action = "DISABLE" if item["enabled"] else "ENABLE"
            run_hidden(["schtasks", "/Change", "/TN", item["task_path"], "/" + action])

        elif t == "Service":
            if item["enabled"]:
                res = Messagebox.okcancel(
                    message=f"&#9888;&#65039; 即将禁用系统服务【{item['name']}】\n"
                            f"{item.get('display_name', '')}\n禁用后可能影响系统或其它软件功能,确认继续?",
                    title="禁用服务确认", oktext="确认禁用", canceltext="取消", parent=self.root
                )
                if not self._is_ok(res):
                    raise RuntimeError("已取消")
                self._set_service_start(item["name"], 4)
                self.disabled_services.add(item["name"])
                self._save_disabled_services()
            else:
                self._set_service_start(item["name"], item.get("start") or 2)
                self.disabled_services.discard(item["name"])
                self._save_disabled_services()

    def _set_service_start(self, svc_name, start_value):
        hkey = None
        try:
            hkey = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
                                  SERVICES_KEY + "\\" + svc_name,
                                  0, winreg.KEY_SET_VALUE | WOW64)
            winreg.SetValueEx(hkey, "Start", 0, winreg.REG_DWORD, int(start_value))
        except FileNotFoundError:
            raise PermissionError("需要管理员权限才能修改服务启动类型")
        finally:
            if hkey:
                winreg.CloseKey(hkey)

    # ============ 删除 ============
    def _delete_one(self, item):
        if item.get("readonly"):
            raise RuntimeError("系统关键项,已锁定不支持删除")
        t = item["type"]
        if t == "File":
            p = item.get("real_path") or item["path"]
            if os.path.exists(p):
                os.remove(p)
        elif t == "Registry":
            user = None
            if item.get("sid"):
                user = {"sid": item["sid"], "profile": item.get("profile", ""),
                        "name": item.get("owner", "")}
            self._del_value(item["hive"], item["key"], item["name"], user)
            self._del_value(item["hive"], item["key"] + "_Disabled", item["name"], user)
        elif t == "Task":
            run_hidden(["schtasks", "/Delete", "/TN", item["task_path"], "/F"])
        elif t == "Service":
            raise RuntimeError("系统服务不支持删除,仅可禁用")

    def _delete_items(self, items, title):
        success, fail, locked = 0, 0, 0
        errors = []
        need_admin_asked = False
        for item in items:
            if item.get("readonly"):
                locked += 1
                continue
            if item.get("scope") != "user" and not need_admin_asked:
                if not self._need_admin("删除系统级/其他用户启动项"):
                    return
                need_admin_asked = True
            try:
                self._delete_one(item)
                success += 1
            except Exception as e:
                fail += 1
                errors.append(f"{item['name']}: {e}")
        self.load_startup_items()
        msg = f"{title}\n成功:{success} 项\n失败:{fail} 项\n锁定跳过:{locked} 项"
        if errors:
            msg += "\n\n" + "\n".join(errors[:6])
        Messagebox.show_info(msg, "结果", parent=self.root)

    def batch_delete_selected(self):
        items = self._selected_items()
        if not items:
            Messagebox.show_warning("请先在表格选中至少一行(按住Ctrl可多选)", "提示", parent=self.root)
            return
        confirm = Messagebox.okcancel(
            message=f"&#9888;&#65039; 准备删除选中的【{len(items)}】条启动项!\n"
                    f"删除前建议导出json备份,确认继续?",
            title="批量删除选中项确认", oktext="确认删除", canceltext="取消", parent=self.root
        )
        if not self._is_ok(confirm):
            return
        self._delete_items(items, "批量删除选中项完成!")

    def delete_all_items(self):
        total = len(self.item_list)
        if total <= 0:
            Messagebox.show_info("当前没有启动项可删除!", "提示", parent=self.root)
            return

        confirm1 = Messagebox.okcancel(
            message=f"&#9888;&#65039; 警告:即将删除当前扫描到的全部【{total}】条启动项!\n"
                    f"(包含所有用户与系统级条目)\n建议先【导出清单】备份!确定继续吗?",
            title="危险操作确认", oktext="继续", canceltext="取消", parent=self.root
        )
        if not self._is_ok(confirm1):
            return

        confirm2 = Messagebox.okcancel(
            message="&#9940; 二次确认!\n此操作删除后无法自动恢复!\n确认彻底删除全部启动项?",
            title="最终确认", oktext="确认删除全部", canceltext="取消", parent=self.root
        )
        if not self._is_ok(confirm2):
            return

        self._delete_items(list(self.item_list), "批量删除完成!")

    # ============ 打开位置 ============
    def open_selected_path(self):
        items = self._selected_items()
        if not items:
            Messagebox.show_warning("请先选中一行", "提示", parent=self.root)
            return
        item = items[0]
        if item["type"] == "Task":
            try:
                subprocess.Popen(["mmc", "taskschd.msc"], creationflags=CREATE_NO_WINDOW)
            except Exception as e:
                Messagebox.show_error(f"打开任务计划程序失败:{e}", "错误", parent=self.root)
            return
        if item["type"] == "Service":
            try:
                subprocess.Popen(["mmc", "services.msc"], creationflags=CREATE_NO_WINDOW)
            except Exception as e:
                Messagebox.show_error(f"打开服务管理器失败:{e}", "错误", parent=self.root)
            return

        path = (item.get("path") or "").strip('"')
        if os.path.isfile(path):
            os.startfile(os.path.dirname(path))
        elif os.path.isdir(path):
            os.startfile(path)
        else:
            dir_path = os.path.dirname(path)
            if dir_path and os.path.exists(dir_path):
                os.startfile(dir_path)
            elif item.get("folder") and os.path.exists(item["folder"]):
                os.startfile(item["folder"])
            else:
                Messagebox.show_error(f"无法找到路径: {path}", "错误", parent=self.root)


if __name__ == "__main__":
    root = TkinterDnD.Tk()
    app = StartupManager(root)
    root.mainloop()
 楼主| hellovirus 发表于 2026-10-18 12:18
本帖最后由 hellovirus 于 2026-10-18 12:31 编辑

自动启动软件修改好了,可以正常使用了,发现一个问题:软件编译后太大了, 有20多M,太不方便了,
又想能不能使用VB6重新写一下,毕竟以前我就是VB6 的老师傅,
于是有了这个VB6的小软件 :

ScreenShot_2026-10-18_122707_789.png

这个程序编译后大小100K。

ScreenShot_2026-10-18_122748_581.png

功能和python写的一样,就不再写说明书了。
VB6的代码:
[Visual Basic] 纯文本查看 复制代码
Option Explicit'=========================================================================
' Form1 代码段(修复版)
' 用法:用记事本打开你工程里的 Form1.frm,把从 "Option Explicit" 到
'       "End Sub" 的全部代码段替换成本文件内容,控件定义段(.frm 头部)
'       不要动。
' 依赖:modStartup.bas(新增标准模块)
'=========================================================================

Private tipList   As Variant
Private tipIndex  As Long
Private mDragOn   As Boolean

' ==================== 初始化 ====================
Private Sub Form_Load()
    Me.Caption = "启动项管理器(拖拽添加 / 启停 / 备份还原)"

    tipList = Array( _
        "★ [拖拽] 将 exe 拖到本窗口即可添加启动项", _
        "☆ [多选] 按住 Ctrl 点击可多选,批量删除", _
        "★ [备份] 导出清单 可备份全部启动项", _
        "☆ [开关] 支持启用/禁用单个启动项", _
        "★ [安全] 删除前自动生成备份,文件进回收站", _
        "☆ [定位] 选中后点【打开位置】定位目录", _
        "★ [新增] 新增启动项支持【浏览】选文件", _
        "☆ [范围] 默认当前用户,可开【深度扫描】看 HKLM / 所有用户" _
    )
    tipIndex = 0
    lblTip.Caption = tipList(0)

    ' 初始化 ListView(新增"来源"列)
    With lvItems
        .View = lvwReport
        .FullRowSelect = True
        .GridLines = True
        .HideSelection = False
        .MultiSelect = True
        .ColumnHeaders.Clear
        .ColumnHeaders.Add , , "序号", 40
        .ColumnHeaders.Add , , "状态", 80
        .ColumnHeaders.Add , , "类型", 80
        .ColumnHeaders.Add , , "名称", 160
        .ColumnHeaders.Add , , "路径", 520
        .ColumnHeaders.Add , , "来源", 170
    End With

    ' 开启拖拽 + 子类化(Form1 本体)
    DragAcceptFiles Me.hWnd, 1
    Set g_Form1 = Me
    g_OldWndProc = SetWindowLong(Me.hWnd, GWL_WNDPROC, AddressOf SubWndProc)
    g_Hooked = True

    ' 让 ListView 也接受拖拽
    DragAcceptFiles lvItems.hWnd, 1
    g_LvHwnd = lvItems.hWnd
    g_OldLvWndProc = SetWindowLong(lvItems.hWnd, GWL_WNDPROC, AddressOf SubLvWndProc)
    g_LvHooked = True

    LoadStartupItems
End Sub

Private Sub Form_Unload(Cancel As Integer)
    UnhookAll
    Set g_Form1 = Nothing
End Sub

Private Sub Timer1_Timer()
    tipIndex = tipIndex + 1
    If tipIndex > UBound(tipList) Then tipIndex = 0
    lblTip.Caption = tipList(tipIndex)
End Sub

' ==================== 拖拽处理 ====================
Public Sub HandleDroppedFiles(files() As String, count As Long)
    Dim i As Long, fpath As String, nm As String, newName As String
    Dim ok As Long

    For i = 0 To count - 1
        fpath = files(i)
        If Dir(fpath, vbNormal Or vbHidden Or vbSystem) <> "" Then
            nm = StripExt(Mid$(fpath, InStrRev(fpath, "\") + 1))
            newName = InputBox("为这个启动项设置名称:" & vbCrLf & _
                               "文件路径:" & fpath, "新增启动项", nm)
            newName = Trim$(newName)
            If newName <> "" Then
                ' 沿用你原有的新增逻辑(Formchoice 未改动)
                Formchoice.AddStartup newName, fpath
                ok = ok + 1
            End If
        End If
    Next

    LoadStartupItems
    If ok > 0 Then MsgBox "已添加 " & ok & " 条启动项。", vbInformation, "拖拽完成"
End Sub

' ==================== 加载启动项 ====================
Public Sub LoadStartupItems()
    Dim i As Long, li As ListItem
    Dim ext As String, amAdmin As Boolean

    lvItems.ListItems.Clear
    g_ItemCount = 0
    ReDim g_Items(0 To 255)
    amAdmin = IsAdmin()

    ' --- 启动文件夹(当前用户 / 所有用户)---
    LoadFolderItems GetStartupFolder(), "当前用户\启动文件夹", False
    If g_ScanAllUsers Then
        LoadFolderItems GetAllUsersStartupFolder(), "所有用户\启动文件夹", Not amAdmin
    End If

    ' --- 注册表 Run / Run_Disabled / RunOnce(当前用户)---
    LoadRegItems HKEY_CURRENT_USER, RUN_KEY, "HKCU\Run", True, "Registry", "CU", False
    LoadRegItems HKEY_CURRENT_USER, RUN_DISABLED_KEY, "HKCU\Run_Disabled", False, "Registry", "CU", False
    LoadRegItems HKEY_CURRENT_USER, RUN_ONCE_KEY, "HKCU\RunOnce", True, "RunOnce", "CU", False

    ' --- 深度扫描:HKLM ---
    If g_ScanAllUsers Then
        LoadRegItems HKEY_LOCAL_MACHINE, RUN_KEY, "HKLM\Run", True, "Registry", "LM", Not amAdmin
        LoadRegItems HKEY_LOCAL_MACHINE, RUN_DISABLED_KEY, "HKLM\Run_Disabled", False, "Registry", "LM", Not amAdmin
        LoadRegItems HKEY_LOCAL_MACHINE, RUN_ONCE_KEY, "HKLM\RunOnce", True, "RunOnce", "LM", Not amAdmin
    End If

    ' --- 只读项:Winlogon / 计划任务 ---
    LoadWinlogonItems amAdmin
    If g_ScanAllUsers Then LoadTaskItems

    ' --- 渲染 ---
    For i = 0 To g_ItemCount - 1
        Set li = lvItems.ListItems.Add(, "K" & CStr(i), CStr(i + 1))
        li.Tag = CStr(i)          ' 用 Tag 绑定索引,不再依赖行号(修复问题 7)

        If g_Items(i).Enabled Then
            li.SubItems(1) = "√ 启用"
        Else
            li.SubItems(1) = "× 禁用"
        End If

        ext = GetExt(g_Items(i).path)
        Select Case ext
            Case "exe":  li.SubItems(2) = "[程序]"
            Case "bat":  li.SubItems(2) = "[批处理]"
            Case "cmd":  li.SubItems(2) = "[DOS命令]"
            Case "vbs", "vbe": li.SubItems(2) = "[VBS]"
            Case "js", "jse", "wsf": li.SubItems(2) = "[脚本]"
            Case "lnk":  li.SubItems(2) = "[快捷方式]"
            Case "url":  li.SubItems(2) = "[网址]"
            Case "ps1":  li.SubItems(2) = "[PowerShell]"
            Case Else
                If Len(ext) > 0 Then li.SubItems(2) = "[" & ext & "]" Else li.SubItems(2) = "[---]"
        End Select

        li.SubItems(3) = g_Items(i).name
        li.SubItems(4) = g_Items(i).path
        li.SubItems(5) = g_Items(i).Source

        If Not g_Items(i).Enabled Then GrayOut li          ' 禁用行整体变灰(修复问题 8)
        If g_Items(i).ReadOnly Then
            li.SubItems(1) = li.SubItems(1) & " ·只读"
            On Error Resume Next
            li.ToolTipText = "系统级只读项(" & g_Items(i).Source & "),本工具不修改、不删除"
            On Error GoTo 0
        End If
    Next

    Me.Caption = "启动项管理器 -AYDQYY helloviurs-52pojie -(共 " & g_ItemCount & " 项" & _
                 IIf(g_ScanAllUsers, "·深度扫描", "·仅当前用户") & ")"
End Sub

Private Sub LoadFolderItems(ByVal folder As String, ByVal srcName As String, ByVal readOnlyFlag As Boolean)
    Dim fname As String, fullPath As String
    Dim isDisabled As Boolean, realName As String
    Dim tgt As String, args As String, src As String

    If Len(folder) = 0 Then Exit Sub
    If Dir(folder, vbDirectory) = "" Then Exit Sub

    src = srcName
    If readOnlyFlag Then src = srcName & "(需管理员)"

    fname = Dir$(folder & "\*.*", vbNormal Or vbHidden Or vbSystem)
    Do While fname <> ""
        If LCase$(fname) <> "desktop.ini" Then
            fullPath = folder & "\" & fname
            isDisabled = EndsWith(fname, DISABLED_SUFFIX)
            realName = StripExt(StripDisabledSuffix(fname))
            FileItemTarget fullPath, tgt, args
            AddItem "File", realName, tgt, BuildCommand(tgt, args), _
                    Not isDisabled, fname, folder, src, readOnlyFlag, 0, "", _
                    IIf(InStr(srcName, "所有用户") > 0, "LM", "CU")
        End If
        fname = Dir$()
    Loop
End Sub

Private Sub LoadRegItems(ByVal hive As Long, ByVal keyPath As String, ByVal srcName As String, _
                         ByVal enabledFlag As Boolean, ByVal itType As String, _
                         ByVal scope As String, ByVal readOnlyFlag As Boolean)
    Dim names() As String, vals() As String, cnt As Long, i As Long
    EnumRegValues hive, keyPath, names, vals, cnt
    For i = 0 To cnt - 1
        AddItem itType, names(i), ExtractPathFromRegValue(vals(i)), vals(i), _
                enabledFlag, "", "", srcName, readOnlyFlag, hive, keyPath, scope
    Next
End Sub

Private Sub LoadWinlogonItems(ByVal amAdmin As Boolean)
    Dim v As String, base As String
    If Not g_ScanAllUsers Then Exit Sub
    base = "HKLM\Winlogon"
    If RegReadValue(HKEY_LOCAL_MACHINE, WINLOGON_KEY, "Shell", v) Then
        If Len(v) > 0 And LCase$(v) <> "explorer.exe" Then
            AddItem "Winlogon", "Shell", ExtractPathFromRegValue(v), v, True, "", "", _
                    base, True, HKEY_LOCAL_MACHINE, WINLOGON_KEY, "LM"
        End If
    End If
    If RegReadValue(HKEY_LOCAL_MACHINE, WINLOGON_KEY, "Userinit", v) Then
        If Len(v) > 0 And LCase$(v) <> "c:\windows\system32\userinit.exe," Then
            AddItem "Winlogon", "Userinit", ExtractPathFromRegValue(v), v, True, "", "", _
                    base, True, HKEY_LOCAL_MACHINE, WINLOGON_KEY, "LM"
        End If
    End If
End Sub

Private Sub LoadTaskItems()
    Dim names() As String, paths() As String, cnt As Long, i As Long
    EnumScheduledTasks names, paths, cnt
    For i = 0 To cnt - 1
        AddItem "Task", StripExt(names(i)), ExtractPathFromRegValue(paths(i)), paths(i), _
                True, "", "", "计划任务(只读)", True, 0, "", "LM"
    Next
End Sub

Private Sub GrayOut(ByVal li As ListItem)
    Dim j As Long
    On Error Resume Next
    li.ForeColor = RGB(136, 136, 136)
    For j = 1 To li.ListSubItems.count
        li.ListSubItems(j).ForeColor = RGB(136, 136, 136)
    Next
    On Error GoTo 0
End Sub

' 从 ListItem 取回 g_Items 下标(不再用行号推算,修复问题 7)
Private Function ItemIndex(ByVal li As ListItem) As Long
    Dim v As String, fromTag As Boolean
    On Error Resume Next
    v = CStr(li.Tag)
    fromTag = (Len(v) > 0)
    On Error GoTo 0
    If Not fromTag Then v = li.Text        ' 兜底:Tag 缺失时退回序号列(1 起)
    If Not IsNumeric(v) Then
        ItemIndex = -1
        Exit Function
    End If
    ItemIndex = CLng(v)
    If Not fromTag Then ItemIndex = ItemIndex - 1
    If ItemIndex < 0 Or ItemIndex >= g_ItemCount Then ItemIndex = -1
End Function

' ==================== 按钮:刷新 ====================
Private Sub cmdRefresh_Click()
    LoadStartupItems
End Sub

' ==================== 按钮:新增 ====================
Private Sub cmdAdd_Click()
    Formadd.Show vbModal
    LoadStartupItems
End Sub

' ==================== 按钮:启用/禁用 ====================
Private Sub cmdToggle_Click()
    Dim sel As ListItem, idx As Long, it As StartupItem

    If lvItems.SelectedItem Is Nothing Then
        MsgBox "请先选中一行", vbExclamation, "提示"
        Exit Sub
    End If
    Set sel = lvItems.SelectedItem
    idx = ItemIndex(sel)
    If idx < 0 Then
        MsgBox "无法定位该项,请刷新后重试。", vbExclamation, "提示"
        Exit Sub
    End If
    it = g_Items(idx)

    If it.ReadOnly Then
        MsgBox "【" & it.name & "】属于系统级只读项(来源:" & it.Source & ")。" & vbCrLf & _
               "为避免系统异常,本工具不修改此类项目。" & vbCrLf & _
               "请用任务计划程序 / msconfig / 系统配置管理它。", vbExclamation, "只读项"
        Exit Sub
    End If

    On Error GoTo ErrHandler
    Select Case it.ItemType
        Case "File":     ToggleFileItem it
        Case "Registry", "RunOnce": ToggleRegItem it
        Case Else
            MsgBox "该类型暂不支持启用/禁用:" & it.ItemType, vbExclamation
            Exit Sub
    End Select

    LoadStartupItems
    MsgBox "状态切换成功!", vbInformation, "成功"
    Exit Sub

ErrHandler:
    MsgBox "切换失败:" & Err.Description, vbCritical, "错误"
End Sub

Private Sub ToggleFileItem(it As StartupItem)
    Dim oldPath As String, newName As String, newPath As String
    oldPath = it.FileDir & "\" & it.FullFileName

    If Dir(oldPath, vbNormal Or vbHidden Or vbSystem) = "" Then
        MsgBox "对应文件已不存在,列表将刷新。" & vbCrLf & oldPath, vbExclamation, "文件丢失"
        LoadStartupItems
        Exit Sub
    End If

    If it.Enabled Then
        newName = it.FullFileName & DISABLED_SUFFIX
    Else
        newName = StripDisabledSuffix(it.FullFileName)
    End If
    newPath = it.FileDir & "\" & newName

    ' 目标名冲突先检查,避免直接 Name 失败(修复问题 8)
    If LCase$(newPath) <> LCase$(oldPath) Then
        If Dir(newPath, vbNormal Or vbHidden Or vbSystem) <> "" Then
            Err.Raise vbObjectError + 1, , "目标文件已存在:" & newName & vbCrLf & _
                      "请手动重命名或删除它后再切换状态。"
        End If
    End If

    Name oldPath As newPath
End Sub

Private Sub ToggleRegItem(it As StartupItem)
    Dim dstKey As String
    dstKey = DisabledKeyOf(it.keyPath)

    If RegValueExists(it.hive, dstKey, it.name) Then
        If MsgBox("目标键中已存在同名项【" & it.name & "】,覆盖吗?", _
                  vbYesNo + vbQuestion, "重名") = vbNo Then Exit Sub
    End If

    ' 先写目标再删源,中途出错也不会丢数据
    If Not RegWriteValue(it.hive, dstKey, it.name, it.RawValue) Then
        Err.Raise vbObjectError + 2, , "写入注册表失败。" & vbCrLf & _
                  "若属于 HKLM,请以管理员身份运行本程序。"
    End If
    RegDeleteValueByName it.hive, it.keyPath, it.name
End Sub

' ==================== 按钮:打开位置 ====================
Private Sub cmdOpen_Click()
    Dim sel As ListItem, idx As Long
    Dim p As String, dirPath As String

    If lvItems.SelectedItem Is Nothing Then
        MsgBox "请先选中一行", vbExclamation, "提示"
        Exit Sub
    End If
    Set sel = lvItems.SelectedItem
    idx = ItemIndex(sel)
    If idx < 0 Then
        MsgBox "无法定位该项,请刷新后重试。", vbExclamation, "提示"
        Exit Sub
    End If

    p = Unquote(Trim$(g_Items(idx).path))
    If Len(p) = 0 Or InStrRev(p, "\") <= 0 Then
        MsgBox "该启动项没有可定位的完整路径。", vbExclamation, "提示"
        Exit Sub
    End If

    ' 先按文件找,再按目录找,并支持 %windir% 之类环境变量(修复问题 1)
    If Dir(p, vbNormal Or vbHidden Or vbSystem) <> "" Then
        dirPath = Left$(p, InStrRev(p, "\") - 1)
    Else
        p = ExpandEnv(p)
        If Dir(p, vbNormal Or vbHidden Or vbSystem) <> "" Then
            dirPath = Left$(p, InStrRev(p, "\") - 1)
        ElseIf InStrRev(p, "\") > 1 Then
            dirPath = ExpandEnv(Left$(p, InStrRev(p, "\") - 1))
            If Dir(dirPath, vbDirectory) = "" Then
                MsgBox "无法找到路径:" & vbCrLf & g_Items(idx).path, vbCritical, "错误"
                Exit Sub
            End If
        Else
            MsgBox "无法找到路径:" & vbCrLf & g_Items(idx).path, vbCritical, "错误"
            Exit Sub
        End If
    End If

    Shell "explorer.exe """ & dirPath & """", vbNormalFocus
End Sub

' ==================== 按钮:删除选中项 ====================
Private Sub cmdDelSel_Click()
    Dim i As Long, n As Long, idx As Long
    Dim delIdx() As Long
    Dim it As StartupItem
    Dim success As Long, Fail As Long, skipped As Long
    Dim logText As String

    If lvItems.ListItems.count = 0 Then
        MsgBox "当前列表为空。", vbInformation, "提示"
        Exit Sub
    End If
    ReDim delIdx(0 To lvItems.ListItems.count - 1)
    n = 0
    For i = 1 To lvItems.ListItems.count
        If lvItems.ListItems(i).Selected Then
            idx = ItemIndex(lvItems.ListItems(i))
            If idx >= 0 Then
                delIdx(n) = idx
                n = n + 1
            End If
        End If
    Next

    If n = 0 Then
        MsgBox "请先选中至少一行", vbExclamation, "提示"
        Exit Sub
    End If

    If MsgBox("准备删除选中的【" & n & "】条启动项。" & vbCrLf & _
              "启动文件夹里的文件会移入回收站,注册表项直接删除。继续?", _
              vbOKCancel + vbExclamation, "确认删除") <> vbOK Then Exit Sub

    For i = n - 1 To 0 Step -1
        it = g_Items(delIdx(i))
        If it.ReadOnly Then
            skipped = skipped + 1
            logText = logText & "· [只读跳过] " & it.name & "(" & it.Source & ")" & vbCrLf
        ElseIf DeleteOne(it, logText) Then
            success = success + 1
        Else
            Fail = Fail + 1
        End If
    Next

    LoadStartupItems
    MsgBox "删除完成!成功:" & success & " 项,失败:" & Fail & " 项,只读跳过:" & skipped & " 项" & _
           IIf(Len(logText) > 0, vbCrLf & vbCrLf & "明细:" & vbCrLf & logText, ""), _
           vbInformation, "结果"
End Sub

' ==================== 按钮:删除全部 ====================
Private Sub cmdDelAll_Click()
    Dim i As Long, it As StartupItem
    Dim success As Long, Fail As Long, skipped As Long
    Dim logText As String, bakPath As String
    Dim delIdx() As Long, n As Long

    If g_ItemCount = 0 Then
        MsgBox "当前没有启动项可删除!", vbInformation, "提示"
        Exit Sub
    End If

    ' 先自动备份(修复问题 8:删了没法后悔)
    bakPath = App.path & "\startup_backup_" & Format$(Now, "yyyymmdd_hhnnss") & ".json"
    If ExportToFile(bakPath) Then
        If MsgBox("已自动备份到:" & vbCrLf & bakPath & vbCrLf & vbCrLf & _
                  "即将删除全部【" & g_ItemCount & "】条启动项,继续?", _
                  vbOKCancel + vbExclamation, "危险操作") <> vbOK Then Exit Sub
    Else
        If MsgBox("自动备份失败!建议先手动【导出备份】。" & vbCrLf & _
                  "仍要删除全部【" & g_ItemCount & "】条启动项吗?", _
                  vbOKCancel + vbCritical, "备份失败") <> vbOK Then Exit Sub
    End If

    If MsgBox("二次确认!删除后无法撤销(回收站里的文件除外)。", _
              vbOKCancel + vbCritical, "最终确认") <> vbOK Then Exit Sub

    ReDim delIdx(0 To g_ItemCount - 1)
    n = 0
    For i = 0 To g_ItemCount - 1
        If Not g_Items(i).ReadOnly Then
            delIdx(n) = i
            n = n + 1
        Else
            skipped = skipped + 1
        End If
    Next

    For i = n - 1 To 0 Step -1
        it = g_Items(delIdx(i))
        If DeleteOne(it, logText) Then success = success + 1 Else Fail = Fail + 1
    Next

    LoadStartupItems
    MsgBox "删除完成!成功:" & success & " 项,失败:" & Fail & " 项,只读跳过:" & skipped & " 项" & _
           IIf(Len(bakPath) > 0, vbCrLf & "备份文件:" & bakPath, "") & _
           IIf(Len(logText) > 0, vbCrLf & vbCrLf & "明细:" & vbCrLf & logText, ""), _
           vbInformation, "结果"
End Sub

' 删除单条:File→回收站;Registry/RunOnce→清两个键
Private Function DeleteOne(it As StartupItem, ByRef logText As String) As Boolean
    Dim ok As Boolean, fullPath As String

    On Error Resume Next
    Select Case it.ItemType
        Case "File"
            fullPath = it.FileDir & "\" & it.FullFileName
            If Dir(fullPath, vbNormal Or vbHidden Or vbSystem) = "" Then
                ok = True
            Else
                ok = DeleteFileToRecycleBin(fullPath)
            End If
        Case "Registry", "RunOnce"
            RegDeleteValueByName it.hive, it.keyPath, it.name
            RegDeleteValueByName it.hive, DisabledKeyOf(it.keyPath), it.name
            ok = True
        Case Else
            logText = logText & "· [不支持] " & it.name & "(" & it.ItemType & ")" & vbCrLf
            DeleteOne = False
            On Error GoTo 0
            Exit Function
    End Select

    If Err.Number <> 0 Then
        logText = logText & "· [失败] " & it.name & ":" & Err.Description & vbCrLf
        Err.Clear
        ok = False
    ElseIf Not ok Then
        logText = logText & "· [失败] " & it.name & "(无权限或文件被占用)" & vbCrLf
    End If
    On Error GoTo 0
    DeleteOne = ok
End Function

' ==================== 按钮:导出 JSON ====================
Private Sub cmdExport_Click()
    dlg.Filter = "JSON备份文件|*.json|所有文件|*.*"
    dlg.DefaultExt = "json"
    dlg.DialogTitle = "导出启动项备份"
    dlg.FileName = "startup_" & Format$(Now, "yyyymmdd") & ".json"
    dlg.CancelError = True
    On Error GoTo CancelIt
    dlg.ShowSave
    On Error GoTo 0

    If ExportToFile(dlg.FileName) Then
        MsgBox "导出成功!共 " & g_ItemCount & " 项" & vbCrLf & dlg.FileName, vbInformation, "导出完成"
    Else
        MsgBox "导出失败,请检查路径是否可写。", vbCritical, "错误"
    End If
    Exit Sub
CancelIt:
End Sub

Private Function ExportToFile(ByVal filePath As String) As Boolean
    Dim fnum As Integer, i As Long, json As String, it As StartupItem
    On Error GoTo Fail

    json = "{" & vbCrLf
    json = json & "  ""app"": ""启动项管理器""," & vbCrLf
    json = json & "  ""version"": """ & JSON_VERSION & """," & vbCrLf
    json = json & "  ""machine"": """ & JsonEscape(ComputerNameSafe()) & """," & vbCrLf
    json = json & "  ""exported_at"": """ & Format$(Now, "yyyy-mm-dd hh:nn:ss") & """," & vbCrLf
    json = json & "  ""scan_scope"": """ & IIf(g_ScanAllUsers, "ALL", "CU") & """," & vbCrLf
    json = json & "  ""count"": " & CStr(g_ItemCount) & "," & vbCrLf
    json = json & "  ""items"": [" & vbCrLf

    For i = 0 To g_ItemCount - 1
        it = g_Items(i)
        json = json & "    {" & vbCrLf
        json = json & "      ""type"": """ & JsonEscape(it.ItemType) & """," & vbCrLf
        json = json & "      ""name"": """ & JsonEscape(it.name) & """," & vbCrLf
        json = json & "      ""path"": """ & JsonEscape(it.path) & """," & vbCrLf
        json = json & "      ""raw_value"": """ & JsonEscape(it.RawValue) & """," & vbCrLf
        json = json & "      ""full_filename"": """ & JsonEscape(it.FullFileName) & """," & vbCrLf
        json = json & "      ""scope"": """ & JsonEscape(it.scope) & """," & vbCrLf
        json = json & "      ""source"": """ & JsonEscape(it.Source) & """," & vbCrLf
        If it.Enabled Then
            json = json & "      ""enabled"": true" & vbCrLf
        Else
            json = json & "      ""enabled"": false" & vbCrLf
        End If
        json = json & "    }"
        If i < g_ItemCount - 1 Then json = json & ","
        json = json & vbCrLf
    Next

    json = json & "  ]" & vbCrLf
    json = json & "}"

    fnum = FreeFile
    Open filePath For Output As #fnum
    Print #fnum, json
    Close #fnum
    ExportToFile = True
    Exit Function
Fail:
    ExportToFile = False
End Function

' ==================== 按钮:导入 JSON ====================
Private Sub cmdImport_Click()
    Dim fnum As Integer, raw As String
    Dim entries() As StartupItem, cnt As Long, i As Long
    Dim success As Long, skip As Long
    Dim logText As String, curNames As String
    Dim it As StartupItem

    dlg.Filter = "JSON备份文件|*.json|所有文件|*.*"
    dlg.DialogTitle = "导入启动项备份"
    dlg.CancelError = True
    On Error GoTo CancelIt
    dlg.ShowOpen
    On Error GoTo 0

    fnum = FreeFile
    Open dlg.FileName For Input As #fnum
    raw = Input$(LOF(fnum), fnum)
    Close #fnum

    If Len(raw) = 0 Then
        MsgBox "文件内容为空。", vbExclamation
        Exit Sub
    End If

    If Not ParseJsonItems(raw, entries, cnt) Or cnt = 0 Then
        MsgBox "无法解析该备份文件(不是有效的 JSON 备份)。", vbCritical, "导入失败"
        Exit Sub
    End If

    curNames = "|"
    For i = 0 To g_ItemCount - 1
        curNames = curNames & LCase$(g_Items(i).name) & "|"
    Next

    For i = 0 To cnt - 1
        it = entries(i)

        If Len(it.name) = 0 Or Len(it.ItemType) = 0 Then
            skip = skip + 1
            logText = logText & "· [跳过] 第 " & (i + 1) & " 条字段不完整" & vbCrLf
            GoTo NextOne
        End If
        If it.ItemType = "Winlogon" Or it.ItemType = "Task" Then
            skip = skip + 1
            logText = logText & "· [只读跳过] " & it.name & "(" & it.ItemType & ")" & vbCrLf
            GoTo NextOne
        End If

        If InStr(curNames, "|" & LCase$(it.name) & "|") > 0 Then
            If MsgBox("启动项【" & it.name & "】已存在,覆盖吗?", _
                      vbYesNo + vbQuestion, "重名") = vbNo Then
                skip = skip + 1
                GoTo NextOne
            End If
        End If

        If ImportOne(it, logText) Then success = success + 1 Else skip = skip + 1

NextOne:
    Next

    LoadStartupItems
    MsgBox "导入完成!成功:" & success & " 项,跳过/失败:" & skip & " 项" & _
           IIf(Len(logText) > 0, vbCrLf & vbCrLf & "明细:" & vbCrLf & logText, ""), _
           vbInformation, "结果"
    Exit Sub
CancelIt:
End Sub

Private Function ImportOne(it As StartupItem, ByRef logText As String) As Boolean
    Dim hive As Long, destDir As String, destFile As String
    Dim target As String, args As String, srcFile As String
    Dim ext As String

    On Error GoTo Fail

    If UCase$(it.scope) = "LM" Then
        hive = HKEY_LOCAL_MACHINE
        destDir = GetAllUsersStartupFolder()
    Else
        hive = HKEY_CURRENT_USER
        destDir = GetStartupFolder()
    End If

    If it.ItemType = "File" Then
        ' 还原目标程序+参数
        If Len(it.RawValue) > 0 Then
            SplitCommand it.RawValue, target, args
        Else
            target = it.path
            args = ""
        End If
        If Len(target) = 0 Then target = it.path
        If Len(target) = 0 Then GoTo Fail

        ' 保留原始扩展名:.lnk/.url 建快捷方式,其余直接复制文件(修复问题 6)
        ext = GetFileExt(it.FullFileName)
        If Len(ext) = 0 Then ext = "lnk"

        destFile = StripDisabledSuffix(it.FullFileName)
        If Len(destFile) = 0 Then destFile = it.name & "." & ext
        If Not it.Enabled Then destFile = destFile & DISABLED_SUFFIX

        If ext = "lnk" Or ext = "url" Then
            If Not CreateShortcut(destDir & "\" & destFile, target, args) Then GoTo Fail
        Else
            srcFile = ExpandEnv(target)
            If Dir(srcFile, vbNormal Or vbHidden Or vbSystem) <> "" Then
                If Dir(destDir & "\" & destFile, vbNormal Or vbHidden Or vbSystem) <> "" Then
                    Kill destDir & "\" & destFile
                End If
                FileCopy srcFile, destDir & "\" & destFile
            Else
                ' 源文件没了,退化成建一个快捷方式,至少保住条目
                If Not CreateShortcut(destDir & "\" & it.name & ".lnk", target, args) Then GoTo Fail
            End If
        End If

        If Err.Number <> 0 Then GoTo Fail
        ImportOne = True
        Exit Function
    End If

    ' Registry / RunOnce
    Dim baseKey As String, disKey As String
    If it.ItemType = "RunOnce" Then
        baseKey = RUN_ONCE_KEY
        disKey = RUN_ONCE_DISABLED_KEY
    Else
        baseKey = RUN_KEY
        disKey = RUN_DISABLED_KEY
    End If

    Dim rawv As String
    rawv = it.RawValue
    If Len(rawv) = 0 Then rawv = BuildCommand(it.path, "")

    If it.Enabled Then
        If Not RegWriteValue(hive, baseKey, it.name, rawv) Then
            logText = logText & "· [失败] " & it.name & ":写入注册表失败(HKLM 需管理员)" & vbCrLf
            ImportOne = False
            Exit Function
        End If
        RegDeleteValueByName hive, disKey, it.name
    Else
        If Not RegWriteValue(hive, disKey, it.name, rawv) Then
            logText = logText & "· [失败] " & it.name & ":写入注册表失败(HKLM 需管理员)" & vbCrLf
            ImportOne = False
            Exit Function
        End If
        RegDeleteValueByName hive, baseKey, it.name
    End If

    ImportOne = True
    Exit Function
Fail:
    logText = logText & "· [失败] " & it.name & ":" & Err.Description & vbCrLf
    ImportOne = False
End Function

' ==========================================================================
' 可选增强:深度扫描开关
' 在窗体上放一个 CheckBox,Name = chkAllUsers,Caption = "深度扫描(HKLM / 所有用户 / 计划任务)"
' 不放控件也不会报错,只是这个功能用不了(可在 modStartup 里直接把 g_ScanAllUsers 设为 True)
' ==========================================================================
Private Sub chkAllUsers_Click()
    g_ScanAllUsers = (chkAllUsers.value = vbChecked)
    If g_ScanAllUsers And Not IsAdmin() Then
        MsgBox "已开启深度扫描,但当前不是管理员:" & vbCrLf & _
               "HKLM 与【所有用户】启动文件夹中的项目只能查看,删除/禁用会失败。" & vbCrLf & _
               "如需修改,请以管理员身份运行本程序。", vbExclamation, "权限提示"
    End If
    LoadStartupItems
End Sub

快启动.rar

88.96 KB, 下载次数: 17, 下载积分: 吾爱币 -1 CB

完整源程序

thghx 发表于 2026-10-18 12:20
你说的问题我也需要,只不过两台电脑隔得很近,用程序控制还不如走过去快
 楼主| hellovirus 发表于 2026-10-18 12:25
本帖最后由 hellovirus 于 2026-10-18 12:40 编辑
thghx 发表于 2026-10-18 12:20
你说的问题我也需要,只不过两台电脑隔得很近,用程序控制还不如走过去快
还要包饺子:
在使用VB6写程序的时候,发现有时候要问AI,因为大部分AI不接受VB6的源程序格式,需要把所有程序文件打包到一个文件,再上传,就用python写了一个小程序,
能打工程文件用到的文件打包成一个txt文件,这就是 下面的程序。
ScreenShot_2026-10-18_123852_860.png
功能很简单,把VBP工程文件拖过去就行 .
程序如下:
[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
VBP / VBG 工程文件合并工具
------------------------------------------------------------------
功能:
  1. 拖入或浏览打开 .vbp(VB6 工程)或 .vbg(VB6 工程组)文件;
  2. 从上到下扫描其中的 Form= / Module= / Class= / UserControl= /
     UserDocument= / PropertyPage= / Designer= 等行,解析出真实文件名;
       Form=Form1.frm                    -> Form1.frm
       Module=Module1; Module1.bas       -> Module1.bas
       Class=clsDB; clsDB.cls            -> clsDB.cls
  3. 按 vbp 中出现的顺序依次打开这些文件,合并写入一个 TXT:
       文件路径 -> 文件内容 -> 该文件行数
  4. 末尾写:总文件数、总行数;另附二进制附属文件清单(仅登记路径)。

用法:
  图形界面: python vbp_merger.py
  命令行  : python vbp_merger.py "D:\Proj\工程1.vbp"
            python vbp_merger.py "D:\Proj\工程1.vbp" -o "D:\out.txt"
            python vbp_merger.py "D:\Proj\工程1.vbp" -t form module class
"""

import os
import sys
from collections import namedtuple
from datetime import datetime

# ---------------------------------------------------------------- 配置

# 读取源文件时的编码尝试顺序(VB6 源文件多为 GBK/GB18030)
ENCODINGS = ("utf-8-sig", "gb18030", "utf-8", "big5", "cp1252", "latin-1")

SEP_LONG = "=" * 74
SEP_MID = "-" * 74

FType = namedtuple("FType", "key label short ext is_text")

# vbp 中的键 -> 文件类型。is_text=False 表示二进制,只登记路径不输出内容
FILE_TYPES = (
    FType("form",          "窗体 Form",                 "窗体 Form",           ".frm", True),
    FType("module",        "标准模块 Module",           "标准模块 Module",     ".bas", True),
    FType("class",         "类模块 Class",              "类模块 Class",        ".cls", True),
    FType("usercontrol",   "用户控件 UserControl",      "用户控件 UserCtrl",   ".ctl", True),
    FType("userdocument",  "ActiveX 文档 UserDocument", "ActiveX 文档",        ".dob", True),
    FType("propertypage",  "属性页 PropertyPage",       "属性页 PropPage",     ".pag", True),
    FType("designer",      "设计器 Designer",           "设计器 Designer",     ".dsr", True),
    FType("resfile32",     "资源文件 ResFile32",        "资源文件 Resource",   ".res", False),
)

KEY2TYPE = {t.key: t for t in FILE_TYPES}

# 扩展名 -> 归属类型(用于识别 vbp 中没登记过的键,以及 .doc 这种别名)
EXT_OWNER = {t.ext: t for t in FILE_TYPES}
EXT_OWNER[".doc"] = KEY2TYPE["userdocument"]      # 个别工程把 ActiveX 文档写成 .doc
EXT_OWNER[".res"] = KEY2TYPE["resfile32"]

# 源码文件的二进制"影子文件"(含窗体上的图片、控件属性等,不可当文本读)
BINARY_COMPANIONS = {
    ".frm": ".frx",
    ".ctl": ".ctx",
    ".dob": ".dox",
    ".doc": ".dox",
    ".pag": ".pgx",
    ".dsr": ".dsx",
}

# 找不到时的替补扩展名
EXT_ALIAS = {".dob": ".doc", ".doc": ".dob"}

DEFAULT_TEXT_KEYS = tuple(t.key for t in FILE_TYPES if t.is_text)


# ---------------------------------------------------------------- 核心逻辑

def read_text(path):
    """读取文本文件,自动尝试多种编码(VB6 文件多为 GBK,不会乱码)。"""
    with open(path, "rb") as f:
        data = f.read()
    for enc in ENCODINGS:
        try:
            return data.decode(enc)
        except (UnicodeDecodeError, LookupError):
            continue
    return data.decode("latin-1", errors="replace")


def extract_filename(value, default_ext=""):
    """
    从 vbp 一行等号右侧的值里解析真实文件名。
      'Module1; Module1.bas' -> Module1.bas
      'Form1.frm'            -> Form1.frm
      'modUtil'              -> modUtil + default_ext
      '"App.res"'            -> App.res
    """
    value = value.strip().strip('"')
    if ";" in value:                                   # "对象名; 文件名" 取后半
        value = value.split(";")[-1].strip()
    value = value.replace("/", os.sep).replace("\\", os.sep).strip()
    if default_ext and not os.path.splitext(value)[1]:
        value += default_ext
    return value


def resolve_file(base_dir, rel):
    """在 vbp 所在目录下定位文件;支持子目录写法与扩展名别名。"""
    stem, ext = os.path.splitext(os.path.basename(rel))
    candidates = [
        os.path.join(base_dir, rel),
        os.path.join(base_dir, os.path.basename(rel)),
    ]
    alt = EXT_ALIAS.get(ext.lower())
    if alt:
        candidates.append(os.path.join(base_dir, stem + alt))
    for c in candidates:
        if os.path.isfile(c):
            return os.path.normpath(os.path.abspath(c))
    return None


def parse_vbp(vbp_path, wanted=DEFAULT_TEXT_KEYS, log=None):
    """
    解析单个 vbp,返回 ([(绝对路径, FType)], 缺失文件名列表)。
    严格保持 vbp 中出现的先后顺序;同一文件只收一次。
    """
    base_dir = os.path.dirname(os.path.abspath(vbp_path))
    items, missing, seen = [], [], set()
    wanted = set(wanted)

    for raw in read_text(vbp_path).splitlines():
        line = raw.strip()
        if not line or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key, value = key.strip().lower(), value.strip().strip('"')
        if not value:
            continue

        ftype = KEY2TYPE.get(key)
        if ftype is None:
            # 未登记的键:只要值里带源码扩展名,也照样收进来(兼容自定义/少见类型)
            tail = value.split(";")[-1].strip()
            ftype = EXT_OWNER.get(os.path.splitext(tail)[1].lower())
            if ftype is not None and log is not None:
                log("识别到未登记类型: %s=%s -> %s" % (key, value, ftype.label))
        if ftype is None or ftype.key not in wanted:
            continue

        rel = extract_filename(value, ftype.ext)
        real = resolve_file(base_dir, rel)
        if real is None:
            missing.append(rel)
            continue
        if real not in seen:
            seen.add(real)
            items.append((real, ftype))

    return items, missing


def parse_projects(path):
    """
    输入 .vbp 返回 [vbp];输入 .vbg(工程组)返回其中所有 vbp 的绝对路径。
    """
    path = os.path.abspath(path)
    ext = os.path.splitext(path)[1].lower()
    if ext != ".vbg":
        return [path]

    base_dir = os.path.dirname(path)
    projects, seen = [], set()
    for line in read_text(path).splitlines():
        line = line.strip()
        if "=" in line:
            value = line.partition("=")[2].strip().strip('"')
        else:
            value = line
        if not value.lower().endswith(".vbp"):
            continue
        real = resolve_file(base_dir, value)
        if real and real not in seen:
            seen.add(real)
            projects.append(real)
    return projects or [path]


def merge(path, out_path=None, wanted=DEFAULT_TEXT_KEYS,
          include_binary=True, log=print):
    """
    把工程(.vbp 或 .vbg)中列出的文件按顺序合并写入 out_path。
    返回 (输出文件路径, 总文件数, 总行数)。
    """
    path = os.path.abspath(path)
    if out_path is None:
        out_path = os.path.splitext(path)[0] + "_合并.txt"

    projects = parse_projects(path)
    if len(projects) > 1:
        log("工程组(%s) 共含 %d 个子工程,将依次合并。" % (os.path.basename(path), len(projects)))

    all_missing, all_binary, total_lines, total_files = [], [], 0, 0

    with open(out_path, "w", encoding="utf-8-sig", newline="\r\n") as out:
        def w(text=""):
            out.write(text + "\n")

        w(SEP_LONG)
        w("VB6 工程文件合并结果")
        w("工程文件: %s" % path)
        w("生成时间: %s" % datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
        w(SEP_LONG)
        w()

        for pidx, vbp in enumerate(projects, 1):
            items, missing = parse_vbp(vbp, wanted, log=log)
            all_missing.extend("%s (%s)" % (m, os.path.basename(vbp)) for m in missing)

            if len(projects) > 1:
                w("#" * 74)
                w("# 工程 %d/%d : %s" % (pidx, len(projects), vbp))
                w("#" * 74)
                w()

            text_items = [(p, t) for p, t in items if t.is_text]
            binaries = [p for p, t in items if not t.is_text]
            if include_binary:
                for p, t in text_items:
                    comp = BINARY_COMPANIONS.get(os.path.splitext(p)[1].lower())
                    if comp:
                        sib = os.path.splitext(p)[0] + comp
                        if os.path.isfile(sib):
                            binaries.append(sib)
            all_binary.extend(binaries)

            if not text_items:
                log("工程 %s 中没有解析到可合并的文本文件。" % os.path.basename(vbp))

            for path_i, ftype in text_items:
                content = read_text(path_i).replace("\r\n", "\n").replace("\r", "\n")
                line_count = len(content.splitlines())

                w(SEP_LONG)
                w("【文件 %d】%s | 路径: %s" % (total_files + 1, ftype.label, path_i))
                w(SEP_MID)
                out.write(content)
                if content and not content.endswith("\n"):
                    w()
                w(SEP_MID)
                w("本文件行数: %d" % line_count)
                w(SEP_LONG)
                w()

                total_files += 1
                total_lines += line_count
                log("已写入 [ %d ] %s  %s(%d 行)" % (total_files, ftype.label, path_i, line_count))

        if include_binary and all_binary:
            w(SEP_LONG)
            w("附属二进制文件清单(不可作文本读取,仅登记路径)")
            w(SEP_MID)
            for i, b in enumerate(all_binary, 1):
                try:
                    size = os.path.getsize(b)
                except OSError:
                    size = -1
                w("  %2d. %s   (%s 字节)" % (i, b, format(size, ",")))
            w(SEP_LONG)
            w()

        if all_missing:
            w(SEP_LONG)
            w("未找到的文件(已跳过)")
            w(SEP_MID)
            for i, m in enumerate(all_missing, 1):
                w("  %2d. %s" % (i, m))
            w(SEP_LONG)
            w()

        w(SEP_LONG)
        w("汇总: 总文件数 = %d, 总行数 = %d" % (total_files, total_lines))
        if include_binary and all_binary:
            w("      另有二进制附属文件 %d 个,未计入文件数与行数。" % len(all_binary))
        if all_missing:
            w("      另有 %d 个文件未找到,已跳过。" % len(all_missing))
        w(SEP_LONG)

    if not total_files:
        log("警告:没有任何文本文件被写入,请检查所选文件类型或工程内容。")
    log("完成!输出文件: %s" % out_path)
    log("总文件数 = %d, 总行数 = %d" % (total_files, total_lines))
    return out_path, total_files, total_lines


# ---------------------------------------------------------------- 图形界面

def _import_tk():
    """延迟导入 tkinter / tkinterdnd2,失败返回 (None, None, None)。"""
    try:
        import tkinter
        from tkinter import filedialog, messagebox, scrolledtext, ttk
    except Exception:
        return None, None, None
    mods = (filedialog, messagebox, scrolledtext, ttk)
    try:
        from tkinterdnd2 import DND_FILES, TkinterDnD
        return tkinter, mods, (DND_FILES, TkinterDnD)
    except Exception:
        return tkinter, mods, None


class App(object):
    def __init__(self, root, mods, dnd):
        filedialog, messagebox, scrolledtext, ttk = mods
        self.filedialog, self.messagebox, self.tk = filedialog, messagebox, mods[0].__class__.__module__
        self.root = root
        root.title("VB6 工程文件合并工具 (vbp / vbg)- AYDQYY - hellovirus -52pojie")
        root.geometry("560x480")
        root.minsize(380, 320)

        pad = {"padx": 8, "pady": 5}

        # ---- 工程文件
        top = ttk.Frame(root)
        top.pack(fill="x", **pad)
        ttk.Label(top, text="工程文件:").pack(side="left")
        self.var_vbp = self._var("str")
        e = ttk.Entry(top, textvariable=self.var_vbp)
        e.pack(side="left", fill="x", expand=True, padx=6)
        ttk.Button(top, text="浏览...", command=self.browse_vbp).pack(side="left")

        # ---- 输出文件
        mid = ttk.Frame(root)
        mid.pack(fill="x", **pad)
        ttk.Label(mid, text="输出 TXT:").pack(side="left")
        self.var_out = self._var("str")
        ttk.Entry(mid, textvariable=self.var_out).pack(side="left", fill="x", expand=True, padx=6)
        ttk.Button(mid, text="浏览...", command=self.browse_out).pack(side="left")

        # ---- 文件类型勾选:每行 PER_ROW 个,共 3 行
        box = ttk.LabelFrame(root, text="要合并的文件类型(按 vbp 中出现的顺序)")
        box.pack(fill="x", **pad)

        PER_ROW = 3
        self.vars = {}
        for i, t in enumerate(FILE_TYPES):
            v = self._var("bool", t.is_text)
            self.vars[t.key] = v
            cb = ttk.Checkbutton(box, text="%s (%s)" % (t.short, t.ext), variable=v)
            cb.grid(row=i // PER_ROW, column=i % PER_ROW, sticky="w", padx=10, pady=4)
        for c in range(PER_ROW):
            box.grid_columnconfigure(c, weight=1, uniform="ftype")

        # 全选 / 清空 放在最后一行的剩余空位;若最后一行已满则另起一行
        btn_row, btn_col = divmod(len(FILE_TYPES), PER_ROW)
        btns = ttk.Frame(box)
        btns.grid(row=btn_row, column=btn_col, sticky="w", padx=10, pady=4)
        ttk.Button(btns, text="全选", width=6, command=lambda: self._set_all(True)).pack(side="left")
        ttk.Button(btns, text="清空", width=6, command=lambda: self._set_all(False)).pack(side="left", padx=6)

        self.var_bin = self._var("bool", True)
        ttk.Checkbutton(root, text="在结果中登记二进制附属文件(.frx/.ctx/.dox/.pgx/.dsx/.res,仅列路径不输出内容)",
                        variable=self.var_bin).pack(anchor="w", padx=12)

        # ---- 操作按钮
        bar = ttk.Frame(root)
        bar.pack(fill="x", **pad)
        self.btn_run = ttk.Button(bar, text="开始合并", command=self.run)
        self.btn_run.pack(side="left")
        ttk.Button(bar, text="打开输出文件", command=self.open_out).pack(side="left", padx=6)
        ttk.Button(bar, text="清空日志", command=lambda: self.txt.delete("1.0", "end")).pack(side="left")

        # ---- 日志
        self.txt = scrolledtext.ScrolledText(root, wrap="word", font=("Consolas", 10))
        self.txt.pack(fill="both", expand=True, padx=8, pady=(0, 8))

        self._say("把 .vbp / .vbg 文件拖进上面的输入框(或点“浏览...”),勾选类型后点“开始合并”。")
        if dnd is None:
            self._say("提示:未安装 tkinterdnd2,拖放暂不可用(只能用浏览按钮)。")
            self._say("      启用拖放请执行:  pip install tkinterdnd2")
        else:
            DND_FILES, TkinterDnD = dnd
            for widget in (e, root):
                try:
                    widget.drop_target_register(DND_FILES)
                    widget.dnd_bind("<<Drop>>", self.on_drop)
                except Exception:
                    pass
            self._say("拖放已启用:可直接把工程文件拖到输入框或窗口任意位置。")

    # ---------- 小工具
    def _var(self, kind, value=""):
        import tkinter
        return tkinter.BooleanVar(master=self.root, value=value) if kind == "bool" \
            else tkinter.StringVar(master=self.root, value=value)

    def _set_all(self, flag):
        for v in self.vars.values():
            v.set(flag)

    # ---------- 事件
    def on_drop(self, event):
        data = (event.data or "").strip()
        if data.startswith("{") and data.endswith("}"):
            data = data[1:-1]
        parts = data.split()
        path = (parts[0] if parts else "").strip("{}").strip('"')
        if not path:
            return
        if os.path.splitext(path)[1].lower() not in (".vbp", ".vbg"):
            self._say("拖入的不是 .vbp / .vbg 文件:%s" % path)
            return
        self.set_vbp(path)

    def set_vbp(self, path):
        self.var_vbp.set(path)
        self.var_out.set(os.path.splitext(path)[0] + "_合并.txt")
        self._say("已选择工程文件: %s" % path)

    def browse_vbp(self):
        p = self.filedialog.askopenfilename(
            title="选择 VB6 工程文件",
            filetypes=[("VB6 工程/工程组", "*.vbp *.vbg"), ("VB6 工程", "*.vbp"),
                       ("VB6 工程组", "*.vbg"), ("所有文件", "*.*")])
        if p:
            self.set_vbp(p)

    def browse_out(self):
        p = self.filedialog.asksaveasfilename(
            title="保存合并结果为", defaultextension=".txt",
            filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")])
        if p:
            self.var_out.set(p)

    def run(self):
        vbp = self.var_vbp.get().strip().strip('"')
        out = self.var_out.get().strip().strip('"')
        if not vbp or not os.path.isfile(vbp):
            self.messagebox.showwarning("提示", "请先选择有效的 .vbp / .vbg 文件。")
            return
        wanted = [k for k, v in self.vars.items() if v.get()]
        if not wanted:
            self.messagebox.showwarning("提示", "请至少勾选一种文件类型。")
            return
        if not out:
            out = os.path.splitext(vbp)[0] + "_合并.txt"
            self.var_out.set(out)

        self.btn_run.config(state="disabled")
        try:
            merge(vbp, out, wanted=wanted, include_binary=self.var_bin.get(), log=self._say)
            self.messagebox.showinfo("完成", "合并完成!\n%s" % out)
        except Exception as exc:
            self.messagebox.showerror("出错", str(exc))
            self._say("错误: %s" % exc)
        finally:
            self.btn_run.config(state="normal")

    def open_out(self):
        p = self.var_out.get().strip()
        if p and os.path.isfile(p):
            try:
                os.startfile(p)                      # Windows
            except Exception:
                import webbrowser
                webbrowser.open("file:///" + os.path.abspath(p))
        else:
            self.messagebox.showwarning("提示", "输出文件还不存在。")

    def _say(self, msg):
        self.txt.insert("end", msg + "\n")
        self.txt.see("end")
        self.root.update_idletasks()


def main_gui():
    tkinter, mods, dnd = _import_tk()
    if tkinter is None:
        print("当前 Python 没有 tkinter,无法启动图形界面。")
        print("Windows 版 Python 默认自带;如缺失请重装并勾选 'tcl/tk and IDLE'。")
        return 1
    root = dnd[1].Tk() if dnd else tkinter.Tk()
    App(root, mods, dnd)
    root.mainloop()
    return 0


# ---------------------------------------------------------------- 入口

def main():
    args = sys.argv[1:]
    if args and not args[0].startswith("-"):
        target = args[0]
        out, wanted = None, DEFAULT_TEXT_KEYS
        i = 1
        while i < len(args):
            if args[i] in ("-o", "--out") and i + 1 < len(args):
                out, i = args[i + 1], i + 2
            elif args[i] in ("-t", "--type") and i + 1 < len(args):
                wanted, i = args[i + 1].replace(",", " ").split(), i + 2
            elif args[i] == "--all":
                wanted, i = tuple(t.key for t in FILE_TYPES), i + 1
            else:
                i += 1
        merge(target, out, wanted=wanted)
        return 0
    return main_gui()


if __name__ == "__main__":
    sys.exit(main())

Cstyle 发表于 2026-10-18 12:26
还是很实用的,有些单位电脑不连外网
 楼主| hellovirus 发表于 2026-10-18 12:46
还是上面相同的问题,python编译后的程序太大,有20多M,又想这么简单的程序,还是用VB6实现更方便一些。
ScreenShot_2026-10-18_124149_397.png
功能相同,程序如下:
[Visual Basic] 纯文本查看 复制代码
Option Explicit
Private Declare Function ShellExecute Lib "shell32.dll" Alias "ShellExecuteA" _
    (ByVal hwnd As Long, ByVal lpOperation As String, ByVal lpFile As String, _
     ByVal lpParameters As String, ByVal lpDirectory As String, ByVal nShowCmd As Long) As Long
Private Const OFN_EXPLORER       As Long = &H80000
Private Const OFN_FILEMUSTEXIST  As Long = &H1000
Private Const OFN_HIDEREADONLY   As Long = &H4
Private Const OFN_OVERWRITEPROMPT As Long = &H2

Private Sub Form_Load()
    Dim i As Long
    Dim dbgWanted As String
    If gTypeCount = 0 Then InitTypes
    Set gLogBox = txtLog
    For i = 0 To gTypeCount - 1
        If i > 0 Then Load chkType(i)
        chkType(i).Caption = gTypes(i).Abbr & " (" & gTypes(i).ext & ")"
        chkType(i).value = IIf(gTypes(i).IsText, vbChecked, vbUnchecked)
        chkType(i).Left = 150 + (i Mod 3) * 2600
        chkType(i).Top = 300 + (i \ 3) * 400
        chkType(i).Width = 2500
        chkType(i).Visible = True
    Next i
    chkBinary.value = vbChecked
    
    dbgWanted = ""
    For i = 0 To gTypeCount - 1
        If chkType(i).value = vbChecked Then dbgWanted = dbgWanted & gTypes(i).key & " "
    Next i
    LogMsg "【调试】当前勾选类型key列表:" & Trim$(dbgWanted)
    LogMsg "把 .vbp / .vbg 文件拖放到本窗口(或点【浏览...】),勾选类型后点【开始合并】。"
End Sub

Private Sub Form_Resize()
    On Error Resume Next
    If Me.WindowState = vbMinimized Then Exit Sub
    If Me.ScaleHeight < 3000 Then Exit Sub
    If Me.ScaleWidth < 4000 Then Exit Sub
    txtVbp.Width = Me.ScaleWidth - 2500
    txtOut.Width = txtVbp.Width
    cmdBrowseVbp.Left = Me.ScaleWidth - 1620
    cmdBrowseOut.Left = cmdBrowseVbp.Left
    fraTypes.Width = Me.ScaleWidth - 270
    chkBinary.Width = Me.ScaleWidth - 420
    txtLog.Width = Me.ScaleWidth - 270
    txtLog.Height = Me.ScaleHeight - txtLog.Top - 120
    On Error GoTo 0
End Sub

Private Sub Form_OLEDragOver(Data As DataObject, Effect As Long, Button As Integer, _
                             Shift As Integer, X As Single, Y As Single, State As Integer)
    If Data.GetFormat(vbCFFiles) Then Effect = vbDropEffectCopy
End Sub
Private Sub Form_OLEDragDrop(Data As DataObject, Effect As Long, Button As Integer, _
                             Shift As Integer, X As Single, Y As Single)
    HandleDrop Data
End Sub
Private Sub txtVbp_OLEDragDrop(Data As DataObject, Effect As Long, Button As Integer, _
                               Shift As Integer, X As Single, Y As Single)
    HandleDrop Data
End Sub
Private Sub txtOut_OLEDragDrop(Data As DataObject, Effect As Long, Button As Integer, _
                               Shift As Integer, X As Single, Y As Single)
    HandleDrop Data
End Sub
Private Sub txtLog_OLEDragDrop(Data As DataObject, Effect As Long, Button As Integer, _
                               Shift As Integer, X As Single, Y As Single)
    HandleDrop Data
End Sub

Private Sub HandleDrop(ByRef Data As DataObject)
    Dim f As Variant
    On Error Resume Next
    If Data.GetFormat(vbCFFiles) Then
        For Each f In Data.Files
            SetProjectFile CStr(f)
            Exit For
        Next f
    End If
    On Error GoTo 0
End Sub

Public Sub SetProjectFile(ByVal p As String)
    Dim e As String
    e = LCase$(GetExt(p))
    If e <> ".vbp" And e <> ".vbg" Then
        LogMsg "拖入的不是 .vbp / .vbg 文件:" & p
        Exit Sub
    End If
    txtVbp.Text = p
    txtOut.Text = StripExt(p) & "_合并.txt"
    LogMsg "已选择工程文件: " & p
End Sub

Private Sub cmdBrowseVbp_Click()
    On Error Resume Next
    dlg.CancelError = True
    dlg.DialogTitle = "选择 VB6 工程文件"
    dlg.Filter = "VB6 工程/工程组 (*.vbp;*.vbg)|*.vbp;*.vbg|VB6 工程 (*.vbp)|*.vbp|VB6 工程组 (*.vbg)|*.vbg|所有文件 (*.*)|*.*"
    dlg.FilterIndex = 1
    dlg.DefaultExt = ""
    dlg.FileName = ""
    dlg.Flags = OFN_EXPLORER Or OFN_FILEMUSTEXIST Or OFN_HIDEREADONLY
    dlg.ShowOpen
    If Err.Number <> 0 Then
        Err.Clear
        On Error GoTo 0
        Exit Sub
    End If
    On Error GoTo 0
    If Len(dlg.FileName) > 0 Then SetProjectFile dlg.FileName
End Sub

Private Sub cmdBrowseOut_Click()
    Dim s As String
    On Error Resume Next
    dlg.CancelError = True
    dlg.DialogTitle = "保存合并结果为"
    dlg.Filter = "文本文件 (*.txt)|*.txt|所有文件 (*.*)|*.*"
    dlg.FilterIndex = 1
    dlg.DefaultExt = "txt"
    dlg.FileName = txtOut.Text
    dlg.Flags = OFN_EXPLORER Or OFN_HIDEREADONLY Or OFN_OVERWRITEPROMPT
    dlg.ShowSave
    If Err.Number <> 0 Then
        Err.Clear
        On Error GoTo 0
        Exit Sub
    End If
    On Error GoTo 0
    s = dlg.FileName
    If Len(s) = 0 Then Exit Sub
    If LCase$(GetExt(s)) <> ".txt" Then s = s & ".txt"
    txtOut.Text = s
End Sub

Private Sub cmdAll_Click()
    Dim i As Long
    For i = 0 To gTypeCount - 1
        chkType(i).value = vbChecked
    Next i
End Sub
Private Sub cmdNone_Click()
    Dim i As Long
    For i = 0 To gTypeCount - 1
        chkType(i).value = vbUnchecked
    Next i
End Sub

Public Sub DoMerge()
    Dim vbp As String
    Dim out As String
    Dim wanted As String
    Dim i As Long
    Dim nFiles As Long
    Dim nLines As Long
    vbp = Trim$(Replace$(txtVbp.Text, """", ""))
    out = Trim$(Replace$(txtOut.Text, """", ""))
    If Len(vbp) = 0 Or Not FileExists(vbp) Then
        MsgBox "请先选择有效的 .vbp / .vbg 文件。", vbExclamation, "提示"
        Exit Sub
    End If
    wanted = ""
    For i = 0 To gTypeCount - 1
        If chkType(i).value = vbChecked Then wanted = wanted & gTypes(i).key & " "
    Next i
    wanted = Trim$(wanted)
    If Len(wanted) = 0 Then
        MsgBox "请至少勾选一种文件类型。", vbExclamation, "提示"
        Exit Sub
    End If
    If Len(out) = 0 Then
        out = StripExt(vbp) & "_合并.txt"
        txtOut.Text = out
    End If
    cmdRun.Enabled = False
    On Error GoTo EH
    nFiles = MergeProject(vbp, out, wanted, (chkBinary.value = vbChecked), nLines)
    cmdRun.Enabled = True
    MsgBox "合并完成!" & vbCrLf & out & vbCrLf & _
           "总文件数 = " & nFiles & ", 总行数 = " & nLines, vbInformation, "完成"
    Exit Sub
EH:
    cmdRun.Enabled = True
    MsgBox "出错: " & Err.Description, vbCritical, "出错"
    LogMsg "错误: " & Err.Description
End Sub

Private Sub cmdRun_Click()
    DoMerge
End Sub

Private Sub cmdOpenOut_Click()
    Dim p As String
    p = Trim$(txtOut.Text)
    If Len(p) = 0 Then
        MsgBox "输出文件还不存在。", vbExclamation, "提示"
        Exit Sub
    End If
    If Not FileExists(p) Then
        MsgBox "输出文件还不存在。", vbExclamation, "提示"
        Exit Sub
    End If
    ShellExecute Me.hwnd, "open", p, vbNullString, vbNullString, 1
End Sub

Private Sub cmdClearLog_Click()
    txtLog.Text = ""
End Sub

Public Sub RunCommandLine(ByVal raw As String)
    Dim args() As String
    Dim n As Long
    Dim i As Long
    Dim lb As Long
    Dim ub As Long
    Dim target As String
    Dim outPath As String
    Dim wanted As String
    args = SplitArgs(raw)
    On Error Resume Next
    n = UBound(args) - LBound(args) + 1
    If Err.Number <> 0 Then n = 0
    On Error GoTo 0
    If n <= 0 Then Exit Sub
    lb = LBound(args)
    ub = UBound(args)
    If Left$(args(lb), 1) = "-" Then Exit Sub
    target = args(lb)
    wanted = ""
    i = lb + 1
    Do While i <= ub
        Select Case LCase$(args(i))
            Case "-o", "--out"
                If i + 1 <= ub Then
                    outPath = args(i + 1)
                    i = i + 2
                Else
                    i = i + 1
                End If
            Case "-t", "--type"
                If i + 1 <= ub Then
                    wanted = Replace$(args(i + 1), ",", " ")
                    i = i + 2
                Else
                    i = i + 1
                End If
            Case "--all"
                wanted = AllKeys()
                i = i + 1
            Case Else
                i = i + 1
        End Select
    Loop
    If Len(Trim$(wanted)) = 0 Then wanted = DefaultTextKeys()
    txtVbp.Text = target
    If Len(outPath) > 0 Then
        txtOut.Text = outPath
    Else
        txtOut.Text = StripExt(target) & "_合并.txt"
    End If
    LogMsg "命令行模式: " & raw
    DoMerge
End Sub

Private Function SplitArgs(ByVal s As String) As String()
    Dim out() As String
    Dim none() As String
    Dim n As Long
    Dim i As Long
    Dim c As String
    Dim cur As String
    Dim inQ As Boolean
    ReDim out(0 To 7)
    n = 0
    cur = ""
    inQ = False
    For i = 1 To Len(s)
        c = Mid$(s, i, 1)
        If c = """" Then
            inQ = Not inQ
        ElseIf c = " " Or c = vbTab Then
            If inQ Then
                cur = cur & c
            Else
                If Len(cur) > 0 Then
                    If n > UBound(out) Then ReDim Preserve out(0 To n * 2)
                    out(n) = cur
                    n = n + 1
                    cur = ""
                End If
            End If
        Else
            cur = cur & c
        End If
    Next i
    If Len(cur) > 0 Then
        If n > UBound(out) Then ReDim Preserve out(0 To n)
        out(n) = cur
        n = n + 1
    End If
    If n = 0 Then
        SplitArgs = none
    Else
        ReDim Preserve out(0 To n - 1)
        SplitArgs = out
    End If
End Function

VB 程序文件汇兑.rar

16.86 KB, 下载次数: 25, 下载积分: 吾爱币 -1 CB

VB6完整源程序

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
qjlfl + 1 + 1 我很赞同!

查看全部评分

于生 发表于 2026-10-18 13:12
这个太实用了,收下了,感谢楼主设计出这么好用的软件
zaijuwuaibujie 发表于 2026-10-18 13:14
不应该是为了顿饺子所以准备了一瓶醋??
策士 发表于 2026-10-18 13:14
这飞鸽得有二十年了
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-20 08:35

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表