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.
- pkgmate-0.0.1.dist-info/METADATA +10 -0
- pkgmate-0.0.1.dist-info/RECORD +36 -0
- pkgmate-0.0.1.dist-info/WHEEL +5 -0
- pkgmate-0.0.1.dist-info/entry_points.txt +2 -0
- pkgmate-0.0.1.dist-info/licenses/LICENSE +674 -0
- pkgmate-0.0.1.dist-info/top_level.txt +1 -0
- pkgtool/__init__.py +26 -0
- pkgtool/__main__.py +7 -0
- pkgtool/app.py +841 -0
- pkgtool/apt/__init__.py +9 -0
- pkgtool/apt/actions.py +193 -0
- pkgtool/apt/deps.py +94 -0
- pkgtool/apt/dpkg.py +153 -0
- pkgtool/apt/lists.py +336 -0
- pkgtool/apt/logs.py +83 -0
- pkgtool/apt/version.py +92 -0
- pkgtool/backends/__init__.py +30 -0
- pkgtool/backends/appimage.py +145 -0
- pkgtool/backends/deb.py +167 -0
- pkgtool/backends/flatpak.py +149 -0
- pkgtool/backends/linyap.py +110 -0
- pkgtool/backends/snap.py +106 -0
- pkgtool/base.py +238 -0
- pkgtool/catalog.py +345 -0
- pkgtool/classify.py +148 -0
- pkgtool/clean.py +288 -0
- pkgtool/cli.py +500 -0
- pkgtool/compress.py +56 -0
- pkgtool/config.py +236 -0
- pkgtool/inventory.py +167 -0
- pkgtool/labels.py +210 -0
- pkgtool/launch.py +156 -0
- pkgtool/remove.py +301 -0
- pkgtool/report.py +339 -0
- pkgtool/tui.py +473 -0
- pkgtool/upgrade.py +46 -0
pkgtool/backends/deb.py
ADDED
|
@@ -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
|
pkgtool/backends/snap.py
ADDED
|
@@ -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
|