Files
Lake ChanandMirko Brombin 86f098cadc Migrate Config from dict to object (#2464)
* refactor: migrate config from dict to object

* fix: type hint import

* fix: BottleConfig.load()

* fix: config loading in BackupManager.import_backup()

* fix: Remove unused import

* fix: more guard code, type hints and Config object usage

* fix: a potential crash when SteamManager.get_app_config() failed

* Revert "fix: Remove unused import"

This reverts commit 2d48a4e08a.

* add: new config field

* fix: Config object value read

* misc: remove review TODO comments

* adapt to new BottleConfig object

* fix RunAsync usage

* add: BottleConfig._fix()

* fix: type hint and Config object usage

* fix: typo in FSR setting default value

* adapt to new BottleConfig object

* remove unnecessary DictCompat method

* add: BottleConfig._filter()

* fix: TemplateManager.new()

* misc: critical guard code

* fix: typo in commit b7ed61e678

* misc: more type hinting

* Revert "remove unnecessary DictCompat method"

This reverts commit 265633e733.

* fix crash when user choose Cancel in ProtonAlertDialog

* fix force stop all programs not work

* adapt NVAPIComponent.check_bottle_nvngx to Config object

* Better DictCompatMixIn

* InstallerManager.__set_parameters incorrect adopt

* fix typo in cbe1dc760b

* adopt LibraryManager.download_thumbnail to Config object

* serialize Config object to yaml automatically

* fix translation and remove workaround in 0830fddbc9

* fix missing locale for Chinese

* comment

* fix incorrect variable init position

* remove 'in' extensions and some bugfix

* fix executable filemode

Co-authored-by: Mirko Brombin <send@mirko.pm>
2023-01-20 17:02:40 +01:00

400 lines
13 KiB
Python

# manager.py
#
# Copyright 2022 brombinmirko <send@mirko.pm>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, in version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import shlex
import shutil
import gi
import os
import locale
import icoextract
from glob import glob
from typing import NewType, Union
from datetime import datetime
from gi.repository import Gdk, Gio, GLib, Gtk
from gettext import gettext as _
from bottles.backend.logger import Logger
from bottles.backend.globals import Paths, user_apps_dir
from bottles.backend.models.config import BottleConfig
from bottles.backend.utils.imagemagick import ImageMagickUtils
from bottles.backend.utils.generic import get_mime
logging = Logger()
class ManagerUtils:
"""
This class contains methods (tools, utilities) that are not
directly related to the Manager.
"""
@staticmethod
def open_filemanager(
config: BottleConfig = None,
path_type: str = "bottle",
component: str = "",
custom_path: str = ""
):
logging.info("Opening the file manager in the path …")
path = ""
if path_type == "bottle" and config is None:
raise NotImplementedError("bottle type need a valid Config")
if path_type == "bottle":
bottle_path = ManagerUtils.get_bottle_path(config)
if config.Environment == "Steam":
bottle_path = config.Path
path = f"{bottle_path}/drive_c"
elif component != "":
if path_type in ["runner", "runner:proton"]:
path = ManagerUtils.get_runner_path(component)
elif path_type == "dxvk":
path = ManagerUtils.get_dxvk_path(component)
elif path_type == "vkd3d":
path = ManagerUtils.get_vkd3d_path(component)
elif path_type == "nvapi":
path = ManagerUtils.get_nvapi_path(component)
elif path_type == "latencyflex":
path = ManagerUtils.get_latencyflex_path(component)
elif path_type == "runtime":
path = Paths.runtimes
elif path_type == "winebridge":
path = Paths.winebridge
if path_type == "custom" and custom_path != "":
path = custom_path
app = Gio.Application.get_default()
window = app.get_active_window()
path = f"file://{path}"
Gtk.show_uri(window, path, Gdk.CURRENT_TIME)
@staticmethod
def get_bottle_path(config: BottleConfig) -> str:
if config.Environment == "Steam":
return os.path.join(Paths.steam, config.CompatData)
return os.path.join(Paths.bottles, config.Path)
@staticmethod
def get_runner_path(runner: str) -> str:
if runner.startswith("sys-"):
return runner
return f"{Paths.runners}/{runner}"
@staticmethod
def get_dxvk_path(dxvk: str) -> str:
return f"{Paths.dxvk}/{dxvk}"
@staticmethod
def get_vkd3d_path(vkd3d: str) -> str:
return f"{Paths.vkd3d}/{vkd3d}"
@staticmethod
def get_nvapi_path(nvapi: str) -> str:
return f"{Paths.nvapi}/{nvapi}"
@staticmethod
def get_latencyflex_path(latencyflex: str) -> str:
return f"{Paths.latencyflex}/{latencyflex}"
@staticmethod
def get_temp_path(dest: str) -> str:
return f"{Paths.temp}/{dest}"
@staticmethod
def get_template_path(template: str) -> str:
return f"{Paths.templates}/{template}"
@staticmethod
def move_file_to_bottle(
file_path: str,
config: BottleConfig,
fn_update: callable = None
) -> Union[str, bool]:
logging.info(f"Adding file {file_path} to the bottle …")
bottle_path = ManagerUtils.get_bottle_path(config)
if not os.path.exists(f"{bottle_path}/storage"):
'''
If the storage folder does not exist for the bottle,
create it before moving the file.
'''
os.makedirs(f"{bottle_path}/storage")
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
file_new_path = f"{bottle_path}/storage/{file_name}"
logging.info(f"Copying file {file_path} to the bottle …")
try:
with open(file_path, "rb") as f_in:
with open(file_new_path, "wb") as f_out:
for i in range(file_size):
f_out.write(f_in.read(1))
_size = i / file_size
if fn_update:
if _size % 0.1 == 0:
GLib.idle_add(fn_update, _size)
GLib.idle_add(fn_update, 1)
return file_new_path
except (OSError, IOError):
logging.error(f"Could not copy file {file_path} to the bottle.")
return False
@staticmethod
def get_exe_parent_dir(config, executable_path):
"""Get parent directory of the executable."""
if "\\" in executable_path:
p = "\\".join(executable_path.split("\\")[:-1])
p = p.replace("C:\\", "\\drive_c\\").replace("\\", "/")
return ManagerUtils.get_bottle_path(config) + p
return os.path.dirname(executable_path)
@staticmethod
def extract_icon(config: BottleConfig, program_name: str, program_path: str) -> str:
from bottles.backend.wine.winepath import WinePath
winepath = WinePath(config)
icon = "com.usebottles.bottles-program"
bottle_icons_path = os.path.join(ManagerUtils.get_bottle_path(config), "icons")
try:
if winepath.is_windows(program_path):
program_path = winepath.to_unix(program_path)
ico_dest_temp = os.path.join(bottle_icons_path, f"_{program_name}.png")
ico_dest = os.path.join(bottle_icons_path, f"{program_name}.png")
ico = icoextract.IconExtractor(program_path)
os.makedirs(bottle_icons_path, exist_ok=True)
if os.path.exists(ico_dest_temp):
os.remove(ico_dest_temp)
if os.path.exists(ico_dest):
os.remove(ico_dest)
ico.export_icon(ico_dest_temp)
if get_mime(ico_dest_temp) == "image/vnd.microsoft.icon":
if not ico_dest_temp.endswith(".ico"):
shutil.move(ico_dest_temp, f"{ico_dest_temp}.ico")
ico_dest_temp = f"{ico_dest_temp}.ico"
im = ImageMagickUtils(ico_dest_temp)
im.convert(ico_dest)
icon = ico_dest
else:
shutil.move(ico_dest_temp, ico_dest)
icon = ico_dest
except: # TODO: handle those
pass
return icon
@staticmethod
def create_desktop_entry(config, program: dict, skip_icon: bool = False, custom_icon: str = "",
use_xdp: bool = False) -> bool:
if not user_apps_dir and not use_xdp:
return False
cmd_legacy = "bottles"
cmd_cli = "bottles-cli"
icon = "com.usebottles.bottles-program"
if "FLATPAK_ID" in os.environ:
cmd_legacy = "flatpak run com.usebottles.bottles"
cmd_cli = "flatpak run --command=bottles-cli com.usebottles.bottles"
if not skip_icon and not custom_icon:
icon = ManagerUtils.extract_icon(config, program.get("name"), program.get("path"))
elif custom_icon:
icon = custom_icon
if not use_xdp:
file_name_template = "%s/%s--%s--%s.desktop"
existing_files = glob(file_name_template % (
Paths.applications,
config.Name,
program.get("name"),
"*"
))
desktop_file = file_name_template % (
Paths.applications,
config.Name,
program.get("name"),
datetime.now().timestamp()
)
if existing_files:
for file in existing_files:
os.remove(file)
with open(desktop_file, "w") as f:
f.write(f"[Desktop Entry]\n")
f.write(f"Name={program.get('name')}\n")
f.write(f"Exec={cmd_cli} run -p {shlex.quote(program.get('name'))} -b '{config.get('Name')}' -- %u\n")
f.write(f"Type=Application\n")
f.write(f"Terminal=false\n")
f.write(f"Categories=Application;\n")
f.write(f"Icon={icon}\n")
f.write(f"Comment=Launch {program.get('name')} using Bottles.\n")
f.write(f"StartupWMClass={program.get('name')}\n")
# Actions
f.write("Actions=Configure;\n")
f.write("[Desktop Action Configure]\n")
f.write("Name=Configure in Bottles\n")
f.write(f"Exec={cmd_legacy} -b '{config.get('Name')}'\n")
return True
'''
WIP: the following code is not working yet, it raises an error:
GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod
import uuid
from gi.repository import Gio, Xdp
portal = Xdp.Portal()
if icon == "com.usebottles.bottles-program":
_icon = Gio.BytesIcon.new(icon.encode("utf-8"))
else:
_icon = Gio.FileIcon.new(Gio.File.new_for_path(icon))
icon_v = _icon.serialize()
token = portal.dynamic_launcher_request_install_token(program.get("name"), icon_v)
portal.dynamic_launcher_install(
token,
f"com.usebottles.bottles.{config.get('Name')}.{program.get('name')}.{str(uuid.uuid4())}.desktop",
"""
[Desktop Entry]
Exec={}
Type=Application
Terminal=false
Categories=Application;
Comment=Launch {} using Bottles.
Actions=Configure;
[Desktop Action Configure]
Name=Configure in Bottles
Exec={}
""".format(
f"{cmd_cli} run -p {shlex.quote(program.get('name'))} -b '{config.get('Path')}'",
program.get("name"),
f"{cmd_legacy} -b '{config.get('Name')}'"
).encode("utf-8")
)
'''
@staticmethod
def browse_wineprefix(wineprefix: dict):
"""Presents a dialog to browse the wineprefix."""
ManagerUtils.open_filemanager(
path_type="custom",
custom_path=wineprefix.get("Path")
)
@staticmethod
def get_languages(from_name=None, from_locale=None, from_index=None, get_index=False, get_locales=False):
locales = [
'sys',
'bg_BG',
'cs_CZ',
'da_DK',
'de_DE',
'el_GR',
'en_US',
'es_ES',
'et_EE',
'fi_FI',
'fr_FR',
'hr_HR',
'hu_HU',
'it_IT',
'lt_LT',
'lv_LV',
'nl_NL',
'no_NO',
'pl_PL',
'pt_PT',
'ro_RO',
'ru_RU',
'sk_SK',
'sl_SI',
'sv_SE',
'tr_TR',
'zh_CN',
'ja_JP',
'zh_TW',
'ko_KR'
]
names = [
_('System'),
_('Bulgarian'),
_('Czech'),
_('Danish'),
_('German'),
_('Greek'),
_('English'),
_('Spanish'),
_('Estonian'),
_('Finnish'),
_('French'),
_('Croatian'),
_('Hungarian'),
_('Italian'),
_('Lithuanian'),
_('Latvian'),
_('Dutch'),
_('Norwegian'),
_('Polish'),
_('Portuguese'),
_('Romanian'),
_('Russian'),
_('Slovak'),
_('Slovenian'),
_('Swedish'),
_('Turkish'),
_('Chinese'),
_('Japanese'),
_('Taiwanese'),
_('Korean')
]
if from_name and from_locale:
raise ValueError("Cannot pass both from_name, from_locale and from_index.")
if from_name:
if from_name not in names:
raise ValueError("Given name not in list.")
i = names.index(from_name)
if get_index:
return i
return from_name, locales[i]
if from_locale:
if from_locale not in locales:
raise ValueError("Given locale not in list.")
i = locales.index(from_locale)
if get_index:
return i
return from_locale, names[i]
if isinstance(from_index, int):
if from_index not in range(0, len(locales)):
raise ValueError("Given index not in range.")
return locales[from_index], names[from_index]
if get_locales:
return locales
return names