Launch LÖVE/Java games and surface launch failures as a popup (v0.1.23)
This commit is contained in:
parent
8b393090ca
commit
a024907696
2 changed files with 205 additions and 7 deletions
|
|
@ -12,6 +12,11 @@ var settings_manager := load("res://core/global/settings_manager.tres") as Setti
|
||||||
## install_progressed/app_* signals back to the LibraryLaunchItem.
|
## install_progressed/app_* signals back to the LibraryLaunchItem.
|
||||||
var _active_item: 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:
|
func _ready() -> void:
|
||||||
super()
|
super()
|
||||||
|
|
@ -21,6 +26,15 @@ func _ready() -> void:
|
||||||
logger.info("itch.io library loaded")
|
logger.info("itch.io library loaded")
|
||||||
itch.logged_in.connect(_on_logged_in)
|
itch.logged_in.connect(_on_logged_in)
|
||||||
itch.install_progressed.connect(_on_install_progressed)
|
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]:
|
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
|
## plugin updates and OS re-images, and is injected into the launch environment
|
||||||
## via LD_LIBRARY_PATH.
|
## via LD_LIBRARY_PATH.
|
||||||
func _user_lib_dir() -> String:
|
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):
|
if DirAccess.dir_exists_absolute(dir):
|
||||||
return dir
|
return dir
|
||||||
return ""
|
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:
|
func _apply_user_library_path(item: LibraryLaunchItem) -> void:
|
||||||
var lib_dir := _user_lib_dir()
|
var lib_dir := _user_lib_dir()
|
||||||
if lib_dir == "":
|
if lib_dir == "":
|
||||||
|
|
@ -575,7 +602,46 @@ func _resolve_launch(install_folder: String) -> Dictionary:
|
||||||
var resolved := _resolve_receipt_launch(install_folder, launch)
|
var resolved := _resolve_receipt_launch(install_folder, launch)
|
||||||
if not (resolved.get("command", "") as String).is_empty():
|
if not (resolved.get("command", "") as String).is_empty():
|
||||||
return resolved
|
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.
|
## 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 {}
|
||||||
return {"command": abs_path, "args": args}
|
return {"command": abs_path, "args": args}
|
||||||
"love":
|
"love":
|
||||||
|
if not FileAccess.file_exists(abs_path):
|
||||||
|
return {}
|
||||||
var love_bin := _find_on_path("love")
|
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 {}
|
||||||
return {"command": love_bin, "args": [abs_path] + args}
|
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":
|
"web", "html":
|
||||||
logger.warn("HTML5 games aren't launchable in OpenGamepadUI yet: " + abs_path)
|
logger.warn("HTML5 games aren't launchable in OpenGamepadUI yet: " + abs_path)
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -669,12 +746,133 @@ func _is_runnable_file(path: String) -> bool:
|
||||||
return FileAccess.get_unix_permissions(path) & 73 != 0
|
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:
|
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(":"):
|
for dir in OS.get_environment("PATH").split(":"):
|
||||||
if dir == "":
|
if dir == "":
|
||||||
continue
|
continue
|
||||||
var candidate := "/".join([dir, bin])
|
candidate = "/".join([dir, bin])
|
||||||
if FileAccess.file_exists(candidate):
|
if _is_runnable_file(candidate):
|
||||||
return candidate
|
return candidate
|
||||||
return ""
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"plugin.id": "itch",
|
"plugin.id": "itch",
|
||||||
"plugin.name": "itch.io",
|
"plugin.name": "itch.io",
|
||||||
"plugin.version": "0.1.20",
|
"plugin.version": "0.1.23",
|
||||||
"plugin.min-api-version": "1.1.0",
|
"plugin.min-api-version": "1.1.0",
|
||||||
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue