feat: custom symlink creation in thread

This commit is contained in:
aclist 2026-06-13 01:50:34 +09:00
parent a8b7ea5008
commit e1cb682e74
8 changed files with 273 additions and 130 deletions

View File

@ -162,9 +162,12 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
proc = subprocess.run([*client_args, *params]) proc = subprocess.run([*client_args, *params])
return proc.returncode return proc.returncode
def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: str) -> int:
def launch_offline(
client: str, appid: int, name: str, mods: list[str], mission: str
) -> int:
"""Launch offline with specific mods/missions""" """Launch offline with specific mods/missions"""
concat = concat_mods(mods) symlinks = ";".join(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
params = [ params = [
"-applaunch", "-applaunch",
@ -173,12 +176,13 @@ def launch_offline(client: str, appid: int, name: str, mods: list[str], mission:
"-nosplash", "-nosplash",
"-skipintro", "-skipintro",
f"-name={name}", f"-name={name}",
f"-mod={concat}", f"-mod={symlinks}",
f"-mission={mission}" f"-mission={mission}",
] ]
proc = subprocess.run([*client_args, *params]) proc = subprocess.run([*client_args, *params])
return proc.returncode return proc.returncode
def find_user_id(path: Path) -> str | None: def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf" resolved_path = path / "config" / "loginusers.vdf"
try: try:

View File

@ -32,10 +32,12 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic" CLIPBOARD = "edit-copy-symbolic"
FOLDER = "folder-symbolic" FOLDER = "folder-symbolic"
EDIT_DELETE = "edit-delete-symbolic"
ERROR = "dialog-error-symbolic" ERROR = "dialog-error-symbolic"
HELP_BUBBLE = "help-about-symbolic" HELP_BUBBLE = "help-about-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic"

View File

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Union
import gi import gi
@ -9,6 +9,7 @@ from gi.repository import Gdk, GObject # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage, ServerTab from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.model.model_factory import FastInsertListStore from dzgui.model.model_factory import FastInsertListStore
from dzgui.views.pages.offline import FolderHBox
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal # TODO: if servers fail to load, may leave dangling widgets waiting for a signal
@ -160,6 +161,14 @@ class Emitter(GObject.GObject):
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def custom_mods_unloaded(self, widget: "FolderHBox") -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool))
def custom_mission_loaded(self, folder: str, is_valid: bool) -> None: def custom_mission_loaded(self, folder: str, is_valid: bool) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def invalid_custom_mods(self) -> None:
pass

View File

