pkgmate 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pkgtool/apt/lists.py ADDED
@@ -0,0 +1,336 @@
1
+ """pkgtool.apt.lists — /var/lib/apt/lists 的唯一解析器。
2
+
3
+ 合并原先两套并存的解析:deb_backend.build_source_index()(来源判定用)和
4
+ apt_repo.load_index()(搜索/升级检测用)。二者读的是同一批 Packages 文件,
5
+ 每次采集要解压解析两遍,且字段各取一半、语义还不完全一致。
6
+
7
+ 一次遍历同时产出:
8
+ · (包名, 版本) → 源标签集合 来源判定:包是否来自某个已配置源
9
+ · 包名 → 全部版本条目 搜索、版本列表、升级候选
10
+ · 包名 → section / priority 卸载分类(classify.py)用
11
+ 源标签 = Release 文件的 Label(缺失时用 Origin,再缺失用文件名前缀)
12
+ + 文件名里解析出的 suite / component,形如 "Ubuntu noble/main"。
13
+
14
+ 无需 root:lists 目录全局可读。
15
+ """
16
+ import glob
17
+ import os
18
+ import re
19
+ import threading
20
+ import time
21
+ from dataclasses import asdict, dataclass
22
+ from email.utils import parsedate_to_datetime
23
+ from functools import cmp_to_key
24
+
25
+ from ..base import matches_tokens, query_tokens
26
+ from ..compress import open_text, read_text, strip_compressed_suffix
27
+ from ..config import CFG
28
+ from .version import newest, ver_cmp, ver_gt
29
+
30
+ # Packages 记录里需要留存的字段(其余忽略)
31
+ _FIELDS = frozenset(("Package", "Version", "Architecture", "Maintainer", "Size",
32
+ "Filename", "Description", "Section", "Priority"))
33
+ _PGP_TAIL = "-----BEGIN PGP SIGNATURE-----"
34
+ _PGP_HEAD = "-----BEGIN PGP SIGNED MESSAGE-----"
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class RepoEntry:
39
+ """某源里某个包的一个版本。"""
40
+ version: str
41
+ repo: str
42
+ suite: str
43
+ component: str
44
+ arch: str
45
+ maintainer: str
46
+ size_kb: int
47
+ filename: str
48
+ date: str
49
+ desc: str
50
+ section: str
51
+ priority: str
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class _Release:
56
+ label: str
57
+ suite: str
58
+ date: str
59
+
60
+
61
+ def _iter_records(lines):
62
+ """流式解析 Debian 控制文件:空行分隔记录;续行(空格/制表符开头)跳过,
63
+ 因此 Description 只留首行摘要,不把多行正文吃进内存。"""
64
+ cur = {}
65
+ for line in lines:
66
+ line = line.rstrip("\n")
67
+ if not line:
68
+ if cur:
69
+ yield cur
70
+ cur = {}
71
+ continue
72
+ if line[0] in " \t":
73
+ continue
74
+ key, sep, value = line.partition(": ")
75
+ if sep and key in _FIELDS and key not in cur:
76
+ cur[key] = value.strip()
77
+ if cur:
78
+ yield cur
79
+
80
+
81
+ def _release_body(path):
82
+ """InRelease 的字段在 PGP 头之后的第一个空行后;Release 文件直接就是字段。"""
83
+ text = read_text(path)
84
+ if text is None:
85
+ return ""
86
+ if text.startswith(_PGP_HEAD):
87
+ _head, _sep, body = text.partition("\n\n")
88
+ text = body
89
+ end = text.find(_PGP_TAIL)
90
+ return text if end < 0 else text[:end]
91
+
92
+
93
+ def _parse_releases(lists_dir):
94
+ """→ {文件名前缀: _Release}。"""
95
+ out = {}
96
+ for path in glob.glob(os.path.join(lists_dir, "*")):
97
+ base = os.path.basename(path)
98
+ if base.endswith(".gpg"):
99
+ continue
100
+ prefix = None
101
+ for suffix in ("_InRelease", "_Release"):
102
+ if base.endswith(suffix):
103
+ prefix = base[: -len(suffix)]
104
+ break
105
+ if prefix is None:
106
+ continue
107
+ label = origin = suite = date = ""
108
+ for line in _release_body(path).splitlines():
109
+ key, sep, value = line.partition(": ")
110
+ if not sep:
111
+ continue
112
+ value = value.strip()
113
+ if key == "Label" and not label:
114
+ label = value
115
+ elif key == "Origin" and not origin:
116
+ origin = value
117
+ elif key == "Suite" and not suite:
118
+ suite = value
119
+ elif key == "Date" and not date:
120
+ try:
121
+ date = parsedate_to_datetime(value).strftime("%Y-%m-%d")
122
+ except (TypeError, ValueError):
123
+ date = value
124
+ # Label 优先于 Origin(apt 自己也是这么显示的):Release 里 Origin 常写在
125
+ # Label 前面,按出现顺序取会把 "NVIDIA CUDA" 丢成 "NVIDIA"。
126
+ out[prefix] = _Release(label or origin or prefix, suite, date)
127
+ return out
128
+
129
+
130
+ def _suite_component(basename, cfg):
131
+ """从 Packages 文件名解析 suite/component:
132
+ 规范仓库 <prefix>_dists_<suite>_<component>_binary-<arch>_Packages[.gz]
133
+ 扁平仓库(sources.list 里写 `deb <url> ./`)文件名里没有 _dists_,
134
+ 硬套位置会把 URL 路径段当成 suite——NVIDIA 的 CUDA 源曾被解析成
135
+ suite="x86%5f64",所以这种情况直接返回空,只留 Release 里的 Label。"""
136
+ core = strip_compressed_suffix(basename).split("_binary-")[0]
137
+ if "_dists_" not in core:
138
+ return "", ""
139
+ toks = core.split("_")
140
+ comp = toks[-1] if toks[-1] in cfg.known_components else ""
141
+ suite = ""
142
+ if len(toks) >= 2 and (comp or toks[-2] not in ("dists", "deb")):
143
+ suite = toks[-2]
144
+ return suite, comp
145
+
146
+
147
+ def _package_files(lists_dir, cfg):
148
+ """→ [(路径, 文件名前缀, suite, component)],已按架构过滤。"""
149
+ skip = re.compile(cfg.index_skip_arch_re)
150
+ out = []
151
+ for path in sorted(set(glob.glob(os.path.join(lists_dir, "*_Packages"))
152
+ + glob.glob(os.path.join(lists_dir, "*_Packages.*")))):
153
+ base = os.path.basename(path)
154
+ if base.endswith((".gpg", ".tmp")) or skip.search(base):
155
+ continue
156
+ core = strip_compressed_suffix(base)
157
+ if not core.endswith("_Packages"):
158
+ continue
159
+ suite, comp = _suite_component(base, cfg)
160
+ out.append((path, core[: -len("_Packages")], suite, comp))
161
+ return out
162
+
163
+
164
+ def _match_release(prefix, releases):
165
+ """Packages 文件名比 Release 多 component/arch 段,取最长前缀匹配。"""
166
+ best = None
167
+ for r in releases:
168
+ if prefix == r or prefix.startswith(r + "_"):
169
+ if best is None or len(r) > len(best):
170
+ best = r
171
+ return releases.get(best)
172
+
173
+
174
+ class RepoIndex:
175
+ """已解析的仓库索引,构造后只读。"""
176
+
177
+ def __init__(self, entries):
178
+ self._entries = entries # name → [RepoEntry]
179
+ self._top = {} # name → 最新版本的 RepoEntry(惰性缓存)
180
+
181
+ def __len__(self):
182
+ return len(self._entries)
183
+
184
+ def __contains__(self, name):
185
+ return name in self._entries
186
+
187
+ def names(self):
188
+ return self._entries.keys()
189
+
190
+ def entries(self, name):
191
+ return self._entries.get(name, [])
192
+
193
+ def top(self, name):
194
+ """最新版本的条目(同版本多源时取先出现的);无此包返回 None。"""
195
+ if name not in self._top:
196
+ es = self._entries.get(name) or []
197
+ if not es:
198
+ self._top[name] = None
199
+ else:
200
+ best = newest(e.version for e in es)
201
+ self._top[name] = next(e for e in es if e.version == best)
202
+ return self._top[name]
203
+
204
+ def candidate(self, name):
205
+ """仓库里的最高版本;包不在任何源则返回 ""。"""
206
+ e = self.top(name)
207
+ return e.version if e else ""
208
+
209
+ def versions(self, name):
210
+ """去重后的版本列表,按 Debian 版本序从高到低。"""
211
+ vs = {e.version for e in self.entries(name)}
212
+ return sorted(vs, key=cmp_to_key(ver_cmp), reverse=True)
213
+
214
+ def repos(self, name, version=None):
215
+ """命中的源标签:给了 version 就按 (名,版本) 精确匹配,否则按包名。"""
216
+ es = self.entries(name)
217
+ if version is not None:
218
+ es = [e for e in es if e.version == version]
219
+ out = []
220
+ for e in es:
221
+ if e.repo not in out:
222
+ out.append(e.repo)
223
+ return out
224
+
225
+ def meta(self, name):
226
+ """→ {section, priority}(取最新版本条目),供卸载分类用。"""
227
+ e = self.top(name)
228
+ return {"section": e.section, "priority": e.priority} if e else {}
229
+
230
+ def search(self, q, limit=30):
231
+ """按包名/描述搜索。
232
+ 排名:完全匹配 > 前缀 > 名字含 > 描述含 > 分词全命中。最后一级用分词,
233
+ 让 "zen browser" 这类多词查询也能命中名字里用下划线/连字符的包。
234
+ 先全量扫描再排序——原实现边扫边在 limit*3 处 break,结果取决于 dict
235
+ 插入顺序,会漏掉更优匹配。"""
236
+ q = (q or "").lower().strip()
237
+ if not q:
238
+ return []
239
+ tokens = query_tokens(q)
240
+ hits = []
241
+ for name in self._entries:
242
+ nl = name.lower()
243
+ e = self.top(name)
244
+ desc = ((e.desc if e else "") or "").lower()
245
+ if nl == q:
246
+ rank = 0
247
+ elif nl.startswith(q):
248
+ rank = 1
249
+ elif q in nl:
250
+ rank = 2
251
+ elif q in desc:
252
+ rank = 3
253
+ elif matches_tokens(f"{name} {desc}", tokens):
254
+ rank = 4
255
+ else:
256
+ continue
257
+ hits.append((rank, name))
258
+ hits.sort()
259
+ out = []
260
+ for _rank, name in hits[:limit]:
261
+ e = self.top(name)
262
+ out.append({"name": name, "version": e.version, "repo": e.repo,
263
+ "suite": e.suite, "component": e.component,
264
+ "maintainer": e.maintainer, "size_kb": e.size_kb,
265
+ "date": e.date, "desc": e.desc, "section": e.section})
266
+ return out
267
+
268
+ def info(self, name):
269
+ """单包详情:全部版本(每源每版本一条)。"""
270
+ es = self.entries(name)
271
+ if not es:
272
+ return None
273
+ # RepoEntry 是 slots dataclass,vars() 会抛 TypeError,得用 asdict
274
+ return {"name": name, "candidate": self.candidate(name),
275
+ "versions": [asdict(e) for e in es]}
276
+
277
+
278
+ _CACHE = {"t": 0.0, "mtime": None, "idx": None}
279
+ _LOCK = threading.Lock()
280
+
281
+
282
+ def _lists_mtime(lists_dir):
283
+ """目录 mtime:apt update 会重写列表文件,比单纯 TTL 更准。"""
284
+ try:
285
+ return os.path.getmtime(lists_dir)
286
+ except OSError:
287
+ return None
288
+
289
+
290
+ def load_index(cfg=CFG, force=False):
291
+ """解析并缓存仓库索引;force=True 忽略缓存。"""
292
+ with _LOCK:
293
+ now = time.time()
294
+ mtime = _lists_mtime(cfg.apt_lists_dir)
295
+ if (_CACHE["idx"] is not None and not force
296
+ and now - _CACHE["t"] < cfg.index_cache_ttl
297
+ and _CACHE["mtime"] == mtime):
298
+ return _CACHE["idx"]
299
+ idx = _build(cfg)
300
+ _CACHE.update(t=now, mtime=mtime, idx=idx)
301
+ return idx
302
+
303
+
304
+ def _build(cfg):
305
+ releases = _parse_releases(cfg.apt_lists_dir)
306
+ entries = {}
307
+ for path, prefix, suite, comp in _package_files(cfg.apt_lists_dir, cfg):
308
+ rel = _match_release(prefix, releases)
309
+ label = rel.label if rel else prefix
310
+ loc = "/".join(x for x in (suite or (rel.suite if rel else ""), comp) if x)
311
+ tag = f"{label} {loc}".strip()
312
+ date = rel.date if rel else ""
313
+ try:
314
+ with open_text(path) as fh:
315
+ for rec in _iter_records(fh):
316
+ name, ver = rec.get("Package"), rec.get("Version")
317
+ arch = rec.get("Architecture", "")
318
+ if not name or not ver or arch not in cfg.index_arches:
319
+ continue
320
+ size = rec.get("Size", "")
321
+ entries.setdefault(name, []).append(RepoEntry(
322
+ version=ver, repo=tag, suite=suite, component=comp,
323
+ arch=arch, maintainer=rec.get("Maintainer", "?"),
324
+ size_kb=int(size) // 1024 if size.isdigit() else 0,
325
+ filename=rec.get("Filename", ""), date=date,
326
+ desc=rec.get("Description", "")[:160],
327
+ section=rec.get("Section", ""),
328
+ priority=rec.get("Priority", "")))
329
+ except OSError:
330
+ continue
331
+ return RepoIndex(entries)
332
+
333
+
334
+ def is_upgrade(candidate, installed):
335
+ """仓库候选版本是否高于已安装版本。"""
336
+ return bool(candidate) and ver_gt(candidate, installed)
pkgtool/apt/logs.py ADDED
@@ -0,0 +1,83 @@
1
+ """pkgtool.apt.logs — dpkg.log / apt history.log 时间线解析。
2
+
3
+ 安装通道判定(base.Channel)的证据来源。合并原先两套 apt history 解析:
4
+ deb_backend.parse_apt_history()(返回 {名: [(时间,版本)]})和
5
+ update_channels.apt_history_names()(返回名字集合,且用 split(", ") 拆条目,
6
+ 遇到版本括号内的逗号就拆错——靠正则补救)。这里统一成一个解析器。
7
+
8
+ 数据来源可信度:
9
+ /var/log/dpkg.log* 所有 dpkg 操作的时间线,含 dpkg -i,最接近事实
10
+ /var/log/apt/history.log* 仅 apt 系工具的操作记录(apt install ./x.deb 也在里面)
11
+ 两者都会轮转,查无记录时只能标 UNKNOWN,不能当成"没装过"。
12
+ """
13
+ import re
14
+ from collections import defaultdict
15
+ from datetime import datetime, timedelta
16
+
17
+ from ..compress import iter_lines
18
+ from ..config import CFG
19
+
20
+ TS_FMT = "%Y-%m-%d %H:%M:%S"
21
+
22
+ # dpkg.log: "2024-01-01 12:00:00 install bash:amd64 <none> 5.2-2ubuntu1"
23
+ # 第 3 字段是安装前版本(新装时为 <none>),第 4 字段才是装上的版本。
24
+ # 原实现取第 3 字段当版本号,拿到的一律是 "<none>"(好在只用了时间戳)。
25
+ _DPKG_INSTALL_RE = re.compile(
26
+ r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) install (\S+) (\S+)(?: (\S+))?")
27
+
28
+ # apt history: "Install: libfoo:amd64 (1.2-3, automatic), bar:amd64 (4.5)"
29
+ # 架构段可有可无,括号内可能带 ", automatic",所以不能用逗号拆条目。
30
+ _APT_ENTRY_RE = re.compile(r"([\w.+\-~]+)(?::[\w\-]+)?\s*\(([^)]*)\)")
31
+
32
+
33
+ def parse_ts(text):
34
+ """→ datetime;解析不了返回 None(不抛)。"""
35
+ try:
36
+ return datetime.strptime(" ".join((text or "").split()), TS_FMT)
37
+ except (ValueError, TypeError):
38
+ return None
39
+
40
+
41
+ def dpkg_installs(cfg=CFG):
42
+ """→ ({包名: (首次 install 时间, 版本)}, 全局最早事件时间)。
43
+ 只取每个包的第一次 install 事件;earliest 用于推算系统"出生时间"。"""
44
+ first = {}
45
+ earliest = None
46
+ for line in iter_lines([cfg.dpkg_log_glob]):
47
+ m = _DPKG_INSTALL_RE.match(line)
48
+ if not m:
49
+ continue
50
+ ts, pkg, _old, new = m.groups()
51
+ pkg = pkg.split(":")[0]
52
+ if earliest is None or ts < earliest:
53
+ earliest = ts
54
+ if pkg not in first:
55
+ first[pkg] = (ts, new or "")
56
+ return first, earliest
57
+
58
+
59
+ def apt_history(cfg=CFG):
60
+ """→ {包名: [(时间, 版本字符串), ...]},来自 Install/Upgrade 行。
61
+ 括号里的 ", automatic" 是 apt 的自动安装标记不是版本的一部分,
62
+ 剥掉(Upgrade 行的 "旧版, 新版" 双版本则保留)。"""
63
+ out = defaultdict(list)
64
+ cur_ts = ""
65
+ for line in iter_lines([cfg.apt_history_glob]):
66
+ if line.startswith("Start-Date:"):
67
+ cur_ts = " ".join(line.split(":", 1)[1].split())
68
+ elif line.startswith(("Install:", "Upgrade:")):
69
+ for name, ver in _APT_ENTRY_RE.findall(line):
70
+ parts = [p.strip() for p in ver.split(",")]
71
+ if parts and parts[-1] == "automatic":
72
+ parts = parts[:-1]
73
+ out[name].append((cur_ts, ", ".join(parts)))
74
+ return dict(out)
75
+
76
+
77
+ def birth_cutoff(earliest_ts, cfg=CFG):
78
+ """系统"出生时间"上界 = 日志最早事件 + birth_margin_hours。
79
+ 在此窗口内装入且无 apt 记录的包,判为镜像自带。日志缺失时返回 None。"""
80
+ start = parse_ts(earliest_ts)
81
+ if start is None:
82
+ return None
83
+ return start + timedelta(hours=cfg.birth_margin_hours)
pkgtool/apt/version.py ADDED
@@ -0,0 +1,92 @@
1
+ """pkgtool.apt.version — Debian 版本号比较(dpkg verrev 算法,纯标准库实现)。
2
+
3
+ 版本格式:[epoch:]upstream_version[-debian_revision]
4
+ 比较规则(dpkg/version.c):
5
+ · epoch 按整数比,缺省为 0
6
+ · upstream 与 revision 各自交替比较「非数字段」和「数字段」:
7
+ 非数字段逐字符按 order() 排序,数字段整体按数值(前导零无意义)
8
+ · order(): '~' < 字符串结束 < 数字 < 字母 < 其他符号
9
+ """
10
+
11
+
12
+ def _order(ch):
13
+ """dpkg 的字符序。ch=None 表示字符串已结束。"""
14
+ if ch == "~":
15
+ return -1
16
+ if ch is None:
17
+ return 0
18
+ if ch.isdigit():
19
+ return 0
20
+ if ch.isalpha():
21
+ return ord(ch)
22
+ return ord(ch) + 256
23
+
24
+
25
+ def _nondigit(s, i):
26
+ j = i
27
+ while j < len(s) and not s[j].isdigit():
28
+ j += 1
29
+ return s[i:j], j
30
+
31
+
32
+ def _digit(s, i):
33
+ j = i
34
+ while j < len(s) and s[j].isdigit():
35
+ j += 1
36
+ return s[i:j], j
37
+
38
+
39
+ def _cmp_part(a, b):
40
+ """交替比较非数字段与数字段,直到两边都走完。"""
41
+ i = j = 0
42
+ while i < len(a) or j < len(b):
43
+ sa, i = _nondigit(a, i)
44
+ sb, j = _nondigit(b, j)
45
+ for k in range(max(len(sa), len(sb))):
46
+ ca = sa[k] if k < len(sa) else None
47
+ cb = sb[k] if k < len(sb) else None
48
+ oa, ob = _order(ca), _order(cb)
49
+ if oa != ob:
50
+ return -1 if oa < ob else 1
51
+ da, i = _digit(a, i)
52
+ db, j = _digit(b, j)
53
+ na = int(da) if da else 0
54
+ nb = int(db) if db else 0
55
+ if na != nb:
56
+ return -1 if na < nb else 1
57
+ return 0
58
+
59
+
60
+ def _split(v):
61
+ """→ (epoch, upstream, revision)。"""
62
+ v = (v or "").strip()
63
+ epoch = 0
64
+ if ":" in v:
65
+ head, v = v.split(":", 1)
66
+ epoch = int(head) if head.isdigit() else 0
67
+ up, sep, rev = v.rpartition("-") # 最后一个连字符之后才是 debian revision
68
+ if not sep:
69
+ up, rev = v, ""
70
+ return epoch, up, rev
71
+
72
+
73
+ def ver_cmp(a, b):
74
+ """Debian 版本比较 → -1 / 0 / 1。"""
75
+ ea, ua, ra = _split(a)
76
+ eb, ub, rb = _split(b)
77
+ if ea != eb:
78
+ return -1 if ea < eb else 1
79
+ return _cmp_part(ua, ub) or _cmp_part(ra, rb)
80
+
81
+
82
+ def ver_gt(a, b):
83
+ return ver_cmp(a, b) > 0
84
+
85
+
86
+ def newest(versions):
87
+ """一组版本字符串里最高的那个;空序列返回 ""。"""
88
+ best = ""
89
+ for v in versions:
90
+ if not best or ver_gt(v, best):
91
+ best = v
92
+ return best
@@ -0,0 +1,30 @@
1
+ """pkgtool.backends — 各包格式采集器的注册表。
2
+
3
+ 新增一种格式 = 新建 <fmt>.py 实现 Backend 子类 + 在 BACKENDS 里加一行,
4
+ 其余模块(inventory / classify / report / cli / app)都不需要改。
5
+ """
6
+ from ..base import Backend
7
+ from ..config import CFG
8
+ from .appimage import AppImageBackend
9
+ from .deb import DebBackend
10
+ from .flatpak import FlatpakBackend
11
+ from .linyap import LinyapBackend
12
+ from .snap import SnapBackend
13
+
14
+ BACKENDS = (DebBackend, SnapBackend, FlatpakBackend, LinyapBackend,
15
+ AppImageBackend)
16
+
17
+
18
+ def available_backends(cfg=CFG):
19
+ """本机存在对应包管理体系的后端类(available() 不得抛异常)。"""
20
+ return [b for b in BACKENDS if b.available(cfg)]
21
+
22
+
23
+ def discover(cfg=CFG):
24
+ """→ 本机可用后端的实例列表。"""
25
+ return [b(cfg) for b in available_backends(cfg)]
26
+
27
+
28
+ __all__ = ["BACKENDS", "Backend", "available_backends", "discover",
29
+ "DebBackend", "SnapBackend", "FlatpakBackend", "LinyapBackend",
30
+ "AppImageBackend"]
@@ -0,0 +1,145 @@
1
+ """pkgtool.backends.appimage — AppImage 嗅探(纯被动,不执行目标文件)。
2
+
3
+ 原理:AppImage v1/v2 = ELF 运行时 + 追加的 squashfs 负载。
4
+ 1. 扫描用户区域找 *.AppImage(主目录/下载、~/Applications、/opt…)
5
+ 2. 在文件里找 squashfs 超块(魔数 hsqs + block_size 是 4096~1MB 的 2 次幂
6
+ + inode 数合理),避开 ELF 运行时内嵌 unsquashfs 代码里的字符串误报
7
+ 3. `unsquashfs -o <off> -l` 列出负载(只读目录树,不解压不执行):
8
+ 取 .desktop 文件名作显示名、usr/bin/* 作可执行文件
9
+ 4. 版本号从文件名解析(CC-Switch-v3.20.4-Linux-x86_64 → 3.20.4)
10
+ 位置语义:~/Applications、/opt、/usr/local/bin 下 = 已安置的便携应用;
11
+ 下载等散落区域 = 未安装的包文件(extra["loose"],与 deb 散落文件同构)。
12
+ """
13
+ import os
14
+ import re
15
+ import shutil
16
+ import struct
17
+ import subprocess
18
+ import time
19
+
20
+ from ..base import Backend, OriginKind, PackageRecord, file_size_mb, scan_file_areas
21
+ from ..config import CFG
22
+
23
+ # 核心版本号 + 可选预发布后缀(beta/rc…),避免吞掉 -Linux-x86_64 这类架构尾巴
24
+ _VERSION_RE = re.compile(
25
+ r"v?(\d+(?:\.\d+)+[A-Za-z0-9.+~]*"
26
+ r"(?:-(?:beta|alpha|rc|dev|pre|nightly)[A-Za-z0-9.]*)?)")
27
+ _USR_BIN_RE = re.compile(r"(?:^|/)usr/bin/([^/]+)$")
28
+
29
+
30
+ def _squashfs_offset(path, limit):
31
+ """找 squashfs 超块偏移;找不到返回 None。"""
32
+ try:
33
+ with open(path, "rb") as fh:
34
+ data = fh.read(limit)
35
+ except OSError:
36
+ return None
37
+ i = 0
38
+ while True:
39
+ i = data.find(b"hsqs", i)
40
+ if i < 0 or i + 16 > len(data):
41
+ return None
42
+ # 超块布局: magic(4) + inode_count(4) + creation_time(4) + block_size(4)
43
+ inodes, _mtime, blk = struct.unpack("<III", data[i + 4:i + 16])
44
+ if (blk and (blk & (blk - 1)) == 0 and 4096 <= blk <= 1 << 20
45
+ and 0 < inodes < 10 ** 6):
46
+ return i
47
+ i += 1
48
+
49
+
50
+ def _payload_listing(path, offset, cfg):
51
+ """unsquashfs -l → (显示名, [usr/bin 可执行])。失败返回 ("", [])。"""
52
+ if not shutil.which("unsquashfs"):
53
+ return "", []
54
+ try:
55
+ p = subprocess.run(["unsquashfs", "-q", "-o", str(offset), "-l", path],
56
+ capture_output=True, text=True, timeout=cfg.timeout_query)
57
+ except (OSError, subprocess.SubprocessError):
58
+ return "", []
59
+ display, bins = "", []
60
+ for line in p.stdout.splitlines():
61
+ line = line.strip()
62
+ if not line or "/" not in line:
63
+ continue
64
+ base = line.rsplit("/", 1)[-1]
65
+ if not display and base.endswith(".desktop"):
66
+ display = base[: -len(".desktop")]
67
+ m = _USR_BIN_RE.search(line)
68
+ if m and m.group(1) not in cfg.appimage_xdg_wrappers:
69
+ bins.append(m.group(1))
70
+ return display, bins[:cfg.appimage_max_exes]
71
+
72
+
73
+ def _version_from_name(stem):
74
+ found = _VERSION_RE.findall(stem)
75
+ return found[-1] if found else ""
76
+
77
+
78
+ class AppImageBackend(Backend):
79
+ pkg_type = "appimage"
80
+
81
+ @classmethod
82
+ def available(cls, cfg=CFG):
83
+ home = cfg.home
84
+ cands = [os.path.join(home, d) for d in
85
+ cfg.appimage_app_dirs + cfg.appimage_download_dirs]
86
+ cands += list(cfg.appimage_system_dirs)
87
+ return any(os.path.isdir(d) for d in cands)
88
+
89
+ def _settled_dirs(self):
90
+ """已安置目录:这些位置下的 .AppImage 算便携应用,其余算散落文件。"""
91
+ home = self.cfg.home
92
+ return [os.path.join(home, d) for d in self.cfg.appimage_app_dirs] \
93
+ + list(self.cfg.appimage_system_dirs)
94
+
95
+ def _record(self, path, loose):
96
+ cfg = self.cfg
97
+ fn = os.path.basename(path)
98
+ stem = fn.rsplit(".", 1)[0] if "." in fn else fn
99
+ try:
100
+ mtime = time.strftime("%Y-%m-%d", time.localtime(os.path.getmtime(path)))
101
+ except OSError:
102
+ mtime = ""
103
+
104
+ name, exes = stem, []
105
+ off = _squashfs_offset(path, cfg.appimage_scan_limit)
106
+ if off is not None:
107
+ display, exes = _payload_listing(path, off, cfg)
108
+ if display:
109
+ name = display
110
+ extra = {"found_at": path, "mtime": mtime}
111
+ if loose:
112
+ extra.update(loose=path, state="uninstalled",
113
+ found_in=os.path.basename(os.path.dirname(path)))
114
+ return PackageRecord(
115
+ pkg_type=self.pkg_type, name=name,
116
+ version=_version_from_name(stem) or "?",
117
+ # name 取自 .desktop 显示名,两个不同文件可能同名;用文件名做变体,
118
+ # 否则 /opt 里已安置的和下载目录里散落的那份会互相顶掉
119
+ variant=fn,
120
+ origin_kind=OriginKind.FILE if loose else OriginKind.LOCAL,
121
+ size_mb=file_size_mb(path),
122
+ install_path=path, executables=exes, extra=extra)
123
+
124
+ def collect(self):
125
+ records, seen = [], set()
126
+
127
+ def add(path, loose):
128
+ try:
129
+ rp = os.path.realpath(path)
130
+ except OSError:
131
+ return
132
+ if rp in seen:
133
+ return
134
+ seen.add(rp)
135
+ records.append(self._record(path, loose))
136
+
137
+ for d in self._settled_dirs():
138
+ if not os.path.isdir(d):
139
+ continue
140
+ for f in sorted(os.listdir(d)):
141
+ if f.lower().endswith(".appimage"):
142
+ add(os.path.join(d, f), loose=False)
143
+ for p in scan_file_areas((".appimage",), cfg=self.cfg):
144
+ add(p, loose=True)
145
+ return records