ybox 0.9.8__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.
- ybox/__init__.py +2 -0
- ybox/cmd.py +307 -0
- ybox/conf/completions/ybox.fish +93 -0
- ybox/conf/distros/arch/add-gpg-key.sh +29 -0
- ybox/conf/distros/arch/distro.ini +192 -0
- ybox/conf/distros/arch/init-base.sh +10 -0
- ybox/conf/distros/arch/init-user.sh +35 -0
- ybox/conf/distros/arch/init.sh +82 -0
- ybox/conf/distros/arch/list_fmt_long.py +76 -0
- ybox/conf/distros/arch/pkgdeps.py +276 -0
- ybox/conf/distros/deb-generic/check-package.sh +77 -0
- ybox/conf/distros/deb-generic/distro.ini +190 -0
- ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
- ybox/conf/distros/deb-generic/init-base.sh +11 -0
- ybox/conf/distros/deb-generic/init-user.sh +3 -0
- ybox/conf/distros/deb-generic/init.sh +136 -0
- ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
- ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
- ybox/conf/distros/deb-oldstable/distro.ini +21 -0
- ybox/conf/distros/deb-stable/distro.ini +21 -0
- ybox/conf/distros/supported.list +5 -0
- ybox/conf/distros/ubuntu2204/distro.ini +21 -0
- ybox/conf/distros/ubuntu2404/distro.ini +21 -0
- ybox/conf/profiles/apps.ini +26 -0
- ybox/conf/profiles/basic.ini +310 -0
- ybox/conf/profiles/dev.ini +25 -0
- ybox/conf/profiles/games.ini +39 -0
- ybox/conf/resources/entrypoint-base.sh +170 -0
- ybox/conf/resources/entrypoint-common.sh +23 -0
- ybox/conf/resources/entrypoint-cp.sh +32 -0
- ybox/conf/resources/entrypoint-root.sh +20 -0
- ybox/conf/resources/entrypoint-user.sh +21 -0
- ybox/conf/resources/entrypoint.sh +249 -0
- ybox/conf/resources/prime-run +13 -0
- ybox/conf/resources/run-in-dir +60 -0
- ybox/conf/resources/run-user-bash-cmd +14 -0
- ybox/config.py +255 -0
- ybox/env.py +205 -0
- ybox/filelock.py +77 -0
- ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
- ybox/pkg/__init__.py +0 -0
- ybox/pkg/clean.py +33 -0
- ybox/pkg/info.py +40 -0
- ybox/pkg/inst.py +638 -0
- ybox/pkg/list.py +191 -0
- ybox/pkg/mark.py +68 -0
- ybox/pkg/repair.py +150 -0
- ybox/pkg/repo.py +251 -0
- ybox/pkg/search.py +52 -0
- ybox/pkg/uninst.py +92 -0
- ybox/pkg/update.py +56 -0
- ybox/print.py +121 -0
- ybox/run/__init__.py +0 -0
- ybox/run/cmd.py +54 -0
- ybox/run/control.py +102 -0
- ybox/run/create.py +1116 -0
- ybox/run/destroy.py +64 -0
- ybox/run/graphics.py +367 -0
- ybox/run/logs.py +57 -0
- ybox/run/ls.py +64 -0
- ybox/run/pkg.py +445 -0
- ybox/schema/0.9.1-added.sql +27 -0
- ybox/schema/0.9.6-added.sql +18 -0
- ybox/schema/init.sql +39 -0
- ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
- ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
- ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
- ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
- ybox/state.py +914 -0
- ybox/util.py +351 -0
- ybox-0.9.8.dist-info/LICENSE +19 -0
- ybox-0.9.8.dist-info/METADATA +533 -0
- ybox-0.9.8.dist-info/RECORD +76 -0
- ybox-0.9.8.dist-info/WHEEL +5 -0
- ybox-0.9.8.dist-info/entry_points.txt +8 -0
- ybox-0.9.8.dist-info/top_level.txt +1 -0
ybox/pkg/inst.py
ADDED
@@ -0,0 +1,638 @@
|
|
1
|
+
"""
|
2
|
+
Methods for package installation on an active ybox container.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import argparse
|
6
|
+
import io
|
7
|
+
import os
|
8
|
+
import re
|
9
|
+
import shutil
|
10
|
+
import subprocess
|
11
|
+
import sys
|
12
|
+
import tempfile
|
13
|
+
from configparser import ConfigParser, SectionProxy
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import Callable, Optional, Union
|
16
|
+
|
17
|
+
from simple_term_menu import TerminalMenu # type: ignore
|
18
|
+
|
19
|
+
from ybox.cmd import PkgMgr, build_shell_command, run_command
|
20
|
+
from ybox.config import Consts, StaticConfiguration
|
21
|
+
from ybox.print import print_error, print_info, print_notice, print_warn
|
22
|
+
from ybox.state import (CopyType, DependencyType, RuntimeConfiguration,
|
23
|
+
YboxStateManagement)
|
24
|
+
from ybox.util import check_package, ini_file_reader, select_item_from_menu
|
25
|
+
|
26
|
+
# match both "Exec=" and "TryExec=" lines (don't capture trailing newline)
|
27
|
+
_EXEC_RE = re.compile(r"^(\s*(Try)?Exec\s*=\s*)(\S+)\s*(.*?)\s*$")
|
28
|
+
# match !p and !a to replace executable program (third group above) and arguments respectively
|
29
|
+
_FLAGS_RE = re.compile("![ap]")
|
30
|
+
_LOCAL_BIN_DIRS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", "/usr/local/sbin"]
|
31
|
+
|
32
|
+
|
33
|
+
def install_package(args: argparse.Namespace, pkgmgr: SectionProxy, docker_cmd: str,
|
34
|
+
conf: StaticConfiguration, runtime_conf: RuntimeConfiguration,
|
35
|
+
state: YboxStateManagement) -> int:
|
36
|
+
"""
|
37
|
+
Install package specified by `args.package` on a ybox container with given podman/docker
|
38
|
+
command. Additional flags honored are `args.quiet` to bypass user confirmation during install,
|
39
|
+
`args.skip_opt_deps` to skip installing optional dependencies of the package,
|
40
|
+
`args.skip_executables` to skip creating wrapper executables for the package executables,
|
41
|
+
`args.skip_desktop_files` to skip creating wrapper desktop files for the package ones.
|
42
|
+
|
43
|
+
When the `args.skip_opt_deps` flag is not enabled, then the package databases are searched
|
44
|
+
for optional dependencies of the package as well as those of the new required dependencies
|
45
|
+
being installed. Recursion level for this search is fixed to 2 for now else the number
|
46
|
+
of packages can be overwhelming with most being largely irrelevant. A selection menu
|
47
|
+
presented to the user allows choosing the optional dependencies to be installed after
|
48
|
+
the main package installation has completed successfully.
|
49
|
+
|
50
|
+
:param args: arguments having `package` and all other attributes passed by the user
|
51
|
+
:param pkgmgr: the `[pkgmgr]` section from `distro.ini` configuration file of the distribution
|
52
|
+
:param docker_cmd: the podman/docker executable to use
|
53
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
54
|
+
:param runtime_conf: the `RuntimeConfiguration` of the container
|
55
|
+
:param state: instance of `YboxStateManagement` having the state of all ybox containers
|
56
|
+
:return: integer exit status of install command where 0 represents success
|
57
|
+
"""
|
58
|
+
quiet_flag = pkgmgr[PkgMgr.QUIET_FLAG.value] if args.quiet else ""
|
59
|
+
# restore the {opt_dep} placeholder in the installation command which will be replaced
|
60
|
+
# before actual execution by _install_package(...)
|
61
|
+
install_cmd = pkgmgr[PkgMgr.INSTALL.value].format(quiet=quiet_flag, opt_dep="{opt_dep}")
|
62
|
+
list_cmd = pkgmgr[PkgMgr.LIST_FILES.value]
|
63
|
+
selected_deps = args.with_opt_deps.split(",") if args.with_opt_deps else None
|
64
|
+
opt_deps_cmd = pkgmgr[PkgMgr.OPT_DEPS.value]
|
65
|
+
# TODO: use this flag for -w option only if get_optional_deps returned the package/provides;
|
66
|
+
# when this is done, then the opt_dep_flag in distro.ini can have proper values
|
67
|
+
# also allow for re-installation of packages using a flag
|
68
|
+
opt_dep_flag = pkgmgr[PkgMgr.OPT_DEP_FLAG.value]
|
69
|
+
check_avail = pkgmgr[PkgMgr.CHECK_AVAIL.value]
|
70
|
+
check_inst = pkgmgr[PkgMgr.CHECK_INSTALL.value]
|
71
|
+
return _install_package(args.package, args, install_cmd, list_cmd, docker_cmd, conf,
|
72
|
+
runtime_conf, state, opt_deps_cmd, opt_dep_flag, False,
|
73
|
+
args.check_package, check_avail, check_inst, selected_deps, args.quiet)
|
74
|
+
|
75
|
+
|
76
|
+
def _install_package(package: str, args: argparse.Namespace, install_cmd: str, list_cmd: str,
|
77
|
+
docker_cmd: str, conf: StaticConfiguration, rt_conf: RuntimeConfiguration,
|
78
|
+
state: YboxStateManagement, opt_deps_cmd: str, opt_dep_flag: str,
|
79
|
+
opt_dep_install: bool, check_pkg: bool, check_avail: str, check_inst: str,
|
80
|
+
selected_deps: Optional[list[str]], quiet: int) -> int:
|
81
|
+
"""
|
82
|
+
Real workhorse for :func:`install_package` that is invoked recursively for
|
83
|
+
optional dependencies if required.
|
84
|
+
|
85
|
+
:param package: the package to be installed
|
86
|
+
:param args: arguments having all the attributes passed by the user (`package` is ignored)
|
87
|
+
:param install_cmd: installation command as read from `distro.ini` configuration file of the
|
88
|
+
distribution which should have an unresolved `{opt_dep}` placeholder
|
89
|
+
for the `opt_dep_flag`
|
90
|
+
:param list_cmd: command to list files for an installed package read from `distro.ini`
|
91
|
+
:param docker_cmd: the podman/docker executable to use
|
92
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
93
|
+
:param rt_conf: the `RuntimeConfiguration` of the container
|
94
|
+
:param state: instance of `YboxStateManagement` having the state of all ybox containers
|
95
|
+
:param opt_deps_cmd: command to determine optional dependencies as read from `distro.ini`
|
96
|
+
:param opt_dep_flag: flag to be added during installation of an optional dependency to mark
|
97
|
+
it as a dependency (as read from `distro.ini`)
|
98
|
+
:param opt_dep_install: `True` if installation is for an optional dependency
|
99
|
+
:param check_pkg: if True then skip installation if package already exists
|
100
|
+
:param check_avail: command to check if package is available in the package repositories
|
101
|
+
:param check_inst: command to check if package is installed
|
102
|
+
:param selected_deps: list of dependencies to install if user has already provided them
|
103
|
+
:param quiet: perform operations quietly
|
104
|
+
:return: exit code of install command for the main package
|
105
|
+
"""
|
106
|
+
# need to determine optional dependencies before installation else second level or higher
|
107
|
+
# dependencies will never be found (as the dependencies are already installed)
|
108
|
+
optional_deps: list[tuple[str, str, int]] = []
|
109
|
+
installed_optional_deps: set[str] = set()
|
110
|
+
if opt_dep_install:
|
111
|
+
resolved_install_cmd = install_cmd.format(opt_dep=opt_dep_flag)
|
112
|
+
else:
|
113
|
+
resolved_install_cmd = install_cmd.format(opt_dep="")
|
114
|
+
# don't exit on error here because the caller may have further actions to perform before exit
|
115
|
+
code = -1
|
116
|
+
if check_pkg:
|
117
|
+
code, inst_pkgs = check_package(docker_cmd, check_inst, package, conf.box_name)
|
118
|
+
if code == 0:
|
119
|
+
if not quiet:
|
120
|
+
suffix = "" if len(inst_pkgs) == 1 and package == inst_pkgs[0] \
|
121
|
+
else f" (as {inst_pkgs})"
|
122
|
+
print_notice(f"'{package}'{suffix} is already installed in '{conf.box_name}'")
|
123
|
+
package = inst_pkgs[0]
|
124
|
+
if code != 0:
|
125
|
+
if check_avail:
|
126
|
+
_, avail_pkgs = check_package(docker_cmd, check_avail, package, conf.box_name)
|
127
|
+
if len(avail_pkgs) > 1:
|
128
|
+
print_notice(f"Multiple packages found for '{package}', select one to install")
|
129
|
+
if selected_pkg := select_item_from_menu(avail_pkgs):
|
130
|
+
package = selected_pkg
|
131
|
+
else:
|
132
|
+
return 1
|
133
|
+
if not quiet:
|
134
|
+
print_info(f"Installing '{package}' in '{conf.box_name}'")
|
135
|
+
code = int(run_command(build_shell_command(
|
136
|
+
docker_cmd, conf.box_name, f"{resolved_install_cmd} {package}"), exit_on_error=False,
|
137
|
+
error_msg=f"installing '{package}'"))
|
138
|
+
# actual installed package name can be different due to package being virtual and/or
|
139
|
+
# having multiple choices
|
140
|
+
if code == 0:
|
141
|
+
code, inst_pkgs = check_package(docker_cmd, check_inst, package, conf.box_name)
|
142
|
+
if code == 0:
|
143
|
+
package = inst_pkgs[0] # pick first since it is sorted by latest installation time
|
144
|
+
else:
|
145
|
+
print_error(f"Package '{package}' was not installed successfully")
|
146
|
+
return 1
|
147
|
+
if code == 0:
|
148
|
+
skip_desktop_files = args.skip_desktop_files
|
149
|
+
skip_executables = args.skip_executables
|
150
|
+
copy_type = CopyType(0)
|
151
|
+
# check if wrappers for optional dependencies have to be created
|
152
|
+
if not opt_dep_install or args.add_dep_wrappers:
|
153
|
+
if not skip_desktop_files:
|
154
|
+
copy_type |= CopyType.DESKTOP
|
155
|
+
if not skip_executables:
|
156
|
+
copy_type |= CopyType.EXECUTABLE
|
157
|
+
# TODO: wrappers for newly installed required dependencies should also be created;
|
158
|
+
# handle DependencyType.SUGGESTION if supported by underlying package manager
|
159
|
+
app_flags: dict[str, str] = {}
|
160
|
+
if args.app_flags:
|
161
|
+
for flag in args.app_flags.split(","):
|
162
|
+
if (split_idx := flag.find("=")) != -1:
|
163
|
+
app_flags[flag[:split_idx]] = flag[split_idx + 1:]
|
164
|
+
local_copies = wrap_container_files(package, copy_type, app_flags, list_cmd,
|
165
|
+
docker_cmd, conf, rt_conf.ini_config,
|
166
|
+
rt_conf.shared_root, quiet)
|
167
|
+
dep_type, dep_of = (DependencyType.OPTIONAL, args.package) if opt_dep_install else (
|
168
|
+
None, "")
|
169
|
+
state.register_package(conf.box_name, package, local_copies, copy_type, app_flags,
|
170
|
+
rt_conf.shared_root, dep_type, dep_of)
|
171
|
+
# get optional deps even if args.skip_opt_deps is true to obtain installed_optional_deps
|
172
|
+
# which need to be registered against this package too (state.register_dependency below)
|
173
|
+
if not opt_dep_install:
|
174
|
+
optional_deps, installed_optional_deps = get_optional_deps(package, docker_cmd,
|
175
|
+
conf.box_name, opt_deps_cmd)
|
176
|
+
# register the recorded optional dependencies for this package too
|
177
|
+
if recorded_deps := state.check_packages(conf.box_name, installed_optional_deps):
|
178
|
+
for dep in recorded_deps:
|
179
|
+
state.register_dependency(conf.box_name, package, dep, DependencyType.OPTIONAL)
|
180
|
+
if optional_deps and selected_deps is None and not args.skip_opt_deps:
|
181
|
+
selected_deps = select_optional_deps(package, optional_deps)
|
182
|
+
if selected_deps:
|
183
|
+
for dep in selected_deps:
|
184
|
+
_install_package(dep, args, install_cmd, list_cmd, docker_cmd, conf, rt_conf,
|
185
|
+
state, "", opt_dep_flag, True, check_pkg, check_avail,
|
186
|
+
check_inst, None, quiet)
|
187
|
+
|
188
|
+
return code
|
189
|
+
|
190
|
+
|
191
|
+
def get_optional_deps(package: str, docker_cmd: str, container_name: str,
|
192
|
+
opt_deps_cmd: str) -> tuple[list[tuple[str, str, int]], set[str]]:
|
193
|
+
"""
|
194
|
+
Find the optional dependencies recursively, removing the ones already installed.
|
195
|
+
|
196
|
+
:param package: package to be installed
|
197
|
+
:param docker_cmd: the podman/docker executable to use
|
198
|
+
:param container_name: name of the ybox container
|
199
|
+
:param opt_deps_cmd: command to determine optional dependencies as read from `distro.ini`
|
200
|
+
:return: first part is list of tuples having the name of optional dependency, its description
|
201
|
+
and an integer `level` denoting its depth in the dependency tree
|
202
|
+
(i.e. level 1 means immediate dependency of the package, 2 means dependency of
|
203
|
+
another dependency which is being newly installed and so on);
|
204
|
+
second part of the tuple is the set of optional dependencies of the package that
|
205
|
+
are already installed and registered as dependency in state.db for some other package
|
206
|
+
"""
|
207
|
+
optional_deps: list[tuple[str, str, int]] = []
|
208
|
+
installed_optional_deps: set[str] = set()
|
209
|
+
pkg_start = "Found optional dependencies"
|
210
|
+
pkg_prefix = "PKG:"
|
211
|
+
pkg_sep = Consts.default_field_separator()
|
212
|
+
# fill in the expected separator, prefix and header line
|
213
|
+
opt_deps_cmd = opt_deps_cmd.format(separator=pkg_sep, prefix=pkg_prefix, header=pkg_start)
|
214
|
+
# Expected format of output below is -- PKG:<name>::::<level>::::<installed>::::<description>
|
215
|
+
# This is preceded by a line "Found optional dependencies".
|
216
|
+
# Print other lines on output as is which are for informational purpose.
|
217
|
+
# Code below does progressive display of output which is required for showing stuff like
|
218
|
+
# download progress properly.
|
219
|
+
# The following alternatives were considered:
|
220
|
+
# 1) print PKG: lines to stderr: this works only if "-t" is removed from docker exec
|
221
|
+
# otherwise both stdout and stderr are combined to tty, but if it is removed
|
222
|
+
# then you can no longer see the progressive download due to buffering
|
223
|
+
# 2) redirect PKG: lines somewhere else like a common file: this can be done but will
|
224
|
+
# likely be more messy than the code below (e.g. handle concurrent executions),
|
225
|
+
# but still can be considered in future
|
226
|
+
with subprocess.Popen(build_shell_command(
|
227
|
+
docker_cmd, container_name, f"{opt_deps_cmd} {package}"),
|
228
|
+
stdout=subprocess.PIPE) as deps_result:
|
229
|
+
line = bytearray()
|
230
|
+
# possible end of lines
|
231
|
+
eol1 = b"\r"[0]
|
232
|
+
eol2 = b"\n"[0]
|
233
|
+
buffered = 0
|
234
|
+
assert deps_result.stdout is not None
|
235
|
+
# readline does not work for in-place updates like from aria2
|
236
|
+
while char := deps_result.stdout.read(1):
|
237
|
+
sys.stdout.buffer.write(char)
|
238
|
+
buffered += 1
|
239
|
+
if char[0] == eol1 or char[0] == eol2:
|
240
|
+
sys.stdout.flush()
|
241
|
+
buffered = 0
|
242
|
+
output = line.decode("utf-8")
|
243
|
+
line.clear()
|
244
|
+
if output == pkg_start:
|
245
|
+
break
|
246
|
+
else:
|
247
|
+
line.append(char[0])
|
248
|
+
if buffered >= 4: # flush frequently to show download progress, for example
|
249
|
+
sys.stdout.flush()
|
250
|
+
buffered = 0
|
251
|
+
sys.stdout.flush()
|
252
|
+
while pkg_out := deps_result.stdout.readline():
|
253
|
+
output = pkg_out.decode("utf-8")
|
254
|
+
# there can be a trailing '\n' from the loop before due to '\r\n' ending
|
255
|
+
if output == "\n":
|
256
|
+
continue
|
257
|
+
name, level, installed, desc = output[len(pkg_prefix):].split(pkg_sep, maxsplit=3)
|
258
|
+
desc = desc.rstrip()
|
259
|
+
if installed.strip().lower() == "true":
|
260
|
+
installed_optional_deps.add(name)
|
261
|
+
else:
|
262
|
+
optional_deps.append((name, desc, int(level)))
|
263
|
+
|
264
|
+
if deps_result.wait(60) != 0:
|
265
|
+
print_warn(f"FAILED to determine optional dependencies of {package} -- "
|
266
|
+
"see the output above for details. Skipping optional dependencies.")
|
267
|
+
return [], installed_optional_deps
|
268
|
+
|
269
|
+
return optional_deps, installed_optional_deps
|
270
|
+
|
271
|
+
|
272
|
+
def select_optional_deps(package: str, deps: list[tuple[str, str, int]]) -> list[str]:
|
273
|
+
"""
|
274
|
+
Show a selection menu to the user having optional dependencies of a package to be installed.
|
275
|
+
|
276
|
+
:param package: package that is being installed
|
277
|
+
:param deps: list of dependencies as tuples from :func:`get_optional_deps`
|
278
|
+
:return: list of names of the selected optional dependencies (or empty list for no selection)
|
279
|
+
"""
|
280
|
+
menu_options = [f"{'*' if level <= 1 else ''} {name} ({desc})" for name, desc, level in deps]
|
281
|
+
print_info(f"Select optional dependencies of {package} "
|
282
|
+
"(starred ones are the immediate dependencies):")
|
283
|
+
# don't select on <Enter> (multi_select_select_on_accept) and allow for empty selection
|
284
|
+
terminal_menu = TerminalMenu(menu_options, multi_select=True, show_multi_select_hint=True,
|
285
|
+
multi_select_select_on_accept=False, multi_select_empty_ok=True)
|
286
|
+
selection = terminal_menu.show()
|
287
|
+
return [deps[index][0] for index in selection] if isinstance(selection, tuple) else []
|
288
|
+
|
289
|
+
|
290
|
+
def wrap_container_files(package: str, copy_type: CopyType, app_flags: dict[str, str],
|
291
|
+
list_cmd: str, docker_cmd: str, conf: StaticConfiguration,
|
292
|
+
box_conf: Union[str, ConfigParser], shared_root: str,
|
293
|
+
quiet: int) -> list[str]:
|
294
|
+
"""
|
295
|
+
Create wrappers in host environment to invoke container's desktop files and executables.
|
296
|
+
|
297
|
+
:param package: the package to be installed
|
298
|
+
:param copy_type: the `CopyType` to tell whether to create wrapper .desktop files and/or
|
299
|
+
wrapper executables that invoke corresponding ones of the container
|
300
|
+
:param app_flags: application flags that have been explicitly specified with --app-flags
|
301
|
+
:param list_cmd: command to list files for an installed package read from `distro.ini`
|
302
|
+
:param docker_cmd: the podman/docker executable to use
|
303
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
304
|
+
:param box_conf: the resolved INI format configuration of the container as a string or
|
305
|
+
a `ConfigParser` object
|
306
|
+
:param shared_root: the local shared root directory if `shared_root` is provided
|
307
|
+
for the container
|
308
|
+
:param quiet: perform operations quietly
|
309
|
+
:return: the list of paths of the wrapper files
|
310
|
+
"""
|
311
|
+
if not copy_type:
|
312
|
+
return []
|
313
|
+
# skip on errors below and do not fail the installation
|
314
|
+
package_files = run_command(build_shell_command(
|
315
|
+
docker_cmd, conf.box_name, list_cmd.format(package=package), enable_pty=False),
|
316
|
+
capture_output=True, exit_on_error=False, error_msg=f"listing files of '{package}'")
|
317
|
+
if isinstance(package_files, int):
|
318
|
+
return []
|
319
|
+
wrapper_files: list[str] = []
|
320
|
+
desktop_dirs = Consts.container_desktop_dirs()
|
321
|
+
icon_dir_pattern = re.compile(f"({')|('.join(Consts.container_icon_dirs())})")
|
322
|
+
# map of found icons where key is the name of icon file (without extension) while the value
|
323
|
+
# is a tuple with first one being a float inverse priority (lower is better) followed by path
|
324
|
+
selected_icons: dict[str, tuple[float, str]] = {}
|
325
|
+
executable_dirs = Consts.container_bin_dirs()
|
326
|
+
man_dir_pattern = Consts.container_man_dir_pattern()
|
327
|
+
# get the parsed container configuration
|
328
|
+
parsed_box_conf = _get_parsed_box_conf(box_conf)
|
329
|
+
# read the container configuration for [app_flags] section
|
330
|
+
app_flags_section = parsed_box_conf["app_flags"] \
|
331
|
+
if parsed_box_conf and parsed_box_conf.has_section("app_flags") else None
|
332
|
+
file_paths = [(os.path.dirname(file), filename, file) for file in package_files.splitlines()
|
333
|
+
if (filename := os.path.basename(file).strip())] # empty name means directory
|
334
|
+
|
335
|
+
# if an executable from a package is skipped by user, then skip all of them for consistency
|
336
|
+
for file_dir, filename, file in file_paths:
|
337
|
+
if file_dir in executable_dirs:
|
338
|
+
# check for additional flags for the executables as specified in [app_flags] section
|
339
|
+
if app_flags_section and (flags := app_flags_section.get(filename)):
|
340
|
+
# command-line --app-flags will override those in the configuration files
|
341
|
+
app_flags.setdefault(filename, flags)
|
342
|
+
if copy_type & CopyType.EXECUTABLE:
|
343
|
+
if not _can_wrap_executable(filename, file, conf, quiet):
|
344
|
+
# clear EXECUTABLE mask so that no wrapper executable is created
|
345
|
+
copy_type &= ~CopyType.EXECUTABLE
|
346
|
+
|
347
|
+
# the "-it" flag is used for both desktop file and executable for podman/docker exec
|
348
|
+
# since it is safe (unless the app may need stdin in which case Terminal must be true
|
349
|
+
# in its desktop file in which case a terminal will be opened during execution)
|
350
|
+
for file_dir, filename, file in file_paths:
|
351
|
+
# check if this is a .desktop directory and copy it over adding appropriate
|
352
|
+
# "docker exec" prefix to the command
|
353
|
+
if copy_type & CopyType.DESKTOP:
|
354
|
+
if file_dir in desktop_dirs:
|
355
|
+
_wrap_desktop_file(filename, file, docker_cmd, conf, app_flags, wrapper_files)
|
356
|
+
continue # if it is a .desktop file, then skip executable check
|
357
|
+
if _select_app_icon(file_dir, filename, file, icon_dir_pattern, selected_icons):
|
358
|
+
continue # if it is an icon file, then skip executable check
|
359
|
+
if copy_type & CopyType.EXECUTABLE:
|
360
|
+
if file_dir in executable_dirs:
|
361
|
+
_wrap_executable(filename, file, docker_cmd, conf, app_flags, wrapper_files)
|
362
|
+
elif shared_root and man_dir_pattern.match(file_dir):
|
363
|
+
_link_man_page(file, shared_root, conf, wrapper_files)
|
364
|
+
if selected_icons:
|
365
|
+
_copy_app_icons(selected_icons, docker_cmd, conf, quiet, wrapper_files)
|
366
|
+
|
367
|
+
return wrapper_files
|
368
|
+
|
369
|
+
|
370
|
+
def _get_parsed_box_conf(box_conf: Union[str, ConfigParser]) -> Optional[ConfigParser]:
|
371
|
+
"""
|
372
|
+
Get the parsed `ConfigParser` for the container configuration.
|
373
|
+
|
374
|
+
:param box_conf: the resolved INI format configuration of the container as a string or
|
375
|
+
a `ConfigParser` object
|
376
|
+
:return: the `ConfigParser` object for the container configuration
|
377
|
+
"""
|
378
|
+
if isinstance(box_conf, ConfigParser):
|
379
|
+
return box_conf
|
380
|
+
if not box_conf:
|
381
|
+
return None
|
382
|
+
with io.StringIO(box_conf) as box_conf_fd:
|
383
|
+
return ini_file_reader(box_conf_fd, interpolation=None, case_sensitive=False)
|
384
|
+
|
385
|
+
|
386
|
+
def _replace_flags(match: re.Match[str], flags: str, program: str, args: str) -> str:
|
387
|
+
"""
|
388
|
+
`_FLAGS_RE.sub` callback to replace `!p` in a value of `[app_flags]` section with given
|
389
|
+
`program` and `!a` with `args` while honoring `!!` escape for literal `!`.
|
390
|
+
|
391
|
+
:param match: an `re.Match` for `_FLAGS_RE` pattern defined in this module
|
392
|
+
:param flags: the value in `[app_flags]` section for `program` name as the key
|
393
|
+
:param program: the executable which can be its full path or the name
|
394
|
+
:param args: arguments to be passed to the `program`
|
395
|
+
:return: the substitution string for `_FLAGS_RE.sub` call
|
396
|
+
"""
|
397
|
+
# check for !![ap]
|
398
|
+
if (start := match.start()) > 0 and flags[start - 1] == "!":
|
399
|
+
return match.group()[1]
|
400
|
+
if match.group()[1] == "p":
|
401
|
+
return program
|
402
|
+
return args
|
403
|
+
|
404
|
+
|
405
|
+
def docker_cp_action(docker_cmd: str, box_name: str, src: str,
|
406
|
+
func: Callable[[str], None]) -> int:
|
407
|
+
"""
|
408
|
+
Copy a file from docker container to a temporary location on host and perform given action.
|
409
|
+
This does not use the `cp` command of podman/docker because it fails for rootless docker.
|
410
|
+
|
411
|
+
:param docker_cmd: the podman/docker executable to use
|
412
|
+
:param box_name: name of the ybox container
|
413
|
+
:param src: path of the file to be copied on the container
|
414
|
+
:param func: function that takes temporary copied file as an argument with no return
|
415
|
+
:return: exit code of the podman/docker command
|
416
|
+
"""
|
417
|
+
src_dir = os.path.dirname(src)
|
418
|
+
src_file = os.path.basename(src)
|
419
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
420
|
+
# use shell pipe and tar instead of python Popen and tarfile which will require much more
|
421
|
+
# code unncessarily and may not be able to use `run_command`
|
422
|
+
shell_cmd = (f"'{docker_cmd}' exec '{box_name}' tar -C '{src_dir}' -cpf - '{src_file}' | "
|
423
|
+
f"tar -C '{temp_dir}' -xpf -")
|
424
|
+
if (code := int(run_command(["/bin/sh", "-c", shell_cmd], exit_on_error=False,
|
425
|
+
error_msg=f"copying of file from '{box_name}:{src}'"))) == 0:
|
426
|
+
func(f"{temp_dir}/{src_file}")
|
427
|
+
return code
|
428
|
+
|
429
|
+
|
430
|
+
def _wrap_desktop_file(filename: str, file: str, docker_cmd: str, conf: StaticConfiguration,
|
431
|
+
app_flags: dict[str, str], wrapper_files: list[str]) -> None:
|
432
|
+
"""
|
433
|
+
For a desktop file, add "podman/docker exec ..." to its Exec/TryExec lines. Also read
|
434
|
+
the additional flags for the command passed in `app_flags` and add them to an appropriate
|
435
|
+
position in the Exec/TryExec lines.
|
436
|
+
|
437
|
+
:param filename: name of the desktop file being wrapped
|
438
|
+
:param file: full path of the desktop file being wrapped
|
439
|
+
:param docker_cmd: the podman/docker executable to use
|
440
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
441
|
+
:param app_flags: map of executable file name to the value from [app_flags] section from the
|
442
|
+
container configuration
|
443
|
+
:param wrapper_files: the accumulated list of all wrapper files so far
|
444
|
+
"""
|
445
|
+
# container name is added to desktop file to make it unique
|
446
|
+
wrapper_name = f"ybox.{conf.box_name}.{filename}"
|
447
|
+
|
448
|
+
def replace_executable(match: re.Match[str]) -> str:
|
449
|
+
program = match.group(3)
|
450
|
+
args = match.group(4)
|
451
|
+
# check for additional flags to be added
|
452
|
+
if flags := app_flags.get(os.path.basename(program), ""):
|
453
|
+
full_cmd = _FLAGS_RE.sub(
|
454
|
+
lambda f_match: _replace_flags(f_match, flags, program, args), flags)
|
455
|
+
elif args:
|
456
|
+
full_cmd = f"{program} {args}"
|
457
|
+
else:
|
458
|
+
full_cmd = program
|
459
|
+
# pseudo-tty cannot be allocated with rootless docker outside of a terminal app
|
460
|
+
return (f'{match.group(1)}{docker_cmd} exec -e=XAUTHORITY -e=DISPLAY '
|
461
|
+
f'-e=FREETYPE_PROPERTIES {conf.box_name} /usr/local/bin/run-in-dir '
|
462
|
+
f'"" {full_cmd}\n')
|
463
|
+
|
464
|
+
# the destination will be $HOME/.local/share/applications
|
465
|
+
os.makedirs(conf.env.user_applications_dir, mode=Consts.default_directory_mode(),
|
466
|
+
exist_ok=True)
|
467
|
+
wrapper_file = f"{conf.env.user_applications_dir}/{wrapper_name}"
|
468
|
+
print_notice(f"Linking container desktop file {file} to {wrapper_file}")
|
469
|
+
|
470
|
+
def write_desktop_file(src: str) -> None:
|
471
|
+
with open(wrapper_file, "w", encoding="utf-8") as wrapper_fd:
|
472
|
+
with open(src, "r", encoding="utf-8") as src_fd:
|
473
|
+
wrapper_fd.writelines(_EXEC_RE.sub(replace_executable, line) for line in src_fd)
|
474
|
+
if docker_cp_action(docker_cmd, conf.box_name, file, write_desktop_file) == 0:
|
475
|
+
wrapper_files.append(wrapper_file)
|
476
|
+
|
477
|
+
|
478
|
+
def _select_app_icon(file_dir: str, filename: str, file: str, icon_dir_pattern: re.Pattern[str],
|
479
|
+
selected_icons: dict[str, tuple[float, str]]) -> bool:
|
480
|
+
"""
|
481
|
+
Check if given file is an application icon file and fill in the given dict of `selected_icons`
|
482
|
+
if there is no icon selected with the same name so far or it is higher priority than the
|
483
|
+
existing one in `selected_icons`.
|
484
|
+
|
485
|
+
:param file_dir: the directory of the application icon file
|
486
|
+
:param filename: name of the application icon file
|
487
|
+
:param file: full path of the application icon file
|
488
|
+
:param icon_dir_pattern: a regular expression of the form `(<dir1>|<dir2>|...)` capturing all
|
489
|
+
the standard icon directories and also separately capture icon size
|
490
|
+
for directory of the form `/path/64x64/...` (i.e. capture `64`)
|
491
|
+
:param selected_icons: dictionary of icon names (i.e. file name without extension) to a tuple
|
492
|
+
having inverse priority as a float and full path of the icon file
|
493
|
+
:return: `True` if `file_dir` was one of the app icon directories else `False`
|
494
|
+
"""
|
495
|
+
if icon_match := icon_dir_pattern.fullmatch(file_dir):
|
496
|
+
# find index of the first pattern that matched
|
497
|
+
match_groups = icon_match.groups()
|
498
|
+
dir_idx = next(idx for idx, d in enumerate(match_groups) if d)
|
499
|
+
# deal with 16x16 kind of directories; max dimension expected is 512
|
500
|
+
if len(match_groups) >= dir_idx + 2 and (icon_dim_str := icon_match.group(
|
501
|
+
dir_idx + 2)) and (icon_dim := float(icon_dim_str)) < 1024:
|
502
|
+
# larger `icon_dim` should give smaller `inv_priority`, hence (1.0 - ...)
|
503
|
+
inv_priority = dir_idx + 1.0 - icon_dim / 1024.0
|
504
|
+
else:
|
505
|
+
inv_priority = float(dir_idx)
|
506
|
+
icon_name = Path(filename).stem # name is without extension
|
507
|
+
if not (existing := selected_icons.get(icon_name)) or inv_priority < existing[0]:
|
508
|
+
selected_icons[icon_name] = (inv_priority, file)
|
509
|
+
return True
|
510
|
+
return False
|
511
|
+
|
512
|
+
|
513
|
+
def _copy_app_icons(selected_icons: dict[str, tuple[float, str]], docker_cmd: str,
|
514
|
+
conf: StaticConfiguration, quiet: int, wrapper_files: list[str]) -> None:
|
515
|
+
"""
|
516
|
+
Copy application icons (as accumulated in `selected_icons`) from the container to user's
|
517
|
+
standard application icon directory. It will also ask for confirmation (if `-q/--quiet` flag
|
518
|
+
was not pass) if an icon with same name already exists in user's directory.
|
519
|
+
|
520
|
+
:param selected_icons: dictionary of icon names (i.e. file name without extension) to a tuple
|
521
|
+
having inverse priority as a float and full path of the icon file
|
522
|
+
:param docker_cmd: the podman/docker executable to use
|
523
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
524
|
+
:param quiet: perform operations quietly: a value != 0 will skip overwriting existing icon
|
525
|
+
file in user's application icon directory without confirmation
|
526
|
+
:param wrapper_files: the accumulated list of all wrapper files so far
|
527
|
+
"""
|
528
|
+
target_icon_dir = Path(conf.env.user_base, "share", "icons")
|
529
|
+
os.makedirs(target_icon_dir, mode=Consts.default_directory_mode(), exist_ok=True)
|
530
|
+
for icon_name, (_, icon_path) in selected_icons.items():
|
531
|
+
if existing_icons := [str(p) for p in target_icon_dir.glob(f"{icon_name}.*")]:
|
532
|
+
resp = input(f"Application icon(s) [{' '.join(existing_icons)}] already present. "
|
533
|
+
"Override? (y/N) ") if quiet == 0 else "N"
|
534
|
+
if resp.strip().lower() != "y":
|
535
|
+
print_warn(f"Skipping copying of application icon {icon_path}")
|
536
|
+
continue
|
537
|
+
target_icon_path = target_icon_dir.joinpath(os.path.basename(icon_path))
|
538
|
+
print_notice(f"Copying application icon file {icon_path} to {target_icon_path}")
|
539
|
+
# copy from temporary file over the existing one, if any, to overwrite rather than move
|
540
|
+
# (which will preserve all of its hard links, for example)
|
541
|
+
exists = os.path.exists(target_icon_path)
|
542
|
+
if docker_cp_action(docker_cmd, conf.box_name, icon_path,
|
543
|
+
lambda src, dest=target_icon_path: shutil.copy2(src, dest)) == 0:
|
544
|
+
# skip registration of icon file it already existed and was overwritten so that
|
545
|
+
# it is not removed on package uninstall
|
546
|
+
if not exists:
|
547
|
+
wrapper_files.append(str(target_icon_path))
|
548
|
+
|
549
|
+
|
550
|
+
def _can_wrap_executable(filename: str, file: str, conf: StaticConfiguration, quiet: int) -> bool:
|
551
|
+
"""
|
552
|
+
For an executable, check if a wrapper executable that invokes "podman/docker exec" should
|
553
|
+
be created (with user confirmation or allow without confirmation if `quiet` is non-zero).
|
554
|
+
|
555
|
+
:param filename: name of the executable file being wrapped
|
556
|
+
:param file: full path of the executable file being wrapped
|
557
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
558
|
+
:param quiet: perform operations quietly: a value of 1 will skip overwriting existing wrapper
|
559
|
+
file without confirmation while a value of 2 will also skip overriding
|
560
|
+
system executable, if present, without confirmation
|
561
|
+
:return: `True` if the wrapper executable file name if allowed else `False`
|
562
|
+
"""
|
563
|
+
wrapper_exec = _get_wrapper_executable(filename, conf)
|
564
|
+
if os.path.exists(wrapper_exec):
|
565
|
+
resp = input(
|
566
|
+
f"Target file {wrapper_exec} already exists. Overwrite? (y/N) ") if quiet == 0 else "N"
|
567
|
+
if resp.strip().lower() != "y":
|
568
|
+
print_warn(f"Skipping local wrapper for {file}")
|
569
|
+
return False
|
570
|
+
# also check if creating user executable will override system executable
|
571
|
+
for bin_dir in _LOCAL_BIN_DIRS:
|
572
|
+
sys_exec = f"{bin_dir}/{filename}"
|
573
|
+
if os.path.exists(sys_exec):
|
574
|
+
resp = input(f"Target file {wrapper_exec} will override system installed "
|
575
|
+
f"{sys_exec}. Continue? (y/N) ") if quiet < 2 else "N"
|
576
|
+
if resp.strip().lower() != "y":
|
577
|
+
print_warn(f"Skipping local wrapper for {file}")
|
578
|
+
return False
|
579
|
+
break
|
580
|
+
return True
|
581
|
+
|
582
|
+
|
583
|
+
def _wrap_executable(filename: str, file: str, docker_cmd: str, conf: StaticConfiguration,
|
584
|
+
app_flags: dict[str, str], wrapper_files: list[str]) -> None:
|
585
|
+
"""
|
586
|
+
For an executable, create a wrapper executable that invokes "podman/docker exec".
|
587
|
+
|
588
|
+
:param filename: name of the executable file being wrapped
|
589
|
+
:param file: full path of the executable file being wrapped
|
590
|
+
:param docker_cmd: the podman/docker executable to use
|
591
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
592
|
+
:param app_flags: map of executable file name to the value from [app_flags] section from the
|
593
|
+
container configuration
|
594
|
+
:param wrapper_files: the accumulated list of all wrapper files so far
|
595
|
+
"""
|
596
|
+
os.makedirs(conf.env.user_executables_dir, mode=Consts.default_directory_mode(),
|
597
|
+
exist_ok=True)
|
598
|
+
wrapper_exec = _get_wrapper_executable(filename, conf)
|
599
|
+
print_notice(f"Linking container executable {file} to {wrapper_exec}")
|
600
|
+
# ensure to change working directory to same on as on host if possible using `run-in-dir`
|
601
|
+
# check for additional flags to be added
|
602
|
+
if flags := app_flags.get(filename, ""):
|
603
|
+
full_cmd = '/usr/local/bin/run-in-dir "`pwd`" ' + _FLAGS_RE.sub(
|
604
|
+
lambda f_match: _replace_flags(f_match, flags, f'"{file}"', '"$@"'), flags)
|
605
|
+
else:
|
606
|
+
full_cmd = f'/usr/local/bin/run-in-dir "`pwd`" "{file}" "$@"'
|
607
|
+
exec_content = ("#!/bin/sh\n",
|
608
|
+
f"exec {docker_cmd} exec -it -e=XAUTHORITY -e=DISPLAY "
|
609
|
+
f"-e=FREETYPE_PROPERTIES {conf.box_name} ", full_cmd)
|
610
|
+
with open(wrapper_exec, "w", encoding="utf-8") as wrapper_fd:
|
611
|
+
wrapper_fd.writelines(exec_content)
|
612
|
+
os.chmod(wrapper_exec, mode=0o755, follow_symlinks=True)
|
613
|
+
wrapper_files.append(wrapper_exec)
|
614
|
+
|
615
|
+
|
616
|
+
def _get_wrapper_executable(filename: str, conf: StaticConfiguration) -> str:
|
617
|
+
"""get the file path for local wrapper executable"""
|
618
|
+
return f"{conf.env.user_executables_dir}/{filename}"
|
619
|
+
|
620
|
+
|
621
|
+
def _link_man_page(file: str, shared_root: str, conf: StaticConfiguration,
|
622
|
+
wrapper_files: list[str]) -> None:
|
623
|
+
"""
|
624
|
+
For an executable, create a wrapper executable that invokes "podman/docker exec".
|
625
|
+
|
626
|
+
:param file: full path of the executable file being wrapped
|
627
|
+
:param shared_root: the local shared root directory if `shared_root` is provided
|
628
|
+
for the container
|
629
|
+
:param conf: the :class:`StaticConfiguration` for the container
|
630
|
+
:param wrapper_files: the accumulated list of all wrapper files so far
|
631
|
+
"""
|
632
|
+
man_dir_base = file.index("/man/") # expect /man/ to exist in the file path
|
633
|
+
linked_man_page = Path(conf.env.user_base, "share", "man", file[man_dir_base + 5:])
|
634
|
+
print_notice(f"Linking man page {file} to {linked_man_page}")
|
635
|
+
linked_man_page.parent.mkdir(parents=True, exist_ok=True)
|
636
|
+
linked_man_page.unlink(missing_ok=True)
|
637
|
+
linked_man_page.symlink_to(f"{shared_root}{file}")
|
638
|
+
wrapper_files.append(str(linked_man_page))
|