Files
deb_viewer/deb_viewer.py
T
2026-06-29 08:43:51 +08:00

789 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
deb查看器 - Windows 下的 Debian 安装包查看/解压工具
功能:
- 打开或拖放 .deb 文件
- 显示包元信息(包名、版本、架构、依赖等)
- 预览 DEBIAN 目录中的脚本和文件
- 解压到指定目录,软链接转为文本文件
"""
import os
import sys
import struct
import stat
import json
import locale
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading
import tempfile
import shutil
# ─── 可选依赖 ─────────────────────────────────────────────
try:
import windnd
HAS_WINDND = True
except ImportError:
HAS_WINDND = False
try:
import zstandard as zstd
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
import gzip
import lzma
import bz2
import tarfile
# ═══════════════════════════════════════════════════════════
# ar 格式解析
# ═══════════════════════════════════════════════════════════
def parse_ar(data: bytes) -> dict[str, bytes]:
"""解析 ar 归档,返回 {文件名: 内容} 字典"""
result = {}
if data[:8] != b'!<arch>\n':
raise ValueError("不是有效的 ar 归档格式")
pos = 8
while pos < len(data):
if pos + 60 > len(data):
break
header = data[pos:pos + 60]
if header[58:60] != b'`\n':
break
name = header[:16].decode('ascii').strip().rstrip('/')
size = int(header[48:58].decode('ascii').strip())
pos += 60
result[name] = data[pos:pos + size]
pos += size
if pos % 2 == 1:
pos += 1
return result
# ═══════════════════════════════════════════════════════════
# 解压缩
# ═══════════════════════════════════════════════════════════
def decompress(data: bytes) -> bytes:
"""自动检测并解压缩 tar 数据"""
if data[:2] == b'\x1f\x8b':
return gzip.decompress(data)
if data[:6] == b'\xfd7zXZ\x00':
return lzma.decompress(data)
if data[:3] == b'BZh':
return bz2.decompress(data)
if data[:4] == b'\x28\xb5\x2f\xfd':
if HAS_ZSTD:
return zstd.ZstdDecompressor().decompress(data)
raise ImportError("检测到 zstd 压缩,需要安装 zstandardpip install zstandard")
return data # 未压缩
# ═══════════════════════════════════════════════════════════
# DEBIAN 文件信息结构
# ═══════════════════════════════════════════════════════════
class DebFileInfo:
"""DEBIAN 目录中的单个文件信息"""
def __init__(self, name: str, size: int, mtime: float, mode: int):
self.name = name
self.size = size
self.mtime = mtime
self.mode = mode
def is_text_content(data: bytes) -> bool:
"""判断内容是否为文本"""
if b'\x00' in data[:8192]:
return False
try:
data[:8192].decode('utf-8')
return True
except UnicodeDecodeError:
try:
data[:8192].decode('latin-1')
return True
except Exception:
return False
# ═══════════════════════════════════════════════════════════
# 配置文件 & 编码工具
# ═══════════════════════════════════════════════════════════
def _get_config_path() -> str:
"""获取配置文件路径(用户临时目录)"""
return os.path.join(tempfile.gettempdir(), 'deb_viewer_config.json')
def _load_config() -> dict:
path = _get_config_path()
if os.path.isfile(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_config(cfg: dict):
path = _get_config_path()
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
except OSError:
pass
def _get_ansi_codepage() -> str:
"""获取 Windows ANSI 代码页(用于 windnd 路径解码)"""
try:
import ctypes
cp = ctypes.windll.kernel32.GetACP()
return f'cp{cp}'
except Exception:
return locale.getpreferredencoding(False) or 'mbcs'
# ═══════════════════════════════════════════════════════════
# .deb 包解析
# ═══════════════════════════════════════════════════════════
def scan_deb(filepath: str) -> dict:
"""扫描 .deb 文件,返回解析结果"""
with open(filepath, 'rb') as f:
data = f.read()
archive = parse_ar(data)
# ── debian-binary ──
deb_binary = archive.get('debian-binary', b'').decode('utf-8', errors='replace').strip()
# ── control.tar.* ──
control_raw = None
for key in ('control.tar.gz', 'control.tar.xz', 'control.tar.zst',
'control.tar.bz2', 'control.tar'):
if key in archive:
control_raw = archive[key]
break
if control_raw is None:
raise ValueError("未找到 control.tar.*")
control_data = decompress(control_raw)
info = {'deb_binary': deb_binary, 'fields': {}, 'debian_files': []}
with tarfile.open(fileobj=__import__('io').BytesIO(control_data)) as tf:
for m in tf.getmembers():
name = os.path.basename(m.name)
if not name:
continue
info['debian_files'].append(DebFileInfo(
name=name, size=m.size,
mtime=m.mtime, mode=m.mode
))
if name == 'control':
fobj = tf.extractfile(m)
if fobj:
text = fobj.read().decode('utf-8', errors='replace')
current_key = None
for line in text.split('\n'):
if line and not line[0].isspace() and ':' in line:
k, v = line.split(':', 1)
current_key = k.strip()
info['fields'][current_key] = v.strip()
elif current_key and line.startswith((' ', '\t')):
info['fields'][current_key] += '\n' + line.rstrip()
# ── data.tar.* 仅读取文件列表 ──
data_raw = None
for key in ('data.tar.gz', 'data.tar.xz', 'data.tar.zst',
'data.tar.bz2', 'data.tar'):
if key in archive:
data_raw = archive[key]
break
data_entries = []
if data_raw:
data_decompressed = decompress(data_raw)
with tarfile.open(fileobj=__import__('io').BytesIO(data_decompressed)) as tf:
for m in tf.getmembers():
entry = {
'name': m.name,
'size': m.size,
'isdir': m.isdir(),
'issym': m.issym(),
'linkname': m.linkname if m.issym() else '',
}
data_entries.append(entry)
info['data_entries'] = data_entries
return info
def read_debian_file(filepath: str, filename: str) -> bytes | None:
"""从 .deb 中读取 DEBIAN 目录下的指定文件内容"""
with open(filepath, 'rb') as f:
data = f.read()
archive = parse_ar(data)
control_raw = None
for key in ('control.tar.gz', 'control.tar.xz', 'control.tar.zst',
'control.tar.bz2', 'control.tar'):
if key in archive:
control_raw = archive[key]
break
if not control_raw:
return None
control_data = decompress(control_raw)
with tarfile.open(fileobj=__import__('io').BytesIO(control_data)) as tf:
for m in tf.getmembers():
if os.path.basename(m.name) == filename:
fobj = tf.extractfile(m)
return fobj.read() if fobj else None
return None
# ═══════════════════════════════════════════════════════════
# 解压功能
# ═══════════════════════════════════════════════════════════
def extract_deb(filepath: str, dest: str, progress_cb=None):
"""解压 .deb 到目标目录,软链接转为文本文件"""
with open(filepath, 'rb') as f:
data = f.read()
archive = parse_ar(data)
data_raw = None
for key in ('data.tar.gz', 'data.tar.xz', 'data.tar.zst',
'data.tar.bz2', 'data.tar'):
if key in archive:
data_raw = archive[key]
break
if not data_raw:
raise ValueError("未找到 data.tar.*")
data_decompressed = decompress(data_raw)
# 同时解压 DEBIAN 目录
control_raw = None
for key in ('control.tar.gz', 'control.tar.xz', 'control.tar.zst',
'control.tar.bz2', 'control.tar'):
if key in archive:
control_raw = archive[key]
break
os.makedirs(dest, exist_ok=True)
# ── 解压 DEBIAN/ ──
if control_raw:
control_data = decompress(control_raw)
deb_dir = os.path.join(dest, 'DEBIAN')
os.makedirs(deb_dir, exist_ok=True)
with tarfile.open(fileobj=__import__('io').BytesIO(control_data)) as tf:
for m in tf.getmembers():
basename = os.path.basename(m.name)
if not basename:
continue
out_path = os.path.join(deb_dir, basename)
if m.isdir():
continue
fobj = tf.extractfile(m)
if fobj:
with open(out_path, 'wb') as out:
out.write(fobj.read())
# ── 解压 data/ ──
with tarfile.open(fileobj=__import__('io').BytesIO(data_decompressed)) as tf:
members = tf.getmembers()
total = len(members)
for i, m in enumerate(members):
# 构造输出路径,去掉第一层目录(如有)
parts = m.name.split('/')
if len(parts) > 1 and parts[0]:
rel = '/'.join(parts[1:])
else:
rel = m.name
if not rel:
continue
out_path = os.path.join(dest, rel)
out_path = os.path.normpath(out_path)
# 安全检查:防止路径穿越
if not os.path.abspath(out_path).startswith(os.path.abspath(dest)):
continue
if m.isdir():
os.makedirs(out_path, exist_ok=True)
elif m.issym():
# 软链接 → 文本文件,内容为目标路径
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'w', encoding='utf-8') as f:
f.write(m.linkname)
elif m.islnk():
# 硬链接 → 复制目标文件内容
os.makedirs(os.path.dirname(out_path), exist_ok=True)
fobj = tf.extractfile(m)
if fobj:
with open(out_path, 'wb') as f:
f.write(fobj.read())
else:
# 普通文件
os.makedirs(os.path.dirname(out_path), exist_ok=True)
fobj = tf.extractfile(m)
if fobj:
with open(out_path, 'wb') as f:
shutil.copyfileobj(fobj, f)
try:
os.chmod(out_path, m.mode)
except (OSError, AttributeError):
pass
if progress_cb:
progress_cb(i + 1, total)
# ═══════════════════════════════════════════════════════════
# GUI 主类
# ═══════════════════════════════════════════════════════════
class DebViewerApp:
"""deb查看器主窗口"""
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("deb查看器")
self.root.geometry("960x720")
self.root.minsize(800, 600)
self.current_file = None
self.control_text = {} # control 文件内容缓存
self._build_menu()
self._build_toolbar()
self._build_info_area()
self._build_debian_area()
self._build_preview_area()
self._build_statusbar()
self._setup_dnd()
# 命令行参数
if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
self.root.after(100, lambda: self._open_file(sys.argv[1]))
# ─── 菜单 ───────────────────────────────────────────
def _build_menu(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="打开(O)", command=self._browse_open,
accelerator="Ctrl+O")
file_menu.add_command(label="解压到...", command=self._browse_extract,
accelerator="Ctrl+E")
file_menu.add_separator()
file_menu.add_command(label="退出", command=self.root.quit)
menubar.add_cascade(label="文件", menu=file_menu)
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="关于", command=self._show_about)
menubar.add_cascade(label="帮助", menu=help_menu)
self.root.bind('<Control-o>', lambda e: self._browse_open())
self.root.bind('<Control-e>', lambda e: self._browse_extract())
# ─── 工具栏 ──────────────────────────────────────────
def _build_toolbar(self):
bar = ttk.Frame(self.root, padding=4)
bar.pack(fill='x')
ttk.Button(bar, text="📂 打开", command=self._browse_open).pack(side='left', padx=2)
self.btn_extract = ttk.Button(bar, text="📦 解压到...", command=self._browse_extract)
self.btn_extract.pack(side='left', padx=2)
self.btn_extract.state(['disabled'])
ttk.Separator(bar, orient='vertical').pack(side='left', fill='y', padx=6)
self.lbl_file = ttk.Label(bar, text="拖放 .deb 文件到此处,或点击打开")
self.lbl_file.pack(side='left', padx=6)
# ─── 包信息区 ────────────────────────────────────────
def _build_info_area(self):
lf = ttk.LabelFrame(self.root, text=" 📋 包信息 ", padding=8)
lf.pack(fill='x', padx=8, pady=(4, 2))
self.info_canvas = tk.Canvas(lf, height=140, highlightthickness=0)
scrollbar = ttk.Scrollbar(lf, orient='vertical', command=self.info_canvas.yview)
self.info_frame = ttk.Frame(self.info_canvas)
self.info_frame.bind('<Configure>',
lambda e: self.info_canvas.configure(scrollregion=self.info_canvas.bbox('all')))
self._info_win = self.info_canvas.create_window((0, 0), window=self.info_frame, anchor='nw')
self.info_canvas.configure(yscrollcommand=scrollbar.set)
# 内部 Frame 宽度跟随 Canvas 宽度
self.info_canvas.bind('<Configure>',
lambda e: self.info_canvas.itemconfigure(self._info_win, width=e.width))
self.info_canvas.pack(side='left', fill='both', expand=True)
scrollbar.pack(side='right', fill='y')
# 鼠标滚轮滚动
self.info_canvas.bind('<Enter>',
lambda e: self.info_canvas.bind_all('<MouseWheel>', self._on_info_scroll))
self.info_canvas.bind('<Leave>',
lambda e: self.info_canvas.unbind_all('<MouseWheel>'))
self.info_labels = {}
def _on_info_scroll(self, event):
self.info_canvas.yview_scroll(-1 * (event.delta // 120), 'units')
def _set_info(self, fields: dict):
for w in self.info_frame.winfo_children():
w.destroy()
self.info_labels.clear()
if not fields:
ttk.Label(self.info_frame, text="(无数据)").grid(row=0, column=0, sticky='w')
return
important = [
('Package', '包名'), ('Version', '版本'), ('Architecture', '架构'),
('Maintainer', '维护者'), ('Installed-Size', '安装大小'),
('Depends', '依赖'), ('Pre-Depends', '预依赖'),
('Section', '分类'), ('Priority', '优先级'),
('Homepage', '主页'), ('Description', '描述'),
]
row = 0
shown = set()
for key, label in important:
if key in fields:
ttk.Label(self.info_frame, text=f"{label}:", font=('', 9, 'bold')
).grid(row=row, column=0, sticky='ne', padx=(0, 6), pady=1)
val = fields[key]
lbl = ttk.Label(self.info_frame, text=val, wraplength=700, justify='left')
lbl.grid(row=row, column=1, sticky='w', pady=1)
shown.add(key)
row += 1
# 其他字段
others = {k: v for k, v in fields.items() if k not in shown}
if others:
ttk.Separator(self.info_frame, orient='horizontal'
).grid(row=row, column=0, columnspan=2, sticky='ew', pady=4)
row += 1
for k, v in others.items():
ttk.Label(self.info_frame, text=f"{k}:", font=('', 8)
).grid(row=row, column=0, sticky='ne', padx=(0, 6), pady=1)
ttk.Label(self.info_frame, text=v, wraplength=700, foreground='gray40',
justify='left').grid(row=row, column=1, sticky='w', pady=1)
row += 1
# ─── DEBIAN 文件树 ───────────────────────────────────
def _build_debian_area(self):
lf = ttk.LabelFrame(self.root, text=" 📁 DEBIAN 目录 ", padding=4)
lf.pack(fill='x', padx=8, pady=2)
cols = ('size', 'mode')
self.tree = ttk.Treeview(lf, columns=cols, height=5, show='tree headings',
selectmode='browse')
self.tree.heading('#0', text='文件名', anchor='w')
self.tree.heading('size', text='大小', anchor='e')
self.tree.heading('mode', text='权限', anchor='center')
self.tree.column('#0', width=260, minwidth=160)
self.tree.column('size', width=90, minwidth=60, anchor='e')
self.tree.column('mode', width=100, minwidth=70, anchor='center')
sb = ttk.Scrollbar(lf, orient='vertical', command=self.tree.yview)
self.tree.configure(yscrollcommand=sb.set)
self.tree.pack(side='left', fill='both', expand=True)
sb.pack(side='right', fill='y')
self.tree.bind('<<TreeviewSelect>>', self._on_tree_select)
self.debian_file_map = {} # iid → DebFileInfo
def _set_debian_files(self, files: list[DebFileInfo]):
self.tree.delete(*self.tree.get_children())
self.debian_file_map.clear()
for f in files:
iid = self.tree.insert('', 'end', text=f.name,
values=(self._fmt_size(f.size), oct(f.mode)[-4:]))
self.debian_file_map[iid] = f
def _on_tree_select(self, event):
sel = self.tree.selection()
if not sel:
return
finfo = self.debian_file_map.get(sel[0])
if not finfo:
return
self._preview_debian_file(finfo.name)
# ─── 预览区 ──────────────────────────────────────────
def _build_preview_area(self):
lf = ttk.LabelFrame(self.root, text=" 👁 预览 ", padding=4)
lf.pack(fill='both', expand=True, padx=8, pady=(2, 4))
self.preview_text = tk.Text(lf, wrap='word', font=('Consolas', 10),
state='disabled', undo=False)
sb = ttk.Scrollbar(lf, orient='vertical', command=self.preview_text.yview)
self.preview_text.configure(yscrollcommand=sb.set)
self.preview_text.pack(side='left', fill='both', expand=True)
sb.pack(side='right', fill='y')
# 语法高亮标签
self.preview_text.tag_configure('header', foreground='#2563eb', font=('Consolas', 10, 'bold'))
self.preview_text.tag_configure('binary', foreground='#9ca3af', font=('Consolas', 10, 'italic'))
self.preview_text.tag_configure('keyword', foreground='#dc2626')
self.preview_text.tag_configure('comment', foreground='#6b7280', font=('Consolas', 10, 'italic'))
self.preview_text.tag_configure('path', foreground='#059669')
def _set_preview(self, text: str, tag: str = None):
self.preview_text.config(state='normal')
self.preview_text.delete('1.0', 'end')
if tag:
self.preview_text.insert('1.0', text, tag)
else:
self.preview_text.insert('1.0', text)
self._apply_syntax_highlight()
self.preview_text.config(state='disabled')
def _apply_syntax_highlight(self):
"""对 shell 脚本做简单语法高亮"""
content = self.preview_text.get('1.0', 'end')
lines = content.split('\n')
for i, line in enumerate(lines):
start = f"{i+1}.0"
stripped = line.lstrip()
if stripped.startswith('#'):
self.preview_text.tag_add('comment', start,
f"{i+1}.{len(line)}")
for kw in ('if', 'then', 'else', 'elif', 'fi', 'case', 'esac',
'for', 'while', 'do', 'done', 'function', 'return',
'exit', 'echo', 'set ', 'dpkg', 'apt-get'):
idx = line.find(kw)
if idx >= 0:
self.preview_text.tag_add('keyword', f"{i+1}.{idx}",
f"{i+1}.{idx + len(kw)}")
# ─── 状态栏 ──────────────────────────────────────────
def _build_statusbar(self):
self.status_var = tk.StringVar(value="就绪")
bar = ttk.Label(self.root, textvariable=self.status_var,
relief='sunken', anchor='w', padding=(6, 2))
bar.pack(fill='x', side='bottom')
def _set_status(self, text: str):
self.status_var.set(text)
self.root.update_idletasks()
# ─── 拖放 ────────────────────────────────────────────
def _setup_dnd(self):
if HAS_WINDND:
windnd.hook_dropfiles(self.root, func=self._on_drop)
else:
# 简单的 Tkinter 拖放提示
self.root.drop_target_register = None
def _on_drop(self, files):
if files:
path = files[0]
if isinstance(path, bytes):
path = path.decode(_get_ansi_codepage(), errors='replace')
if path.lower().endswith('.deb'):
self._open_file(path)
else:
messagebox.showwarning("提示", "请拖放 .deb 格式的文件")
# ─── 文件操作 ────────────────────────────────────────
def _browse_open(self):
# 初始目录:上次打开的文件所在目录,否则 exe/script 同级目录
cfg = _load_config()
init_dir = cfg.get('last_dir', '')
if not init_dir or not os.path.isdir(init_dir):
if getattr(sys, 'frozen', False):
init_dir = os.path.dirname(sys.executable)
else:
init_dir = os.path.dirname(os.path.abspath(__file__))
path = filedialog.askopenfilename(
title="选择 .deb 文件",
initialdir=init_dir,
filetypes=[("Debian 包", "*.deb"), ("所有文件", "*.*")]
)
if path:
_save_config({'last_dir': os.path.dirname(path)})
self._open_file(path)
def _open_file(self, path: str):
if not os.path.isfile(path):
messagebox.showerror("错误", f"文件不存在:{path}")
return
self.current_file = path
self.lbl_file.config(text=os.path.basename(path))
self._set_status(f"正在解析:{path} ...")
self.btn_extract.state(['disabled'])
def _load():
try:
info = scan_deb(path)
self.root.after(0, lambda: self._on_loaded(info))
except Exception as e:
self.root.after(0, lambda: self._on_load_error(str(e)))
threading.Thread(target=_load, daemon=True).start()
def _on_loaded(self, info: dict):
self.control_text.clear()
# 包信息
self._set_info(info['fields'])
# DEBIAN 文件树
self._set_debian_files(info['debian_files'])
# 缓存 DEBIAN 文件内容
for f in info['debian_files']:
content = read_debian_file(self.current_file, f.name)
if content is not None:
self.control_text[f.name] = content
# 默认预览 control
if 'control' in self.control_text:
self._preview_debian_file('control')
# 启用解压按钮
self.btn_extract.state(['!disabled'])
pkg = info['fields'].get('Package', '?')
ver = info['fields'].get('Version', '?')
data_count = len(info.get('data_entries', []))
self._set_status(f"已加载:{pkg} {ver} | 包含 {data_count} 个文件/目录")
def _on_load_error(self, err: str):
self._set_status(f"解析失败")
messagebox.showerror("解析错误", f"无法解析 .deb 文件:\n{err}")
def _preview_debian_file(self, name: str):
content = self.control_text.get(name)
if content is None:
self._set_preview(f"(无法读取 {name}", 'binary')
return
if name == 'control':
self._set_preview(content.decode('utf-8', errors='replace'))
elif name.endswith(('.sh', '.bash')):
self._set_preview(content.decode('utf-8', errors='replace'))
elif is_text_content(content):
self._set_preview(content.decode('utf-8', errors='replace'))
else:
self._set_preview(
f"[二进制文件 {name}{len(content)} 字节]\n"
f"十六进制预览(前 512 字节):\n\n"
+ self._hex_dump(content[:512]),
'binary'
)
@staticmethod
def _hex_dump(data: bytes, cols: int = 16) -> str:
lines = []
for i in range(0, len(data), cols):
chunk = data[i:i+cols]
hex_part = ' '.join(f'{b:02x}' for b in chunk)
ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
lines.append(f'{i:08x} {hex_part:<{cols*3}} {ascii_part}')
return '\n'.join(lines)
# ─── 解压 ────────────────────────────────────────────
def _browse_extract(self):
if not self.current_file:
messagebox.showinfo("提示", "请先打开一个 .deb 文件")
return
dest = filedialog.askdirectory(title="选择解压目标文件夹")
if not dest:
return
pkg = self.control_text.get('Package', b'').decode() if 'Package' in self.control_text else 'deb'
target = os.path.join(dest, os.path.splitext(os.path.basename(self.current_file))[0])
if os.path.exists(target) and os.listdir(target):
if not messagebox.askyesno("确认", f"目录已存在且非空:\n{target}\n\n是否继续解压到此目录?"):
return
self._set_status("正在解压...")
self.btn_extract.state(['disabled'])
def _do_extract():
try:
def progress(done, total):
self.root.after(0, lambda: self._set_status(
f"解压中... {done}/{total}"))
extract_deb(self.current_file, target, progress_cb=progress)
self.root.after(0, lambda: self._on_extract_done(target))
except Exception as e:
self.root.after(0, lambda: self._on_extract_error(str(e)))
threading.Thread(target=_do_extract, daemon=True).start()
def _on_extract_done(self, path: str):
self.btn_extract.state(['!disabled'])
self._set_status(f"解压完成:{path}")
if messagebox.askyesno("解压完成", f"文件已解压到:\n{path}\n\n是否打开该文件夹?"):
os.startfile(path)
def _on_extract_error(self, err: str):
self.btn_extract.state(['!disabled'])
self._set_status("解压失败")
messagebox.showerror("解压错误", f"解压失败:\n{err}")
# ─── 工具 ────────────────────────────────────────────
@staticmethod
def _fmt_size(n: int) -> str:
if n < 1024:
return f"{n} B"
elif n < 1024 * 1024:
return f"{n/1024:.1f} KB"
else:
return f"{n/(1024*1024):.1f} MB"
def _show_about(self):
messagebox.showinfo("关于 deb查看器",
"deb查看器 v1.0\n\n"
"Windows 下的 Debian 安装包查看/解压工具\n\n"
"功能:\n"
" • 查看 .deb 包的元信息\n"
" • 预览 DEBIAN 目录中的脚本和文件\n"
" • 解压到指定目录(软链接转文本文件)\n\n"
f"可选依赖:\n"
f" • windnd (拖放支持): {'✓ 已安装' if HAS_WINDND else '✗ 未安装'}\n"
f" • zstandard (zstd压缩): {'✓ 已安装' if HAS_ZSTD else '✗ 未安装'}\n"
)
# ═══════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════
def main():
root = tk.Tk()
app = DebViewerApp(root)
# 无 windnd 时的拖放替代方案:绑定事件
if not HAS_WINDND:
root.bind('<Button-1>', lambda e: None)
root.mainloop()
if __name__ == '__main__':
main()