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/base.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""pkgtool.base — 跨格式统一数据模型、后端契约、文件扫描。
|
|
2
|
+
|
|
3
|
+
这一层只定义"数据长什么样",不含任何展示文案(见 labels.py)和采集逻辑
|
|
4
|
+
(见 backends/)。核心设计约束:
|
|
5
|
+
|
|
6
|
+
· 结论一律用枚举 code 存,不存中文显示串。原实现把结论编码进
|
|
7
|
+
origin="local(不在任何源)" / channel="preinstalled(镜像自带)" 这类字符串,
|
|
8
|
+
三个调用方各自用 startswith 反解,字面量一改就静默出错。
|
|
9
|
+
· 「安装通道」(channel) 与「当前是否在 apt 源里」(in_repo) 是两个正交维度,
|
|
10
|
+
必须分开存。原实现挤在 extra["channel"] 一个字段里,导致后端算出的
|
|
11
|
+
preinstalled 结论被上层用索引结论覆盖(154 个镜像自带包被误报成 apt 安装)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
from .config import CFG
|
|
24
|
+
|
|
25
|
+
# 包名 / 版本 / flatpak app-id 的白名单。允许 epoch 的冒号、Debian 版本的 ~ +,
|
|
26
|
+
# 以及下划线——flatpak 的 app-id 规范允许 [A-Za-z0-9._-],实际大量存在
|
|
27
|
+
# (app.zen_browser.zen),漏掉下划线会导致这些应用根本装不了。
|
|
28
|
+
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.:+_\-~]{0,199}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_safe_name(value):
|
|
32
|
+
"""校验将被拼进 apt/snap/flatpak 命令行的标识符。
|
|
33
|
+
首字符必须字母数字:拒绝前导 '-'(会被当成选项,属参数注入)、
|
|
34
|
+
空白与 shell 元字符。所有写操作入口都必须先过这一关。"""
|
|
35
|
+
return bool(value) and bool(_SAFE_NAME_RE.match(str(value)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
BYTES_PER_MB = 1048576
|
|
39
|
+
|
|
40
|
+
# 搜索时当成词分隔符的字符:空白 + app.zen_browser.zen 里的 . 和 _
|
|
41
|
+
_QUERY_SPLIT_RE = re.compile(r"[\s._\-/+:]+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def query_tokens(q):
|
|
45
|
+
"""把查询串拆成小写 token:空白与 . _ - / + : 都算分隔符。
|
|
46
|
+
整串子串匹配搜不到 "zen browser"→app.zen_browser.zen(一边是空格、
|
|
47
|
+
一边是下划线),拆词后两边归一到同一形态才能对上。"""
|
|
48
|
+
return [t for t in _QUERY_SPLIT_RE.split((q or "").lower()) if t]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def matches_tokens(haystack, tokens):
|
|
52
|
+
"""全部 token 都出现才算命中(AND 语义)。haystack 同样先做分隔符归一。"""
|
|
53
|
+
if not tokens:
|
|
54
|
+
return False
|
|
55
|
+
h = " " + _QUERY_SPLIT_RE.sub(" ", (haystack or "").lower()) + " "
|
|
56
|
+
return all(t in h for t in tokens)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def file_size_mb(path):
|
|
60
|
+
"""文件/目录占用(MB,一位小数)。目录递归累加,读不到的条目跳过。"""
|
|
61
|
+
total = 0
|
|
62
|
+
try:
|
|
63
|
+
if os.path.isfile(path) or os.path.islink(path):
|
|
64
|
+
total = os.path.getsize(path)
|
|
65
|
+
else:
|
|
66
|
+
for dirpath, _dirs, files in os.walk(path):
|
|
67
|
+
for f in files:
|
|
68
|
+
try:
|
|
69
|
+
total += os.path.getsize(os.path.join(dirpath, f))
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
except OSError:
|
|
73
|
+
return 0.0
|
|
74
|
+
return round(total / BYTES_PER_MB, 1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_under_home(path, cfg=CFG):
|
|
78
|
+
"""路径是否在当前用户主目录内(决定删除要不要特权)。"""
|
|
79
|
+
home = os.path.abspath(cfg.home) + os.sep
|
|
80
|
+
return os.path.abspath(path).startswith(home)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def delete_paths(paths):
|
|
84
|
+
"""普通用户权限删除文件/目录 → (成功列表, 失败信息列表)。"""
|
|
85
|
+
removed, failed = [], []
|
|
86
|
+
for p in paths:
|
|
87
|
+
try:
|
|
88
|
+
if os.path.isdir(p) and not os.path.islink(p):
|
|
89
|
+
shutil.rmtree(p)
|
|
90
|
+
else:
|
|
91
|
+
os.unlink(p)
|
|
92
|
+
removed.append(p)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
failed.append(f"{p}: {e}")
|
|
95
|
+
return removed, failed
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Channel(str, Enum):
|
|
99
|
+
"""安装通道:这个包是怎么进到系统里的。"""
|
|
100
|
+
APT = "apt" # 仓库安装,apt 可管理
|
|
101
|
+
APT_LOCAL = "apt-local" # apt install ./x.deb:历史有记录但当前无源可更新
|
|
102
|
+
DPKG_LOCAL = "dpkg-local" # dpkg -i 手动安装
|
|
103
|
+
PREINSTALLED = "preinstalled" # 镜像自带(出生时间窗内装入)
|
|
104
|
+
UNKNOWN = "unknown" # dpkg.log 与 apt history 都查无记录
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OriginKind(str, Enum):
|
|
108
|
+
"""来源:包从哪来。"""
|
|
109
|
+
REPO = "repo" # (包, 版本) 精确命中某个已配置源
|
|
110
|
+
REPO_STALE = "repo-stale" # 包在源里但版本对不上:源已更新或源被移除
|
|
111
|
+
LOCAL = "local" # 不在任何源 = 本地 .deb
|
|
112
|
+
FILE = "file" # 磁盘上散落的包文件(未安装 / 已安装的重复文件)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PkgClass(str, Enum):
|
|
116
|
+
"""卸载风险分类:只有 APP 允许删(判定规则见 classify.py)。"""
|
|
117
|
+
BASE = "base"
|
|
118
|
+
LIBRARY = "library"
|
|
119
|
+
SYSTEM = "system"
|
|
120
|
+
APP = "app"
|
|
121
|
+
FILE = "file"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _code(v):
|
|
125
|
+
return v.value if isinstance(v, Enum) else (v or "")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class PackageRecord:
|
|
130
|
+
"""一条已安装包的记录(跨格式统一结构)。"""
|
|
131
|
+
pkg_type: str
|
|
132
|
+
name: str
|
|
133
|
+
version: str = "?"
|
|
134
|
+
variant: str = "" # 同名多实例的区分维度(flatpak 的 arch/branch)
|
|
135
|
+
origin_kind: OriginKind = OriginKind.LOCAL
|
|
136
|
+
origin_repos: list = field(default_factory=list)
|
|
137
|
+
channel: Channel | None = None
|
|
138
|
+
pkg_class: PkgClass | None = None
|
|
139
|
+
class_reason: str = ""
|
|
140
|
+
in_repo: bool = False
|
|
141
|
+
candidate: str = ""
|
|
142
|
+
size_mb: float = 0.0 # 包自身已安装体积
|
|
143
|
+
exclusive_deps: list = field(default_factory=list) # 只被它一个包依赖的包
|
|
144
|
+
deps_size_mb: float = 0.0 # 上面那些独占依赖的体积合计
|
|
145
|
+
install_path: str = ""
|
|
146
|
+
executables: list = field(default_factory=list)
|
|
147
|
+
first_install: str = ""
|
|
148
|
+
extra: dict = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def key(self):
|
|
152
|
+
"""全局唯一键:(类型, 名称, 变体)。用于按名字定位记录与去重。"""
|
|
153
|
+
return (self.pkg_type, self.name, self.variant)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def upgradable(self):
|
|
157
|
+
return bool(self.candidate)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def is_loose_file(self):
|
|
161
|
+
"""磁盘上的包文件(不是一条已安装记录)。"""
|
|
162
|
+
return bool(self.extra.get("loose"))
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def total_size_mb(self):
|
|
166
|
+
"""自身 + 独占依赖。独占依赖 = 只有这个包在用、没人共用的那些,
|
|
167
|
+
删掉这个包它们也就没用了(定义见 apt/deps.py)。"""
|
|
168
|
+
return round(self.size_mb + self.deps_size_mb, 1)
|
|
169
|
+
|
|
170
|
+
def to_row(self):
|
|
171
|
+
"""→ CSV 行。全部用 code,extra 用 JSON(原先 k=v 空格拼接会被含空格的值破坏)。"""
|
|
172
|
+
return [self.pkg_type, self.name, self.variant, self.version,
|
|
173
|
+
_code(self.origin_kind), ";".join(self.origin_repos),
|
|
174
|
+
_code(self.channel), int(self.in_repo), self.candidate,
|
|
175
|
+
_code(self.pkg_class), self.class_reason,
|
|
176
|
+
self.size_mb, len(self.exclusive_deps), self.deps_size_mb,
|
|
177
|
+
self.total_size_mb, self.install_path,
|
|
178
|
+
";".join(self.executables), self.first_install,
|
|
179
|
+
json.dumps(self.extra, ensure_ascii=False, sort_keys=True)]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
CSV_HEADER = ["pkg_type", "name", "variant", "version", "origin_kind",
|
|
183
|
+
"origin_repos", "channel", "in_repo", "candidate", "pkg_class",
|
|
184
|
+
"class_reason", "size_mb", "exclusive_deps", "deps_size_mb",
|
|
185
|
+
"total_size_mb", "install_path", "executables", "first_install",
|
|
186
|
+
"extra"]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Backend(ABC):
|
|
190
|
+
"""一种包格式的后端。新增格式 = 新建 backends/<fmt>.py + 在 backends/__init__ 注册。
|
|
191
|
+
|
|
192
|
+
契约:只采集事实,不做分类判定(pkg_class 留给 inventory 层统一填,
|
|
193
|
+
避免原实现里 deb 在后端分类、snap/flatpak 在 UI 分类的不对称)。
|
|
194
|
+
"""
|
|
195
|
+
pkg_type: str = "?"
|
|
196
|
+
|
|
197
|
+
def __init__(self, cfg=CFG):
|
|
198
|
+
self.cfg = cfg
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
@abstractmethod
|
|
202
|
+
def available(cls, cfg=CFG) -> bool:
|
|
203
|
+
"""该包管理体系在这台机器上是否存在(不得抛异常)。"""
|
|
204
|
+
|
|
205
|
+
@abstractmethod
|
|
206
|
+
def collect(self) -> list:
|
|
207
|
+
"""返回 [PackageRecord, ...]。实现要求:
|
|
208
|
+
- 优先读文件系统/缓存目录(离线可用、无需 root)
|
|
209
|
+
- CLI 查询仅作增强,失败要能降级
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def scan_file_areas(suffixes, maxdepth=None, cfg=CFG):
|
|
214
|
+
"""扫描用户可写区域里的包文件(.deb/.AppImage…),不进隐藏目录/缓存。
|
|
215
|
+
覆盖:主目录(含中文"下载")+ config.scan_system_roots。realpath 去重。"""
|
|
216
|
+
maxdepth = cfg.scan_maxdepth if maxdepth is None else maxdepth
|
|
217
|
+
suf = tuple(s.lower() for s in suffixes)
|
|
218
|
+
seen = set()
|
|
219
|
+
for root in cfg.scan_roots:
|
|
220
|
+
if not os.path.isdir(root):
|
|
221
|
+
continue
|
|
222
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
223
|
+
depth = dirpath[len(root):].count(os.sep)
|
|
224
|
+
dirnames[:] = [d for d in dirnames
|
|
225
|
+
if d not in cfg.prune_dirs and not d.startswith(".")]
|
|
226
|
+
if depth >= maxdepth:
|
|
227
|
+
dirnames[:] = []
|
|
228
|
+
for f in filenames:
|
|
229
|
+
if not f.lower().endswith(suf):
|
|
230
|
+
continue
|
|
231
|
+
p = os.path.join(dirpath, f)
|
|
232
|
+
try:
|
|
233
|
+
rp = os.path.realpath(p)
|
|
234
|
+
except OSError:
|
|
235
|
+
continue
|
|
236
|
+
if rp not in seen:
|
|
237
|
+
seen.add(rp)
|
|
238
|
+
yield p
|
pkgtool/catalog.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""pkgtool.catalog — 跨格式远程目录搜索与安装(apt / snap / flatpak)。
|
|
2
|
+
|
|
3
|
+
三个来源的取数方式差别很大,这里统一成 CatalogItem:
|
|
4
|
+
|
|
5
|
+
apt 读本地 /var/lib/apt/lists 索引。离线、毫秒级、含描述与全部候选版本。
|
|
6
|
+
snap `snap find`,联网查 Snap Store,约 2 秒,含发布者/注记/摘要。
|
|
7
|
+
flatpak `flatpak remote-ls`,读本地 remote 的 refs 缓存,0.3 秒 / 3483 条,
|
|
8
|
+
能拿到安装体积,但**没有描述**——描述在 appstream 目录缓存里。
|
|
9
|
+
|
|
10
|
+
flatpak 为什么不用 `flatpak search`:它依赖 remote 的 appstream 缓存,本机
|
|
11
|
+
没有时会去联网拉取,实测挂住 25 秒且无任何输出。remote-ls 走 refs 缓存,
|
|
12
|
+
稳定可用,代价是搜不到摘要、只能按名字和 app-id 匹配。
|
|
13
|
+
|
|
14
|
+
各源结果都缓存:flatpak 缓存全量清单(之后每次搜索只是本地过滤,输入即搜),
|
|
15
|
+
snap 按查询词缓存(它必须联网,不能每敲一个字符就打一次商店)。
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from .apt import actions, lists
|
|
26
|
+
from .base import is_safe_name, matches_tokens, query_tokens
|
|
27
|
+
from .config import CFG
|
|
28
|
+
|
|
29
|
+
SOURCES = ("apt", "snap", "flatpak")
|
|
30
|
+
|
|
31
|
+
_SNAP_SPLIT_RE = re.compile(r"\s{2,}")
|
|
32
|
+
_FLATPAK_COLUMNS = "name,application,installed,branch"
|
|
33
|
+
_C_ENV = {"LC_ALL": "C", "LANG": "C"} # 固定表头语言,解析才稳
|
|
34
|
+
|
|
35
|
+
_LOCK = threading.Lock()
|
|
36
|
+
_FLATPAK_CACHE = {"t": 0.0, "items": None}
|
|
37
|
+
_SNAP_CACHE = {}
|
|
38
|
+
|
|
39
|
+
# 最近一次 search() 的每源耗时与失败信息,供界面提示"哪个源慢/挂了"
|
|
40
|
+
LAST_ELAPSED = {}
|
|
41
|
+
LAST_ERRORS = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class CatalogItem:
|
|
46
|
+
"""一条可安装条目(跨源统一结构)。"""
|
|
47
|
+
source: str # apt / snap / flatpak
|
|
48
|
+
name: str # 安装标识:apt 包名 / snap 名 / flatpak app-id
|
|
49
|
+
display: str = "" # 显示名(flatpak 有独立的 name 字段)
|
|
50
|
+
version: str = ""
|
|
51
|
+
summary: str = ""
|
|
52
|
+
publisher: str = "" # apt 维护者 / snap 发布者 / flatpak remote
|
|
53
|
+
size_text: str = ""
|
|
54
|
+
channel: str = "" # apt 的 suite/component、snap 的注记、flatpak 的 branch
|
|
55
|
+
remote: str = "" # flatpak 的 remote 名
|
|
56
|
+
classic: bool = False # snap 经典 confinement,安装时必须带 --classic
|
|
57
|
+
installed: bool = False # 本机是否已装
|
|
58
|
+
extra: dict = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def title(self):
|
|
62
|
+
return self.display or self.name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run(argv, cfg, timeout=None):
|
|
66
|
+
"""跑一个查询命令 → (stdout 行列表, 失败原因)。
|
|
67
|
+
任何失败都转成空结果 + 错误串(搜索不该抛异常),调用方借此区分
|
|
68
|
+
"确实没搜到"和"查询失败"——snapd 忙碌时 snap find 会失败,静默吞掉
|
|
69
|
+
会让用户把"查询挂了"当成"商店里没有"。"""
|
|
70
|
+
try:
|
|
71
|
+
p = subprocess.run(argv, capture_output=True, text=True,
|
|
72
|
+
timeout=timeout or cfg.timeout_catalog,
|
|
73
|
+
env=dict(os.environ, **_C_ENV))
|
|
74
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
75
|
+
return [], f"{type(e).__name__}: {e}"
|
|
76
|
+
if p.returncode != 0:
|
|
77
|
+
tail = [ln for ln in (p.stderr or "").splitlines() if ln.strip()]
|
|
78
|
+
return [], tail[-1][:200] if tail else f"退出码 {p.returncode}"
|
|
79
|
+
return p.stdout.splitlines(), ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------- 各来源 ----------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _search_apt(q, limit, cfg):
|
|
86
|
+
out = []
|
|
87
|
+
for x in lists.load_index(cfg).search(q, limit):
|
|
88
|
+
out.append(CatalogItem(
|
|
89
|
+
source="apt", name=x["name"], display=x["name"], version=x["version"],
|
|
90
|
+
summary=x.get("desc") or "", publisher=x.get("maintainer") or "",
|
|
91
|
+
size_text=f"{x['size_kb']}K" if x.get("size_kb") else "",
|
|
92
|
+
channel=" ".join(y for y in (x.get("suite"), x.get("component")) if y),
|
|
93
|
+
remote=x.get("repo") or "",
|
|
94
|
+
extra={"section": x.get("section", "")}))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _search_snap(q, limit, cfg):
|
|
99
|
+
"""`snap find` 按 2+ 空格分列:Name Version Publisher Notes Summary。
|
|
100
|
+
表头随 locale 变,所以统一用 LC_ALL=C 固定成英文再跳过首行。"""
|
|
101
|
+
now = time.time()
|
|
102
|
+
with _LOCK:
|
|
103
|
+
hit = _SNAP_CACHE.get(q)
|
|
104
|
+
if hit and now - hit[0] < cfg.catalog_cache_ttl:
|
|
105
|
+
return hit[1][:limit]
|
|
106
|
+
lines, err = _run(["snap", "find", q], cfg)
|
|
107
|
+
if not lines and err:
|
|
108
|
+
LAST_ERRORS.append(f"snap: {err}") # 查询失败要让人看见
|
|
109
|
+
out = []
|
|
110
|
+
for i, line in enumerate(lines):
|
|
111
|
+
f = _SNAP_SPLIT_RE.split(line.strip())
|
|
112
|
+
if len(f) < 5:
|
|
113
|
+
continue
|
|
114
|
+
if i == 0 and f[0] == "Name":
|
|
115
|
+
continue # 表头
|
|
116
|
+
notes = f[3]
|
|
117
|
+
out.append(CatalogItem(source="snap", name=f[0], display=f[0],
|
|
118
|
+
version=f[1], publisher=f[2], channel=notes,
|
|
119
|
+
summary=" ".join(f[4:]),
|
|
120
|
+
classic="classic" in notes.lower()))
|
|
121
|
+
with _LOCK:
|
|
122
|
+
_SNAP_CACHE[q] = (now, out)
|
|
123
|
+
return out[:limit]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def flatpak_remote_urls(cfg=CFG, user=False):
|
|
127
|
+
"""→ {remote 名: url}。user=True 查用户级安装。
|
|
128
|
+
系统级配的 remote 对 --user 安装不可见,这两套是各自独立的配置。"""
|
|
129
|
+
scope = "--user" if user else "--system"
|
|
130
|
+
out = {}
|
|
131
|
+
lines, _err = _run(["flatpak", "remotes", scope, "--columns=name,url"], cfg)
|
|
132
|
+
for line in lines:
|
|
133
|
+
f = line.split("\t")
|
|
134
|
+
if len(f) >= 2 and f[0].strip():
|
|
135
|
+
out[f[0].strip()] = f[1].strip()
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def flatpak_remotes(cfg=CFG):
|
|
140
|
+
return list(flatpak_remote_urls(cfg))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def flatpak_user_missing_remote(remote, cfg=CFG):
|
|
144
|
+
"""--user 安装前检查该 remote 在用户级是否存在;缺失时返回可直接执行的修复命令。
|
|
145
|
+
系统级与用户级是两套独立的 remote 配置,只配了系统级时 --user 会报
|
|
146
|
+
"未发现用于 xxx 的远程引用",那句报错看不出该怎么修。"""
|
|
147
|
+
if remote in flatpak_remote_urls(cfg, user=True):
|
|
148
|
+
return ""
|
|
149
|
+
head = f"用户级 flatpak 安装里没有 “{remote}”(系统级配的 remote 对 --user 不可见)。"
|
|
150
|
+
url = flatpak_remote_urls(cfg).get(remote, "")
|
|
151
|
+
if not url:
|
|
152
|
+
return head + f" 系统级也没配,无法推断 URL,请手动 flatpak remote-add --user {remote} <url>"
|
|
153
|
+
# ostree 约定:<repo 根>/<remote 名>.flatpakrepo,不把 flathub 地址写死在代码里
|
|
154
|
+
ref = url.rstrip("/") + f"/{remote}.flatpakrepo"
|
|
155
|
+
return f"{head}\n 先执行:flatpak remote-add --user {remote} {ref}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _flatpak_catalog(cfg):
|
|
159
|
+
"""全部 remote 的应用清单(缓存)。→ [CatalogItem]
|
|
160
|
+
|
|
161
|
+
必须逐个 installation 显式指定 --system / --user:同一个 remote 名
|
|
162
|
+
(flathub)常常两个 installation 都配了,这时不带作用域的
|
|
163
|
+
`flatpak remote-ls flathub` 会直接报 "Remote 'flathub' found in
|
|
164
|
+
multiple installations" 并返回空,整个 flatpak 搜索就静默失效了。
|
|
165
|
+
两个作用域都扫,按 app-id 去重(系统级优先,因为它是默认安装目标)。"""
|
|
166
|
+
now = time.time()
|
|
167
|
+
with _LOCK:
|
|
168
|
+
if _FLATPAK_CACHE["items"] is not None \
|
|
169
|
+
and now - _FLATPAK_CACHE["t"] < cfg.catalog_cache_ttl:
|
|
170
|
+
return _FLATPAK_CACHE["items"]
|
|
171
|
+
out, seen = [], set()
|
|
172
|
+
for scope, is_user in (("--system", False), ("--user", True)):
|
|
173
|
+
for remote in flatpak_remote_urls(cfg, user=is_user):
|
|
174
|
+
lines, _err = _run(["flatpak", "remote-ls", "--app", scope,
|
|
175
|
+
f"--columns={_FLATPAK_COLUMNS}", remote], cfg)
|
|
176
|
+
for line in lines:
|
|
177
|
+
f = line.split("\t")
|
|
178
|
+
if len(f) < 4 or not f[1] or f[1] in seen:
|
|
179
|
+
continue
|
|
180
|
+
seen.add(f[1])
|
|
181
|
+
# flatpak 的体积列用不间断空格分隔("518.3\xa0MB"),不换成
|
|
182
|
+
# 普通空格会打乱终端对齐、也没法当普通字符串比较
|
|
183
|
+
out.append(CatalogItem(source="flatpak", name=f[1], display=f[0],
|
|
184
|
+
size_text=f[2].replace("\xa0", " ").strip(),
|
|
185
|
+
channel=f[3].strip(), publisher=remote,
|
|
186
|
+
remote=remote,
|
|
187
|
+
extra={"scope": "user" if is_user else "system"}))
|
|
188
|
+
with _LOCK:
|
|
189
|
+
_FLATPAK_CACHE.update(t=now, items=out)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _rank(items, q, limit):
|
|
194
|
+
"""本地过滤 + 排名:完全匹配 > 前缀 > 子串 > 分词全命中。
|
|
195
|
+
最后一级用分词,这样 "zen browser" 才搜得到 app.zen_browser.zen ——
|
|
196
|
+
整串子串匹配对不上(名字里是下划线,查询里是空格)。"""
|
|
197
|
+
ql = q.lower()
|
|
198
|
+
tokens = query_tokens(q)
|
|
199
|
+
hits = []
|
|
200
|
+
for it in items:
|
|
201
|
+
n, d = it.name.lower(), it.display.lower()
|
|
202
|
+
if ql in (n, d):
|
|
203
|
+
r = 0
|
|
204
|
+
elif n.startswith(ql) or d.startswith(ql):
|
|
205
|
+
r = 1
|
|
206
|
+
elif ql in n or ql in d:
|
|
207
|
+
r = 2
|
|
208
|
+
elif matches_tokens(f"{it.name} {it.display}", tokens):
|
|
209
|
+
r = 3
|
|
210
|
+
else:
|
|
211
|
+
continue
|
|
212
|
+
hits.append((r, it.display or it.name, it))
|
|
213
|
+
hits.sort(key=lambda t: (t[0], t[1]))
|
|
214
|
+
return [t[2] for t in hits[:limit]]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _search_flatpak(q, limit, cfg):
|
|
218
|
+
return _rank(_flatpak_catalog(cfg), q, limit)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_SEARCHERS = {"apt": _search_apt, "snap": _search_snap, "flatpak": _search_flatpak}
|
|
222
|
+
# 搜索来源名 → PackageRecord.pkg_type(apt 对应的是 deb)
|
|
223
|
+
_SOURCE_TYPE = {"apt": "deb", "snap": "snap", "flatpak": "flatpak"}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def available(cfg=CFG):
|
|
227
|
+
"""本机可用的搜索来源。"""
|
|
228
|
+
out = []
|
|
229
|
+
if os.path.isdir(cfg.apt_lists_dir):
|
|
230
|
+
out.append("apt")
|
|
231
|
+
if shutil.which("snap") and os.path.isdir(cfg.snap_store_dir):
|
|
232
|
+
out.append("snap")
|
|
233
|
+
if shutil.which("flatpak"):
|
|
234
|
+
out.append("flatpak")
|
|
235
|
+
return tuple(out)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def search(query, sources=None, limit=30, cfg=CFG, installed=None):
|
|
239
|
+
"""跨源搜索 → [CatalogItem],按来源分组、组内按相关度排。
|
|
240
|
+
sources=None 表示本机全部可用来源;每个来源各自取 limit 条,
|
|
241
|
+
免得一个源的结果把其他源挤掉。
|
|
242
|
+
installed 是 {(source, name)} 集合,用来标注"已安装"。"""
|
|
243
|
+
q = (query or "").strip()
|
|
244
|
+
if not q:
|
|
245
|
+
return []
|
|
246
|
+
sources = tuple(s for s in (sources or available(cfg)) if s in _SEARCHERS)
|
|
247
|
+
installed = installed or set()
|
|
248
|
+
out = []
|
|
249
|
+
LAST_ERRORS.clear()
|
|
250
|
+
LAST_ELAPSED.clear()
|
|
251
|
+
for s in sources:
|
|
252
|
+
pkg_type = _SOURCE_TYPE[s]
|
|
253
|
+
t0 = time.time()
|
|
254
|
+
try:
|
|
255
|
+
found = _SEARCHERS[s](q, limit, cfg)
|
|
256
|
+
except Exception as e: # noqa: BLE001 单源失败不拖垮整体
|
|
257
|
+
found = []
|
|
258
|
+
LAST_ERRORS.append(f"{s}: {type(e).__name__}: {e}")
|
|
259
|
+
LAST_ELAPSED[s] = round(time.time() - t0, 1)
|
|
260
|
+
for it in found:
|
|
261
|
+
it.installed = (pkg_type, it.name) in installed
|
|
262
|
+
out.extend(found)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def installed_keys(inv):
|
|
267
|
+
"""从 Inventory 提取 {(pkg_type, name)},供 search 标注已安装。"""
|
|
268
|
+
return {(r.pkg_type, r.name) for r in inv.records}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def installed_names(cfg=CFG, sources=SOURCES):
|
|
272
|
+
"""→ {(pkg_type, name)},只跑相关的后端,不做全量盘点。
|
|
273
|
+
搜索不该为了标注"已安装"而等一次 4 秒的全量采集:deb 读 status 只要
|
|
274
|
+
0.01 秒,snap / flatpak 各自也就 0.1 秒。"""
|
|
275
|
+
out = set()
|
|
276
|
+
if "apt" in sources:
|
|
277
|
+
from .apt import dpkg
|
|
278
|
+
out |= {("deb", n) for n in dpkg.installed(cfg)}
|
|
279
|
+
if "snap" in sources:
|
|
280
|
+
from .backends.snap import SnapBackend
|
|
281
|
+
if SnapBackend.available(cfg):
|
|
282
|
+
out |= {("snap", r.name) for r in SnapBackend(cfg).collect()}
|
|
283
|
+
if "flatpak" in sources:
|
|
284
|
+
from .backends.flatpak import FlatpakBackend
|
|
285
|
+
if FlatpakBackend.available(cfg):
|
|
286
|
+
out |= {("flatpak", r.name) for r in FlatpakBackend(cfg).collect()}
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------- 安装 ----------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def install_argv(item, version="", user=False):
|
|
294
|
+
"""→ (argv, privileged);名称非法时返回 (None, 错误信息)。
|
|
295
|
+
user=True 只对 flatpak 有意义:装到 ~/.local/share/flatpak,不需要 root。"""
|
|
296
|
+
if not is_safe_name(item.name):
|
|
297
|
+
return None, f"名称非法,拒绝安装:{item.name!r}"
|
|
298
|
+
if item.source == "apt":
|
|
299
|
+
spec = f"{item.name}={version}" if version else item.name
|
|
300
|
+
if version and not is_safe_name(version):
|
|
301
|
+
return None, f"版本非法:{version!r}"
|
|
302
|
+
return ["apt-get", "install", "-y", spec], True
|
|
303
|
+
if item.source == "snap":
|
|
304
|
+
argv = ["snap", "install"]
|
|
305
|
+
if item.classic:
|
|
306
|
+
argv.append("--classic") # 经典 confinement 不带这个标志会直接失败
|
|
307
|
+
return argv + [item.name], True
|
|
308
|
+
if item.source == "flatpak":
|
|
309
|
+
# 作用域必须显式给:remote 同名存在于两个 installation 时,
|
|
310
|
+
# 不带 --system/--user 的 flatpak 命令会报 multiple installations
|
|
311
|
+
argv = ["flatpak", "install", "-y", "--user" if user else "--system"]
|
|
312
|
+
if item.remote and is_safe_name(item.remote):
|
|
313
|
+
argv.append(item.remote)
|
|
314
|
+
return argv + [item.name], not user
|
|
315
|
+
return None, f"不支持安装的来源:{item.source}"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def install(item, version="", user=False, on_line=None, cfg=CFG):
|
|
319
|
+
"""安装一条目录项 → actions.Result。"""
|
|
320
|
+
argv, privileged = install_argv(item, version, user)
|
|
321
|
+
if argv is None:
|
|
322
|
+
return actions.Result(ok=False, error=privileged) # 此时第二项是错误信息
|
|
323
|
+
if item.source == "flatpak" and user and item.remote:
|
|
324
|
+
missing = flatpak_user_missing_remote(item.remote, cfg)
|
|
325
|
+
if missing:
|
|
326
|
+
return actions.Result(ok=False, error=missing, command=argv)
|
|
327
|
+
if privileged:
|
|
328
|
+
return actions.run_privileged(argv, timeout=cfg.timeout_upgrade,
|
|
329
|
+
on_line=on_line, cfg=cfg)
|
|
330
|
+
return actions.run_plain(argv, timeout=cfg.timeout_upgrade, cfg=cfg)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def describe(item, cfg=CFG):
|
|
334
|
+
"""单条详情文本。apt 给全部候选版本,snap 给 `snap info`,flatpak 给基本字段。"""
|
|
335
|
+
if item.source == "apt":
|
|
336
|
+
from . import report
|
|
337
|
+
info = lists.load_index(cfg).info(item.name)
|
|
338
|
+
return report.render_versions(info) if info else "仓库中无此包"
|
|
339
|
+
if item.source == "snap":
|
|
340
|
+
lines, _err = _run(["snap", "info", item.name], cfg)
|
|
341
|
+
return "\n".join(lines) if lines else "(snap info 无输出)"
|
|
342
|
+
bits = [("app-id", item.name), ("显示名", item.display),
|
|
343
|
+
("remote", item.remote), ("branch", item.channel),
|
|
344
|
+
("体积", item.size_text)]
|
|
345
|
+
return "\n".join(f" {k:<10}{v}" for k, v in bits if v)
|