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.
Files changed (76) hide show
  1. ybox/__init__.py +2 -0
  2. ybox/cmd.py +307 -0
  3. ybox/conf/completions/ybox.fish +93 -0
  4. ybox/conf/distros/arch/add-gpg-key.sh +29 -0
  5. ybox/conf/distros/arch/distro.ini +192 -0
  6. ybox/conf/distros/arch/init-base.sh +10 -0
  7. ybox/conf/distros/arch/init-user.sh +35 -0
  8. ybox/conf/distros/arch/init.sh +82 -0
  9. ybox/conf/distros/arch/list_fmt_long.py +76 -0
  10. ybox/conf/distros/arch/pkgdeps.py +276 -0
  11. ybox/conf/distros/deb-generic/check-package.sh +77 -0
  12. ybox/conf/distros/deb-generic/distro.ini +190 -0
  13. ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
  14. ybox/conf/distros/deb-generic/init-base.sh +11 -0
  15. ybox/conf/distros/deb-generic/init-user.sh +3 -0
  16. ybox/conf/distros/deb-generic/init.sh +136 -0
  17. ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
  18. ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
  19. ybox/conf/distros/deb-oldstable/distro.ini +21 -0
  20. ybox/conf/distros/deb-stable/distro.ini +21 -0
  21. ybox/conf/distros/supported.list +5 -0
  22. ybox/conf/distros/ubuntu2204/distro.ini +21 -0
  23. ybox/conf/distros/ubuntu2404/distro.ini +21 -0
  24. ybox/conf/profiles/apps.ini +26 -0
  25. ybox/conf/profiles/basic.ini +310 -0
  26. ybox/conf/profiles/dev.ini +25 -0
  27. ybox/conf/profiles/games.ini +39 -0
  28. ybox/conf/resources/entrypoint-base.sh +170 -0
  29. ybox/conf/resources/entrypoint-common.sh +23 -0
  30. ybox/conf/resources/entrypoint-cp.sh +32 -0
  31. ybox/conf/resources/entrypoint-root.sh +20 -0
  32. ybox/conf/resources/entrypoint-user.sh +21 -0
  33. ybox/conf/resources/entrypoint.sh +249 -0
  34. ybox/conf/resources/prime-run +13 -0
  35. ybox/conf/resources/run-in-dir +60 -0
  36. ybox/conf/resources/run-user-bash-cmd +14 -0
  37. ybox/config.py +255 -0
  38. ybox/env.py +205 -0
  39. ybox/filelock.py +77 -0
  40. ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
  41. ybox/pkg/__init__.py +0 -0
  42. ybox/pkg/clean.py +33 -0
  43. ybox/pkg/info.py +40 -0
  44. ybox/pkg/inst.py +638 -0
  45. ybox/pkg/list.py +191 -0
  46. ybox/pkg/mark.py +68 -0
  47. ybox/pkg/repair.py +150 -0
  48. ybox/pkg/repo.py +251 -0
  49. ybox/pkg/search.py +52 -0
  50. ybox/pkg/uninst.py +92 -0
  51. ybox/pkg/update.py +56 -0
  52. ybox/print.py +121 -0
  53. ybox/run/__init__.py +0 -0
  54. ybox/run/cmd.py +54 -0
  55. ybox/run/control.py +102 -0
  56. ybox/run/create.py +1116 -0
  57. ybox/run/destroy.py +64 -0
  58. ybox/run/graphics.py +367 -0
  59. ybox/run/logs.py +57 -0
  60. ybox/run/ls.py +64 -0
  61. ybox/run/pkg.py +445 -0
  62. ybox/schema/0.9.1-added.sql +27 -0
  63. ybox/schema/0.9.6-added.sql +18 -0
  64. ybox/schema/init.sql +39 -0
  65. ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
  66. ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
  67. ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
  68. ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
  69. ybox/state.py +914 -0
  70. ybox/util.py +351 -0
  71. ybox-0.9.8.dist-info/LICENSE +19 -0
  72. ybox-0.9.8.dist-info/METADATA +533 -0
  73. ybox-0.9.8.dist-info/RECORD +76 -0
  74. ybox-0.9.8.dist-info/WHEEL +5 -0
  75. ybox-0.9.8.dist-info/entry_points.txt +8 -0
  76. 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))