@ -1,17 +1,23 @@
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Union from typing import Callable, TYPE_CHECKING, Union
import dzgui.api.pefile as PeFile
from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import launch_offline from dzgui.api.steam import launch_offline
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util.symlink import clone_symlinks from dzgui.util.symlink import create_custom_symlinks
from dzgui.views.dialogs.filepicker import FolderPicker from dzgui.views.dialogs.filepicker import FolderPicker
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.model.model_factory import FastInsertListStore
logger = logging.getLogger(APP_NAME)
class OfflineManager: class OfflineManager:
@ -35,65 +41,73 @@ class OfflineManager:
if folder is None: if folder is None:
return return
is_valid = is_mission(folder) is_valid = is_mission(folder)
# TODO: stop storing values in here
if is_valid:
# TODO: str
self.mission_folder = str(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid) self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def get_custom_mods(self, local_mods: list[str]) -> None: def find_custom_mods(self, callback: Callable) -> None:
folder = self.open_folderpicker(dialogs.custom_mod_dialog) folder = self.open_folderpicker(dialogs.custom_mod_dialog)
window = self.controller.get_window()
window.set_sensitive(False)
if folder is None: if folder is None:
window.set_sensitive(True)
return return
self.parse_custom_mods(local_mods, folder) # NOTE: starts spinner in calling UI
callback()
self.parse_custom_mods(folder)
@call_on_thread(dialogs.parsing_mods) @call_on_thread(dialogs.parsing_mods, show_dialog=False)
def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: def parse_custom_mods(self, folder: str) -> None:
mods = get_custom_mods(Path(folder)) mods = get_custom_mods(Path(folder))
store = ModelFactory().make_mod_store() store = ModelFactory().make_mod_store()
store.extend(mods) store.extend(mods)
func = StoredFunc(self.post_mod_loading, store, folder)
func = StoredFunc(
lambda: self.emitter.emit("custom_mods_loaded", store, folder)
)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def setup( def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None:
self, window = self.controller.get_window()
appid: int, window.set_sensitive(True)
mission: str = "", self.emitter.emit("custom_mods_loaded", store, folder)
local_mods: list[str] | None = None,
custom_mods: list[str] | None = None,
) -> None:
self.appid = appid
self.local_mods = local_mods
self.custom_mods = custom_mods
self.launch()
@call_on_thread(dialogs.waiting_for_launch) @call_on_thread(dialogs.waiting_for_launch)
def launch(self) -> None: def launch(
self,
appid: int,
mission: str,
local_mods: list[str],
custom_folder: str,
custom_mods: list[str],
) -> None:
# NOTE: local_mods and custom_mods are lists of symlinks
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
name = self.controller.query_config(Preferences.NAME) name = self.controller.query_config(Preferences.NAME)
steam_path = self.controller.query_config(Preferences.DEFAULT) steam_path = self.controller.query_config(Preferences.DEFAULT)
new_symlinks: list[str] = []
combined_mods: list[str] = [] combined_mods: list[str] = []
if self.local_mods is not None:
combined_mods.extend(self.local_mods)
if self.custom_mods is not None: if len(local_mods) > 0:
# TODO: new function that creates symlinks in game path combined_mods.extend(local_mods)
# based on selected mods
clone_symlinks(Path(steam_path)) if len(custom_mods) > 0:
new_symlinks = create_custom_symlinks(
Path(steam_path), Path(custom_folder), custom_mods
)
combined_mods.extend(new_symlinks) combined_mods.extend(new_symlinks)
launch_offline(client, appid, name, combined_mods, mission)
launch_offline(client, self.appid, name, combined_mods, self.mission_folder)
def open_folderpicker(self, title: str) -> Union["Path", None]: def open_folderpicker(self, title: str) -> Union["Path", None]:
picker = FolderPicker(self.controller.get_window(), title) picker = FolderPicker(self.controller.get_window(), title)
folder = picker.pick_folder() folder = picker.pick_folder()
picker.destroy() picker.destroy()
return folder return folder
def has_dayz_exp(self) -> bool:
try:
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
steam_path = Path(default_steam_path)
dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
if dayz_exp is None:
return False
return True
except Exception as e:
logger.warning(e)
return False

View File

@ -12,3 +12,9 @@ local_frame = "Installed mods"
no_mods = "No valid mods found" no_mods = "No valid mods found"
no_mission = "Not a valid mission" no_mission = "Not a valid mission"
custom_eventbox = "Set the root folder. Mods must be in atomic folders and have a meta.cpp file at a minimum."
mission_eventbox = "Select a single mission folder containing an init.c file."
folder_prefix = "<b>Current folder: </b>"
unset_button = "Unset folder"

View File

@ -12,10 +12,13 @@ logger = logging.getLogger(APP_NAME)
def rebuild_symlinks(config: Path) -> None: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path) steam_path = Path(path)
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
# NOTE: unlink stale symlinks # NOTE: unlink stale symlinks
# TODO: expunge ephemeral (@C) links (possibility of name collisions)
# if str(file)[:2] == "@C": file.unlink()
for file in dayz_path.iterdir(): for file in dayz_path.iterdir():
if file.is_symlink() and file.exists() is False: if file.is_symlink() and file.exists() is False:
file.unlink() file.unlink()
@ -30,6 +33,21 @@ def rebuild_symlinks(config: Path) -> None:
clone_symlinks(steam_path) clone_symlinks(steam_path)
def create_custom_symlinks(steam_path: Path, custom_dir: Path, uids: list[str]) -> None:
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
links = []
for uid in uids:
md5sum = _hash(uid, use_custom=True)
source = dayz_path.joinpath(md5sum)
target = custom_dir.joinpath(uid)
links.append(md5sum)
if source.exists() is False:
source.symlink_to(target)
clone_symlinks(steam_path)
return links
def clone_symlinks(steam_path: Path) -> None: def clone_symlinks(steam_path: Path) -> None:
""" """
Shares symlinks between builds. Used after any symlink operation Shares symlinks between builds. Used after any symlink operation

