diff --git a/core/library_itch.gd b/core/library_itch.gd index 6b5aef6..72f91b5 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -12,6 +12,11 @@ var settings_manager := load("res://core/global/settings_manager.tres") as Setti ## install_progressed/app_* signals back to the LibraryLaunchItem. var _active_item: LibraryLaunchItem +## pid -> launch time (msec) for itch.io games being watched for launch failure. +var _launch_watch := {} +## pids already diagnosed, so a failed game is only reported once. +var _diagnosed_pids := {} + func _ready() -> void: super() @@ -21,6 +26,15 @@ func _ready() -> void: logger.info("itch.io library loaded") itch.logged_in.connect(_on_logged_in) itch.install_progressed.connect(_on_install_progressed) + # Detect itch.io games that die before creating a window (a missing shared + # library, a broken install, ...) and surface a clear, actionable error + # instead of leaving the user stuck on a black in-game screen. + if load("res://core/global/launch_manager.tres") != null: + var watch_timer := Timer.new() + watch_timer.wait_time = 2.0 + watch_timer.autostart = true + watch_timer.timeout.connect(_check_failed_launches) + add_child(watch_timer) func get_library_launch_items() -> Array[LibraryLaunchItem]: @@ -102,12 +116,25 @@ func _restore_launch_focus() -> void: ## plugin updates and OS re-images, and is injected into the launch environment ## via LD_LIBRARY_PATH. func _user_lib_dir() -> String: - var dir := OS.get_environment("HOME") + "/.local/share/opengamepadui/lib" + var dir := _lib_hint() if DirAccess.dir_exists_absolute(dir): return dir return "" +## The path users drop missing shared libraries into (see _user_lib_dir). +func _lib_hint() -> String: + return OS.get_environment("HOME") + "/.local/share/opengamepadui/lib" + + +## User-level runtime binaries for itch games (the LOVE runtime, a JRE, ...), +## e.g. ~/.local/share/opengamepadui/libexec. Lives under the persistent OGPU +## data dir so it survives plugin updates and OS re-images. Drop `love` and/or +## `java` (or symlinks to them) here. +func _runtime_dir() -> String: + return OS.get_environment("HOME") + "/.local/share/opengamepadui/libexec" + + func _apply_user_library_path(item: LibraryLaunchItem) -> void: var lib_dir := _user_lib_dir() if lib_dir == "": @@ -575,7 +602,46 @@ func _resolve_launch(install_folder: String) -> Dictionary: var resolved := _resolve_receipt_launch(install_folder, launch) if not (resolved.get("command", "") as String).is_empty(): return resolved - return {"command": _find_executable_fallback(install_folder), "args": []} + return _resolve_fallback_launch(install_folder) + + +## Best-effort launch resolution when the receipt is missing or doesn't name a +## usable target. Preference order: real native executables (largest first), +## then runtime-bundled games detected by file extension (.love / .jar), which +## butlerd itself doesn't stub into receipts. Each runtime is looked up via +## _find_on_path(); if missing, a warning tells the user where to drop it. +func _resolve_fallback_launch(install_folder: String) -> Dictionary: + if install_folder == "": + return {"command": "", "args": []} + var exe := _find_executable_fallback(install_folder) + if exe != "": + return {"command": exe, "args": []} + var out := [] + OS.execute("bash", [ + "-c", + "find " + install_folder.c_escape() + + " -maxdepth 4 -type f \\( -name '*.love' -o -name '*.jar' \\) -printf '%s %p\\n' | sort -rn" + ], out) + if out.is_empty(): + return {"command": "", "args": []} + var lines := (out[0] as String).split("\n") + for line in lines: + var path := line.strip_edges().get_slice(" ", 1) + if path == "" or path.contains("/.itch/"): + continue + if path.ends_with(".love"): + var love_bin := _find_on_path("love") + if love_bin == "": + logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir()) + return {"command": "", "args": []} + return {"command": love_bin, "args": [path]} + if path.ends_with(".jar"): + var java_bin := _find_on_path("java") + if java_bin == "": + logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir()) + return {"command": "", "args": []} + return {"command": java_bin, "args": ["-jar", path]} + return {"command": "", "args": []} ## Reads the `launch` block of butlerd's install receipt, if any. @@ -614,10 +680,21 @@ func _resolve_receipt_launch(install_folder: String, launch: Dictionary) -> Dict return {} return {"command": abs_path, "args": args} "love": + if not FileAccess.file_exists(abs_path): + return {} var love_bin := _find_on_path("love") - if love_bin == "" or not FileAccess.file_exists(abs_path): + if love_bin == "": + logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir()) return {} return {"command": love_bin, "args": [abs_path] + args} + "jar": + if not FileAccess.file_exists(abs_path): + return {} + var java_bin := _find_on_path("java") + if java_bin == "": + logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir()) + return {} + return {"command": java_bin, "args": ["-jar", abs_path] + args} "web", "html": logger.warn("HTML5 games aren't launchable in OpenGamepadUI yet: " + abs_path) return {} @@ -669,12 +746,133 @@ func _is_runnable_file(path: String) -> bool: return FileAccess.get_unix_permissions(path) & 73 != 0 -## Looks up a binary on PATH, e.g. the LOVE runtime for .love games. +## Looks up a runtime binary (love, java, ...). Checks the persistent runtime +## dir first (where users can drop a LOVE runtime or JRE), then PATH. func _find_on_path(bin: String) -> String: + var candidate := "/".join([_runtime_dir(), bin]) + if _is_runnable_file(candidate): + return candidate for dir in OS.get_environment("PATH").split(":"): if dir == "": continue - var candidate := "/".join([dir, bin]) - if FileAccess.file_exists(candidate): + candidate = "/".join([dir, bin]) + if _is_runnable_file(candidate): return candidate return "" + + +## When a game dies before creating a window, OGPU's LaunchManager never marks +## it stopped (its app type stays UNKNOWN), so the user is left on a black +## in-game screen with no clue what happened. Poll running apps; for one of +## ours that is dead and windowless, run diagnostics (ldd for missing shared +## libraries, ...) and show a notification pointing at the fix. Forcing the app +## to STOPPED triggers LaunchManager's normal cleanup, which removes the stuck +## in-game state and returns the user to the library UI where the notification +## is visible. +func _check_failed_launches() -> void: + var launch_manager := load("res://core/global/launch_manager.tres") as LaunchManager + if launch_manager == null: + return + var now := Time.get_ticks_msec() + var running_pids := {} + for app in launch_manager.get_running(): + var item: LibraryLaunchItem = app.launch_item + if item == null or item._provider_id != library_id: + continue + running_pids[app.pid] = true + if app.created_window: + # The game got a window, so it launched fine. Stop watching it. + _launch_watch.erase(app.pid) + continue + if not _launch_watch.has(app.pid): + _launch_watch[app.pid] = now + continue + if app.is_running(): + continue + if now - int(_launch_watch[app.pid]) < 5000: + continue + if app.pid in _diagnosed_pids: + continue + _diagnosed_pids[app.pid] = true + _launch_watch.erase(app.pid) + _handle_launch_failure(app) + # Forget apps that are no longer running at all (launched fine and exited, + # or were stopped some other way) so the watch list stays small. + var stale := [] + for pid in _launch_watch: + if not running_pids.has(pid): + stale.append(pid) + for pid in stale: + _launch_watch.erase(pid) + + +func _handle_launch_failure(app: RunningApp) -> void: + var item: LibraryLaunchItem = app.launch_item + var message := _diagnose_launch_failure(item) + logger.warn("Game '" + item.name + "' failed to start: " + message) + # Return to the library UI first (LaunchManager cleans up a STOPPED app and + # pops the stuck black in-game state), then show the failure as a popup. + app.state = app.STATE.STOPPED + _show_failure_dialog.call_deferred(item.name, message) + + +## Shows the launch failure as a modal popup (OGPU's Dialog component) instead +## of a transient toast, so the user has time to read and act on the message. +func _show_failure_dialog(game_name: String, message: String) -> void: + var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene + if dialog_scene == null: + logger.warn("Dialog component unavailable; launch failure not shown to user.") + return + var ui: Node = get_tree().get_first_node_in_group("main") + if ui == null: + ui = get_tree().root + var dialog: Control = dialog_scene.instantiate() + ui.add_child(dialog) + # The Dialog scene is authored with zero-size anchors; make it (and its + # Spacer) fill the screen so the panel is centered over the UI. + dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dialog.size = (ui as Control).size if ui is Control else Vector2(1920, 1080) + (dialog.get_node("Spacer") as Control).set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dialog.cancel_visible = false + dialog.closed.connect(func() -> void: dialog.queue_free()) + dialog.open(null, "Failed to start: " + game_name + "\n\n" + message, "OK") + + +## Figures out why a game failed to start and returns a human-readable, +## actionable explanation (missing shared library, missing binary, missing +## game file, ...). +func _diagnose_launch_failure(item: LibraryLaunchItem) -> String: + var command: String = item.command + if command.is_empty(): + return "No launch command was set for this game. Reinstall it to repair the install." + if not FileAccess.file_exists(command): + return "Executable not found: " + command + ". Reinstall the game to repair the install." + if not _is_runnable_file(command): + return "Executable is missing the execute bit: " + command + var missing := _missing_shared_libs(command) + if not missing.is_empty(): + return "Missing shared libraries: " + missing + ". Drop the .so files into " + _lib_hint() + " and launch again." + var args := item.args + if args.size() > 0 and not FileAccess.file_exists(args[0]): + return "Game file not found: " + args[0] + ". Reinstall the game." + return "The game exited before opening a window. Command was: " + command + + +## Runs ldd on an ELF executable and returns the shared libraries the loader +## can't find, e.g. "libvlc.so.5" (the exact cause of Friday Night Funkin' +## failing to launch until its libraries were made available). +func _missing_shared_libs(binary: String) -> String: + var out := [] + OS.execute("ldd", [binary], out) + if out.is_empty(): + return "" + var missing := [] + for raw in (out[0] as String).split("\n"): + var line: String = raw.strip_edges() + # A missing dependency looks like "libfoo.so => not found". + if not line.contains("=>") or not line.contains("not found"): + continue + var name := line.get_slice("=>", 0).strip_edges() + if name != "": + missing.append(name) + return ", ".join(missing) diff --git a/plugin.json b/plugin.json index f3abf1b..5354611 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.20", + "plugin.version": "0.1.23", "plugin.min-api-version": "1.1.0", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",