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.
@@ -0,0 +1,167 @@
1
+ """pkgtool.backends.deb — deb/apt 已安装包 + 磁盘散落 .deb 文件的采集。
2
+
3
+ 数据来源(按可信度排序):
4
+ 1. /var/lib/dpkg/status 当前装了什么(名字/版本/状态)
5
+ 2. /var/lib/dpkg/info/*.list 每个包的文件清单(安装路径、可执行文件)
6
+ 3. /var/log/dpkg.log* 所有 dpkg 操作的时间线(含 dpkg -i,最接近事实)
7
+ 4. /var/log/apt/history.log* 仅 apt 系工具的操作记录(apt install ./x.deb 也在里面)
8
+ 5. extended_states auto/manual 标记的权威存储
9
+ 6. /var/lib/apt/lists 当前已配置的源(apt 不持久记录"包从哪个源下载",
10
+ 只能用当前索引反推来源)
11
+
12
+ 关键设计:安装通道(channel) 与 是否在源里(in_repo) 是两个正交维度,分开存。
13
+ 原实现把两者挤进 extra["channel"] 一个字段,上层再用索引结论覆盖后端的日志结论,
14
+ 154 个镜像自带包因此被误报成 "apt 源安装",前端判预装的分支成了死代码。
15
+ """
16
+ import os
17
+
18
+ from ..apt import deps, dpkg, lists, logs
19
+ from ..base import (Backend, Channel, OriginKind, PackageRecord, file_size_mb,
20
+ scan_file_areas)
21
+ from ..config import CFG
22
+
23
+
24
+ def resolve_channel(has_log, in_history, in_repo, cutoff, log_ts=None):
25
+ """安装通道判定 —— 全项目唯一一处(原实现分散在 deb_backend.collect 和
26
+ update_channels.deb_channels,两套规则还会互相覆盖)。
27
+
28
+ 优先级(从强证据到弱证据):
29
+ 1. dpkg.log 无记录:apt history 有 → APT;否则在源里 → APT(日志已轮转,
30
+ 但 apt 能管);都不满足 → UNKNOWN
31
+ 2. 首次安装落在系统出生时间窗内,且 apt history 无记录 → PREINSTALLED
32
+ (history 优先于时间窗:镜像构建期的 apt 操作也会进 history)
33
+ 3. apt history 有记录:在源里 → APT;不在 → APT_LOCAL(源已移除/本地 .deb)
34
+ 4. 只有 dpkg.log 记录(= 有人跑过 dpkg -i)→ DPKG_LOCAL
35
+ 注意:即使这个包同时存在于源里,也判 DPKG_LOCAL —— 通道回答的是
36
+ "怎么装进来的","能不能用 apt 升级"由 in_repo/candidate 单独回答。
37
+ """
38
+ if not has_log:
39
+ if in_history:
40
+ return Channel.APT
41
+ return Channel.APT if in_repo else Channel.UNKNOWN
42
+ if in_history:
43
+ return Channel.APT if in_repo else Channel.APT_LOCAL
44
+ if cutoff is not None:
45
+ dt = logs.parse_ts(log_ts)
46
+ if dt is not None and dt <= cutoff:
47
+ return Channel.PREINSTALLED
48
+ return Channel.DPKG_LOCAL
49
+
50
+
51
+ def resolve_origin(name, version, index):
52
+ """来源判定 → (OriginKind, [源标签])。三级置信度:
53
+ (包,版本) 精确命中某源 → REPO(高置信)
54
+ 包在源里但版本对不上 → REPO_STALE(源已更新,或源被移除后重新配置过)
55
+ 包不在任何源 → LOCAL(本地 .deb,高置信)
56
+ """
57
+ exact = index.repos(name, version)
58
+ if exact:
59
+ return OriginKind.REPO, exact
60
+ any_ver = index.repos(name)
61
+ if any_ver:
62
+ return OriginKind.REPO_STALE, any_ver
63
+ return OriginKind.LOCAL, []
64
+
65
+
66
+ def _top_dirs(files):
67
+ """文件清单里的顶层目录(去重排序)。
68
+ 清单首行常是 "/."(dpkg 用来声明根目录所有权),lstrip 后会变成 ".",
69
+ 不排除就会在安装路径里显示成一个 meaningless 的点。"""
70
+ out = set()
71
+ for p in files:
72
+ if not p.startswith("/"):
73
+ continue
74
+ head = p.lstrip("/").split("/")[0]
75
+ if head and head != ".":
76
+ out.add(head)
77
+ return sorted(out)
78
+
79
+
80
+ class DebBackend(Backend):
81
+ pkg_type = "deb"
82
+
83
+ @classmethod
84
+ def available(cls, cfg=CFG):
85
+ return os.path.isfile(cfg.dpkg_status)
86
+
87
+ def collect(self):
88
+ cfg = self.cfg
89
+ installed = dpkg.installed(cfg)
90
+ first_install, earliest = logs.dpkg_installs(cfg)
91
+ history = logs.apt_history(cfg)
92
+ manual, auto = dpkg.apt_marks(cfg)
93
+ ext_autos, ext_path = dpkg.extended_states(cfg)
94
+ cutoff = logs.birth_cutoff(earliest, cfg)
95
+ index = lists.load_index(cfg)
96
+ graph = deps.DepGraph(installed, manual)
97
+
98
+ records = []
99
+ for name in sorted(installed):
100
+ entry = installed[name]
101
+ version = entry.version
102
+ files = dpkg.package_files(name, cfg)[0]
103
+ exes = [p for p in files
104
+ if any(p.startswith(d + "/") for d in cfg.bin_dirs)
105
+ and os.access(p, os.X_OK)]
106
+ top_dirs = _top_dirs(files)
107
+ desktops = [f.rsplit("/", 1)[-1][: -len(".desktop")]
108
+ for f in files if f.endswith(".desktop")]
109
+
110
+ log_ts, _log_ver = first_install.get(name, ("", ""))
111
+ channel = resolve_channel(has_log=bool(log_ts),
112
+ in_history=name in history,
113
+ in_repo=name in index, cutoff=cutoff,
114
+ log_ts=log_ts)
115
+ mark = "manual" if name in manual else ("auto" if name in auto else "?")
116
+ ext_mark = "absent" if ext_path is None else \
117
+ ("auto" if ext_autos.get(name, False) else "manual")
118
+ extra = {"apt_mark": mark, "ext_states": ext_mark, "top_dirs": top_dirs}
119
+ if mark != "?" and ext_mark != "absent" and mark != ext_mark:
120
+ extra["mark_conflict"] = f"apt-mark={mark} extended_states={ext_mark}"
121
+ if len(desktops) == 1:
122
+ # 只有一个 .desktop 时才把它当作该包的应用标识。装了多个的包
123
+ # (ubuntu-settings 顺带 gnome-initial-setup.desktop / info.desktop)
124
+ # 拿第一个去匹配残留,会删到别的包的配置目录。
125
+ extra["desktop_id"] = desktops[0]
126
+ elif desktops:
127
+ extra["desktop_files"] = desktops
128
+
129
+ kind, repos = resolve_origin(name, version, index)
130
+ candidate = index.candidate(name)
131
+ own, dep_mb, _total = graph.total_mb(name)
132
+ records.append(PackageRecord(
133
+ pkg_type=self.pkg_type, name=name, version=version,
134
+ origin_kind=kind, origin_repos=repos, channel=channel,
135
+ in_repo=name in index,
136
+ candidate=candidate if lists.is_upgrade(candidate, version) else "",
137
+ size_mb=own, exclusive_deps=sorted(graph.exclusive(name)),
138
+ deps_size_mb=dep_mb,
139
+ install_path=";".join(top_dirs[:cfg.top_dirs_limit]),
140
+ executables=exes, first_install=log_ts, extra=extra))
141
+
142
+ records.extend(self._loose_files(installed, index))
143
+ return records
144
+
145
+ def _loose_files(self, installed, index):
146
+ """磁盘上散落(未安装,或已安装但文件重复留着)的 .deb。"""
147
+ out = []
148
+ for path in scan_file_areas((".deb",), cfg=self.cfg):
149
+ info = dpkg.deb_file_info(path, cfg=self.cfg)
150
+ if not info:
151
+ continue
152
+ name, version = info["Package"], info.get("Version", "?")
153
+ kind, repos = resolve_origin(name, version, index)
154
+ out.append(PackageRecord(
155
+ pkg_type=self.pkg_type, name=name, version=version,
156
+ # 用文件名做变体:散落文件与同名已安装包是两条不同的事实
157
+ #(一条说"装了什么",一条说"磁盘上还留着多大的安装包"),
158
+ # 合并成一条会把"这个 .deb 可以删了回收 220MB"的信息丢掉。
159
+ variant=os.path.basename(path),
160
+ origin_kind=OriginKind.FILE,
161
+ in_repo=name in index,
162
+ size_mb=file_size_mb(path), extra={
163
+ "loose": path,
164
+ "found_in": os.path.basename(os.path.dirname(path)),
165
+ "state": "duplicate" if name in installed else "uninstalled",
166
+ "repo_kind": kind.value, "repo_labels": repos}))
167
+ return out
@@ -0,0 +1,149 @@
1
+ """pkgtool.backends.flatpak — flatpak 包识别。
2
+
3
+ 数据源(优先文件系统,元数据就在安装目录里):
4
+ <instance>/app/<app-id>/<arch>/<branch>/<commit>/metadata INI 格式元数据
5
+ [Application] name/runtime/command/...(注意:通常没有 version 字段)
6
+ <commit>/export/bin/ 该 app 暴露的命令
7
+ <commit>/files/ 实际文件负载
8
+ 实例位置:system=/var/lib/flatpak user=~/.local/share/flatpak
9
+ 增强(失败自动降级,版本号只能从这里拿):
10
+ flatpak list --app/--runtime --columns=...
11
+ 远端更新检测(需要联网,默认不做,见 check_updates):
12
+ flatpak remote-ls --updates
13
+ """
14
+ import configparser
15
+ import glob
16
+ import os
17
+ import re
18
+ import subprocess
19
+
20
+ from ..base import Backend, OriginKind, PackageRecord, file_size_mb
21
+ from ..config import CFG
22
+
23
+ # app-id 允许下划线:app.zen_browser.zen 这类很常见,漏掉它会让这些应用
24
+ # 的更新检测被静默跳过(正则不匹配就当成非法行丢弃)
25
+ _APP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*\.[A-Za-z0-9_\-]+$")
26
+ _COLUMNS = "application,version,name,arch,branch,installation,origin"
27
+
28
+
29
+ def _flatpak_list(kind, cfg):
30
+ """flatpak list --app|--runtime → [(id, version, name, arch, branch, install, origin)]。
31
+ 显式指定 --columns,避免默认列序随版本/locale 变化。"""
32
+ try:
33
+ out = subprocess.run(
34
+ ["flatpak", "list", f"--{kind}", f"--columns={_COLUMNS}"],
35
+ capture_output=True, text=True, timeout=cfg.timeout_query).stdout
36
+ except (OSError, subprocess.SubprocessError):
37
+ return []
38
+ rows = []
39
+ for line in out.splitlines():
40
+ cols = [c.strip() for c in line.split("\t")]
41
+ if len(cols) >= 7 and cols[0]:
42
+ rows.append(tuple(cols[:7]))
43
+ return rows
44
+
45
+
46
+ def _parse_metadata(path):
47
+ """读 flatpak metadata 的 [Application] 段。
48
+ 注意用 read_file 而不是 read:read() 不接受 errors=,原实现传了这个参数,
49
+ TypeError 又被 except Exception 静默吞掉,结果每个 metadata 都解析成空 dict。"""
50
+ cp = configparser.ConfigParser(interpolation=None)
51
+ try:
52
+ with open(path, encoding="utf-8", errors="replace") as fh:
53
+ cp.read_file(fh)
54
+ except (configparser.Error, OSError, UnicodeDecodeError):
55
+ return {}
56
+ return dict(cp["Application"]) if cp.has_section("Application") else {}
57
+
58
+
59
+ def check_updates(cfg=CFG, timeout=90):
60
+ """有新版的 flatpak 应用 → {app_id: 显示名}。
61
+ 需要联网(每个 installation 约 2 秒),所以默认不调用,由 --check-updates 触发。
62
+
63
+ 两个 installation 都要查:不带作用域时 flatpak 只看默认(系统)安装,
64
+ 用户级装的应用有新版会被整个漏掉。"""
65
+ out = {}
66
+ for scope in ("--system", "--user"):
67
+ try:
68
+ p = subprocess.run(["flatpak", "remote-ls", "--updates", scope,
69
+ "--columns=name,application"],
70
+ capture_output=True, text=True, timeout=timeout)
71
+ except (OSError, subprocess.SubprocessError):
72
+ continue
73
+ for line in p.stdout.splitlines():
74
+ cols = [c.strip() for c in line.split("\t")]
75
+ if len(cols) >= 2 and _APP_ID_RE.match(cols[1]):
76
+ out[cols[1]] = cols[0]
77
+ return out
78
+
79
+
80
+ class FlatpakBackend(Backend):
81
+ pkg_type = "flatpak"
82
+ runtime_pkg_type = "flatpak-runtime"
83
+
84
+ @classmethod
85
+ def available(cls, cfg=CFG):
86
+ return (os.path.isdir(os.path.join(cfg.flatpak_system_dir, "app"))
87
+ or os.path.isdir(os.path.join(cfg.flatpak_user_dir, "app")))
88
+
89
+ def _instances(self):
90
+ for label, d in (("system", self.cfg.flatpak_system_dir),
91
+ ("user", self.cfg.flatpak_user_dir)):
92
+ if os.path.isdir(os.path.join(d, "app")):
93
+ yield label, d
94
+
95
+ def collect(self):
96
+ cfg = self.cfg
97
+ cli_app = {(r[0], r[3], r[4]): r for r in _flatpak_list("app", cfg)}
98
+ cli_rt = {(r[0], r[3], r[4]): r for r in _flatpak_list("runtime", cfg)}
99
+ records = []
100
+ for inst_label, base in self._instances():
101
+ exports_bin = (os.path.join(base, "exports", "bin") if inst_label == "user"
102
+ else cfg.flatpak_exports_bin)
103
+ for kind, cli_db in (("app", cli_app), ("runtime", cli_rt)):
104
+ app_dir = os.path.join(base, kind)
105
+ if not os.path.isdir(app_dir):
106
+ continue
107
+ for meta in glob.glob(os.path.join(app_dir, "*/*/*/*/metadata")):
108
+ records.append(self._record(meta, kind, inst_label,
109
+ cli_db, exports_bin))
110
+ return [r for r in records if r is not None]
111
+
112
+ def _record(self, meta, kind, inst_label, cli_db, exports_bin):
113
+ commit_dir = os.path.dirname(meta)
114
+ # active/current 是回指真实 commit 目录的符号链接,跳过避免重复计数
115
+ if os.path.islink(commit_dir):
116
+ return None
117
+ parts = meta.split(os.sep) # .../app/<id>/<arch>/<branch>/<commit>/metadata
118
+ app_id, arch, branch = parts[-5], parts[-4], parts[-3]
119
+ md = _parse_metadata(meta)
120
+ row = cli_db.get((app_id, arch, branch))
121
+ version, disp_name, origin = (row[1], row[2], row[6]) if row else ("?", "", "")
122
+
123
+ cmd = md.get("command", "")
124
+ exes = []
125
+ exp_bin = os.path.join(commit_dir, "export", "bin")
126
+ if os.path.isdir(exp_bin):
127
+ exes += [os.path.join(exp_bin, e) for e in sorted(os.listdir(exp_bin))]
128
+ if os.path.isdir(exports_bin):
129
+ for e in sorted(os.listdir(exports_bin)):
130
+ if e in (app_id, cmd):
131
+ exes.append(os.path.join(exports_bin, e))
132
+
133
+ extra = {"kind": kind, "installation": inst_label, "arch": arch,
134
+ "branch": branch, "commit": os.path.basename(commit_dir)[:12]}
135
+ if disp_name:
136
+ extra["display_name"] = disp_name
137
+ if md.get("runtime"):
138
+ extra["runtime"] = md["runtime"]
139
+ return PackageRecord(
140
+ pkg_type=self.runtime_pkg_type if kind == "runtime" else self.pkg_type,
141
+ name=app_id, version=version or "?",
142
+ # 同一 app-id 可以并存多个 arch/branch(如 GL.default 的 25.08 与
143
+ # 25.08-extra),不设变体维度它们会撞 key,按名字定位时无法区分
144
+ variant=f"{arch}/{branch}",
145
+ origin_kind=OriginKind.REPO if origin else OriginKind.LOCAL,
146
+ origin_repos=[origin] if origin else [f"flatpak({inst_label})"],
147
+ size_mb=file_size_mb(os.path.join(commit_dir, "files")),
148
+ install_path=os.path.join(commit_dir, "files"),
149
+ executables=exes, extra=extra)
@@ -0,0 +1,110 @@
1
+ """pkgtool.backends.linyap — 如意玲珑(linyaps)应用识别。
2
+
3
+ 数据源(离线、无需 root):
4
+ ll-cli list --json 已安装应用与 base/runtime 的完整元数据
5
+ <repo>/states.json 的 layers CLI 不可用时的降级来源(字段与上同构)
6
+
7
+ 实测与社区反馈的坑点,这里逐条处理:
8
+ · ll-cli 不带 --json 的表格输出会漏条目(本机 VSCode + base 两层只有
9
+ --json 吐出来了),所以只用 --json,表格一概不解析
10
+ · base 层的 kind 也是 "runtime",区分应用与运行时只看 kind,不看名字
11
+ · ref 完整形态是 渠道:ID/版本/架构/模块;应用与 deb/snap 同名很常见
12
+ (firefox 三格式都有),变体里保留架构与模块
13
+ · 同一应用多版本可能并存(升级后旧版未清),同变体键取先出现的,
14
+ 避免升级期间两条记录互相顶掉
15
+ """
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import time
20
+
21
+ from ..base import (BYTES_PER_MB, Backend, OriginKind, PackageRecord,
22
+ is_safe_name)
23
+ from ..config import CFG
24
+
25
+
26
+ def _list_json(cfg):
27
+ """ll-cli list --json → [PackageInfo dict];任何失败返回 []。"""
28
+ try:
29
+ p = subprocess.run(["ll-cli", "list", "--json"],
30
+ capture_output=True, text=True,
31
+ timeout=cfg.timeout_query)
32
+ except (OSError, subprocess.SubprocessError):
33
+ return []
34
+ if p.returncode != 0:
35
+ return []
36
+ try:
37
+ data = json.loads(p.stdout)
38
+ except json.JSONDecodeError:
39
+ return []
40
+ return data if isinstance(data, list) else []
41
+
42
+
43
+ def _states_layers(cfg):
44
+ """states.json 的 layers 数组,CLI 失败时的降级来源。"""
45
+ path = os.path.join(cfg.linyap_repo_dir, "states.json")
46
+ try:
47
+ with open(path, encoding="utf-8", errors="replace") as fh:
48
+ data = json.load(fh)
49
+ except (OSError, json.JSONDecodeError):
50
+ return []
51
+ layers = data.get("layers") if isinstance(data, dict) else None
52
+ if not isinstance(layers, list):
53
+ return []
54
+ # 字段结构未经真实数据核实,只接受长得像 PackageInfo 的条目
55
+ return [x for x in layers
56
+ if isinstance(x, dict) and x.get("id") and x.get("version")]
57
+
58
+
59
+ def _first_install(ts):
60
+ try:
61
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(ts)))
62
+ except (TypeError, ValueError, OSError, OverflowError):
63
+ return ""
64
+
65
+
66
+ class LinyapBackend(Backend):
67
+ pkg_type = "linyap"
68
+
69
+ @classmethod
70
+ def available(cls, cfg=CFG):
71
+ return (os.path.isdir(cfg.linyap_repo_dir)
72
+ or bool(cls._which_ll_cli()))
73
+
74
+ @staticmethod
75
+ def _which_ll_cli():
76
+ import shutil
77
+ return shutil.which("ll-cli")
78
+
79
+ def collect(self):
80
+ items = _list_json(self.cfg) or _states_layers(self.cfg)
81
+ records = []
82
+ for it in items:
83
+ app_id = it.get("id")
84
+ version = it.get("version") or "?"
85
+ if not app_id or not is_safe_name(str(app_id)):
86
+ continue
87
+ archs = it.get("arch") or []
88
+ module = it.get("module") or ""
89
+ arch = archs[0] if archs else "?"
90
+ extra = {"kind": it.get("kind") or "",
91
+ "channel": it.get("channel") or "",
92
+ "module": module,
93
+ "arch": ",".join(str(a) for a in archs)}
94
+ if it.get("base"):
95
+ extra["base"] = it["base"]
96
+ command = [str(c) for c in (it.get("command") or [])]
97
+ if command:
98
+ extra["command"] = " ".join(command)
99
+ records.append(PackageRecord(
100
+ pkg_type=self.pkg_type, name=str(app_id), version=version,
101
+ variant=f"{arch}/{module or '?'}",
102
+ origin_kind=(OriginKind.REPO if it.get("channel")
103
+ else OriginKind.LOCAL),
104
+ origin_repos=(["linglong " + it["channel"]]
105
+ if it.get("channel") else []),
106
+ size_mb=round(float(it.get("size") or 0) / BYTES_PER_MB, 1),
107
+ executables=command,
108
+ first_install=_first_install(it.get("install_time")),
109
+ extra=extra))
110
+ return records
@@ -0,0 +1,106 @@
1
+ """pkgtool.backends.snap — snap 包识别。
2
+
3
+ 数据源(优先文件系统,离线可用、无需 root):
4
+ /var/lib/snapd/snaps/<name>_<rev>.snap 已下载的每个修订版本(持久缓存)
5
+ /snap/<name>/current -> <rev> 当前激活的修订
6
+ /snap/<name>/<rev>/bin/ 包内可执行文件
7
+ /snap/<name>.<app> /snap/bin 下的全局命令入口
8
+ 增强(失败自动降级):
9
+ snap list —— 人类可读版本、追踪频道、发布者。表头随 locale 变,
10
+ 所以按列位置解析,并要求第 3 列是纯数字修订号才认这一行。
11
+ """
12
+ import glob
13
+ import os
14
+ import re
15
+ import subprocess
16
+
17
+ from ..base import Backend, OriginKind, PackageRecord, file_size_mb
18
+ from ..config import CFG
19
+
20
+ _SNAP_FILE_RE = re.compile(r"(.+)_(\d+)\.snap$")
21
+ _STORE_LABEL = "snap-store"
22
+
23
+
24
+ class SnapBackend(Backend):
25
+ pkg_type = "snap"
26
+
27
+ @classmethod
28
+ def available(cls, cfg=CFG):
29
+ return os.path.isdir(cfg.snap_store_dir)
30
+
31
+ def _list_info(self):
32
+ """解析 `snap list` 表格 → {包名: (版本, 修订, 追踪, 发布者)}。"""
33
+ try:
34
+ out = subprocess.run(["snap", "list"], capture_output=True, text=True,
35
+ timeout=self.cfg.timeout_query).stdout
36
+ except (OSError, subprocess.SubprocessError):
37
+ return {}
38
+ info = {}
39
+ for line in out.splitlines():
40
+ cols = line.split()
41
+ # 位置: [名称, 版本, 修订, 追踪, (发布者), (注记)] —— 与表头语言无关
42
+ if len(cols) >= 4 and cols[2].isdigit() and not cols[0][0].isupper():
43
+ info[cols[0]] = (cols[1], cols[2], cols[3],
44
+ cols[4] if len(cols) > 4 else "")
45
+ return info
46
+
47
+ def stored_revisions(self):
48
+ """→ {包名: {修订号: .snap 文件路径}}。clean 子命令用它找旧修订。"""
49
+ stored = {}
50
+ for f in glob.glob(os.path.join(self.cfg.snap_store_dir, "*.snap")):
51
+ m = _SNAP_FILE_RE.match(os.path.basename(f))
52
+ if m:
53
+ stored.setdefault(m.group(1), {})[m.group(2)] = f
54
+ return stored
55
+
56
+ def active_revision(self, name, revs):
57
+ """/snap/<name>/current 符号链接指向的修订;链接缺失时取最高修订兜底。
58
+ 注意按整数比:修订号是数字,字符串比较会得出 "9" > "10"。"""
59
+ try:
60
+ tgt = os.readlink(os.path.join(self.cfg.snap_mount_dir, name, "current"))
61
+ if tgt.isdigit() and tgt in revs:
62
+ return tgt
63
+ except OSError:
64
+ pass
65
+ return max(revs, key=int)
66
+
67
+ def _executables(self, name, rev):
68
+ cfg = self.cfg
69
+ exes = []
70
+ bin_dir = os.path.join(cfg.snap_mount_dir, name, rev, "bin")
71
+ if os.path.isdir(bin_dir):
72
+ for e in sorted(os.listdir(bin_dir)):
73
+ if not os.path.isdir(os.path.join(bin_dir, e)):
74
+ exes.append(f"{cfg.snap_mount_dir}/{name}/current/bin/{e}")
75
+ for w in sorted(glob.glob(os.path.join(cfg.snap_mount_dir, "bin", "*"))):
76
+ wn = os.path.basename(w)
77
+ if wn == name or wn.startswith(name + "."):
78
+ exes.append(w)
79
+ return exes
80
+
81
+ def collect(self):
82
+ info = self._list_info()
83
+ records = []
84
+ for name, revs in sorted(self.stored_revisions().items()):
85
+ active = self.active_revision(name, revs)
86
+ version, tracking, publisher = "?", "", ""
87
+ row = info.get(name)
88
+ if row and row[1] == active:
89
+ version, _rev, tracking, publisher = row
90
+
91
+ extra = {"revision": active,
92
+ "stored_revs": ",".join(sorted(revs, key=int)),
93
+ "snap_file": revs[active]}
94
+ if tracking:
95
+ extra["tracking"] = tracking
96
+ if publisher:
97
+ extra["publisher"] = publisher
98
+ records.append(PackageRecord(
99
+ pkg_type=self.pkg_type, name=name, version=version,
100
+ # 有追踪频道 = 来自 snap store 且能自动刷新;否则只是本地缓存目录里的文件
101
+ origin_kind=OriginKind.REPO if tracking else OriginKind.LOCAL,
102
+ origin_repos=[f"{_STORE_LABEL} {tracking}".strip()] if tracking else [],
103
+ size_mb=file_size_mb(revs[active]),
104
+ install_path=f"{self.cfg.snap_mount_dir}/{name}",
105
+ executables=self._executables(name, active), extra=extra))
106
+ return records