View File

@ -54,7 +54,12 @@ class FolderPicker(Gtk.FileChooserDialog):
return None return None
if res == Gtk.ResponseType.OK: if res == Gtk.ResponseType.OK:
folder = self.get_current_folder() folder = self.get_current_folder()
if folder is not None: if folder is None:
self.destroy() try:
return Path(folder) folder = Path.from_uri(self.get_uri())
except Exception:
# FIXME
return Path("/")
self.destroy()
return Path(folder)
return None return None

View File

@ -1,20 +1,22 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from enum import Enum
from typing import Self, Sequence, TYPE_CHECKING, Union from typing import Self, Sequence, TYPE_CHECKING, Union
from dzgui.util import css from dzgui.util import css
import dzgui.api.pefile as PeFile
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, APPID_DAYZ,
APPID_DAYZ_EXP, APPID_DAYZ_EXP,
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
EDIT_DELETE,
ERROR,
FOLDER, FOLDER,
) )
from dzgui.const.enum import NotebookPage, Preferences from dzgui.const.enum import NotebookPage, Preferences
from dzgui.managers.offline import OfflineManager from dzgui.managers.offline import OfflineManager
from dzgui.strings import generic, offline from dzgui.strings import generic, offline
from dzgui.views.components.buttons import IconTextButton from dzgui.views.components.buttons import Icon, IconTextButton
from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import OfflineModTreeView from dzgui.views.trees.tree_mods import OfflineModTreeView
@ -23,7 +25,7 @@ from dzgui.views.trees.tree_mods import OfflineModTreeView
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa from gi.repository import Gtk, Gdk, GObject # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@ -31,6 +33,11 @@ if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore from dzgui.model.model_factory import FastInsertListStore
class FolderError(Enum):
NO_VALID_MODS = 1
NO_VALID_MISSION = 2
class GenericBox(Gtk.Box): class GenericBox(Gtk.Box):
def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None: def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None:
super().__init__(orientation=orientation, spacing=spacing) super().__init__(orientation=orientation, spacing=spacing)
@ -57,15 +64,40 @@ class PageHeading(Gtk.Label):
css.add_class(self, "page-heading") css.add_class(self, "page-heading")
class ErrorPopover(Gtk.Popover):
def __init__(self) -> None:
super().__init__(position=Gtk.PositionType.RIGHT)
self.hbox = HBox()
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
error_icon = Icon(ERROR, margin_start=10)
self.hbox.extend([error_icon, self.label])
self.add(self.hbox)
self.show_all()
self.popdown()
def set_label(self, error: FolderError, msg: str) -> None:
match error:
case FolderError.NO_VALID_MODS:
prefix = offline.no_mods
case FolderError.NO_VALID_MISSION:
prefix = offline.no_mission
self.label.set_label(f"{prefix}: '{msg}'")
class FolderHBox(HBox): class FolderHBox(HBox):
def __init__(self, btn_label: str) -> None: def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None:
super().__init__(spacing=10) super().__init__(spacing=10)
self.set_margin_start(10) self.set_margin_start(5)
self.set_margin_end(10) self.set_margin_end(10)
self.set_margin_bottom(5) self.set_margin_bottom(5)
# TODO: alternate class for left-aligned icons self.folder = ""
self.controller = controller
self.emitter = controller.get_emitter()
self.eb = InfoEventBox(eb_text, controller)
self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT) self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT)
self.button.set_halign(Gtk.Align.START) self.button.set_halign(Gtk.Align.START)
self.button.set_image_position(Gtk.PositionType.LEFT) self.button.set_image_position(Gtk.PositionType.LEFT)
@ -75,19 +107,69 @@ class FolderHBox(HBox):
) )
self.label = Gtk.Label() self.label = Gtk.Label()
self.scrolled_label.add(self.label) self.scrolled_label.add(self.label)
self.extend([self.button, self.scrolled_label])
self.unset_button = IconTextButton(EDIT_DELETE, offline.unset_button)
self.unset_button.connect("clicked", self._on_unset_clicked)
self.spinner = Gtk.Spinner()
self.extend(
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
)
self.pop = ErrorPopover()
self.pop.set_relative_to(self.button)
self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap)
def start_spinner(self) -> None:
self.spinner.show()
self.spinner.start()
def stop_spinner(self) -> None:
self.spinner.hide()
self.spinner.stop()
def get_spinner(self) -> None:
return self.spinner
def _on_unmap(self, widget: Self) -> None:
self.unset_button.hide()
self.label.set_label("")
self.scrolled_label.hide()
def _on_map(self, widget: Self) -> None:
self.spinner.hide()
self.scrolled_label.show()
self.unset_button.hide()
def _on_unset_clicked(self, button: Gtk.Button) -> None:
self.label.set_label("")
self.unset_button.hide()
# FIXME: rename signal
self.emitter.emit("custom_mods_unloaded", self)
def get_button(self) -> Gtk.Button: def get_button(self) -> Gtk.Button:
return self.button return self.button
def set_label(self, label: str) -> None:
# TODO: strings
prefix = "Current folder: "
self.label.set_label(prefix + label)
def hide_label(self) -> None: def hide_label(self) -> None:
self.label.hide() self.label.hide()
def get_folder(self) -> str:
return self.folder
def set_folder(self, folder: str) -> None:
prefix = offline.folder_prefix
self.folder = folder
self.label.set_markup(prefix + folder)
self.label.show()
self.unset_button.show()
def present_error(self, error: FolderError, msg: str) -> None:
self.unset_button.hide()
self.pop.set_label(error, msg)
self.pop.popup()
class ModFrame(HeadingFrame): class ModFrame(HeadingFrame):
def __init__( def __init__(
@ -120,39 +202,29 @@ class ModFrame(HeadingFrame):
sel = self.tree.get_selection() sel = self.tree.get_selection()
sel.connect("changed", self._on_selection_changed) sel.connect("changed", self._on_selection_changed)
# TODO: inherit icons from warnings area in preconnect dialog self.connect("unmap", self._on_unmap)
# TODO: strings
self.error_label = Gtk.Label(
label="No mods found",
halign=Gtk.Align.START,
margin_start=10,
margin_bottom=5,
)
self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3)
def set_error(self, msg: str) -> None: def _on_unmap(self, widget: Self) -> None:
self.error_label.set_label(msg) self.tree.set_model(None)
self.error_label.show() self.tree_vbox.hide()
def hide_errors(self) -> None: def start(self, store: "FastInsertListStore") -> None:
self.error_label.hide() self.tree.set_model(store)
self.show_tree()
def start_empty(self) -> None:
self.error_label.show()
self.collapse_tree()
def hide_all(self) -> None:
self.error_label.hide()
self.collapse_tree()
# TODO: warnings area
# name collision within custom mods
def get_mods(self) -> list[str]: def get_mods(self) -> list[str]:
model, treeiters = self.tree.get_selection().get_selected_rows() model, treeiters = self.tree.get_selection().get_selected_rows()
if model is None: if model is None:
return [] return []
return [model[_iter][1] for _iter in treeiters] # TODO: include mod paths for custom mods
if type(self) is CustomModFrame:
return [model[_iter][2] for _iter in treeiters]
else:
# NOTE: pre-existing, canonical symlinks to published mods
return [model[_iter][1] for _iter in treeiters]
def show_tree(self) -> None:
self.tree_vbox.show()
def collapse_tree(self) -> None: def collapse_tree(self) -> None:
self.tree_vbox.hide() self.tree_vbox.hide()
@ -175,6 +247,8 @@ class ModFrame(HeadingFrame):
else: else:
status = f"Mods selected: {len(rows)}" status = f"Mods selected: {len(rows)}"
self.status.set_label(status) self.status.set_label(status)
# TODO: cleaner delegation
# simply send int value or use emitter
self.parent.check_button() self.parent.check_button()
def set_cursor(self) -> None: def set_cursor(self) -> None:
@ -194,25 +268,39 @@ class CustomModFrame(ModFrame):
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
# TODO: descriptive text here explaining how this area works self.custom_hbox = FolderHBox(
self.custom_hbox = FolderHBox(offline.custom_button) controller, offline.custom_button, offline.custom_eventbox
)
self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked)
self.pack(self.custom_hbox) self.pack(self.custom_hbox)
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded)
self.emitter.connect("custom_mods_unloaded", self._on_custom_mods_unloaded)
self.connect("map", self._on_map)
def _on_map(self, widget: Self) -> None:
self.hide_tree()
def _on_custom_mods_unloaded(self, emitter: "Emitter", widget: FolderHBox) -> None:
# TODO: kludgy workaround for generic button emitting global signal
if widget == self.custom_hbox:
self.hide_tree()
def hide_tree(self) -> None: def hide_tree(self) -> None:
# TODO: no mods message is different from collision message
self.set_error(offline.no_mods)
self.custom_hbox.hide_label() self.custom_hbox.hide_label()
self.tree.set_model(None)
self.tree_vbox.hide() self.tree_vbox.hide()
def present_error(self, folder: str) -> None:
self.hide_tree()
self.custom_hbox.present_error(FolderError.NO_VALID_MODS, folder)
def present_tree(self, store: "FastInsertListStore", folder: str) -> None: def present_tree(self, store: "FastInsertListStore", folder: str) -> None:
self.custom_hbox.set_label(folder) self.custom_hbox.set_folder(folder)
self.tree.set_model(store) self.tree.set_model(store)
self.tree_vbox.show() self.tree_vbox.show()
self.hide_errors()
def _on_custom_mods_loaded( def _on_custom_mods_loaded(
self, self,
@ -220,14 +308,19 @@ class CustomModFrame(ModFrame):
store: "FastInsertListStore", store: "FastInsertListStore",
folder: str, folder: str,
) -> None: ) -> None:
self.custom_hbox.stop_spinner()
if len(store) == 0: if len(store) == 0:
self.hide_tree() self.present_error(folder)
else: else:
self.present_tree(store, folder) self.present_tree(store, folder)
def _on_custom_button_clicked(self, button: Gtk.Button) -> None: def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
local_mods = self.get_mods() callback = self.custom_hbox.start_spinner
self.parent.offline_man.get_custom_mods(local_mods) # TODO: cleaner delegation
self.parent.offline_man.find_custom_mods(callback)
def get_folder(self) -> str:
return self.custom_hbox.get_folder()
class MissionFrame(HeadingFrame): class MissionFrame(HeadingFrame):
@ -238,35 +331,33 @@ class MissionFrame(HeadingFrame):
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
self.mission_hbox = FolderHBox(offline.mission_button) self.mission_hbox = FolderHBox(
controller, offline.mission_button, offline.mission_eventbox
)
self.mission_button = self.mission_hbox.get_button() self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_button_clicked) self.mission_button.connect("clicked", self._on_mission_button_clicked)
self.warning = Gtk.Label( self.frame.add(self.mission_hbox)
halign=Gtk.Align.START, margin_start=10, margin_bottom=5
)
self.vbox = VBox()
self.vbox.extend([self.mission_hbox, self.warning])
self.frame.add(self.vbox)
self.emitter.connect("custom_mission_loaded", self._on_mission_loaded) self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
def _on_mission_loaded( def _on_mission_loaded(
self, emitter: "Emitter", folder: str, is_valid: bool self, emitter: "Emitter", folder: str, is_valid: bool
) -> None: ) -> None:
self.mission_hbox.set_label(folder) if is_valid:
if not is_valid: self.mission_hbox.set_folder(folder)
self.warning.show()
self.warning.set_label(offline.no_mission)
else: else:
self.warning.hide() self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder)
def _on_mission_button_clicked(self, button: Gtk.Button) -> None: def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
self.parent.offline_man.get_mission() self.parent.offline_man.get_mission()
def get_mission(self) -> str:
return self.mission_hbox.get_folder()
class RadioFrame(HeadingFrame): class RadioFrame(HeadingFrame):
def __init__(self, controller: "Controller") -> None: def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.version) super().__init__(heading=offline.version)
self.controller = controller self.controller = controller
@ -286,13 +377,10 @@ class RadioFrame(HeadingFrame):
self.frame.add(self.radio_box) self.frame.add(self.radio_box)
# TODO: abstract out of here # TODO: cleaner delegation
default_steam_path = self.controller.query_config(Preferences.DEFAULT) has_dayz_exp = parent.offline_man.has_dayz_exp()
steam_path = Path(default_steam_path)
dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
self.dayz.connect("toggled", self._on_radio_toggled) self.dayz.connect("toggled", self._on_radio_toggled)
if dayz_exp is None: if has_dayz_exp is False:
self.dayz_exp.set_sensitive(False) self.dayz_exp.set_sensitive(False)
def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None:
@ -315,6 +403,7 @@ class OfflineLoader(Gtk.Box):
self.controller = controller self.controller = controller
self.controller.register_widget("offline_loader", self) self.controller.register_widget("offline_loader", self)
self.emitter = controller.get_emitter()
self.offline_man = OfflineManager(controller) self.offline_man = OfflineManager(controller)
self.add(PageHeading(offline.heading)) self.add(PageHeading(offline.heading))
@ -324,7 +413,7 @@ class OfflineLoader(Gtk.Box):
self.custom_tree = OfflineModTreeView(controller) self.custom_tree = OfflineModTreeView(controller)
self.mission_frame = MissionFrame(self, controller) self.mission_frame = MissionFrame(self, controller)
self.radio_frame = RadioFrame(controller) self.radio_frame = RadioFrame(self, controller)
self.scrollable = Gtk.ScrolledWindow( self.scrollable = Gtk.ScrolledWindow(
vexpand=True, propagate_natural_height=True vexpand=True, propagate_natural_height=True
@ -343,7 +432,7 @@ class OfflineLoader(Gtk.Box):
# TODO: share ConnectBox class with preconnect dialog? # TODO: share ConnectBox class with preconnect dialog?
self.button_box = HBox(spacing=5) self.button_box = HBox(spacing=5)
self.button_box.set_halign(Gtk.Align.END) self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(5) self.button_box.set_margin_top(15)
self.back = Gtk.Button(label="Back") self.back = Gtk.Button(label="Back")
self.ok = Gtk.Button(label="Launch", sensitive=False) self.ok = Gtk.Button(label="Launch", sensitive=False)
self.back.connect("clicked", self._on_back_clicked) self.back.connect("clicked", self._on_back_clicked)
@ -365,28 +454,24 @@ class OfflineLoader(Gtk.Box):
self.ok.set_sensitive(True) self.ok.set_sensitive(True)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
# FIXME: widget is not always in focus
if event.keyval == Gdk.KEY_Escape: if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked") self.back.emit("clicked")
def populate(self, store: Union["FastInsertListStore", None]) -> None: def populate(self, store: Union["FastInsertListStore", None]) -> None:
self.local_frame.set_model(store)
if store is None:
self.local_frame.start_empty()
else:
self.local_frame.hide_errors()
# NOTE: suppress custom tree until explicitly loaded # NOTE: suppress custom tree until explicitly loaded
self.custom_frame.hide_all() if store is None:
return
self.local_frame.start(store)
# self.custom_frame.start(store)
def _on_back_clicked(self, button: Gtk.Button) -> None: def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.MODS) self.controller.open_page(NotebookPage.MODS)
def _on_ok_clicked(self, button: Gtk.Button) -> None: def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO: grab all values in one pass appid = self.radio_frame.get_appid()
# appid = self.radio_frame.get_appid() mission = self.mission_frame.get_mission()
# mission = self.mission_frame.get_mission() local_mods = self.local_frame.get_mods()
# local_mods = self.local_frame.get_mods() custom_folder = self.custom_frame.get_folder()
# custom_mods = self.custom_frame.get_mods() custom_mods = self.custom_frame.get_mods()
# cf. api.mods._hash(uid, use_custom=True) self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)
# TODO: set up all symlinks prior to launch, including custom ones
# self.offline_man.setup(appid, mission, local_mods, custom_mods)
pass