From 97f976ef6ccd4871b74e67685f4df58e7b23f320 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sat, 17 Oct 2026 14:40:39 -0400 Subject: [PATCH] feat: Epic Games Launcher for Linux (#928) * feat: Epic Games Launcher for Linux Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's Steam Runtime 4 and run it under FEX, signed in with the linked Epic account. Epic games launch through a hidden launcher instance so they get the launcher's own arguments, with the exchange-code launch as the fallback, and DroidDeck installs show up in the launcher as installed. In the launcher, touch scrolls like a phone app and the gamepad drives the UI: D-pad and left stick move the selection, A opens, B goes back, the right stick and triggers scroll proportionally, X opens the options menu, Y the keyboard and LB/RB switch tabs. * feat: Epic Games Launcher for Linux Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's Steam Runtime 4 and run it under FEX, signed in with the linked Epic account. Epic games launch through a hidden launcher instance so they get the launcher's own arguments, with the exchange-code launch as the fallback, and DroidDeck installs show up in the launcher as installed. In the launcher, touch scrolls like a phone app and the gamepad drives the UI: D-pad and left stick move the selection, A opens, B goes back, the right stick and triggers scroll proportionally, X opens the options menu, Y the keyboard and LB/RB switch tabs. * Route Epic games through Steam, import launcher installs, offer the second library - Every Epic launch goes Steam -> Epic Games Launcher -> game; Play inside the open launcher hands the game to Steam - Games installed from inside the launcher get a Games-tab entry and launch through Steam; the launcher installs to the last Stores target - Stores install picker offers the Steam second library, with side-by-side choices - uruntime xattr-only failures no longer fail the launcher unpack (app-extract) * Hand launcher Play and Steam links to Steam, wait for FEX on first run Play on a game the launcher just installed is matched to its install record and handed to Steam. Steam games listed in the launcher open steam://rungameid links, which now start the game in a Steam session. Opening the launcher before Steam has downloaded FEX asks Steam for it and starts Steam instead of failing with no-fex. * Address review: FEX check, play-in-Steam action, touch route thread, scroll scale fexReady needs a FEX binary in Steam's FEX tool, not just its folder. The play-in-Steam action stays until the link is handled, so Run Steam at startup can't start a session first. The pad touch route reads the mouse mode without side effects off the main thread and posts the pad update there. Vertical scroll uses the output height. A status-2 unpack counts only when AppRun is there. The xdg-open command is tested end to end for Steam links. * Open the launcher through Steam, recover dropped shortcuts, sign out of both The Epic Games Launcher now runs as its own Steam shortcut, so opening it starts Steam, which opens the launcher, and Play inside it goes to the running Steam. Steam dropped a running shortcut from its library when the game exited after a sleep/wake replay or a server re-logon, and launching it then waited forever at ShowInterstitials. login1 only replays sleep/wake when Steam is asleep. The session restarts Steam when a requested shortcut is missing, and when a game exits with its shortcut gone and nothing else runs. Resuming after a long pause no longer reports failure while traced processes are still waking, so "Steam is paused" goes away on the first tap. Signing out of the launcher signs DroidDeck's Epic store out, and signing out in DroidDeck restarts the launcher signed out and clears the Epic web cookies. * Address review: one session-switch poll, Epic setup can't block a session A new switch confirmation replaces the pending poll instead of adding a second one that could start the session again. Epic library sync and install-folder setup at session start are each guarded, so a bad preference no longer stops the guest from launching. * Uninstall the Epic Games Launcher from the Games tab An Uninstall button next to Launch asks first, closes a running session, and removes the launcher and its runtime. The launcher's sign-in and install records stay, so its games keep their Games-tab entries and a reinstall comes back signed in. Its Steam shortcut goes away on the next Steam start. * Address review: uninstall only after the session has ended The launcher's files are removed only once the session is not running and its phase has ended; a shutdown still in progress after the wait reports failure instead. The confirmation reads the session state from the front-end state. * Find FEX that Steam installed on the second library Steam's tools on a second library live in DroidDeck's private copy, which the session binds over the library. The launcher's FEX check only looked on the shared storage, so with FEX there it always asked Steam for FEX and only Steam opened. It now checks the private copy too. * Start games without Steam, per-game Settings menu with Wine sync "Start without Steam" now really leaves the Steam client out: the game runs in its own Proton/FEX through droiddeck-proton-run in a run session, and the Epic Games Launcher opens on its own with its games playing in it. Run sessions for these games are not pinned to the big cores and get DirectAudio and the added-games list like Steam. The Games tab card is now "Settings", titled " Settings": Start without Steam, FEX preset, Wine sync (ntsync, fsync, esync, wineserver or the app default) and Default, which puts everything changed for the game back to the app's settings. The app-wide Wine sync choice is shown in the Steam settings Session tab. * Address review: uninstall guard, routed games, shortcut matching Opening or reinstalling the Epic Games Launcher waits while it is being uninstalled. Added games that are Steam installs keep their Start without Steam setting under their Steam app id and start as that Steam game. Live shortcut lookups wait for Steam's library to load and match a same-name shortcut by its executable, so no duplicate is added. The session treats the open launcher as itself even when it could not tell its Steam app id. * Address review: drop a queued launcher switch when it is uninstalled A confirmed switch to the Epic Games Launcher that is still waiting for the old session to end is cancelled when the launcher is uninstalled, and never starts while the uninstall runs. The launcher id test sets its environment for its own runs only. --- README.md | 2 +- app/src/main/AndroidManifest.xml | 1 + app/src/main/cpp/waylandcomp/src/compositor.c | 25 +- .../com/droiddeck/launcher/MainActivity.kt | 192 ++ .../com/droiddeck/launcher/SessionActivity.kt | 110 +- .../droiddeck/launcher/agent/AgentGuest.kt | 8 +- .../launcher/core/GameEnvironment.kt | 32 + .../droiddeck/launcher/frontend/AddedGames.kt | 16 +- .../droiddeck/launcher/frontend/Library.kt | 5 +- .../droiddeck/launcher/input/PadBridge.java | 89 +- .../com/droiddeck/launcher/input/PadMouse.kt | 358 ++++ .../launcher/input/ScrollTouchGestures.kt | 190 ++ .../launcher/runtime/RuntimeFileTree.java | 4 +- .../launcher/session/GameEnvironmentStore.kt | 7 + .../launcher/session/NoSteamLaunch.kt | 62 + .../launcher/session/SessionFiles.kt | 4 +- .../launcher/session/SessionPrefs.kt | 30 + .../launcher/session/SessionService.kt | 62 +- .../session/SessionSuspendController.kt | 10 +- .../launcher/stores/SteamLiveShortcuts.kt | 205 +- .../launcher/stores/StoreInstallRoot.kt | 13 +- .../launcher/stores/StoreInstalls.kt | 1 + .../droiddeck/launcher/stores/StoreLaunch.kt | 10 + .../launcher/stores/StoreLaunchRequests.kt | 35 + .../launcher/stores/StoreLoginActivity.kt | 25 +- .../launcher/stores/epic/EpicBackend.kt | 2 + .../launcher/stores/epic/EpicClient.kt | 427 ++++ .../stores/epic/EpicClientInstaller.kt | 357 ++++ .../launcher/stores/epic/EpicClientLibrary.kt | 410 ++++ .../droiddeck/launcher/ui/FrontEndGames.kt | 66 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 2 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 123 +- .../launcher/ui/ModeSettingsDialog.kt | 9 + .../java/com/droiddeck/launcher/ui/StepOut.kt | 38 + .../droiddeck/launcher/ui/StoresDownloads.kt | 5 +- .../launcher/ui/StoresInstallDialog.kt | 29 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 30 +- app/src/main/res/values-ar/strings.xml | 23 +- app/src/main/res/values-bg/strings.xml | 23 +- app/src/main/res/values-cs/strings.xml | 23 +- app/src/main/res/values-da/strings.xml | 23 +- app/src/main/res/values-de/strings.xml | 23 +- app/src/main/res/values-el/strings.xml | 23 +- app/src/main/res/values-es/strings.xml | 23 +- app/src/main/res/values-fi/strings.xml | 23 +- app/src/main/res/values-fr/strings.xml | 23 +- app/src/main/res/values-hi/strings.xml | 23 +- app/src/main/res/values-hu/strings.xml | 23 +- app/src/main/res/values-in/strings.xml | 23 +- app/src/main/res/values-it/strings.xml | 23 +- app/src/main/res/values-ja/strings.xml | 23 +- app/src/main/res/values-ko/strings.xml | 23 +- app/src/main/res/values-ms/strings.xml | 23 +- app/src/main/res/values-nb/strings.xml | 23 +- app/src/main/res/values-nl/strings.xml | 23 +- app/src/main/res/values-pl/strings.xml | 23 +- app/src/main/res/values-pt-rPT/strings.xml | 23 +- app/src/main/res/values-pt/strings.xml | 23 +- app/src/main/res/values-ro/strings.xml | 23 +- app/src/main/res/values-ru/strings.xml | 23 +- app/src/main/res/values-sv/strings.xml | 23 +- app/src/main/res/values-th/strings.xml | 23 +- app/src/main/res/values-tr/strings.xml | 23 +- app/src/main/res/values-uk/strings.xml | 23 +- app/src/main/res/values-vi/strings.xml | 23 +- app/src/main/res/values-zh-rCN/strings.xml | 23 +- app/src/main/res/values-zh-rHK/strings.xml | 23 +- app/src/main/res/values-zh-rTW/strings.xml | 23 +- app/src/main/res/values/strings.xml | 24 +- .../main/res/xml/network_security_config.xml | 7 + .../launcher/core/GameEnvironmentTest.kt | 22 + .../frontend/AddedGamesStoreScanTest.kt | 17 + .../droiddeck/launcher/input/PadMouseTest.kt | 226 ++ .../launcher/input/ScrollTouchGesturesTest.kt | 190 ++ .../launcher/session/AddedGameMoveTest.kt | 69 + .../launcher/session/NoSteamLaunchTest.kt | 95 + .../launcher/stores/StoreGameSidecarTest.kt | 22 + .../launcher/stores/StoreInstallRootTest.kt | 12 + .../launcher/stores/epic/EpicClientTest.kt | 448 ++++ tools/linuxfs/build-x86-preloads.sh | 8 + tools/linuxfs/fex/webkitinput.c | 198 ++ .../overlay/usr/local/bin/droiddeck-agent | 47 + .../overlay/usr/local/bin/droiddeck-epic | 1851 +++++++++++++++++ .../usr/local/bin/droiddeck-epic-steam | 4 + .../overlay/usr/local/bin/droiddeck-fex | 25 +- .../overlay/usr/local/bin/droiddeck-game-env | 33 +- .../overlay/usr/local/bin/droiddeck-login1 | 29 +- .../usr/local/bin/droiddeck-proton-run | 126 +- .../overlay/usr/local/bin/droiddeck-session | 208 +- .../usr/local/bin/droiddeck-steam-games | 5 +- .../usr/local/bin/droiddeck-steam-shortcuts | 6 +- .../usr/local/bin/droiddeck-store-launch | 74 +- .../overlay/usr/local/bin/steam-compatibility | 23 +- tools/release/runtime_inputs.py | 2 +- tools/tests/test_desktop_open.py | 59 + tools/tests/test_droiddeck_agent.py | 47 + tools/tests/test_droiddeck_epic.py | 1205 +++++++++++ tools/tests/test_droiddeck_fex.py | 56 + tools/tests/test_game_environment.py | 28 +- tools/tests/test_login1.py | 59 +- tools/tests/test_output_refresh.py | 1 + tools/tests/test_session_run_status.py | 74 + tools/tests/test_steam_cold_launch.py | 25 +- tools/tests/test_steam_games.py | 11 + tools/tests/test_steam_shortcut_recovery.py | 174 ++ tools/tests/test_steam_shortcuts.py | 12 + tools/tests/test_store_launch.py | 2 +- 107 files changed, 8860 insertions(+), 371 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/PadMouse.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientInstaller.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/com/droiddeck/launcher/input/PadMouseTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/ScrollTouchGesturesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/AddedGameMoveTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt create mode 100644 tools/linuxfs/fex/webkitinput.c create mode 100644 tools/linuxfs/overlay/usr/local/bin/droiddeck-epic create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-epic-steam create mode 100644 tools/tests/test_droiddeck_epic.py create mode 100644 tools/tests/test_session_run_status.py create mode 100644 tools/tests/test_steam_shortcut_recovery.py diff --git a/README.md b/README.md index 95ea46013..e55303c4c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big ## Requirements and install -Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier. +Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier. On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 869492a29..a21fea19f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ p1, m->p2); } -static void scroll_event(int steps) { +static void send_axis(struct seat_pointer *sp, uint32_t t, uint32_t axis, int value120) { + if (!value120) return; + if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION) + wl_pointer_send_axis_value120(sp->ptr, axis, value120); + else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION && value120 % 120 == 0) + wl_pointer_send_axis_discrete(sp->ptr, axis, value120 / 120); + wl_pointer_send_axis(sp->ptr, t, axis, wl_fixed_from_double(value120 / 12.0)); +} + +static void axis_event(int vertical120, int horizontal120) { struct surface *target = g_active_constraint ? g_active_constraint->surface : g_desktop ? g_desktop : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y)); - if (!target || !steps) return; + if (!target || (!vertical120 && !horizontal120)) return; struct wl_client *client = wl_resource_get_client(target->resource); struct seat_pointer *sp; if (!pointer_for(client)) return; @@ -2853,17 +2862,18 @@ static void scroll_event(int steps) { uint32_t t = now_ms(); pointer_focus(target->resource, wl_fixed_from_double(g_ptr_x - tx), wl_fixed_from_double(g_ptr_y - ty)); for_each_pointer_of(client, sp) { - if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION) - wl_pointer_send_axis_value120(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps * 120); - else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION) - wl_pointer_send_axis_discrete(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps); - wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL, wl_fixed_from_int(steps * 10)); + send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120); + send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120); if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION) wl_pointer_send_frame(sp->ptr); } wl_display_flush_clients(g_display); } +static void scroll_event(int steps) { + axis_event(steps * 120, 0); +} + static int update_modifiers(uint32_t evdev, int pressed) { static const struct { uint32_t evdev, mask; } mod_keys[] = { { 42, 0x1 }, { 54, 0x1 }, @@ -2957,6 +2967,7 @@ static int on_input_readable(int fd, uint32_t mask, void *data) { schedule_render(); break; case 10: break; + case 11: axis_event(m.p1, m.p2); break; default: deliver_pointer(&m); break; } } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 5c9b0d994..31a570494 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -109,6 +109,10 @@ class MainActivity : ComponentActivity() { private var frameGenLabel by mutableStateOf("") private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) + private var pendingSwitch by mutableStateOf(null) + private var switchingTo: Intent? = null + private var switchPoll: Runnable? = null + private var switchDeadline = 0L private var showResetSettings by mutableStateOf(false) private var lowSpace by mutableStateOf(null) private var showNonAdreno by mutableStateOf(null) @@ -506,6 +510,7 @@ class MainActivity : ComponentActivity() { android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show() return false } + if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, game)) return launchWithoutSteam(game, returnHome) if (SessionState.running) { if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) { android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show() @@ -534,6 +539,159 @@ class MainActivity : ComponentActivity() { }, steamSession = true) } + private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean { + val app = applicationContext + Thread({ + val added = if (game.library == Library.ADDED) runCatching { com.droiddeck.launcher.session.NoSteamLaunch.addedGame(app, game) }.getOrNull() else null + val command = com.droiddeck.launcher.session.NoSteamLaunch.command(game, added) + ui.post { + if (isDestroyed) return@post + if (command == null) { + android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show() + return@post + } + com.droiddeck.launcher.session.NoSteamLaunch.remember(command) + val next = Intent(this, SessionActivity::class.java) + .putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, command.program) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray()) + when { + !SessionState.running -> startSessionOrWait(next) + SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show() + SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> + startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME) + .putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)) + else -> pendingSwitch = next + } + } + }, "no-steam-launch").start() + return true + } + + private fun opensEpicLauncher(next: Intent): Boolean = + next.getStringExtra(SessionService.EXTRA_PROGRAM) == com.droiddeck.launcher.session.NoSteamLaunch.EPIC || + next.getStringExtra(SessionService.EXTRA_STEAM_URL) == com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl( + com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)) + + private fun uninstallEpicLauncher() { + if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) return + com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = true + pendingSwitch?.takeIf(::opensEpicLauncher)?.let { pendingSwitch = null } + switchingTo?.takeIf(::opensEpicLauncher)?.let { + switchPoll?.let { poll -> ui.removeCallbacks(poll) } + switchPoll = null + switchingTo = null + } + if (SessionState.running) SessionService.stop(this) + val app = applicationContext + val deadline = android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS + Thread({ + while ((SessionState.running || SessionState.phase !in SESSION_ENDED) && android.os.SystemClock.uptimeMillis() < deadline) { + Thread.sleep(SESSION_SWITCH_POLL_MS) + } + val ended = !SessionState.running && SessionState.phase in SESSION_ENDED + val done = try { + ended && com.droiddeck.launcher.stores.epic.EpicClient.uninstall(app) + } finally { + com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = false + } + ui.post { + if (!isDestroyed) refresh() + android.widget.Toast.makeText(app, if (done) R.string.epic_client_uninstalled else R.string.epic_client_uninstall_failed, + android.widget.Toast.LENGTH_LONG).show() + } + }, "epic-uninstall").start() + } + + private fun openEpicLauncher(): Boolean { + if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) { + android.widget.Toast.makeText(this, R.string.epic_client_uninstalling, android.widget.Toast.LENGTH_SHORT).show() + return false + } + if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) { + android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show() + return false + } + if (!com.droiddeck.launcher.stores.epic.EpicClient.launcherRunning(this) && !com.droiddeck.launcher.stores.epic.EpicClient.fexReady(this)) { + com.droiddeck.launcher.stores.epic.EpicClient.requestFex(this) + android.widget.Toast.makeText(this, R.string.epic_client_needs_fex, android.widget.Toast.LENGTH_LONG).show() + if (!SessionState.running) startSteamSession() + else if (SessionState.mode == SessionService.MODE_STEAM) startActivity(Intent(this, SessionActivity::class.java) + .setAction(SessionService.ACTION_RESUME).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)) + return true + } + if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)) { + com.droiddeck.launcher.stores.epic.EpicClient.launcherEntry(this, Library.EPIC_LAUNCHER_NAME)?.let { return launchWithoutSteam(it, false) } + } + val launcher = Intent(this, SessionActivity::class.java) + .putExtra(SessionActivity.EXTRA_RETURN_HOME, false) + .putExtra(SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl( + com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId))) + if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested) { + val app = applicationContext + val shortcut = com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId + val listed = com.droiddeck.launcher.frontend.AddedGames.inSteam(app, shortcut) + if (listed && !SessionService.launchGame(this, com.droiddeck.launcher.stores.epic.EpicClient.gameId(shortcut))) { + android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show() + return false + } + startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME) + .putExtra(SessionActivity.EXTRA_RETURN_HOME, false) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)) + if (!listed) Thread({ + com.droiddeck.launcher.frontend.AddedGamesPublisher.republish(app) + val id = com.droiddeck.launcher.stores.epic.EpicClient.steamLauncherId(app) ?: shortcut + ui.post { SessionService.launchGame(app, com.droiddeck.launcher.stores.epic.EpicClient.gameId(id)) } + }, "epic-launcher-open").start() + return true + } + if (SessionState.running) { + if (SessionState.stopRequested) android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show() + else pendingSwitch = launcher + return true + } + return startSessionOrWait(launcher) + } + + private fun startSessionOrWait(next: Intent): Boolean { + val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL)) + if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show() + return started + } + + private fun startAfterSession(next: Intent) { + SessionService.stop(this) + awaitSessionEnd(next, android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS) + } + + private fun awaitSessionEnd(next: Intent, deadline: Long) { + switchingTo = next + switchDeadline = deadline + switchPoll?.let { ui.removeCallbacks(it) } + val poll = object : Runnable { + override fun run() { + if (isDestroyed) return + if (SessionState.running || SessionState.phase !in SESSION_ENDED) { + if (android.os.SystemClock.uptimeMillis() < deadline) ui.postDelayed(this, SESSION_SWITCH_POLL_MS) + else { + switchPoll = null + switchingTo = null + android.widget.Toast.makeText(this@MainActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show() + } + return + } + switchPoll = null + switchingTo = null + if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling && opensEpicLauncher(next)) return + startSessionOrWait(next) + } + } + switchPoll = poll + ui.postDelayed(poll, SESSION_SWITCH_POLL_MS) + } + private fun handleGameLink(games: List) { if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return val id = pendingGameLink ?: return @@ -666,6 +824,8 @@ class MainActivity : ComponentActivity() { com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } } }, + onEpicLauncher = { openEpicLauncher() }, + onUninstallEpicLauncher = { uninstallEpicLauncher() }, onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) }, onExportGameFile = { g -> pickGameExport(g) }, onSyncGameFiles = { pickGameExport(null) }, @@ -927,6 +1087,15 @@ class MainActivity : ComponentActivity() { onDismiss = { showNonAdreno = null }, ) } + pendingSwitch?.let { next -> + ConfirmDialog( + title = androidx.compose.ui.res.stringResource(R.string.drawer_stop_title), + text = runningLabel ?: "", + confirm = androidx.compose.ui.res.stringResource(R.string.drawer_stop), + onConfirm = { pendingSwitch = null; startAfterSession(next) }, + onDismiss = { pendingSwitch = null }, + ) + } if (showRemove) ConfirmDialog( title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title), text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text), @@ -965,6 +1134,10 @@ class MainActivity : ComponentActivity() { } if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } + else { + pendingSwitch = savedIntent(savedInstanceState, KEY_PENDING_SWITCH) + savedIntent(savedInstanceState, KEY_SWITCHING_TO)?.let { awaitSessionEnd(it, savedInstanceState.getLong(KEY_SWITCH_DEADLINE)) } + } val wanted = ArrayList() if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { @@ -1111,6 +1284,19 @@ class MainActivity : ComponentActivity() { refreshSecondScreenDisplays() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) } + switchingTo?.let { + outState.putParcelable(KEY_SWITCHING_TO, it) + outState.putLong(KEY_SWITCH_DEADLINE, switchDeadline) + } + } + + private fun savedIntent(state: Bundle, key: String): Intent? = + if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java) + else @Suppress("DEPRECATION") state.getParcelable(key) + override fun onDestroy() { updates.unregister() super.onDestroy() @@ -1877,6 +2063,12 @@ class MainActivity : ComponentActivity() { companion object { private const val TAG = "MainActivity" private const val MEDIA_SETTLE_MS = 1500L + private const val SESSION_SWITCH_WAIT_MS = 30_000L + private const val SESSION_SWITCH_POLL_MS = 250L + private const val KEY_PENDING_SWITCH = "pending_switch" + private const val KEY_SWITCHING_TO = "switching_to" + private const val KEY_SWITCH_DEADLINE = "switch_deadline" + private val SESSION_ENDED = setOf(SessionPhase.IDLE, SessionPhase.FAILED) private val ZIP_EXT = listOf("zip") private val WCP_EXT = listOf("wcp") } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f03842acc..180b9f5fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -48,6 +48,7 @@ import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAchievements import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreLaunchRequests +import com.droiddeck.launcher.stores.epic.EpicClient import com.droiddeck.launcher.ui.SessionAchievements import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.FrameGen @@ -69,12 +70,15 @@ import com.droiddeck.launcher.input.ControllerPrefs import com.droiddeck.launcher.input.SessionClipboard import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.input.PadMouse +import com.droiddeck.launcher.input.PadState import com.droiddeck.launcher.input.SteamControllerBle import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplays import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures +import com.droiddeck.launcher.input.ScrollTouchGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState import com.droiddeck.launcher.session.PerfHud @@ -183,6 +187,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var watching = true private lateinit var touchpad: TouchpadGestures private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap + private lateinit var scrollTouch: ScrollTouchGestures + private lateinit var padMouse: PadMouse + @Volatile private var padMouseOn = false + private var padMouseCheckedAt = 0L + private var scrollTouchGesture = false + private var scrollRestX = 0f + private var scrollRestY = 0f private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f)) private var cursorVisible by mutableStateOf(false) @@ -198,6 +209,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (pointerLocked == locked) return@post pointerLocked = locked Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released") + padMouseCheckedAt = 0L + if (::padMouse.isInitialized) padMouseMode() // Touchpad deltas are measured from the middle while locked (moveLockedPointer). if (locked && ::touchpad.isInitialized) { drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) } @@ -363,6 +376,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Log.i(TAG, "input device $deviceId disconnected") joyConVr?.refresh() padBridge?.onDeviceRemoved(deviceId) + if (::padMouse.isInitialized) padMouse.reset() refreshControls() updatePointerCapture() updateHandCamera() @@ -465,6 +479,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2) + padMouse = PadMouse(padMouseListener) + bridge.setTouchRoute(object : PadBridge.TouchRoute { + override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn + override fun accept(state: PadState) { + if (Looper.myLooper() == Looper.getMainLooper()) padMouse.onTouchPad(state) + else PadState().also { it.copyFrom(state) }.let { copy -> uiHandler.post { padMouse.onTouchPad(copy) } } + } + }) + scrollTouch = ScrollTouchGestures(PointerGestures.slop(this), + android.view.ViewConfiguration.get(this).scaledMinimumFlingVelocity.toFloat(), scrollListener) val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL) root.pointerIcon = noCursor surfaceView.pointerIcon = noCursor @@ -614,6 +638,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (found == null) achievementsOpen = false } androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() } + LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) { + if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset() + } if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }, onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, @@ -1224,6 +1251,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val nextTouchMode = SessionPrefs.touchMode(this) if (touchMode != nextTouchMode) { if (::touchpad.isInitialized) touchpad.cancel() + if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { @@ -1774,6 +1802,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event) if (joyConVr?.onKeyEvent(event) == true) return true + if (fromController && event.keyCode != KeyEvent.KEYCODE_BACK && padMouseMode() && padMouse.onKey(event)) return true if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) { val down = event.action == KeyEvent.ACTION_DOWN @@ -1818,6 +1847,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection() if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event) if (joyConVr?.onMotionEvent(event) == true) return true + if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true if (padBridge?.onMotionEvent(event) == true) return true if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true return super.dispatchGenericMotionEvent(event) @@ -1920,6 +1950,67 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + private val scrollListener = object : ScrollTouchGestures.Listener { + override fun onMove(x: Float, y: Float) { + val rect = drawnRect() ?: return + val cx = x.coerceIn(rect.left, rect.right) + val cy = y.coerceIn(rect.top, rect.bottom) + WaylandCompositor.nativeSendPointer(1, InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height)) + cursorVisible = false + } + override fun onButton(button: Int, pressed: Boolean) { + WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + } + override fun onScroll(dx: Float, dy: Float) { + val rect = drawnRect() ?: return + val out = SessionState.outputSize + scrollRestX += dx * out.first / rect.width() + scrollRestY += dy * out.second / rect.height() + val h = scrollRestX.toInt() + val v = scrollRestY.toInt() + if (h == 0 && v == 0) return + scrollRestX -= h + scrollRestY -= v + WaylandCompositor.nativeSendSceneInput(11, ScrollTouchGestures.wheelUnits(v, out.second), ScrollTouchGestures.wheelUnits(h, out.first)) + } + override fun onLongPress() = pointerListener.onLongPress() + } + + private val padMouseListener = object : PadMouse.Listener { + override fun active(): Boolean = padMouseMode() + override fun onScrollStart() { + scrollRestX = 0f + scrollRestY = 0f + val rect = drawnRect() ?: return + val x = rect.left + rect.width() * PAD_SCROLL_POINTER_X + WaylandCompositor.nativeSendPointer(1, InputSpace.x(x, surfaceView.width), InputSpace.y(rect.centerY(), surfaceView.height)) + cursorVisible = false + } + override fun onScroll(dx: Float, dy: Float) = scrollListener.onScroll(dx, dy) + override fun onKey(evdev: Int, pressed: Boolean) = sendGuestKey(evdev, pressed) + override fun onKeyboard() { + uiHandler.post { togglePcKeyboard() } + } + override fun onNavigate() { + cursorVisible = false + } + override fun scrollSpeed(): Float = (drawnRect()?.height() ?: 0f) * PAD_SCROLL_HEIGHTS_PER_S + } + + private fun padMouseMode(): Boolean { + val now = android.os.SystemClock.uptimeMillis() + if (now - padMouseCheckedAt < PAD_MOUSE_CHECK_MS) return padMouseOn + padMouseCheckedAt = now + val on = !pointerLocked && EpicClient.launcherFocused(this) && !EpicClient.playing(this) + if (padMouseOn && !on) padMouse.reset() + if (!padMouseOn && on) padBridge?.releaseAll() + padMouseOn = on + return on + } + + private fun scrollTouchMode(): Boolean = + (routedTouchMode == SessionPrefs.TOUCH_AUTO || routedTouchMode == SessionPrefs.TOUCH_DIRECT) && padMouseMode() + private fun moveLockedPointer(x: Float, y: Float) { val rect = drawnRect() ?: return val scale = SessionState.outputSize.first / rect.width() @@ -2147,6 +2238,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (next == routedTouchMode) return if (::touchpad.isInitialized) touchpad.cancel() + if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0) if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false routedTouchMode = next @@ -2180,6 +2272,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onTouchEvent(event: MotionEvent): Boolean { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + scrollTouchGesture = scrollTouchMode() + scrollRestX = 0f + scrollRestY = 0f + } + if (scrollTouchGesture) return scrollTouch.onTouch(event) if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") { val wasActive = threeFingerTap.active @@ -2429,6 +2527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenControls?.releaseAll() releasePads() touchpad.cancel() + scrollTouch.cancel() guestKeysDown.toList().forEach { sendGuestKey(it, false) } keyboard?.hide() sessionClipboard.stop() @@ -2481,6 +2580,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) + startedScreens++ SessionService.setActivityVisible(this, true) Glasses.rescan() updateGlassesScreen() @@ -2493,8 +2593,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updateHandCamera() displayManager.unregisterDisplayListener(displayListener) closeSecondScreen(reset = false) + startedScreens = (startedScreens - 1).coerceAtLeast(0) updateGlassesScreen() - SessionService.setActivityVisible(this, false) + if (startedScreens == 0) SessionService.setActivityVisible(this, false) super.onStop() } @@ -2559,6 +2660,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener) releaseDrawerDirection() onScreenControls?.releaseAll() + if (::scrollTouch.isInitialized) scrollTouch.cancel() + if (::padMouse.isInitialized) padMouse.reset() resumed = false updatePointerCapture() updatePadMotion() @@ -2873,6 +2976,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) + if (!hasFocus && ::padMouse.isInitialized) padMouse.reset() if (runtimeRemovalBlocked) return if (hasFocus && !pipUi) { goFullscreen() @@ -2986,9 +3090,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { const val EXTRA_STEAMVR = "steamVr" private const val LEAVE_TIMEOUT_MS = 1_500L private const val TAG = "SessionActivity" + private var startedScreens = 0 private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION private const val CURSOR_PAD_HOLD_MS = 1200L + private const val PAD_MOUSE_CHECK_MS = 500L + private const val PAD_SCROLL_POINTER_X = 0.985f + private const val PAD_SCROLL_HEIGHTS_PER_S = 3f private const val DRAWER_HAT_THRESHOLD = 0.5f private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt index 3b265ebb9..2295c407e 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -136,8 +136,12 @@ object AgentGuest { return null } - fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject { - if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context) + fun steamEval(context: Context, expression: String, timeoutMs: Long): JSONObject = + call(context, JSONObject().put("kind", "cdp").put("expression", expression).put("timeout", timeoutMs / 1000.0), + timeoutMs + 2_000, trusted = true) + + fun call(context: Context, request: JSONObject, timeoutMs: Long, trusted: Boolean = false): JSONObject { + if (!trusted && request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context) if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session") hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)") val dir = dir(context) diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt index 045b52ed4..c8341afc2 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -1,5 +1,7 @@ package com.droiddeck.launcher.core +import com.droiddeck.launcher.session.SessionPrefs + object GameEnvironment { data class Config( val shared: Map = emptyMap(), @@ -83,4 +85,34 @@ object GameEnvironment { } const val CUSTOM_FEX_PRESET = "__custom__" + + val syncBackends = listOf(SessionPrefs.SYNC_NTSYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_WINESERVER) + + val syncKeys = listOf("BL_SYNC", "BL_FSYNC_FIRST", "BL_SYNC_FALLBACK", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC") + + fun syncEnvironment(backend: String): Map { + require(backend in syncBackends) { "unknown sync $backend" } + return buildMap { + put("BL_SYNC", if (backend == SessionPrefs.SYNC_NTSYNC) "1" else "0") + put("BL_FSYNC_FIRST", if (backend == SessionPrefs.SYNC_FSYNC) "1" else "0") + put("BL_SYNC_FALLBACK", if (backend == SessionPrefs.SYNC_WINESERVER) "0" else "1") + if (backend == SessionPrefs.SYNC_FSYNC) put("PROTON_NO_ESYNC", "1") + } + } + + fun gameSync(config: Config, scope: String): String? { + require(scope.isNotEmpty() && validScope(scope)) + val entries = config.games[scope].orEmpty().filterKeys { it in syncKeys } + if (entries.isEmpty()) return null + return syncBackends.firstOrNull { entries == syncEnvironment(it) } ?: CUSTOM_SYNC + } + + fun withGameSync(config: Config, scope: String, backend: String?): Config { + require(scope.isNotEmpty() && validScope(scope)) + val entries = config.entries(scope).filterKeys { it !in syncKeys }.toMutableMap() + if (backend != null) entries += syncEnvironment(backend) + return config.withEntries(scope, entries) + } + + const val CUSTOM_SYNC = "__custom__" } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 5725ac546..1b0fb7ce8 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -6,6 +6,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONObject import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreInstallRoot import java.io.File @@ -296,13 +297,16 @@ object AddedGames { private fun scanGame(context: Context, folder: File, out: MutableList, single: AddedExes.Entry? = null) { run { val sidecar = StoreGameSidecar.read(folder) + sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } } val ranking = rank(folder) val candidates = ranking.exes val picked = SessionPrefs.addedGameExe(context, folder.path) val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile } ?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile } val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return - val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found + val epicLauncher = sidecar?.takeIf { it.store == Store.EPIC && (chosen == null || canonical(chosen) == canonical(it.exeFile(folder))) } + ?.launcherFile(folder)?.takeIf { it.isFile } + val launch = epicLauncher ?: if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found val foundGuest = guestPath(context, launch) if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return } val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name @@ -343,6 +347,9 @@ object AddedGames { JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 } }.getOrNull() + fun inSteam(context: Context, appId: Long): Boolean = + accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))?.let { shortcutRecord(it, appId) } != null + private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching { JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString()) }.getOrNull() @@ -382,7 +389,8 @@ object AddedGames { } } - fun writeListing(context: Context, games: List): File { + fun writeListing(context: Context, games: List, + extra: List = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File { val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() } val json = StringBuilder("[") games.forEachIndexed { i, g -> @@ -399,6 +407,10 @@ object AddedGames { if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}') json.append('}') } + extra.forEachIndexed { i, entry -> + if (games.isNotEmpty() || i > 0) json.append(',') + json.append(entry.toString()) + } file.writeText(json.append(']').toString()) return file } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 26f6e79f0..24c2379e4 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -24,6 +24,8 @@ object Library { val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString() } const val ADDED = "added" + const val EPIC_LAUNCHER = "epic-launcher" + const val EPIC_LAUNCHER_NAME = "Epic Games Launcher" const val SOURCE_STEAM = "steam" class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null) @@ -192,7 +194,8 @@ object Library { if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER || program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) { com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') } - } else specs.firstOrNull { it.program == program }?.name + } else com.droiddeck.launcher.session.NoSteamLaunch.nameFor(program, com.droiddeck.launcher.session.SessionState.programArgs) + ?: specs.firstOrNull { it.program == program }?.name val flatpakNames = java.util.concurrent.ConcurrentHashMap() diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 4f0edcc31..f25c4d32f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -30,6 +30,7 @@ public final class PadBridge { KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR, KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2 }; + private static final int[] ROUTED_PASS_THROUGH = {PadState.GUIDE, PadState.QAM, PadState.SELECT}; private static final long QAM_GUIDE_LEAD_MIN_MS = 80; private static final long QAM_GUIDE_LEAD_MAX_MS = 1500; private static final int QAM_GUIDE_LEAD_FRAMES = 8; @@ -54,6 +55,8 @@ public final class PadBridge { private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; private volatile boolean selectSteam; + private TouchRoute touchRoute; + private final PadState routedTouch = new PadState(); private boolean systemGuidePressed; private boolean systemQamPressed; private boolean qamChordActive; @@ -313,26 +316,11 @@ public final class PadBridge { } pad.leftX = axis(event, MotionEvent.AXIS_X); pad.leftY = axis(event, MotionEvent.AXIS_Y); - boolean wiredXboxLayout = device != null - && ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063) - || (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0)) - && device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null - && device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null - && device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null - && device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null; - pad.rightX = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z); - pad.rightY = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ); - float lt; - float rt; - if (wiredXboxLayout) { - lt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f)); - rt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f)); - } else { - lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER); - rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER); - if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE); - if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS); - } + boolean wired = wiredXboxLayout(event); + pad.rightX = rightX(event, wired); + pad.rightY = rightY(event, wired); + float lt = leftTrigger(event, wired); + float rt = rightTrigger(event, wired); pad.leftTrigger = lt; pad.rightTrigger = rt; float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); @@ -349,7 +337,36 @@ public final class PadBridge { return true; } + public interface TouchRoute { + boolean active(); + + void accept(PadState state); + } + + public synchronized void setTouchRoute(TouchRoute route) { + touchRoute = route; + routedTouch.clear(); + } + public synchronized void applyTouch(java.util.function.Consumer mutation) { + if (touchRoute != null && touchRoute.active()) { + mutation.accept(routedTouch); + touchRoute.accept(routedTouch); + boolean changed = false; + for (int button : ROUTED_PASS_THROUGH) { + boolean down = routedTouch.isDown(button); + if (state.isDown(button) == down) continue; + state.press(button, down); + changed = true; + } + if (changed) { + activeControllerId = NO_CONTROLLER; + slotControllers.set(0, NO_CONTROLLER); + publish(); + } + return; + } + routedTouch.clear(); mutation.accept(state); activeControllerId = NO_CONTROLLER; slotControllers.set(0, NO_CONTROLLER); @@ -380,6 +397,7 @@ public final class PadBridge { } public synchronized void releaseAll() { + routedTouch.clear(); releaseBackPaddles(); state.clear(); publish(); @@ -607,6 +625,37 @@ public final class PadBridge { writer.writePad(effectiveState); } + public static boolean wiredXboxLayout(MotionEvent event) { + InputDevice device = event.getDevice(); + return device != null + && ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063) + || (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0)) + && device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null + && device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null + && device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null + && device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null; + } + + public static float rightX(MotionEvent event, boolean wired) { + return axis(event, wired ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z); + } + + public static float rightY(MotionEvent event, boolean wired) { + return axis(event, wired ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ); + } + + public static float leftTrigger(MotionEvent event, boolean wired) { + if (wired) return Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f)); + float value = event.getAxisValue(MotionEvent.AXIS_LTRIGGER); + return value != 0f ? value : event.getAxisValue(MotionEvent.AXIS_BRAKE); + } + + public static float rightTrigger(MotionEvent event, boolean wired) { + if (wired) return Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f)); + float value = event.getAxisValue(MotionEvent.AXIS_RTRIGGER); + return value != 0f ? value : event.getAxisValue(MotionEvent.AXIS_GAS); + } + private static float axis(MotionEvent event, int axis) { float value = event.getAxisValue(axis); return Math.abs(value) < DEAD_ZONE ? 0f : value; diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMouse.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMouse.kt new file mode 100644 index 000000000..77fb4f6fd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMouse.kt @@ -0,0 +1,358 @@ +package com.droiddeck.launcher.input + +import android.os.SystemClock +import android.view.Choreographer +import android.view.KeyEvent +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.hypot + +class PadMouse(private val listener: Listener, private val clock: () -> Long = SystemClock::uptimeMillis) { + interface Listener { + fun active(): Boolean + fun onScrollStart() + fun onScroll(dx: Float, dy: Float) + fun onKey(evdev: Int, pressed: Boolean) + fun onKeyboard() + fun onNavigate() + fun scrollSpeed(): Float + } + + private class Sticks { + var leftX = 0f + var leftY = 0f + var rightX = 0f + var rightY = 0f + var leftTrigger = 0f + var rightTrigger = 0f + + fun clear() { + leftX = 0f + leftY = 0f + rightX = 0f + rightY = 0f + leftTrigger = 0f + rightTrigger = 0f + } + } + + private val choreographer = Choreographer.getInstance() + private val physical = HashMap() + private val touch = Sticks() + private val total = Sticks() + private val triggerAxes = HashMap() + private val wiredLayout = HashMap() + private val down = HashMap>() + private val sent = HashMap() + private val held = LinkedHashMap() + private var scrolling = false + private var stickDirection = NONE + private var stickRepeatAt = 0L + private var frameAt = 0L + private var running = false + + private val frame = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!running) return + if (!listener.active()) { + reset() + return + } + val dt = if (frameAt == 0L) 1f / 60f else ((frameTimeNanos - frameAt) / 1e9f).coerceIn(0f, 0.05f) + frameAt = frameTimeNanos + step(dt) + if (moving()) choreographer.postFrameCallback(this) else stop() + } + } + + fun onKey(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_L2 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R2) { + if (event.repeatCount == 0 && !hasTriggerAxes(event)) { + val sticks = physical.getOrPut(event.deviceId) { Sticks() } + val value = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_L2) sticks.leftTrigger = value else sticks.rightTrigger = value + wake() + } + return true + } + val button = BUTTONS[event.keyCode] ?: return false + if (event.repeatCount > 0) return true + when (event.action) { + KeyEvent.ACTION_DOWN -> set(source(event.deviceId, SOURCE_KEYS), button, true) + KeyEvent.ACTION_UP -> set(source(event.deviceId, SOURCE_KEYS), button, false) + } + return true + } + + fun onMotion(event: MotionEvent): Boolean { + if (event.actionMasked != MotionEvent.ACTION_MOVE) return true + val sticks = physical.getOrPut(event.deviceId) { Sticks() } + val wired = wiredLayout.getOrPut(event.deviceId) { PadBridge.wiredXboxLayout(event) } + sticks.leftX = event.getAxisValue(MotionEvent.AXIS_X) + sticks.leftY = event.getAxisValue(MotionEvent.AXIS_Y) + sticks.rightX = PadBridge.rightX(event, wired) + sticks.rightY = PadBridge.rightY(event, wired) + if (hasTriggerAxes(event)) { + sticks.leftTrigger = PadBridge.leftTrigger(event, wired) + sticks.rightTrigger = PadBridge.rightTrigger(event, wired) + } + val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X) + val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + val hat = source(event.deviceId, SOURCE_HAT) + set(hat, UP, hatY < -0.5f) + set(hat, DOWN, hatY > 0.5f) + set(hat, LEFT, hatX < -0.5f) + set(hat, RIGHT, hatX > 0.5f) + wake() + return true + } + + fun onTouchPad(state: PadState) { + touch.leftX = state.leftX + touch.leftY = state.leftY + touch.rightX = state.rightX + touch.rightY = state.rightY + touch.leftTrigger = state.leftTrigger + touch.rightTrigger = state.rightTrigger + for (button in TOUCH_BUTTONS) { + set(SOURCE_TOUCH, button, state.isDown(button)) + } + set(SOURCE_TOUCH, UP, state.up) + set(SOURCE_TOUCH, DOWN, state.down) + set(SOURCE_TOUCH, LEFT, state.left) + set(SOURCE_TOUCH, RIGHT, state.right) + wake() + } + + fun reset() { + stop() + physical.clear() + triggerAxes.clear() + wiredLayout.clear() + touch.clear() + down.clear() + sent.clear() + stickDirection = NONE + scrolling = false + held.keys.toList().reversed().forEach { code -> + held.remove(code) + send(code, false) + } + } + + internal fun step(dt: Float) { + sum() + val lx = total.leftX + val ly = total.leftY + val held = heldDirection() + val direction = when { + held != NONE -> held + hypot(lx, ly) < STICK_NAVIGATE -> NONE + abs(lx) > abs(ly) -> if (lx > 0) RIGHT else LEFT + else -> if (ly > 0) DOWN else UP + } + val now = clock() + if (direction != stickDirection) { + stickDirection = direction + if (direction != NONE) { + tap(direction) + stickRepeatAt = now + REPEAT_FIRST_MS + } + } else if (direction != NONE && now >= stickRepeatAt) { + tap(direction) + stickRepeatAt = now + REPEAT_MS + } + val gain = gain(total.rightX, total.rightY) + val sx = total.rightX * gain + val triggers = trigger(total.rightTrigger) - trigger(total.leftTrigger) + val vertical = (total.rightY * gain + triggers).coerceIn(-1f, 1f) + if (sx != 0f || vertical != 0f) { + if (!scrolling) { + scrolling = true + listener.onScrollStart() + } + val speed = listener.scrollSpeed() * dt + listener.onScroll(sx * speed, vertical * speed) + } else if (scrolling) { + scrolling = false + press(KEY_SETTLE) + release(KEY_SETTLE) + } + } + + private fun set(source: Int, button: Int, pressed: Boolean) { + val sources = down.getOrPut(button) { HashSet() } + val was = sources.isNotEmpty() + if (pressed) sources.add(source) else sources.remove(source) + val now = sources.isNotEmpty() + if (was == now) return + if (button in DIRECTIONS) { + if (now) step(0f) + wake() + return + } + if (now) { + if (button == PadState.Y) { + listener.onKeyboard() + return + } + val codes = codesFor(button) + sent[button] = codes + codes.forEach { press(it) } + } else { + sent.remove(button)?.reversed()?.forEach { release(it) } + } + } + + private fun hasTriggerAxes(event: android.view.InputEvent): Boolean = triggerAxes.getOrPut(event.deviceId) { + val device = event.device ?: return@getOrPut true + device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || device.getMotionRange(MotionEvent.AXIS_BRAKE) != null || + (event is MotionEvent && wiredLayout.getOrPut(event.deviceId) { PadBridge.wiredXboxLayout(event) }) + } + + private fun heldDirection(): Int { + for (direction in DIRECTIONS) if (down[direction]?.isNotEmpty() == true) return direction + return NONE + } + + private fun sum() { + total.leftX = touch.leftX + total.leftY = touch.leftY + total.rightX = touch.rightX + total.rightY = touch.rightY + total.leftTrigger = touch.leftTrigger + total.rightTrigger = touch.rightTrigger + for (sticks in physical.values) { + total.leftX += sticks.leftX + total.leftY += sticks.leftY + total.rightX += sticks.rightX + total.rightY += sticks.rightY + total.leftTrigger += sticks.leftTrigger + total.rightTrigger += sticks.rightTrigger + } + } + + private fun tap(direction: Int) { + listener.onNavigate() + val codes = codesFor(direction) + codes.forEach { press(it) } + codes.reversed().forEach { release(it) } + } + + private fun codesFor(button: Int): IntArray = when (button) { + PadState.A -> intArrayOf(KEY_ENTER) + PadState.X -> intArrayOf(KEY_MENU) + PadState.B -> intArrayOf(KEY_ESC, KEY_BACK) + PadState.START -> intArrayOf(KEY_ENTER) + PadState.LB -> intArrayOf(KEY_LEFTCTRL, KEY_PAGEUP) + PadState.RB -> intArrayOf(KEY_LEFTCTRL, KEY_PAGEDOWN) + UP -> intArrayOf(KEY_UP) + DOWN -> intArrayOf(KEY_DOWN) + LEFT -> intArrayOf(KEY_LEFT) + RIGHT -> intArrayOf(KEY_RIGHT) + else -> IntArray(0) + } + + private fun wake() { + if (moving() && !running) { + running = true + frameAt = 0L + choreographer.postFrameCallback(frame) + } + } + + private fun moving(): Boolean { + if (heldDirection() != NONE || stickDirection != NONE || scrolling) return true + sum() + return hypot(total.leftX, total.leftY) >= STICK_NAVIGATE || gain(total.rightX, total.rightY) != 0f || + trigger(total.leftTrigger) != 0f || trigger(total.rightTrigger) != 0f + } + + private fun stop() { + running = false + frameAt = 0L + choreographer.removeFrameCallback(frame) + } + + private fun press(code: Int) { + val count = held[code] ?: 0 + held[code] = count + 1 + if (count == 0) send(code, true) + } + + private fun release(code: Int) { + val count = held[code] ?: return + if (count > 1) { + held[code] = count - 1 + return + } + held.remove(code) + send(code, false) + } + + private fun send(code: Int, pressed: Boolean) { + listener.onKey(code, pressed) + } + + companion object { + private const val DEAD_ZONE = 0.15f + private const val STICK_NAVIGATE = 0.5f + private const val REPEAT_FIRST_MS = 300L + private const val REPEAT_MS = 120L + private const val SOURCE_KEYS = 0 + private const val SOURCE_HAT = 1 + private const val SOURCE_TOUCH = Int.MIN_VALUE + private const val NONE = -1 + private const val UP = 100 + private const val DOWN = 101 + private const val LEFT = 102 + private const val RIGHT = 103 + private val DIRECTIONS = intArrayOf(UP, DOWN, LEFT, RIGHT) + private val TOUCH_BUTTONS = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB, PadState.START) + private const val KEY_ESC = 1 + private const val KEY_ENTER = 28 + private const val KEY_UP = 103 + private const val KEY_LEFT = 105 + private const val KEY_RIGHT = 106 + private const val KEY_DOWN = 108 + private const val KEY_BACK = 158 + private const val KEY_LEFTCTRL = 29 + private const val KEY_PAGEUP = 104 + private const val KEY_PAGEDOWN = 109 + private const val KEY_MENU = 127 + private const val KEY_SETTLE = 183 + + private val BUTTONS = mapOf( + KeyEvent.KEYCODE_BUTTON_A to PadState.A, + KeyEvent.KEYCODE_BUTTON_B to PadState.B, + KeyEvent.KEYCODE_BUTTON_X to PadState.X, + KeyEvent.KEYCODE_BUTTON_Y to PadState.Y, + KeyEvent.KEYCODE_BUTTON_L1 to PadState.LB, + KeyEvent.KEYCODE_BUTTON_R1 to PadState.RB, + KeyEvent.KEYCODE_BUTTON_START to PadState.START, + KeyEvent.KEYCODE_DPAD_UP to UP, + KeyEvent.KEYCODE_DPAD_DOWN to DOWN, + KeyEvent.KEYCODE_DPAD_LEFT to LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT to RIGHT, + ) + + internal fun curve(x: Float, y: Float): Pair { + val gain = gain(x, y) + return x * gain to y * gain + } + + private fun gain(x: Float, y: Float): Float { + val length = hypot(x, y) + if (length <= DEAD_ZONE) return 0f + val scaled = (length.coerceAtMost(1f) - DEAD_ZONE) / (1f - DEAD_ZONE) + return scaled * scaled / length + } + + private fun source(deviceId: Int, kind: Int): Int = deviceId * 2 + kind + + + + private fun trigger(value: Float): Float = + if (value <= DEAD_ZONE) 0f else (value.coerceAtMost(1f) - DEAD_ZONE) / (1f - DEAD_ZONE) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt new file mode 100644 index 000000000..3bb1c9c1e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/ScrollTouchGestures.kt @@ -0,0 +1,190 @@ +package com.droiddeck.launcher.input + +import android.os.Handler +import android.os.Looper +import android.view.Choreographer +import android.view.MotionEvent +import android.view.VelocityTracker +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.hypot +import kotlin.math.pow + +class ScrollTouchGestures( + private val slopPx: Float, + private val minFlingPx: Float, + private val listener: Listener, +) { + interface Listener { + fun onMove(x: Float, y: Float) + fun onButton(button: Int, pressed: Boolean) + fun onScroll(dx: Float, dy: Float) + fun onLongPress() + } + + private val handler = Handler(Looper.getMainLooper()) + private val choreographer = Choreographer.getInstance() + private var tracker: VelocityTracker? = null + private var pointerId = -1 + private var startX = 0f + private var startY = 0f + private var lastX = 0f + private var lastY = 0f + private var movedAt = 0L + private var scrolling = false + private var vertical = true + private var held = false + private var dragging = false + private var stoppedFling = false + private var clicked = 0 + private var flingX = 0f + private var flingY = 0f + private var flingAt = 0L + private var flinging = false + + private val longPress = Runnable { + held = true + listener.onLongPress() + } + private val clickUp = Runnable { releaseClick() } + private val flingFrame = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!flinging) return + val dt = if (flingAt == 0L) 1f / 60f else ((frameTimeNanos - flingAt) / 1e9f).coerceIn(0f, 0.05f) + flingAt = frameTimeNanos + val decay = exp(-dt / FLING_TIME_S) + listener.onScroll(flingX * dt, flingY * dt) + flingX *= decay + flingY *= decay + if (hypot(flingX, flingY) < FLING_STOP_PX) flinging = false else choreographer.postFrameCallback(this) + } + } + + fun cancel() { + handler.removeCallbacks(longPress) + stopFling() + releaseClick() + if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false) + dragging = false + held = false + scrolling = false + pointerId = -1 + tracker?.recycle() + tracker = null + } + + fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + stoppedFling = flinging + cancel() + pointerId = event.getPointerId(0) + startX = event.x + startY = event.y + lastX = startX + lastY = startY + tracker = VelocityTracker.obtain().also { it.addMovement(event) } + listener.onMove(startX, startY) + handler.postDelayed(longPress, HOLD_MS) + } + MotionEvent.ACTION_MOVE -> { + val index = event.findPointerIndex(pointerId).takeIf { it >= 0 } ?: return true + tracker?.addMovement(event) + val x = event.getX(index) + val y = event.getY(index) + if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) { + handler.removeCallbacks(longPress) + if (held) { + dragging = true + listener.onButton(PointerGestures.BTN_LEFT, true) + } else { + scrolling = true + vertical = abs(y - startY) >= abs(x - startX) + lastX = startX + lastY = startY + } + } + if (dragging) listener.onMove(x, y) + follow(event, x, y) + } + MotionEvent.ACTION_POINTER_UP -> { + if (event.getPointerId(event.actionIndex) == pointerId) cancel() + } + MotionEvent.ACTION_UP -> { + handler.removeCallbacks(longPress) + if (pointerId == -1 || event.getPointerId(0) != pointerId) return true + tracker?.addMovement(event) + follow(event, event.x, event.y) + when { + scrolling -> tracker?.takeIf { event.eventTime - movedAt <= FLING_STILL_MS }?.let { t -> + t.computeCurrentVelocity(1000) + val vx = if (vertical) 0f else -t.getXVelocity(pointerId) + val vy = if (vertical) -t.getYVelocity(pointerId) else 0f + if (hypot(vx, vy) >= minFlingPx) startFling(vx, vy) + } + dragging -> listener.onButton(PointerGestures.BTN_LEFT, false) + held -> click(PointerGestures.BTN_RIGHT) + !stoppedFling -> click(PointerGestures.BTN_LEFT) + } + dragging = false + held = false + scrolling = false + pointerId = -1 + tracker?.recycle() + tracker = null + } + MotionEvent.ACTION_CANCEL -> cancel() + } + return true + } + + private fun follow(event: MotionEvent, x: Float, y: Float) { + if (!scrolling || (x == lastX && y == lastY)) return + movedAt = event.eventTime + if (vertical) listener.onScroll(0f, lastY - y) else listener.onScroll(lastX - x, 0f) + lastX = x + lastY = y + } + + private fun click(button: Int) { + releaseClick() + listener.onButton(button, true) + clicked = button + handler.postDelayed(clickUp, CLICK_MS) + } + + private fun releaseClick() { + handler.removeCallbacks(clickUp) + if (clicked != 0) listener.onButton(clicked, false) + clicked = 0 + } + + private fun startFling(vx: Float, vy: Float) { + flingX = vx + flingY = vy + flingAt = 0L + flinging = true + choreographer.postFrameCallback(flingFrame) + } + + private fun stopFling() { + flinging = false + choreographer.removeFrameCallback(flingFrame) + } + + companion object { + fun wheelUnits(pixels: Int, viewSize: Int): Int { + if (pixels == 0) return 0 + val step = floor(viewSize.toDouble().pow(2.0 / 3.0)).coerceAtLeast(1.0) + val units = (abs(pixels) * 120.0 / step).toInt() + 1 + return if (pixels < 0) -units else units + } + + private const val HOLD_MS = 500L + private const val CLICK_MS = 50L + private const val FLING_TIME_S = 0.45f + private const val FLING_STOP_PX = 40f + private const val FLING_STILL_MS = 60L + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java index 44c78c84e..5fb94de05 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -12,7 +12,7 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.function.LongConsumer; import java.util.function.Predicate; -final class RuntimeFileTree { +public final class RuntimeFileTree { private RuntimeFileTree() {} static volatile Predicate refuseForTest; @@ -35,7 +35,7 @@ final class RuntimeFileTree { if (progress != null) progress.accept(++removed[0]); } - static boolean deleteWhatCan(File root, LongConsumer progress) { + public static boolean deleteWhatCan(File root, LongConsumer progress) { return deleteWhatCan(root.toPath(), progress, new long[]{0}); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index 023835fcc..351fe304c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -64,6 +64,13 @@ object GameEnvironmentStore { return config } + @Synchronized + fun setGameSync(context: Context, scope: String, backend: String?): GameEnvironment.Config { + val config = GameEnvironment.withGameSync(read(context), scope, backend) + save(context, config) + return config + } + @Synchronized fun setGameNoSteam(context: Context, scope: String, on: Boolean): GameEnvironment.Config { val config = GameEnvironment.withGameNoSteam(read(context), scope, on) diff --git a/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt new file mode 100644 index 000000000..6787d92d2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.stores.epic.EpicClient + +object NoSteamLaunch { + const val PROTON_RUN = "/usr/local/bin/droiddeck-proton-run" + const val EPIC = "/usr/local/bin/droiddeck-epic" + + class Command(val program: String, val args: List, val name: String) + + fun scope(game: Library.SteamGame): String? = when { + game.library == Library.EPIC_LAUNCHER -> EpicClient.shortcutAppId.toString() + game.library == Library.ADDED || game.appId > 0 -> game.profileKey + else -> null + }?.takeIf { it.isNotEmpty() && GameEnvironment.validScope(it) } + + fun enabled(config: GameEnvironment.Config, game: Library.SteamGame): Boolean = + scope(game)?.let { GameEnvironment.gameNoSteam(config, it) } == true + + fun enabled(context: Context, game: Library.SteamGame): Boolean = + scope(game) != null && runCatching { enabled(GameEnvironmentStore.read(context), game) }.getOrDefault(false) + + fun enabled(context: Context, appId: Long): Boolean = runCatching { + GameEnvironment.gameNoSteam(GameEnvironmentStore.read(context), (appId and 0xFFFFFFFFL).toString()) + }.getOrDefault(false) + + fun command(game: Library.SteamGame, added: AddedGames.Game?): Command? = when { + game.library == Library.EPIC_LAUNCHER -> Command(EPIC, listOf("run"), game.name) + game.library == Library.ADDED -> added?.let { a -> a.steamAppId?.let { Command(PROTON_RUN, listOf("--app", it.toString()), a.name) } ?: addedCommand(a) } + game.appId > 0 -> Command(PROTON_RUN, listOf("--app", game.appId.toString()), game.name) + else -> null + } + + fun addedCommand(game: AddedGames.Game): Command = Command( + PROTON_RUN, + listOf("--app", (game.appId and 0xFFFFFFFFL).toString(), "--dir", game.guestDir, "--options", game.launchOptions, "--", game.guestExe), + game.name, + ) + + fun settingsId(game: AddedGames.Game): Long = (game.steamAppId?.toLong() ?: game.appId) and 0xFFFFFFFFL + + fun addedGame(context: Context, game: Library.SteamGame): AddedGames.Game? { + val id = game.appId.toLong() and 0xFFFFFFFFL + return AddedGames.scan(context).firstOrNull { settingsId(it) == id } + } + + fun nameFor(program: String?, args: List): String? = when (program) { + EPIC -> Library.EPIC_LAUNCHER_NAME + PROTON_RUN -> args.getOrNull(args.indexOf("--app") + 1)?.takeIf { args.contains("--app") }?.let { names[it] } + else -> null + } + + val names = java.util.concurrent.ConcurrentHashMap() + + fun remember(command: Command) { + if (command.program == PROTON_RUN) command.args.getOrNull(command.args.indexOf("--app") + 1)?.let { names[it] = command.name } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index a4c3f4994..bfdbd88e0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -67,6 +67,8 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim", "usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts", "usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch", + "usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic", + "usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam", "usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url", "usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games", "usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults", @@ -121,7 +123,7 @@ object SessionFiles { "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json") val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" } - } + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } + } + listOf("libthunkaudit.so", "libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } val optional = (arrayOf( "usr/local/bin/gamescope" to "usr/local/bin/gamescope", "usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index f61c6c1a8..d54632130 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -610,6 +610,36 @@ object SessionPrefs { fun addedGameStoredAppId(context: Context, folderPath: String): Long? = prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L } + fun followStoreGame(context: Context, key: String, folderPath: String, gone: (String) -> Boolean) = synchronized(addedGameLock) { + val p = prefs(context) + val previous = p.getString("storeFolder:$key", null) + if (previous == folderPath) return@synchronized + if (previous != null && !gone(previous)) return@synchronized + val edit = p.edit().putString("storeFolder:$key", folderPath) + if (previous != null && !p.contains("addedAppId:$folderPath")) { + val moved = movedAddedGame(p.all, previous, folderPath) + moved.keys.forEach { edit.remove(it) } + moved.values.forEach { (name, value) -> + when (value) { + is String -> edit.putString(name, value) + is Long -> edit.putLong(name, value) + is Int -> edit.putInt(name, value) + is Boolean -> edit.putBoolean(name, value) + is Float -> edit.putFloat(name, value) + } + } + val removed = removedAddedGames(context) + if (previous in removed) edit.putString("removedAddedGames", removed.map { if (it == previous) folderPath else it }.joinToString("\n")) + } + edit.apply() + } + + internal fun movedAddedGame(all: Map, from: String, to: String): Map> = + all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.mapValues { (name, value) -> + val moved = if (value is String && (value == from || value.startsWith("$from/"))) to + value.removePrefix(from) else value + name.removeSuffix(from) + to to moved + } + fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) { val p = prefs(context) p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 6b8d4061f..3a0231746 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -72,7 +72,8 @@ class SessionService : Service() { private var steamDownloadsInBackground = false private var activityVisible = true private var xrActivityVisible = false - private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible, screenOn, xrActivityVisible) + private var signInHold = false + private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible) private var screenOn = true private var manualPauseRequested = false private var steamSleepToken: String? = null @@ -183,6 +184,15 @@ class SessionService : Service() { updateSuspendPolicy() return START_NOT_STICKY } + ACTION_SIGN_IN_HOLD -> { + val hold = intent?.getBooleanExtra(EXTRA_HOLD, false) == true + if (hold == signInHold) return START_NOT_STICKY + signInHold = hold + if (hold) manualPauseRequested = false + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } ACTION_SUSPEND_POLICY_CHANGED -> { if (!SessionState.running) return START_NOT_STICKY suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) @@ -219,6 +229,7 @@ class SessionService : Service() { steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) activityVisible = true xrActivityVisible = false + signInHold = false screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true manualPauseRequested = false suspendDownloadNow = false @@ -325,6 +336,7 @@ class SessionService : Service() { .onFailure { Log.w(TAG, "forced components", it) } } addClientEnvironment(guest, steamHere) + if (steamHere) guest.add("BL_EPIC_APP_ID=" + com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId) val fastPathAt = guest.size val pulse = startAudio(guest, sessionDir) @@ -350,7 +362,15 @@ class SessionService : Service() { addControllerEnvironment(guest, fakeInputDir, sessionDir) SessionState.padBridge?.syncConnectedControllers() } - if (steamHere) { + val noSteamGame = noSteamGame() + if (noSteamGame) guest.add("BL_PIN_CORES=0") + if ((steamHere || SessionState.program == NoSteamLaunch.EPIC) && com.droiddeck.launcher.stores.epic.EpicClient.read(this).installed) { + runCatching { com.droiddeck.launcher.stores.epic.EpicClientLibrary.sync(this) } + .onFailure { Log.w(TAG, "Epic library sync failed", it) } + runCatching { com.droiddeck.launcher.stores.epic.EpicClient.prepareInstallDir(this) } + .onFailure { Log.w(TAG, "Epic install directory setup failed", it) } + } + if (steamHere || noSteamGame) { val added = com.droiddeck.launcher.frontend.AddedGames.scan(this) val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) guest.add("BL_ADDED_GAMES=" + listing.path) @@ -457,8 +477,9 @@ class SessionService : Service() { Log.i(TAG, "an earlier session's process ended ($status); the current one carries on") return@start } - Log.i(TAG, "session ended: $status") - stopSession(status ?: -1) + val program = programStatus(sessionRoot) + Log.i(TAG, "session ended: $status" + if (program != null) " (program $program)" else "") + stopSession(program ?: status ?: -1) }, null) Log.i(TAG, "session pid $pid, log ${sessionLog.path}") if (gen != sessionGen || !SessionState.running) { @@ -579,8 +600,11 @@ class SessionService : Service() { .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } } + private fun noSteamGame(): Boolean = + SessionState.mode == MODE_RUN && SessionState.program in setOf(NoSteamLaunch.PROTON_RUN, NoSteamLaunch.EPIC) + private fun startAudio(guest: MutableList, sessionDir: File): PulseAudioComponent { - val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) && + val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || noSteamGame()) && SessionPrefs.directAudio(this) val wantsMic = SessionPrefs.micEnabled(this) && checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED @@ -1005,10 +1029,15 @@ class SessionService : Service() { private var launchWatcher: android.os.FileObserver? = null + private fun programStatus(dir: File): Int? = + runCatching { File(dir, RUN_STATUS).readText().trim().toInt() }.getOrNull() + private fun watchLaunchRequests(dir: File) { launchWatcher?.stopWatching() listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp", - "steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp" + "steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp", + RUN_STATUS, "$RUN_STATUS.tmp", "epic-game", "epic-game.tmp", com.droiddeck.launcher.stores.epic.EpicClient.PLAYING, + com.droiddeck.launcher.stores.epic.EpicClient.OPEN, com.droiddeck.launcher.stores.epic.EpicClient.FOCUSED_APP ).forEach { File(dir, it).delete() } val gen = sessionGen @Suppress("DEPRECATION") @@ -1335,6 +1364,7 @@ class SessionService : Service() { updateDownloadMonitoring() val stoppedGen = sessionGen val stoppedSession = SessionState.sessionId + val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC SessionState.stopRequested = false SessionEvents.record("guest.exited", mapOf("status" to status)) GpuClockPin.stop(this) @@ -1375,15 +1405,25 @@ class SessionService : Service() { if (prootPid > 1) teardown(prootPid) killGuestLeftovers() finishSessionStop(status, stoppedGen) + if (launcherEnded) importLauncherGames() }, "session-teardown").start() } else { - Thread({ com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession); finishSessionStop(status, stoppedGen) }, "gog-session-stop").start() + Thread({ + com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession) + finishSessionStop(status, stoppedGen) + if (launcherEnded) importLauncherGames() + }, "gog-session-stop").start() } } if (prootPid > 1) acquireLocks() if (controller != null) controller.closeAndResume(finishAfterTeardown) else finishAfterTeardown() } + private fun importLauncherGames() { + runCatching { com.droiddeck.launcher.stores.epic.EpicClientLibrary.importClientGames(this) } + .onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") } + } + override fun onTaskRemoved(rootIntent: Intent?) { if (pipTask || xrActivityVisible) { Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy") @@ -1590,6 +1630,8 @@ class SessionService : Service() { private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN" private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE" private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN" + private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD" + private const val EXTRA_HOLD = "hold" private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY" private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY" private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED" @@ -1613,6 +1655,7 @@ class SessionService : Service() { const val MODE_DESKTOP = "lxqt" const val MODE_RUN = "run" const val EXTRA_PROGRAM = "program" + private const val RUN_STATUS = "run-status" const val EXTRA_PROGRAM_ARGS = "programArgs" const val EXTRA_STEAM_UI = "steamUi" const val EXTRA_STEAM_URL = "steamUrl" @@ -1655,6 +1698,11 @@ class SessionService : Service() { context.startService(Intent(context, SessionService::class.java).setAction(action)) } + fun holdForSignIn(context: Context, hold: Boolean) { + if (!SessionState.running) return + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SIGN_IN_HOLD).putExtra(EXTRA_HOLD, hold)) + } + fun resume(context: Context) { if (!SessionState.running) return context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index 662d44e71..fbe908408 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -211,11 +211,14 @@ internal class SessionSuspendController( } } for (record in ordered) send(record, SIGCONT) - repeat(8) { + val waiting = ordered.toMutableList() + repeat(RESTORE_CHECKS) { check -> Thread.sleep(25L) - if (ordered.all(::isRunningOrGone)) return true + waiting.removeAll { isRunningOrGone(it) } + if (waiting.isEmpty()) return true + if (check % 8 == 7) for (record in waiting) send(record, SIGCONT) } - return ordered.all(::isRunningOrGone) + return false } private fun send(record: ProcessRecord, signal: Int) { @@ -242,5 +245,6 @@ internal class SessionSuspendController( private companion object { const val SIGSTOP = 19 const val SIGCONT = 18 + const val RESTORE_CHECKS = 80 } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt index 03504baac..345f4da32 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -3,47 +3,34 @@ package com.droiddeck.launcher.stores import android.content.Context import android.util.Base64 import android.util.Log +import com.droiddeck.launcher.agent.AgentGuest import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.frontend.LogoPosition -import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import org.json.JSONArray import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.net.HttpURLConnection -import java.net.Socket -import java.net.URL -import java.security.SecureRandom object SteamLiveShortcuts { private const val TAG = "SteamLive" private const val TAG_NAME = "droiddeck-app" private const val COMPAT_TOOL = "droiddeck-proton-arm64" + private const val EVAL_TIMEOUT_MS = 20_000L + private const val THAW_WAIT_MS = 15_000L fun sync(context: Context, games: List, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) { val wanted = games.filter(include) if (wanted.isEmpty() || !clientRunning()) return val list = JSONArray() - for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir)) + for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir).put("appid", g.appId and 0xFFFFFFFFL)) val js = """ (async () => { const want = ${list}; - const have = await SteamClient.Apps.GetAllShortcuts(); - const strip = s => (s || "").replace(/^"|"$/g, ""); let added = 0; for (const w of want) { - if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue; - const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, ""); - if (!id) continue; - try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {} - try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {} - added++; + if (await ${KNOWN}(w.appid, w.name, w.exe)) continue; + if (await ${ADD}(w.name, w.exe, w.dir, true)) added++; } return added; })() @@ -53,6 +40,36 @@ object SteamLiveShortcuts { if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client") } + fun ensure(context: Context, name: String, exe: String, dir: String, compat: Boolean, appId: Long? = null): Long? { + if (!awake(THAW_WAIT_MS)) return null + val js = """ + (async () => { + const known = await ${KNOWN}(${appId?.and(0xFFFFFFFFL) ?: 0}, ${JSONObject.quote(name)}, ${JSONObject.quote(exe)}); + if (known) return known; + return await ${ADD}(${JSONObject.quote(name)}, ${JSONObject.quote(exe)}, ${JSONObject.quote(dir)}, $compat); + })() + """.trimIndent() + val id = evaluate(context, js)?.toLongOrNull()?.and(0xFFFFFFFFL)?.takeIf { it != 0L } ?: return null + Log.i(TAG, "live shortcuts: $name is $id") + return id + } + + fun known(context: Context, appId: Long): Boolean? { + if (!clientRunning()) return null + val js = """ + (() => { + const store = globalThis.appStore; + if (typeof store?.GetAppOverviewByAppID !== 'function' || !store.m_bIsInitialized) return 'unknown'; + return store.GetAppOverviewByAppID(${appId and 0xFFFFFFFFL}) != null ? 'yes' : 'no'; + })() + """.trimIndent() + return when (evaluate(context, js)) { + "yes" -> true + "no" -> false + else -> null + } + } + fun remove(context: Context, appId: Long) { if (!clientRunning()) return val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()") @@ -100,114 +117,60 @@ object SteamLiveShortcuts { if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: logo position for $id") } - private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested + private fun clientRunning(): Boolean = + SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested && !SessionState.suspended + + private fun awake(waitMs: Long): Boolean { + val end = android.os.SystemClock.uptimeMillis() + waitMs + while (SessionState.running && SessionState.suspended && android.os.SystemClock.uptimeMillis() < end) Thread.sleep(100) + return clientRunning() + } private fun evaluate(context: Context, expression: String): String? { - val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port") - val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null - val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null - var wsUrl: String? = null - for (i in 0 until targets.length()) { - val t = targets.optJSONObject(i) ?: continue - if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break } - } - if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null } - return try { - WebSocket(wsUrl, 8000).use { ws -> - ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject() - .put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString()) - val deadline = System.currentTimeMillis() + 15_000 - while (System.currentTimeMillis() < deadline) { - val message = JSONObject(ws.receiveText() ?: return null) - if (message.optInt("id") != 1) continue - if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null } - val result = message.optJSONObject("result") ?: return null - result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null } - return result.optJSONObject("result")?.opt("value")?.toString() - } - null - } + val answer = try { + AgentGuest.steamEval(context, expression, EVAL_TIMEOUT_MS) } catch (e: Exception) { Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}") - null + return null } + if (!answer.optBoolean("ok")) { + Log.w(TAG, "cdp: ${answer.optJSONObject("error")?.optString("message")}") + return null + } + return answer.opt("value")?.takeIf { it != JSONObject.NULL }?.toString() } - private fun get(url: String): String { - val c = URL(url).openConnection() as HttpURLConnection - c.connectTimeout = 3000; c.readTimeout = 3000 - try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() } - } + private val KNOWN = """(async (appid, name, exe) => { + const store = globalThis.appStore; + if (typeof store?.GetAppOverviewByAppID !== 'function' || !store.m_bIsInitialized) throw new Error("appStore is not ready"); + const byId = appid ? store.GetAppOverviewByAppID(appid) : null; + if (byId) return byId.appid; + const strip = s => (s || "").replace(/^"|"$/g, ""); + const details = id => new Promise(done => { + let handle = null, answer; + const finish = d => { clearTimeout(timer); try { handle?.unregister(); } catch (e) {} done(d); }; + const timer = setTimeout(() => finish(null), 4000); + handle = SteamClient.Apps.RegisterForAppDetails(id, d => { answer = d; if (handle) finish(d); }); + if (answer !== undefined) finish(answer); + }); + for (const a of store.allApps.filter(a => a.app_type == 1073741824 && a.display_name === name)) { + const d = await details(a.appid); + if (!d) throw new Error("no details for shortcut " + a.appid); + if (strip(d.strShortcutExe) === strip(exe)) return a.appid; + } + return 0; + })""" - private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable { - private val socket: Socket - private val out: OutputStream - private val input: DataInputStream - - init { - val u = URL(url.replaceFirst("ws://", "http://")) - socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs } - out = socket.getOutputStream() - input = DataInputStream(socket.getInputStream()) - val key = ByteArray(16).also { SecureRandom().nextBytes(it) } - val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "") - out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + - "Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray()) - out.flush() - val status = readLine(input) ?: throw java.io.IOException("no upgrade response") - if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status") - while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break } - } - - private fun readLine(s: InputStream): String? { - val buf = ByteArrayOutputStream() - while (true) { - val b = s.read() - if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8") - if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r') - buf.write(b) - } - } - - fun sendText(text: String) { - val payload = text.toByteArray() - val mask = ByteArray(4).also { SecureRandom().nextBytes(it) } - val frame = ByteArrayOutputStream() - frame.write(0x81) - when { - payload.size < 126 -> frame.write(0x80 or payload.size) - payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) } - else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) } - } - frame.write(mask) - for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF) - out.write(frame.toByteArray()); out.flush() - } - - fun receiveText(): String? { - val message = ByteArrayOutputStream() - while (true) { - val b0 = input.read(); if (b0 < 0) return null - val b1 = input.read(); if (b1 < 0) return null - val fin = b0 and 0x80 != 0 - val opcode = b0 and 0x0F - var len = (b1 and 0x7F).toLong() - if (len == 126L) len = input.readUnsignedShort().toLong() - else if (len == 127L) len = input.readLong() - val masked = b1 and 0x80 != 0 - val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null - if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large") - val data = ByteArray(len.toInt()).also { input.readFully(it) } - if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte() - when (opcode) { - 0x8 -> return null - 0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() } - 0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") } - else -> {} - } - } - } - - override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } } - } + private val ADD = """(async (name, exe, dir, compat) => { + const id = await SteamClient.Apps.AddShortcut(name, exe, dir, ""); + if (!id) return 0; + try { SteamClient.Apps.SetShortcutName(id, name); } catch (e) {} + try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {} + if (compat) { try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {} } + for (let i = 0; i < 40; i++) { + try { if (globalThis.appStore.GetAppOverviewByAppID(id)) break; } catch (e) {} + await new Promise(r => setTimeout(r, 250)); + } + return id; + })""" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt index d36f88678..611b7f3db 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt @@ -14,7 +14,7 @@ object StoreInstallRoot { private const val ON_VOLUME = "Games" const val GUEST_EXTERNAL = "/mnt/droiddeck-stores" - class Target(val root: File, val label: String, val removable: Boolean) { + class Target(val root: File, val label: String, val removable: Boolean, val folder: Boolean = false) { val freeBytes: Long get() = availableBytes(root) } @@ -77,7 +77,16 @@ object StoreInstallRoot { fun targets(context: Context): List { val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false) val cards = GameStorage.options(context).map { Target(File(it.path, ON_VOLUME), it.name, removable = true) } - return listOf(internal) + cards + val library = GameStorage.effective(context)?.let { lib -> + val root = File(lib.path, ON_VOLUME) + if (cards.any { it.root.absolutePath == root.absolutePath }) return@let null + val volume = runCatching { context.getSystemService(StorageManager::class.java)?.getStorageVolume(File(lib.path)) }.getOrNull() + val name = File(lib.path).name.ifBlank { lib.label } + val where = volume?.takeUnless { it.isPrimary }?.getDescription(context) + val label = if (where.isNullOrBlank()) name else context.getString(com.droiddeck.launcher.R.string.gstore_folder_on_volume, name, where) + Target(root, label, removable = true, folder = true) + } + return listOf(internal) + cards + listOfNotNull(library) } fun installRoot(context: Context): File = internalRoot(context) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 0e629a6e7..80a05b01d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -63,6 +63,7 @@ object StoreInstalls { } catch (e: Exception) { Log.w(TAG, "registration: ${e.message}") } + com.droiddeck.launcher.stores.epic.EpicClient.libraryChanged(app) } fun uninstall(context: Context, game: InstalledStoreGame, progress: (Float) -> Unit = {}, removed: () -> Unit = {}) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index a50dfcd90..4486df81e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -9,6 +9,7 @@ object StoreLaunch { private const val TAG = "StoreLaunch" const val LAUNCHER = ".droiddeck-launch.bat" const val EPIC_CODE = ".droiddeck-epic-code" + const val EPIC_ARGS = ".droiddeck-epic-args" fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC @@ -30,6 +31,7 @@ object StoreLaunch { val args = sidecar.args.joinToString(" ") { quoteArg(it) } val run = "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "") if (sidecar.store == Store.EPIC) { + lines += "if exist \"%~dp0$EPIC_ARGS\" goto client" lines += "set \"$CODE_VAR=\"" lines += "if exist \"%~dp0$EPIC_CODE\" set /p $CODE_VAR=<\"%~dp0$EPIC_CODE\"" lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\"" @@ -38,6 +40,13 @@ object StoreLaunch { lines += "goto :eof" lines += ":signed" lines += "set \"$CODE_VAR=\" & $run -AUTH_LOGIN=unused -AUTH_PASSWORD=%$CODE_VAR% -AUTH_TYPE=exchangecode" + lines += "goto :eof" + lines += ":client" + lines += "set \"$CLIENT_VAR=\"" + lines += "set /p $CLIENT_VAR=<\"%~dp0$EPIC_ARGS\"" + lines += "del \"%~dp0$EPIC_ARGS\"" + lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\"" + lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%" } else { lines += run } @@ -52,6 +61,7 @@ object StoreLaunch { } private const val CODE_VAR = "DD_X" + private const val CLIENT_VAR = "DD_A" fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) { if (sidecar.launcher != LAUNCHER) return diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index 968aec7cc..4005ff2b3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -88,6 +88,41 @@ object StoreLaunchRequests { JSONObject().put("ok", false).put("reason", e.javaClass.simpleName) } } + "epic-sign-in" -> { + val url = request.optString("url") + try { + if (com.droiddeck.launcher.stores.epic.EpicClient.signIn(app, url, request.optBoolean("auto"))) JSONObject().put("ok", true) + else JSONObject().put("ok", false).put("reason", "not-sign-in") + } catch (e: Exception) { + JSONObject().put("ok", false).put("reason", e.javaClass.simpleName) + } + } + "epic-play-in-steam" -> { + val id = request.optString("id") + if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id") + else try { + com.droiddeck.launcher.stores.epic.EpicClient.playInSteam(app, id) + } catch (e: Exception) { + JSONObject().put("ok", false).put("reason", e.javaClass.simpleName) + } + } + "steam-run" -> try { + com.droiddeck.launcher.stores.epic.EpicClient.runSteamGame(app, request.optString("id")) + } catch (e: Exception) { + JSONObject().put("ok", false).put("reason", e.javaClass.simpleName) + } + "epic-signed-out" -> { + val signedIn = com.droiddeck.launcher.stores.epic.EpicCredentialStore.isLoggedIn(app) + if (signedIn) { + Log.i(TAG, "the Epic Games Launcher was signed out; signing the Epic store out too") + StoresState.signOut(app, Store.EPIC) + } + JSONObject().put("ok", true).put("signedOut", signedIn) + } + "epic-client-handoff" -> { + if (com.droiddeck.launcher.stores.epic.EpicClient.canHandOff(app)) JSONObject().put("ok", true) + else JSONObject().put("ok", false).put("reason", "no-account") + } else -> JSONObject().put("ok", false).put("reason", "unknown-op") } try { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt index 6d688f464..241635843 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt @@ -36,9 +36,10 @@ class StoreLoginActivity : ComponentActivity() { companion object { private const val TAG = "StoreLogin" const val EXTRA_STORE = "store" + const val EXTRA_CLIENT_URL = "client_url" private const val KEY_STATE = "oauth_state" @Volatile internal var floodColor: Int? = null - private const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" + internal const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" fun generateState(): String { val bytes = ByteArray(24) @@ -56,6 +57,7 @@ class StoreLoginActivity : ComponentActivity() { } private var store: Store? = null + private var clientUrl: String? = null private var flow: Flow? = null private var webViewRef: WebView? = null private var popupWebView: WebView? = null @@ -78,8 +80,9 @@ class StoreLoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) store = Store.byId(intent.getStringExtra(EXTRA_STORE)) + clientUrl = intent.getStringExtra(EXTRA_CLIENT_URL)?.takeIf { store == Store.EPIC } oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState() - flow = store?.let { LoginFlows.forStore(it, oauthState!!) } + flow = newFlow(oauthState!!) floodColor?.let { c -> floodColor = null flooded = true @@ -109,6 +112,12 @@ class StoreLoginActivity : ComponentActivity() { webView.loadUrl(flow!!.startUrl) } + private fun newFlow(state: String): Flow? { + val url = clientUrl + if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url) + return store?.let { LoginFlows.forStore(it, state) } + } + override fun finish() { super.finish() @Suppress("DEPRECATION") if (flooded) overridePendingTransition(0, 0) @@ -187,7 +196,7 @@ class StoreLoginActivity : ComponentActivity() { captured.set(false) val fresh = generateState() oauthState = fresh - flow = store?.let { LoginFlows.forStore(it, fresh) } ?: return + flow = newFlow(fresh) ?: return webViewRef?.let { it.settings.userAgentString = flow!!.userAgent; it.loadUrl(flow!!.startUrl) } } @@ -323,6 +332,16 @@ class StoreLoginActivity : ComponentActivity() { oauthState?.let { outState.putString(KEY_STATE, it) } } + override fun onStart() { + super.onStart() + if (clientUrl != null && !isFinishing) com.droiddeck.launcher.session.SessionService.holdForSignIn(this, true) + } + + override fun onStop() { + if (clientUrl != null && !isChangingConfigurations) com.droiddeck.launcher.session.SessionService.holdForSignIn(this, false) + super.onStop() + } + override fun onDestroy() { popupWebView?.let { popupWebView = null; destroyPopupView(it) } webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index f5e4ba0ac..316cabb65 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -33,6 +33,8 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { override fun signOut(context: Context) { runCatching { EpicLibrary.forget(context) } + runCatching { EpicClient.signOut(context) } + EpicClient.forgetWebSignIn() } override fun syncLibrary(context: Context, force: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt new file mode 100644 index 000000000..a1bad4622 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt @@ -0,0 +1,427 @@ +package com.droiddeck.launcher.stores.epic + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.storage.SteamStorage +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLog +import com.droiddeck.launcher.stores.StoreLoginActivity +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.SteamLiveShortcuts +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.zip.CRC32 + +object EpicClient { + private const val TAG = "EpicClient" + const val DOWNLOAD_ID = "epic-games-launcher" + const val DOWNLOAD_KEY = "epic:$DOWNLOAD_ID" + const val PROGRAM = "/usr/local/bin/droiddeck-epic" + const val PLAYING = "epic-playing" + const val STEAM_LAUNCHER = "/usr/local/bin/droiddeck-epic-steam" + const val OPEN = "epic-open" + const val FOCUSED_APP = "steam-focused-app" + private const val STEAM_DIR = "/usr/local/bin" + private const val SETTINGS = "settings/app-settings.json" + private const val STOCK_INSTALL_DIR = "/root/Games" + private const val FEX_DIR = "FEX-Emu" + private val FEX_PREFIXES = listOf("usr/", "", "files/usr/", "files/") + private const val FEX_WANTED = "root/.config/droiddeck/fex-wanted" + private const val ICON = "epic-games-launcher.png" + const val LAUNCHER_APP_ID = 0 + private const val PREF_CHECKED = "client_checked_at" + private const val CHECK_EVERY_MS = 6 * 60 * 60 * 1000L + private const val IMPORT_EVERY_MS = 15_000L + private const val STATE = "root/.local/share/droiddeck-epic" + private const val DATA = "root/.local/share/com.epicgames.eglv2" + private const val AUTH = "auth/auth.json" + private const val SIGNED_OUT = "signed-out" + private const val AUTO_SIGN_IN = "auto-sign-in" + private val UNINSTALL_PATHS = listOf("current.json", "app", "runtime", "downloads", "bin", "fex", "run", "logs", "status.json", "instance.json") + private const val COOKIE_DOMAIN = ".epicgames.com" + private val COOKIE_PATHS = listOf("/", "/id") + private val COOKIE_URLS = listOf( + "https://www.epicgames.com/id/login", "https://www.epicgames.com/", "https://epicgames.com/", + "https://store.epicgames.com/", "https://accounts.epicgames.com/", "https://launcher.store.epicgames.com/", + ) + private val work = Executors.newSingleThreadExecutor { r -> Thread(r, "epic-client") } + @Volatile private var importedAt = 0L + + data class Status( + val installed: Boolean = false, + val version: String? = null, + val runtimeVersion: String? = null, + ) + + var status by mutableStateOf(Status()) + private set + + fun stateDir(context: Context): File = File(LinuxRuntime.rootDir(context), STATE) + + fun dataDir(context: Context): File = File(LinuxRuntime.rootDir(context), DATA) + + fun downloadsDir(context: Context): File = File(stateDir(context), "downloads") + + fun guestPath(context: Context, file: File): String? { + val root = LinuxRuntime.rootDir(context).absolutePath + val path = file.absolutePath + return if (path.startsWith("$root/")) "/" + path.removePrefix("$root/") else null + } + + private fun ready(dir: File): Boolean = File(dir, ".droiddeck-ready").isFile + + internal fun canHandOff(context: Context): Boolean = EpicCredentialStore.isLoggedIn(context.applicationContext) + + fun signOut(context: Context): Boolean { + val app = context.applicationContext + val auth = File(dataDir(app), AUTH) + val signedIn = launcherSignedIn(auth) + if (signedIn) runCatching { File(stateDir(app), SIGNED_OUT).writeText("${System.currentTimeMillis() / 1000}\n") } + auth.delete() + File(stateDir(app), AUTO_SIGN_IN).delete() + Log.i(TAG, if (signedIn) "signed the Epic Games Launcher out too" else "the Epic Games Launcher was already signed out") + return signedIn + } + + internal fun launcherSignedIn(auth: File): Boolean = + runCatching { JSONObject(auth.readText()).optJSONObject("prod")?.optString("refresh_token").orEmpty().isNotEmpty() }.getOrDefault(false) + + fun forgetWebSignIn() { + android.os.Handler(android.os.Looper.getMainLooper()).post { + runCatching { + val cookies = android.webkit.CookieManager.getInstance() + var cleared = 0 + for (url in COOKIE_URLS) { + val lines = expiredCookies(cookies.getCookie(url), Uri.parse(url).host) + for (line in lines) cookies.setCookie(url, line) + cleared += lines.size + } + cookies.flush() + Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)") + }.onFailure { Log.w(TAG, "could not clear the Epic web sign-in: ${it.javaClass.simpleName}") } + } + } + + internal fun expiredCookies(header: String?, host: String? = null): List { + val domains = listOfNotNull(COOKIE_DOMAIN, host?.takeIf { it.endsWith(COOKIE_DOMAIN) }) + return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct() + .flatMap { name -> + COOKIE_PATHS.flatMap { path -> + listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" } + } + } + } + + fun read(context: Context): Status { + val app = context.applicationContext + if (!LinuxRuntime.isInstalled(app)) return Status() + val state = stateDir(app) + val current = runCatching { JSONObject(File(state, "current.json").readText()) }.getOrNull() + val version = current?.optString("app")?.ifEmpty { null } + val runtimeVersion = current?.optString("runtime")?.ifEmpty { null } + val installed = version != null && runtimeVersion != null && + ready(File(state, "app/$version")) && ready(File(state, "runtime/$runtimeVersion")) + return Status(installed, version, runtimeVersion) + } + + fun refresh(context: Context) { + val app = context.applicationContext + work.execute { + val next = runCatching { read(app) }.getOrNull() ?: return@execute + if (next.installed && System.currentTimeMillis() - importedAt >= IMPORT_EVERY_MS) { + importedAt = System.currentTimeMillis() + runCatching { EpicClientLibrary.importClientGames(app) } + .onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") } + } + StoresState.post { if (status != next) status = next } + } + } + + fun ensure(context: Context) { + val app = context.applicationContext + if (!LinuxRuntime.isInstalled(app)) { refresh(app); return } + work.execute { + val before = read(app) + StoresState.post { if (status != before) status = before } + if (DownloadQueue.isActive(DOWNLOAD_KEY) || !before.installed) return@execute + syncLibrary(app) + val prefs = EpicPrefs.get(app) + val now = System.currentTimeMillis() + if (now - prefs.getLong(PREF_CHECKED, 0L) < CHECK_EVERY_MS) return@execute + val latest = EpicClientSources.launcher()?.version + prefs.edit().putLong(PREF_CHECKED, if (latest != null) now else 0L).apply() + val after = read(app) + StoresState.post { if (status != after) status = after } + if (updateWanted(after, latest)) StoresState.post { enqueue(app) } + } + } + + internal fun updateWanted(current: Status, latest: String?): Boolean = + current.installed && latest != null && latest != current.version + + @Volatile var uninstalling = false + + fun install(context: Context) { + val app = context.applicationContext + if (status.installed || uninstalling) return + enqueue(app) + } + + private fun syncLibrary(app: Context) { + if (launcherRunning(app)) return + EpicClientLibrary.sync(app) + } + + private fun enqueue(app: Context) { + if (DownloadQueue.isActive(DOWNLOAD_KEY)) return + val entry = DownloadEntry( + Store.EPIC, DOWNLOAD_ID, app.getString(R.string.epic_client_title), + location = StoreInstallRoot.labelFor(app, stateDir(app)), + ) + DownloadQueue.enqueue(app, entry) { EpicClientInstaller(app) } + } + + internal fun installed(context: Context) { + val app = context.applicationContext + EpicPrefs.get(app).edit().putLong(PREF_CHECKED, System.currentTimeMillis()).apply() + refresh(app) + work.execute { syncLibrary(app) } + } + + fun libraryChanged(context: Context) { + val app = context.applicationContext + work.execute { if (read(app).installed) syncLibrary(app) } + } + + val shortcutAppId: Long = CRC32().apply { update("\"$STEAM_LAUNCHER\"${Library.EPIC_LAUNCHER_NAME}".toByteArray()) }.value or 0x80000000L + + fun gameId(appId: Long): String = java.lang.Long.toUnsignedString((appId shl 32) or 0x02000000L) + + fun steamListing(context: Context): JSONObject? { + val current = read(context) + if (!current.installed) return null + val entry = JSONObject().put("name", Library.EPIC_LAUNCHER_NAME).put("exe", STEAM_LAUNCHER).put("folder", STEAM_DIR) + .put("dir", STEAM_DIR).put("appid", shortcutAppId).put("seen", 0).put("native", true) + icon(context, current.version)?.let { guestPath(context, it) }?.let { entry.put("art", JSONObject().put("icon", it)) } + return entry + } + + fun steamLauncherId(context: Context): Long? = + SteamLiveShortcuts.ensure(context, Library.EPIC_LAUNCHER_NAME, STEAM_LAUNCHER, STEAM_DIR, compat = false, appId = shortcutAppId) + + fun icon(context: Context, version: String?): File? = + version?.let { File(stateDir(context), "app/$it/$ICON") }?.takeIf(File::isFile) + + fun launcherEntry(context: Context, name: String, current: Status = status, art: File? = icon(context, current.version)): Library.SteamGame? { + if (!current.installed) return null + return Library.SteamGame( + appId = LAUNCHER_APP_ID, name = name, art = art, library = Library.EPIC_LAUNCHER, + gameId = LAUNCHER_APP_ID.toLong(), source = Store.EPIC.id, + ) + } + + fun withLauncher(games: List, launcher: Library.SteamGame?): List { + if (launcher == null) return games + val at = games.indexOfFirst { it.source == Store.EPIC.id }.takeIf { it >= 0 } ?: games.size + return games.subList(0, at) + launcher + games.subList(at, games.size) + } + + private fun steamSession(): Boolean = + SessionState.running && !SessionState.stopRequested && SessionState.mode == SessionService.MODE_STEAM + + fun launcherSession(): Boolean = + SessionState.running && !SessionState.stopRequested && SessionState.mode == SessionService.MODE_RUN && + SessionState.program == com.droiddeck.launcher.session.NoSteamLaunch.EPIC + + fun uninstall(context: Context): Boolean { + val app = context.applicationContext + val state = stateDir(app) + com.droiddeck.launcher.stores.download.DownloadQueue.clear(app, DOWNLOAD_KEY) + val removed = UNINSTALL_PATHS.map { File(state, it) } + .map { com.droiddeck.launcher.runtime.RuntimeFileTree.deleteWhatCan(it, null) } + .all { it } + val next = read(app) + StoresState.post { status = next } + Log.i(TAG, if (removed) "uninstalled the Epic Games Launcher" else "could not remove every Epic Games Launcher file") + return removed && !next.installed + } + + fun launcherRunning(context: Context): Boolean = + (steamSession() || launcherSession()) && File(LinuxRuntime.sessionRoot(context), OPEN).exists() + + fun launcherFocused(context: Context): Boolean { + val root = LinuxRuntime.sessionRoot(context) + if (launcherSession()) return File(root, OPEN).exists() + if (!steamSession()) return false + val open = runCatching { File(root, OPEN).readText().trim() }.getOrNull() ?: return false + val focused = runCatching { File(root, FOCUSED_APP).readText().trim() }.getOrNull() ?: return false + return focused == (open.takeIf { it.length > 1 && it.all(Char::isDigit) } ?: shortcutAppId.toString()) + } + + fun playing(context: Context): Boolean = File(LinuxRuntime.sessionRoot(context), PLAYING).exists() + + fun fexReady(context: Context): Boolean { + val usr = File(LinuxRuntime.rootDir(context), "usr/bin") + if (File(usr, "FEXServer").isFile && fexBinary(usr)) return true + return Library.steamLibraries(context).any { (library, _) -> + val manifest = File(library, "steamapps/appmanifest_${SteamStorage.FEX}.acf") + val dir = runCatching { SteamStorage.parseManifest(manifest, manifest.readText())?.installDir }.getOrNull() + val roots = listOf(library, com.droiddeck.launcher.session.SecondaryLibrary.privateRoot(context.filesDir, library)) + listOfNotNull(dir, FEX_DIR).distinct().any { name -> + roots.any { root -> + val tool = File(root, "steamapps/common/$name") + FEX_PREFIXES.any { fexBinary(File(tool, "${it}bin")) } + } + } + } + } + + private fun fexBinary(bin: File): Boolean = File(bin, "FEX").isFile || File(bin, "FEXInterpreter").isFile + + fun requestFex(context: Context) { + runCatching { + File(LinuxRuntime.rootDir(context), FEX_WANTED).apply { parentFile?.mkdirs() }.writeText("1\n") + }.onFailure { Log.w(TAG, "could not ask Steam for FEX: ${it.javaClass.simpleName}") } + } + + fun prepareInstallDir(context: Context): String? { + val app = context.applicationContext + val dirs = StoreInstallRoot.targets(app).mapNotNull { t -> + val folder = StoreInstallRoot.storeDir(t.root, Store.EPIC) + AddedGames.guestPath(app, folder)?.let { t.root.absolutePath to (folder to it) } + }.toMap() + if (dirs.isEmpty()) return null + val chosen = dirs[SessionPrefs.storesInstallTarget(app)] ?: dirs[StoreInstallRoot.internalRoot(app).absolutePath] ?: dirs.values.first() + val file = File(dataDir(app), SETTINGS) + val settings = runCatching { JSONObject(file.readText()) }.getOrNull() ?: JSONObject() + val current = settings.optString("defaultInstallDir").trimEnd('/') + if (current.isNotEmpty() && current != STOCK_INSTALL_DIR && dirs.values.none { it.second == current } && !(current.endsWith("/${Store.EPIC.shortLabel}") && (current.startsWith("/mnt/droiddeck-") || current.startsWith("/root/Storage/")))) return current + if (!chosen.first.isDirectory && !chosen.first.mkdirs()) return null + if (current == chosen.second) return current + return runCatching { + file.parentFile?.mkdirs() + val staged = File(file.path + ".tmp") + staged.writeText(settings.put("defaultInstallDir", chosen.second).toString(2)) + check(staged.renameTo(file)) + Log.i(TAG, "the Epic Games Launcher installs games to ${chosen.second}") + chosen.second + }.onFailure { Log.w(TAG, "could not set the launcher's install folder: ${it.javaClass.simpleName}") }.getOrNull() + } + + fun playInSteam(context: Context, id: String): JSONObject { + if (!steamSession()) return failed("no-steam") + runCatching { EpicClientLibrary.importClientGames(context) }.onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") } + val game = steamShortcut(AddedGames.scan(context), id) ?: return failed("not-registered") + if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(context, com.droiddeck.launcher.session.NoSteamLaunch.settingsId(game))) return failed("no-steam-game") + if (!AddedGames.inSteam(context, game.appId) || SteamLiveShortcuts.known(context, game.appId) == false) return failed("not-in-steam") + if (!SessionService.launchGame(context, gameId(game.appId))) return failed("busy") + Log.i(TAG, "the Epic Games Launcher hands $id to Steam as ${gameId(game.appId)}") + return JSONObject().put("ok", true) + } + + fun runSteamGame(context: Context, id: String): JSONObject { + if (!com.droiddeck.launcher.frontend.GameLaunchLink.validId(id)) return failed("bad-id") + if (!steamSession()) return failed("no-steam") + if (!SessionService.launchGame(context, id)) return failed("busy") + Log.i(TAG, "the Epic Games Launcher hands Steam game $id to Steam") + return JSONObject().put("ok", true) + } + + private fun failed(reason: String): JSONObject = JSONObject().put("ok", false).put("reason", reason) + + internal fun steamShortcut(games: List, id: String): AddedGames.Game? = + games.firstOrNull { it.source == Store.EPIC.id && it.storeId == id } + + fun redirectOf(url: String): String? { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null + if (uri.scheme != "https" || uri.host !in SIGN_IN_HOSTS || uri.path?.startsWith("/id/authorize") != true) return null + val redirect = runCatching { uri.getQueryParameter("redirect_uri") }.getOrNull() ?: return null + return redirect.takeIf { loopback(it) } + } + + fun loopback(url: String): Boolean { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false + return uri.scheme == "http" && uri.host in LOOPBACK && uri.port in 1..65535 && uri.userInfo == null + } + + fun signIn(context: Context, url: String, auto: Boolean): Boolean { + if (redirectOf(url) == null) return false + context.startActivity( + Intent(context, StoreLoginActivity::class.java) + .putExtra(StoreLoginActivity.EXTRA_STORE, Store.EPIC.id) + .putExtra(StoreLoginActivity.EXTRA_CLIENT_URL, if (auto) withoutAccountPrompt(url) else url) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + Log.i(TAG, "sign-in for the Epic Games Launcher: ${StoreLog.redactUrl(url)}") + return true + } + + internal fun withoutAccountPrompt(url: String): String { + val query = url.substringAfter('?', "") + if (query.isEmpty()) return url + val kept = query.split('&').filter { it != "prompt=select_account" } + if (kept.size == query.split('&').size) return url + return url.substringBefore('?') + if (kept.isEmpty()) "" else "?" + kept.joinToString("&") + } + + fun deliver(url: String): Boolean { + if (!loopback(url)) return false + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = false + } + val code = conn.responseCode + runCatching { (if (code < 400) conn.inputStream else conn.errorStream)?.use { it.readBytes() } } + Log.i(TAG, "sign-in handed to the launcher: HTTP $code") + code in 200..399 + } catch (e: Exception) { + Log.w(TAG, "the launcher's sign-in listener did not answer: ${e.javaClass.simpleName}") + false + } finally { + runCatching { conn?.disconnect() } + } + } + + internal fun loginFlow(url: String): StoreLoginActivity.Flow? { + val redirect = redirectOf(url) ?: return null + val agent = runCatching { EpicNative.call("auth.config")?.optString("webViewUserAgent") }.getOrNull()?.ifEmpty { null } + return ClientLogin(url, redirect, agent ?: StoreLoginActivity.CHROME_UA) + } + + private class ClientLogin( + override val startUrl: String, + private val redirect: String, + override val userAgent: String, + ) : StoreLoginActivity.Flow { + override fun isRedirect(url: String): Boolean = + url == redirect || url.startsWith("$redirect?") || url.startsWith("$redirect#") + + override fun finish(activity: StoreLoginActivity, view: android.webkit.WebView, url: String): String? = + if (deliver(url)) null else activity.getString(R.string.epic_client_sign_in_failed) + } + + private val SIGN_IN_HOSTS = setOf("www.epicgames.com", "epicgames.com") + private val LOOPBACK = setOf("127.0.0.1", "localhost") +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientInstaller.kt new file mode 100644 index 000000000..12116732c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientInstaller.kt @@ -0,0 +1,357 @@ +package com.droiddeck.launcher.stores.epic + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import com.droiddeck.launcher.stores.formatBytes +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.net.URI +import java.net.URL +import java.net.URLDecoder +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicBoolean + +internal object EpicClientSources { + const val LAUNCHER_URL = "https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncher-x86_64.AppImage" + const val LAUNCHER_FILE = "EpicGamesLauncher-x86_64.AppImage" + const val RUNTIME_BASE = "https://repo.steampowered.com/steamrt4/images/" + const val RUNTIME_FILE = "com.valvesoftware.SteamRuntime.Platform-amd64,i386-steamrt4-runtime.tar.gz" + const val GLES_URL = "https://deb.debian.org/debian/pool/main/libg/libglvnd/libgles2_1.7.0-1+b2_amd64.deb" + const val GLES_FILE = "libgles2_1.7.0-1+b2_amd64.deb" + const val GLES_SHA256 = "e12c18aa77939d122ee1dea520ba0440b97dc19200b7396e03dff03a3ece74c1" + const val USER_AGENT = "DroidDeck" + private val VERSION = Regex("[A-Za-z0-9._+-]{1,64}") + private val SUM = Regex("^([0-9a-fA-F]{64})\\s+\\*?(.+)$") + + class Source(val url: String, val version: String, val sha256: String? = null) + + fun launcher(): Source? { + val location = redirect(LAUNCHER_URL) ?: return null + val version = launcherVersion(location) ?: return null + return Source(location, version) + } + + fun launcherVersion(location: String): String? { + val uri = runCatching { URI(location) }.getOrNull() ?: return null + if (uri.scheme != "https") return null + val raw = Regex("/Builds/EGLv2/([^/]+)/Linux/").find(uri.rawPath ?: return null)?.groupValues?.get(1) ?: return null + val version = runCatching { URLDecoder.decode(raw.replace("+", "%2B"), "UTF-8") }.getOrNull() ?: return null + return version.takeIf { VERSION.matches(it) && it != "." && it != ".." } + } + + fun runtime(): Source? { + val version = text(RUNTIME_BASE + "latest-public-stable/VERSION.txt")?.trim()?.takeIf { VERSION.matches(it) && it.startsWith("4.") } ?: return null + val sums = text("$RUNTIME_BASE$version/SHA256SUMS") ?: return null + val sha = sha256For(sums, RUNTIME_FILE) ?: return null + return Source("$RUNTIME_BASE$version/" + RUNTIME_FILE.replace(",", "%2C"), version, sha) + } + + fun gles(): Source = Source(GLES_URL, "1.7.0-1+b2", GLES_SHA256) + + fun sha256For(sums: String, file: String): String? = sums.lineSequence() + .mapNotNull { SUM.find(it.trim()) } + .firstOrNull { it.groupValues[2].trim() == file } + ?.groupValues?.get(1)?.lowercase() + + private fun redirect(url: String): String? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = 20_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", USER_AGENT) + } + val code = conn.responseCode + val location = conn.getHeaderField("Location") + if (code in 300..399 && location != null) URI(url).resolve(location).toString() else null + } catch (e: Exception) { + null + } finally { + runCatching { conn?.disconnect() } + } + } + + private fun text(url: String): String? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 20_000 + readTimeout = 30_000 + setRequestProperty("User-Agent", USER_AGENT) + } + if (conn.responseCode != 200) null else conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + } catch (e: Exception) { + null + } finally { + runCatching { conn?.disconnect() } + } + } + + private val PART_MIB = longArrayOf(5, 8, 10, 15, 16, 25, 32, 50, 64, 100, 128, 256, 512) + + fun etagMatches(file: File, etag: String): Boolean? { + val m = Regex("^(?:W/)?\"?([0-9a-fA-F]{32})(?:-(\\d+))?\"?$").matchEntire(etag.trim()) ?: return null + if (etag.trim().startsWith("W/")) return null + val want = m.groupValues[1].lowercase() + val count = m.groupValues[2].toLongOrNull() ?: return hex(digest(file, "MD5", 0L, file.length())) == want + val size = file.length() + val candidates = PART_MIB.map { it shl 20 }.filter { (size + it - 1) / it == count } + if (candidates.isEmpty()) return null + return candidates.any { multipart(file, it) == want } + } + + private fun multipart(file: File, partSize: Long): String { + val outer = MessageDigest.getInstance("MD5") + var start = 0L + val size = file.length() + while (start < size) { + val end = minOf(size, start + partSize) + outer.update(digest(file, "MD5", start, end)) + start = end + } + return hex(outer.digest()) + } + + fun sha256(file: File): String = hex(digest(file, "SHA-256", 0L, file.length())) + + private fun digest(file: File, algorithm: String, start: Long, end: Long): ByteArray { + val md = MessageDigest.getInstance(algorithm) + RandomAccessFile(file, "r").use { raf -> + raf.seek(start) + val buf = ByteArray(1 shl 20) + var left = end - start + while (left > 0) { + val n = raf.read(buf, 0, minOf(buf.size.toLong(), left).toInt()) + if (n < 0) break + md.update(buf, 0, n) + left -= n + } + } + return md.digest() + } + + private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + + fun isAppImage(file: File): Boolean = runCatching { + RandomAccessFile(file, "r").use { raf -> + val head = ByteArray(11) + raf.readFully(head) + head[0] == 0x7f.toByte() && head[1] == 'E'.code.toByte() && head[2] == 'L'.code.toByte() && head[3] == 'F'.code.toByte() && + head[8] == 'A'.code.toByte() && head[9] == 'I'.code.toByte() && head[10] == 2.toByte() + } + }.getOrDefault(false) +} + +internal class EpicClientInstaller(private val app: Context) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + @Volatile private var connection: HttpURLConnection? = null + + private class Part(val url: String, val file: File, val sha256: String?, val appImage: Boolean) { + var size = -1L + var mismatch: String? = null + } + + override fun run(handle: DownloadQueue.JobHandle): String? { + handle.stage(DownloadStage.MANIFEST, app.getString(R.string.epic_client_checking)) + if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.epic_client_no_runtime)) + val launcher = EpicClientSources.launcher() ?: throw IllegalStateException(app.getString(R.string.epic_client_unreachable)) + val runtime = EpicClientSources.runtime() ?: throw IllegalStateException(app.getString(R.string.epic_client_unreachable)) + if (cancelled.get()) return null + val state = EpicClient.stateDir(app) + val before = EpicClient.read(app) + val needApp = !(before.version == launcher.version && File(state, "app/${launcher.version}/.droiddeck-ready").isFile) + val needRuntime = !(before.runtimeVersion == runtime.version && File(state, "runtime/${runtime.version}/.droiddeck-ready").isFile) + val dir = EpicClient.downloadsDir(app).apply { mkdirs() } + val gles = EpicClientSources.gles() + val parts = buildList { + if (needApp) add(Part(launcher.url, File(dir, "${launcher.version}-${EpicClientSources.LAUNCHER_FILE}"), null, true)) + if (needRuntime) { + add(Part(runtime.url, File(dir, "steamrt4-${runtime.version}.tar.gz"), runtime.sha256, false)) + add(Part(gles.url, File(dir, EpicClientSources.GLES_FILE), gles.sha256, false)) + } + } + val keep = parts.flatMap { listOf(it.file.name, it.file.name + PARTIAL, it.file.name + TAG) }.toSet() + dir.listFiles()?.forEach { if (it.name !in keep) it.deleteRecursively() } + if (parts.isNotEmpty()) { + parts.forEach { it.size = length(it.url) } + val total = parts.sumOf { it.size.coerceAtLeast(0L) } + val have = parts.sumOf { p -> if (p.file.isFile) p.file.length() else File(p.file.path + PARTIAL).length() } + val needed = (total - have).coerceAtLeast(0L) + (if (needApp) APP_ROOM else 0L) + (if (needRuntime) RUNTIME_ROOM else 0L) + val free = StoreInstallRoot.availableBytes(state) + if (StoreInstallRoot.insufficientSpace(needed, free)) { + throw IllegalStateException(app.getString(R.string.stores_storage_insufficient, formatBytes(needed), formatBytes(free))) + } + var base = 0L + for (part in parts) { + fetch(part, handle, base, total) + if (cancelled.get()) return null + base += part.size.coerceAtLeast(part.file.length()) + } + } + if (cancelled.get()) return null + handle.stage(DownloadStage.INSTALL, app.getString(R.string.epic_client_unpacking)) + val argv = mutableListOf(EpicClient.PROGRAM, "install", "--app-version", launcher.version, "--runtime-version", runtime.version) + fun guest(file: File) = EpicClient.guestPath(app, file) ?: throw IllegalStateException(app.getString(R.string.epic_client_unpack_failed, file.name)) + if (needApp) argv += listOf("--app", guest(parts[0].file)) + if (needRuntime) argv += listOf("--runtime", guest(parts[parts.size - 2].file), "--gles", guest(parts[parts.size - 1].file)) + var answer: JSONObject? = null + val code = GuestCommand.run(app, argv, fakeRoot = true, logName = "epic-client") { line -> + if (line.startsWith("{")) runCatching { JSONObject(line) }.getOrNull()?.let { answer = it } + } + val result = answer + if (code != 0 || result?.optBoolean("installed") != true) { + val reason = result?.optString("reason")?.ifEmpty { null } ?: "exit $code" + throw IllegalStateException(app.getString(R.string.epic_client_unpack_failed, reason)) + } + parts.forEach { it.file.delete() } + handle.log("Epic Games Launcher ${launcher.version} installed (runtime ${runtime.version})") + EpicClient.installed(app) + return state.absolutePath + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + runCatching { connection?.disconnect() } + if (deleteFiles) EpicClient.downloadsDir(app).deleteRecursively() + } + + private fun length(url: String): Long { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "HEAD" + connectTimeout = 20_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", EpicClientSources.USER_AGENT) + setRequestProperty("Accept-Encoding", "identity") + } + if (conn.responseCode == 200) conn.contentLengthLong else -1L + } catch (e: Exception) { + -1L + } finally { + runCatching { conn?.disconnect() } + } + } + + private fun fetch(part: Part, handle: DownloadQueue.JobHandle, base: Long, total: Long) { + if (part.file.isFile && (part.size <= 0 || part.file.length() == part.size)) { + handle.progress(base + part.file.length(), total) + return + } + part.file.delete() + val partial = File(part.file.path + PARTIAL) + val tag = File(part.file.path + TAG) + var attempt = 0 + while (true) { + if (cancelled.get()) return + try { + transfer(part, partial, tag, handle, base, total) + if (cancelled.get()) return + verify(part, partial, runCatching { tag.readText().trim() }.getOrDefault("")) + if (!partial.renameTo(part.file)) throw IOException("could not keep ${part.file.name}") + tag.delete() + return + } catch (e: IOException) { + if (cancelled.get()) return + attempt++ + handle.log("Epic Games Launcher: ${part.file.name} attempt $attempt: ${e.message ?: e.javaClass.simpleName}") + if (attempt >= ATTEMPTS) throw IllegalStateException(app.getString(R.string.epic_client_download_failed, e.message ?: e.javaClass.simpleName)) + val until = System.currentTimeMillis() + (2_000L shl (attempt - 1)).coerceAtMost(30_000L) + while (System.currentTimeMillis() < until && !cancelled.get()) Thread.sleep(100) + } + } + } + + private fun transfer(part: Part, partial: File, tag: File, handle: DownloadQueue.JobHandle, base: Long, total: Long) { + val have = if (partial.isFile) partial.length() else 0L + if (part.size > 0 && have > part.size) { partial.delete(); tag.delete(); throw IOException("partial file larger than the download") } + if (part.size > 0 && have == part.size) return + val etag = runCatching { tag.readText().trim() }.getOrDefault("") + val conn = (URL(part.url).openConnection() as HttpURLConnection).apply { + connectTimeout = 20_000 + readTimeout = 30_000 + setRequestProperty("User-Agent", EpicClientSources.USER_AGENT) + setRequestProperty("Accept-Encoding", "identity") + if (have > 0) { + setRequestProperty("Range", "bytes=$have-") + if (etag.isNotEmpty()) setRequestProperty("If-Range", etag) + } + } + connection = conn + try { + val code = conn.responseCode + val append = when (code) { + 206 -> { + val start = Regex("bytes (\\d+)-").find(conn.getHeaderField("Content-Range") ?: "")?.groupValues?.get(1)?.toLongOrNull() + if (start != have) throw IOException("the server resumed at the wrong place") + true + } + 200 -> false + 416 -> { partial.delete(); tag.delete(); throw IOException("HTTP 416") } + else -> throw IOException("HTTP $code") + } + val served = conn.getHeaderField("ETag")?.takeIf { it.isNotBlank() } + if (served != null) tag.writeText(served) else if (!append) tag.delete() + val length = conn.contentLengthLong + if (length > 0) { + val full = if (append) have + length else length + if (part.size > 0 && full != part.size) throw IOException("the download changed size") + part.size = full + } + var done = if (append) have else 0L + conn.inputStream.use { input -> + FileOutputStream(partial, append).use { out -> + val buf = ByteArray(256 * 1024) + while (!cancelled.get()) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + done += n + handle.progress(base + done, total) + } + out.fd.sync() + } + } + if (!cancelled.get() && part.size > 0 && done != part.size) throw IOException("the download ended early") + } finally { + connection = null + runCatching { conn.disconnect() } + } + } + + private fun verify(part: Part, file: File, etag: String) { + if (part.size > 0 && file.length() != part.size) { file.delete(); throw IOException("wrong size") } + part.sha256?.let { want -> + if (EpicClientSources.sha256(file) != want) { file.delete(); throw IOException("checksum mismatch") } + } + if (part.appImage) { + if (!EpicClientSources.isAppImage(file)) { file.delete(); throw IOException("not an AppImage") } + if (EpicClientSources.etagMatches(file, etag) == false) { + val seen = EpicClientSources.sha256(file) + if (part.mismatch != seen) { + part.mismatch = seen + file.delete() + throw IOException("checksum mismatch") + } + } + } + } + + private companion object { + const val PARTIAL = ".part" + const val TAG = ".etag" + const val ATTEMPTS = 6 + const val APP_ROOM = 600L shl 20 + const val RUNTIME_ROOM = 560L shl 20 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt new file mode 100644 index 000000000..8521a90f5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt @@ -0,0 +1,410 @@ +package com.droiddeck.launcher.stores.epic + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.frontend.AddedExes +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.frontend.AddedGamesPublisher +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLaunch +import com.droiddeck.launcher.stores.SteamLiveShortcuts +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest + +internal object EpicClientLibrary { + private const val TAG = "EpicClientLibrary" + private const val V1 = "root/.local/share/Epic" + private const val NO_GUID = "00000000000000000000000000000000" + + class Install( + val appName: String, + val namespace: String, + val catalogItemId: String, + val title: String, + val version: String, + val exe: String, + val location: String, + val folder: File, + val deploymentId: String, + val size: Long, + ) { + val signature: String get() = "$location|$version|$exe" + } + + fun manifestsDir(context: Context): File = File(LinuxRuntime.rootDir(context), "$V1/EpicGamesLauncher/Data/Manifests") + + fun aggregateFile(context: Context): File = File(LinuxRuntime.rootDir(context), "$V1/UnrealEngineLauncher/LauncherInstalled.dat") + + fun installationsFile(context: Context): File = File(EpicClient.dataDir(context), "installations.jsonl") + + fun importMarker(context: Context): File = File(EpicClient.dataDir(context), "import-scan/imported.json") + + private fun ledgerFile(context: Context): File = File(EpicClient.stateDir(context), "registered.json") + private fun importsFile(context: Context): File = File(EpicClient.stateDir(context), "client-imports.json") + + fun guid(appName: String): String = + MessageDigest.getInstance("MD5").digest("droiddeck-epic:$appName".toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02X".format(it) } + + fun installs(context: Context): List { + val app = context.applicationContext + val prefs = EpicPrefs.get(app) + return StoreInstallRoot.gameFolders(app).mapNotNull { folder -> + val sidecar = StoreGameSidecar.read(folder) ?: return@mapNotNull null + if (sidecar.store != Store.EPIC || !sidecar.isInstalled || sidecar.exe.isEmpty() || + sidecar.extra["clientManaged"] == "true") return@mapNotNull null + val namespace = sidecar.extra["namespace"].orEmpty() + val item = sidecar.extra["catalogItemId"].orEmpty() + if (namespace.isEmpty() || item.isEmpty()) return@mapNotNull null + val location = AddedGames.guestPath(app, folder) ?: return@mapNotNull null + Install( + sidecar.id, namespace, item, sidecar.title, sidecar.installVersion, sidecar.exe, location, folder, + sidecar.extra["deploymentId"].orEmpty(), prefs.getLong("size_${sidecar.id}", 0L), + ) + }.distinctBy { it.appName } + } + + fun item(install: Install, tags: List, ownership: Boolean): JSONObject { + val guid = guid(install.appName) + val store = "${install.location}/.egstore" + return JSONObject() + .put("FormatVersion", 0) + .put("bIsIncompleteInstall", false) + .put("LaunchCommand", "") + .put("LaunchExecutable", install.exe) + .put("ManifestLocation", store) + .put("ManifestHash", "") + .put("bIsApplication", true) + .put("bIsExecutable", true) + .put("bIsManaged", false) + .put("bNeedsValidation", false) + .put("bRequiresAuth", true) + .put("bAllowMultipleInstances", false) + .put("bCanRunOffline", false) + .put("bAllowUriCmdArgs", false) + .put("bLaunchElevated", false) + .put("BaseURLs", JSONArray()) + .put("BuildLabel", "Live") + .put("AppCategories", JSONArray(listOf("public", "games", "applications"))) + .put("ChunkDbs", JSONArray()) + .put("CompatibleApps", JSONArray()) + .put("DisplayName", install.title) + .put("InstallationGuid", guid) + .put("InstallLocation", install.location) + .put("InstallSessionId", guid) + .put("InstallTags", JSONArray(tags)) + .put("InstallComponents", JSONArray()) + .put("HostInstallationGuid", NO_GUID) + .put("PrereqIds", JSONArray()) + .put("PrereqSHA1Hash", "") + .put("LastPrereqSucceededSHA1Hash", "") + .put("StagingLocation", "$store/bps") + .put("TechnicalType", "games") + .put("VaultThumbnailUrl", "") + .put("VaultTitleText", "") + .put("InstallSize", install.size) + .put("MainWindowProcessName", "") + .put("ProcessNames", JSONArray()) + .put("BackgroundProcessNames", JSONArray()) + .put("IgnoredProcessNames", JSONArray()) + .put("DlcProcessNames", JSONArray()) + .put("MandatoryAppFolderName", install.folder.name) + .put("OwnershipToken", if (ownership) "true" else "false") + .put("SidecarConfigRevision", 0) + .put("SidecarDeploymentId", install.deploymentId) + .put("CatalogNamespace", install.namespace) + .put("CatalogItemId", install.catalogItemId) + .put("AppName", install.appName) + .put("AppVersionString", install.version) + .put("MainGameCatalogNamespace", install.namespace) + .put("MainGameCatalogItemId", install.catalogItemId) + .put("MainGameAppName", install.appName) + .put("AllowedUriEnvVars", JSONArray()) + } + + internal fun aggregateFromManifests(manifests: File, installs: List, previous: JSONObject? = null): JSONObject { + val entries = linkedMapOf() + val old = previous?.optJSONArray("InstallationList") + if (old != null) for (index in 0 until old.length()) { + val entry = old.optJSONObject(index) ?: continue + val appName = entry.optString("AppName").ifEmpty { entry.optString("ArtifactId") } + if (appName.isNotEmpty()) entries[appName] = entry + } + manifests.listFiles { file -> file.isFile && file.extension == "item" } + .orEmpty().sortedBy { it.name }.forEach { file -> + val item = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return@forEach + if (item.optBoolean("bIsIncompleteInstall", true)) return@forEach + val appName = item.optString("AppName") + val location = item.optString("InstallLocation") + val namespace = item.optString("CatalogNamespace") + val catalogItem = item.optString("CatalogItemId") + if (appName.isEmpty() || location.isEmpty() || namespace.isEmpty() || catalogItem.isEmpty()) return@forEach + entries[appName] = JSONObject().put("InstallLocation", location).put("NamespaceId", namespace) + .put("ItemId", catalogItem).put("ArtifactId", appName).put("AppVersion", item.optString("AppVersionString")) + .put("AppName", appName) + } + installs.forEach { install -> + entries[install.appName] = JSONObject().put("InstallLocation", install.location).put("NamespaceId", install.namespace) + .put("ItemId", install.catalogItemId).put("ArtifactId", install.appName).put("AppVersion", install.version) + .put("AppName", install.appName) + } + return JSONObject().put("InstallationList", JSONArray(entries.values.toList())) + } + + internal fun executableRelative(installLocation: String, executable: String): String? { + val location = installLocation.trimEnd('/') + val path = executable.replace('\\', '/').trim() + val raw = when { + path.startsWith("$location/") -> path.removePrefix("$location/") + path.startsWith('/') -> return null + else -> path.removePrefix("./") + } + if (location.isEmpty() || !location.startsWith('/') || raw.isEmpty() || + !raw.endsWith(".exe", ignoreCase = true) || raw.split('/').any { it.isEmpty() || it == "." || it == ".." } || raw.contains(':')) return null + return raw + } + + private fun hostFolder(context: Context, guest: String): File? { + if (!guest.startsWith('/') || guest.split('/').any { it == ".." }) return null + val mounts = mutableListOf>() + SessionPrefs.gameFolderBinds(context).forEach { (host, path) -> mounts += path.trimEnd('/') to host } + StoreInstallRoot.externalRoots(context).forEach { (host, path) -> mounts += path.trimEnd('/') to host.absolutePath } + GameStorage.effective(context)?.let { mounts += "/mnt/droiddeck-sd" to it.path } + SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { mounts += "/root/ROMs" to it } + mounts.sortedByDescending { it.first.length }.forEach { (mount, host) -> + if (guest == mount || guest.startsWith("$mount/")) { + val suffix = guest.removePrefix(mount).trimStart('/') + return File(host, suffix).takeIf { it.isDirectory && AddedGames.guestPath(context, it)?.trimEnd('/') == guest.trimEnd('/') } + } + } + val root = LinuxRuntime.rootDir(context) + if (guest == "/") return root + return File(root, guest.removePrefix("/")).takeIf { it.isDirectory && AddedGames.guestPath(context, it)?.trimEnd('/') == guest.trimEnd('/') } + } + + @Synchronized + internal fun importClientGames(context: Context): Boolean { + val app = context.applicationContext + val manifests = manifestsDir(app) + val before = runCatching { JSONObject(importsFile(app).readText()) }.getOrDefault(JSONObject()) + val after = JSONObject() + val imported = ArrayList() + val addedExes = AddedExes.list(app) + var changed = false + var scanComplete = true + val items = LinkedHashMap() + manifests.listFiles { file -> file.isFile && file.extension == "item" } + .orEmpty().sortedBy { it.name }.forEach { file -> + val item = runCatching { JSONObject(file.readText()) }.getOrNull() + if (item == null) scanComplete = false else items[item.optString("AppName").trim()] = item + } + val (records, readable) = clientInstallations(installationsFile(app)) + if (!readable) scanComplete = false + if (records.isNotEmpty()) { + val titles = runCatching { EpicLibrary.cached(app).associate { it.appName to it.title } }.getOrDefault(emptyMap()) + for (record in records) { + val appName = record.optString("artifactId").trim() + if (appName.isNotEmpty()) items[appName] = manifestOf(record, titles[appName]) + } + } + items.values.forEach { item -> + val appName = item.optString("AppName").trim() + if (item.optBoolean("bIsIncompleteInstall", true)) { + before.optJSONObject(appName)?.let { after.put(appName, it) } + return@forEach + } + val namespace = item.optString("CatalogNamespace").trim() + val catalogItem = item.optString("CatalogItemId").trim() + val location = item.optString("InstallLocation").trimEnd('/') + val exe = executableRelative(location, item.optString("LaunchExecutable")) + if (appName.isEmpty() || namespace.isEmpty() || catalogItem.isEmpty() || exe == null) { + before.optJSONObject(appName)?.let { after.put(appName, it) } + return@forEach + } + val folder = hostFolder(app, location)?.takeIf { it.isDirectory } + if (folder == null) { before.optJSONObject(appName)?.let { after.put(appName, it) }; return@forEach } + val executable = File(folder, exe) + if (!executable.isFile || !runCatching { executable.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)) { + before.optJSONObject(appName)?.let { after.put(appName, it) } + return@forEach + } + val existing = StoreGameSidecar.read(folder) + if (existing != null && (existing.store != Store.EPIC || existing.id != appName || existing.extra["clientManaged"] != "true")) { + before.optJSONObject(appName)?.let { after.put(appName, it) } + return@forEach + } + val previous = before.optJSONObject(appName) + if (previous != null && previous.optString("folder") != folder.path) { + val oldFolder = File(previous.optString("folder")) + StoreGameSidecar.read(oldFolder)?.takeIf { it.store == Store.EPIC && it.extra["clientManaged"] == "true" }?.let { + StoreGameSidecar.file(oldFolder).delete() + File(oldFolder, StoreLaunch.LAUNCHER).delete() + } + AddedExes.drop(app, oldFolder.path) + SessionPrefs.addedGameStoredAppId(app, oldFolder.path)?.let { SteamLiveShortcuts.remove(app, it) } + changed = true + } + val next = StoreGameSidecar( + Store.EPIC, appName, item.optString("DisplayName").ifEmpty { appName }, exe, + installVersion = item.optString("AppVersionString"), installedAt = existing?.installedAt?.takeIf { it > 0 } ?: folder.lastModified(), + cover = existing?.cover, hero = existing?.hero, + extra = mapOf("namespace" to namespace, "catalogItemId" to catalogItem, + "deploymentId" to item.optString("SidecarDeploymentId"), "clientManaged" to "true"), + epic = existing?.epic ?: com.droiddeck.launcher.stores.EpicOptions(), + cloud = existing?.cloud ?: true, + launcher = StoreLaunch.LAUNCHER, + ) + val sidecarChanged = existing?.toJson()?.toString(2) != next.toJson().toString(2) + val launcher = File(folder, StoreLaunch.LAUNCHER) + if (sidecarChanged || runCatching { launcher.readText() }.getOrNull() != StoreLaunch.launcherText(next)) { + StoreLaunch.writeLauncher(folder, next) + next.write(folder) + } + if (addedExes.none { it.folder == folder.path && it.exe == executable.path && !it.picked }) { + AddedExes.put(app, AddedExes.Entry(folder.path, executable.path, picked = false)) + changed = true + } + val game = AddedGames.single(app, folder.path) + if (game != null) imported += game + after.put(appName, JSONObject().put("folder", folder.path).put("exe", executable.path)) + if (sidecarChanged || before.optJSONObject(appName)?.optString("folder") != folder.path || + before.optJSONObject(appName)?.optString("exe") != executable.path || + existing?.installVersion != next.installVersion) changed = true + } + for (name in before.keys().asSequence().toList()) { + if (after.has(name)) continue + if (!scanComplete) { before.optJSONObject(name)?.let { after.put(name, it) }; continue } + val record = before.optJSONObject(name) ?: continue + val folder = File(record.optString("folder")) + val sidecar = StoreGameSidecar.read(folder) + if (sidecar?.store == Store.EPIC && sidecar.extra["clientManaged"] == "true") { + StoreGameSidecar.file(folder).delete() + File(folder, StoreLaunch.LAUNCHER).delete() + } + AddedExes.drop(app, folder.path) + SessionPrefs.addedGameStoredAppId(app, folder.path)?.let { SteamLiveShortcuts.remove(app, it) } + changed = true + } + if (changed) { + write(importsFile(app), after.toString(2)) + AddedGamesPublisher.later(app) { + SteamLiveShortcuts.sync(app, (imported + AddedGames.scan(app).filter { it.source == Store.EPIC.id }).distinctBy { it.guestExe }) { it.source == Store.EPIC.id } + com.droiddeck.launcher.stores.StoresState.notifyLibraryChanged(app) + } + } + return changed + } + + internal fun clientInstallations(file: File): Pair, Boolean> { + if (!file.isFile) return emptyList() to true + val lines = runCatching { file.readLines() }.getOrNull() ?: return emptyList() to false + val latest = LinkedHashMap() + var readable = true + for (line in lines) { + if (line.isBlank()) continue + val value = runCatching { JSONObject(line) }.getOrNull() + if (value == null) { readable = false; continue } + val key = value.optString("installationId").ifEmpty { value.optString("artifactId") } + if (key.isEmpty()) continue + latest.remove(key) + if (!value.optBoolean("deleted")) latest[key] = value + } + return latest.values.toList() to readable + } + + internal fun manifestOf(record: JSONObject, title: String?): JSONObject { + val dir = record.optString("dir").trimEnd('/') + val complete = record.optBoolean("complete", false) && record.optString("platform") == "Windows" + return JSONObject() + .put("AppName", record.optString("artifactId").trim()) + .put("CatalogNamespace", record.optString("namespace")) + .put("CatalogItemId", record.optString("itemId")) + .put("InstallLocation", dir) + .put("LaunchExecutable", record.optString("launchExecutable")) + .put("AppVersionString", record.optString("version")) + .put("SidecarDeploymentId", record.optJSONObject("sidecar")?.optString("deploymentId").orEmpty()) + .put("DisplayName", title?.takeIf(String::isNotBlank) ?: dir.substringAfterLast('/')) + .put("bIsIncompleteInstall", !complete) + } + + @Synchronized + fun sync(context: Context): Boolean { + val app = context.applicationContext + if (!LinuxRuntime.isInstalled(app)) return false + runCatching { importClientGames(app) }.onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") } + val list = installs(app) + val manifests = manifestsDir(app) + val ledgerFile = ledgerFile(app) + val before = runCatching { JSONObject(ledgerFile.readText()) }.getOrDefault(JSONObject()) + val after = JSONObject() + val tags = runCatching { EpicInstallTags.tagsForDevice() }.getOrDefault(emptyList()) + val token by lazy { runCatching { EpicCredentialStore.getValidAccessToken(app) }.getOrNull() } + var changed = false + try { + manifests.mkdirs() + for (install in list) { + val guid = guid(install.appName) + val known = before.optJSONObject(install.appName) + val ownership = known?.takeIf { it.has("ownership") }?.optBoolean("ownership") + ?: token?.let { ownershipToken(it, install) } + if (known?.optString("signature") != install.signature) changed = true + write(File(manifests, "$guid.item"), item(install, tags, ownership == true).toString(2)) + runCatching { egstore(install, guid) { token } }.onFailure { Log.w(TAG, "${install.appName}: .egstore not written (${it.javaClass.simpleName})") } + after.put(install.appName, JSONObject().put("guid", guid).put("signature", install.signature).apply { if (ownership != null) put("ownership", ownership) }) + StoreGameSidecar.read(install.folder)?.let { StoreLaunch.refreshLauncher(install.folder, it) } + } + for (name in before.keys().asSequence().toList()) { + if (after.has(name)) continue + File(manifests, guid(name) + ".item").delete() + changed = true + } + val aggregate = aggregateFile(app) + val previous = runCatching { JSONObject(aggregate.readText()) }.getOrNull() + write(aggregate, aggregateFromManifests(manifests, list, previous).toString(2)) + write(ledgerFile, after.toString(2)) + if (changed && importMarker(app).delete()) Log.i(TAG, "the launcher rescans installed games at its next start") + } catch (e: Exception) { + Log.w(TAG, "registration failed: ${e.javaClass.simpleName}: ${e.message}") + } + return changed + } + + private fun ownershipToken(token: String, install: Install): Boolean? = + runCatching { EpicApiClient.getCustomAttribute(token, install.namespace, install.catalogItemId, "OwnershipToken") } + .getOrNull()?.trim()?.lowercase()?.let { it == "true" } + + private fun egstore(install: Install, guid: String, token: () -> String?) { + val dir = File(install.folder, ".egstore") + dir.mkdirs() + write( + File(dir, "$guid.mancpn"), + JSONObject().put("FormatVersion", 0).put("AppName", install.appName).put("CatalogItemId", install.catalogItemId) + .put("CatalogNamespace", install.namespace).toString(2), + ) + val manifest = File(dir, "$guid.manifest") + if (manifest.isFile && manifest.length() > 0) return + val access = token() ?: return + val info = EpicApiClient.getManifestApiJson(access, install.namespace, install.catalogItemId, install.appName) ?: return + val build = runCatching { JSONObject(info).optString("buildVersion") }.getOrDefault("") + if (install.version.isNotEmpty() && build != install.version) return + val bytes = EpicDownloadManager.downloadManifest(info, EpicDownloadManager.parseCdnUrls(info)) ?: return + val tmp = File(dir, "$guid.manifest.tmp") + tmp.writeBytes(bytes) + if (!tmp.renameTo(manifest)) tmp.delete() + } + + private fun write(file: File, text: String) { + if (runCatching { file.readText() }.getOrNull() == text) return + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(text) + if (!tmp.renameTo(file)) { file.delete(); if (!tmp.renameTo(file)) throw java.io.IOException("could not write ${file.name}") } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index bd5af3d08..f12a712f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -59,26 +59,41 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.stores.epic.EpicClient import kotlinx.coroutines.launch @Composable internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { - val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } } + val pageContext = androidx.compose.ui.platform.LocalContext.current + LaunchedEffect(Unit) { EpicClient.refresh(pageContext) } + val launcherName = stringResource(R.string.epic_client_title) + val epic = EpicClient.status + val launcherArt by androidx.compose.runtime.produceState(null, epic.version) { + value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(pageContext, epic.version) } + } + val games = remember(s.steamGames, s.shortcutPicker, epic, launcherName, launcherArt) { + val sorted = s.steamGames.sortedByDescending { it.lastPlayed } + if (s.shortcutPicker) sorted else EpicClient.withLauncher(sorted, EpicClient.launcherEntry(pageContext, launcherName, epic, launcherArt)) + } + val gameCount = games.count { it.library != Library.EPIC_LAUNCHER } val narrow = LocalNarrowPane.current val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() GamesDialogs(a, s.steamGames) val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true }) - val pageContext = androidx.compose.ui.platform.LocalContext.current + val launch: (Library.SteamGame) -> Unit = { g -> if (g.library == Library.EPIC_LAUNCHER) a.onEpicLauncher() else a.onSteamGame(g) } val onEdit: (Library.SteamGame) -> Unit = { g -> g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) } } - if (current == null) { + if (current == null || gameCount == 0) { Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { Rise(0) { PageHeader(stringResource(R.string.content_games)) { onAdd?.let { AddGameButton(it) } } } Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) } if (!s.shortcutPicker) Rise(2) { - Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) } + Actions { + PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) + games.firstOrNull { it.library == Library.EPIC_LAUNCHER }?.let { SecondaryButton(it.name, enabled = !s.busy, modifier = Modifier.padding(top = 12.dp)) { a.onEpicLauncher() } } + } } GameFileFolderActions(s, a) } @@ -87,12 +102,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o val host = rememberMenuHost() if (games.size == 1) { Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - if (onAdd != null) Rise(0) { GamesHeader(1, onAdd, Modifier.padding(bottom = 10.dp)) } + if (onAdd != null) Rise(0) { GamesHeader(gameCount, onAdd, Modifier.padding(bottom = 10.dp)) } Rise(0) { Row(verticalAlignment = Alignment.Bottom) { GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) { GameHeroCopy(current, if (narrow) 28.sp else 38.sp) - GameActions(current, s, a, onEdit) + GameActions(current, s, a, onEdit, launch) } if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) } @@ -105,7 +120,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o } val pal = LocalPalette.current Row(modifier = modifier) { - GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) }, onAdd = onAdd, + GameList(games, gameCount, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = launch, onAdd = onAdd, modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight()) Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) Column( @@ -114,7 +129,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o ) { GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) { GameHeroCopy(current, if (narrow) 24.sp else 32.sp) - GameActions(current, s, a, onEdit) + GameActions(current, s, a, onEdit, launch) } SectionTitle(stringResource(R.string.games_launch_settings), null) LaunchSettings(s, a, host, current) @@ -142,9 +157,14 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { @Composable -private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit) { +private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit, launch: (Library.SteamGame) -> Unit) { Actions { - PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) } + PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) } + if (g.library == Library.EPIC_LAUNCHER) { + if (!s.shortcutPicker) UninstallEpicLauncher(s, a) + BusyChip(s) + return@Actions + } g.gameFiles?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) } } @@ -160,6 +180,22 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio } } +@Composable +private fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { + var asking by remember { androidx.compose.runtime.mutableStateOf(false) } + SecondaryButton(stringResource(R.string.common_uninstall), enabled = !s.busy, compact = true) { asking = true } + if (asking) { + val body = stringResource(R.string.epic_client_uninstall_body) + ConfirmDialog( + title = stringResource(R.string.stores_uninstall_title, stringResource(R.string.epic_client_title)), + text = if (s.sessionRunning) body + "\n\n" + stringResource(R.string.epic_client_uninstall_stops) else body, + confirm = stringResource(R.string.common_uninstall), + onConfirm = { a.onUninstallEpicLauncher() }, + onDismiss = { asking = false }, + ) + } +} + @Composable private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } @@ -292,7 +328,7 @@ private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.c @Composable private fun GameList( - games: List, current: Library.SteamGame, + games: List, count: Int, current: Library.SteamGame, onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier, ) { val colors = MaterialTheme.colorScheme @@ -300,7 +336,7 @@ private fun GameList( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp), ) { - GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp)) + GamesHeader(count, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp)) val seen = remember { HashSet() } var settled by remember { androidx.compose.runtime.mutableStateOf(false) } for (g in games) key(g.appId) { @@ -462,7 +498,8 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { SourceChip(g.source, small = true) Text( - playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played), + if (g.library == Library.EPIC_LAUNCHER) libraryLabel(g) + else playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @@ -511,6 +548,7 @@ private fun playedSpan(lastPlayed: Long): String? { @Composable private fun libraryLabel(g: Library.SteamGame): String = when { + g.library == Library.EPIC_LAUNCHER -> EpicClient.status.version?.substringBefore('+')?.let { stringResource(R.string.epic_client_version, it) } ?: "" g.library != Library.ADDED -> if (g.library == "internal") stringResource(R.string.games_internal) else g.library g.source != Library.ADDED -> stringResource(R.string.games_store_storage) else -> "" @@ -520,7 +558,7 @@ private fun libraryLabel(g: Library.SteamGame): String = when { private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { val colors = MaterialTheme.colorScheme Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { - val image = g.hero ?: g.art + val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER } if (image != null) AsyncImage( model = image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 104739cfd..d96620da5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -166,6 +166,8 @@ class FrontEndActions( val onSgdbKey: (String) -> Unit = {}, val onStopGameFileSync: () -> Unit = {}, val onCopyGameLink: (Library.SteamGame) -> Unit = {}, + val onEpicLauncher: () -> Unit = {}, + val onUninstallEpicLauncher: () -> Unit = {}, val onDesktop: () -> Unit, val onEmulator: (Library.Emulator) -> Unit, val onFlatpakApp: (String, String) -> Unit = { _, _ -> }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 53650cb76..994aad935 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -500,7 +500,11 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey var wincompOpen by remember(wincompKey) { mutableStateOf(null) } val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() } - val cards = buildList<@Composable (Modifier) -> Unit> { + val steamScope = game?.let { com.droiddeck.launcher.session.NoSteamLaunch.scope(it) } + val steamCard: (@Composable (Modifier) -> Unit)? = if (game != null && steamScope != null) { m -> + Box(m) { GameSettingsCard(host, game, steamScope) } + } else null + val cards = if (game?.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER) listOfNotNull(steamCard) else buildList<@Composable (Modifier) -> Unit> { add { m -> val back = stringResource(R.string.hop_from_games) SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) { @@ -518,6 +522,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost } } if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) } + if (steamCard != null) add(steamCard) val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC || it == com.droiddeck.launcher.stores.Store.GOG } if (cardStore != null && game?.gameFiles != null) add { m -> Box(m) { @@ -548,6 +553,122 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost } } +@Composable +private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, scope: String) { + val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext + val colors = MaterialTheme.colorScheme + var config by remember(scope) { mutableStateOf(runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull()) } + var saving by remember(scope) { mutableStateOf(false) } + val current = config + val noSteam = current != null && com.droiddeck.launcher.core.GameEnvironment.gameNoSteam(current, scope) + val fex = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameFexPreset(it, scope) }.getOrNull() } + val sync = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameSync(it, scope) }.getOrNull() } + val ready = current != null && !saving + val launcher = game.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER + fun save(change: (android.content.Context) -> com.droiddeck.launcher.core.GameEnvironment.Config?) { + if (saving) return + saving = true + Thread({ + val written = runCatching { change(appContext) } + .onFailure { android.util.Log.w("GameSettingsCard", "could not save the settings of $scope: ${it.message}") }.getOrNull() + val next = written ?: runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull() + com.droiddeck.launcher.stores.StoresState.post { + config = next + saving = false + } + }, "game-settings").start() + } + val key = "game-settings" + val fexKey = "game-settings-fex" + val syncKey = "game-settings-sync" + val syncLabels = mapOf( + com.droiddeck.launcher.session.SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), + com.droiddeck.launcher.session.SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync), + com.droiddeck.launcher.session.SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync), + com.droiddeck.launcher.session.SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver), + ) + val appSync = syncLabels[com.droiddeck.launcher.session.SessionPrefs.syncBackend(appContext)] + val useDefault = stringResource(R.string.comp_game_use_general) + val custom = stringResource(R.string.comp_game_fex_custom) + val customSync = stringResource(R.string.game_sync_custom) + val resetDone = stringResource(R.string.comp_game_reset_done, game.name) + SettingCard(stringResource(R.string.game_settings_card), stringResource(if (noSteam) R.string.game_steam_without else R.string.game_steam_with), "card:$key", Modifier.fillMaxSize()) { + host.open = if (host.open == key) null else key + } + AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = stringResource(R.string.game_settings_menu_title, game.name)) { first -> + MenuItem(stringResource(R.string.game_no_steam_title), checked = noSteam, enabled = ready, + detail = stringResource(R.string.game_no_steam_detail), focusRequester = first) { + val on = !noSteam + save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameNoSteam(it, scope, on) } + } + if (!launcher) { + val chosen = com.droiddeck.launcher.core.FexPreset.all.firstOrNull { it.id == fex } + MenuItem( + stringResource(R.string.fex_preset_title), checked = false, enabled = ready, + detail = when { + fex == null -> useDefault + chosen == null -> custom + else -> stringResource(chosen.label) + }, + trailing = { Text("›", fontSize = 16.sp, color = colors.onSurfaceVariant) }, + ) { host.open = fexKey } + MenuItem( + stringResource(R.string.sync_backend_title), checked = false, enabled = ready, + detail = when (sync) { + null -> useDefault + com.droiddeck.launcher.core.GameEnvironment.CUSTOM_SYNC -> customSync + else -> syncLabels[sync] + }, + trailing = { Text("›", fontSize = 16.sp, color = colors.onSurfaceVariant) }, + ) { host.open = syncKey } + } + MenuItem(stringResource(R.string.game_settings_default), checked = false, enabled = ready, + detail = stringResource(R.string.game_settings_default_detail)) { + save { + val failure = runCatching { com.droiddeck.launcher.session.GameProfileManager.reset(it, scope) }.exceptionOrNull() + com.droiddeck.launcher.stores.StoresState.post { + android.widget.Toast.makeText( + it, + if (failure == null) resetDone else it.getString(R.string.comp_game_reset_failed, failure.message.orEmpty()), + android.widget.Toast.LENGTH_SHORT, + ).show() + } + null + } + } + } + AnchoredMenu(host.open == fexKey, onDismiss = { if (host.open == fexKey) host.open = null }, title = stringResource(R.string.fex_preset_title)) { first -> + MenuItem(useDefault, checked = fex == null, enabled = ready, focusRequester = first) { + host.open = key + save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameFexPreset(it, scope, null) } + } + if (fex == com.droiddeck.launcher.core.GameEnvironment.CUSTOM_FEX_PRESET) { + MenuItem(custom, checked = true, enabled = false) {} + } + com.droiddeck.launcher.core.FexPreset.all.forEach { option -> + MenuItem(stringResource(option.label), checked = fex == option.id, enabled = ready, detail = stringResource(option.detail)) { + host.open = key + save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameFexPreset(it, scope, option.id) } + } + } + } + AnchoredMenu(host.open == syncKey, onDismiss = { if (host.open == syncKey) host.open = null }, title = stringResource(R.string.sync_backend_title)) { first -> + MenuItem(useDefault, checked = sync == null, enabled = ready, detail = appSync, focusRequester = first) { + host.open = key + save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameSync(it, scope, null) } + } + if (sync == com.droiddeck.launcher.core.GameEnvironment.CUSTOM_SYNC) { + MenuItem(customSync, checked = true, enabled = false) {} + } + com.droiddeck.launcher.core.GameEnvironment.syncBackends.forEach { option -> + MenuItem(syncLabels.getValue(option), checked = sync == option, enabled = ready) { + host.open = key + save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameSync(it, scope, option) } + } + } + } +} + private const val EPIC_RESOLVE_URL = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Faccount%2Fpersonal" @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 15bc39e5c..89267e8ca 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -299,6 +299,15 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true, onChange = a.onZramCompression, ) + if (s.syncBackend != null) ChoiceRow( + host, "sync", stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), + listOf( + SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), + SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync), + SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync), + SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver), + ), s.syncBackend, onPick = a.onSyncBackend, + ) } if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt index 41bed4640..3791fb782 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt @@ -15,6 +15,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -22,6 +26,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -309,6 +314,39 @@ internal fun StepChoice( } } +@Composable +internal fun StepTile( + label: String, + icon: ImageVector, + modifier: Modifier = Modifier, + detail: String? = null, + warning: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(18.dp) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier.width(184.dp).height(IntrinsicSize.Min).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.5f)) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Icon(icon, null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.fillMaxHeight().aspectRatio(1f)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (detail != null) Text(detail, fontSize = 12.sp, color = if (warning) colors.error else colors.onSurfaceVariant, maxLines = 2) + } + } +} + @Composable internal fun StepOutScope.StepTitle(text: String) { Text( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index d9f2d1417..e61130501 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -89,6 +89,7 @@ import com.droiddeck.launcher.stores.download.DownloadQueue import com.droiddeck.launcher.stores.download.DownloadStage import com.droiddeck.launcher.stores.download.DownloadState import com.droiddeck.launcher.stores.download.StoreDownloadTier +import com.droiddeck.launcher.stores.epic.EpicClient import com.droiddeck.launcher.stores.formatBytes import com.droiddeck.launcher.stores.formatSpeed @@ -215,7 +216,7 @@ private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1) Spacer(Modifier.weight(1f)) if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) { - RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) } + RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } } } } @@ -366,7 +367,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) CancelAction { DownloadQueue.clear(ctx, d.key) } } DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) { - RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) } + RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } } DownloadState.CANCELLED -> {} } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt index a8392465d..565b5cc9a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt @@ -11,8 +11,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.SdCard import androidx.compose.material.icons.outlined.Smartphone import androidx.compose.material3.Icon @@ -51,7 +56,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0) val focus = remember { List(targets.size) { FocusRequester() } } LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } } - AppDialog(shown, close, "installWhere", wide = false, maxWidth = 440.dp) { + AppDialog(shown, close, "installWhere", wide = false, maxWidth = if (targets.size > 2) 600.dp else 440.dp) { Rise(0) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( @@ -65,7 +70,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { } } Rise(1) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { targets.forEachIndexed { i, t -> val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item) val freeBytes = t.freeBytes @@ -80,8 +85,8 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { TargetRow( label = if (t.removable) t.label else stringResource(R.string.stores_target_internal), free = freeText, - removable = t.removable, focus = focus[i], - warning = isLow, + removable = t.removable, folder = t.folder, focus = focus[i], + warning = isLow, modifier = Modifier.weight(1f), ) { SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath) StoresState.install(ctx, item, t.root) @@ -94,22 +99,24 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { } @Composable -private fun TargetRow(label: String, free: String, removable: Boolean, focus: FocusRequester, warning: Boolean = false, onPick: () -> Unit) { +private fun TargetRow(label: String, free: String, removable: Boolean, folder: Boolean, focus: FocusRequester, modifier: Modifier, warning: Boolean = false, onPick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().focusRequester(focus).clip(Shape12) + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier.height(IntrinsicSize.Min).focusRequester(focus).clip(Shape12) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f)) .glideBorder(hot, Shape12, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onPick) .controllerConfirm(onClick = onPick) - .padding(horizontal = 14.dp, vertical = 10.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), ) { - Icon(if (removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp)) - Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - Text(free, fontSize = 12.sp, color = if (warning) colors.error else colors.onSurfaceVariant, maxLines = 1) + Icon(if (folder) Icons.Outlined.Folder else if (removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.fillMaxHeight().aspectRatio(1f)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(free, fontSize = 12.sp, color = if (warning) colors.error else colors.onSurfaceVariant, maxLines = 2) + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 028ada008..e9c0e39ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.SdCard import androidx.compose.material.icons.outlined.Smartphone import androidx.compose.runtime.CompositionLocalProvider @@ -27,6 +28,7 @@ import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.positionInRoot import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.epic.EpicClient import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -176,6 +178,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val grids = remember { HashMap() } val grid = grids.getOrPut(pane) { LazyGridState() } LaunchedEffect(Unit) { StoresState.refresh(ctx) } + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + LaunchedEffect(chip) { + if (chip == Store.EPIC.id) { + EpicClient.ensure(ctx) + while (true) { + delay(5_000) + if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) EpicClient.refresh(ctx) + } + } + } LaunchedEffect(chip, StoresState.accounts[store]) { if (store != null && StoresState.isSignedIn(store)) StoresState.open(ctx, store) } val ff = LocalFrontFocus.current val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current @@ -425,7 +437,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier StepOut( open = StoresMotion.stepOpen, pill = ask.anchor.translate(-hostAt), side = ask.side, accent = ask.accent, onDismiss = { StoresMotion.fold() }, onClosed = { StoresMotion.closed(ask) }, - pillCorner = ask.pillCorner, items = ask.items, handle = ask.handle, content = ask.content, + pillCorner = ask.pillCorner, maxWidth = ask.maxWidth, items = ask.items, handle = ask.handle, content = ask.content, ) } } @@ -457,11 +469,12 @@ private fun InstallWhere(item: CatalogItem) { val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0) StoresMotion.ask(StepAsk( anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 12.dp, items = 1 + targets.size, + maxWidth = (28 + 192 * targets.size - 8).dp, onDismiss = { if (StoresState.pendingInstall === item) StoresState.pendingInstall = null }, handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) }, ) { StepTitle(title) - StepList { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { targets.forEachIndexed { i, t -> val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item) val freeBytes = t.freeBytes @@ -473,12 +486,13 @@ private fun InstallWhere(item: CatalogItem) { } else { stringResource(R.string.stores_target_free, formatBytes(freeBytes)) } - StepChoice( + StepTile( label = if (t.removable) t.label else internalLabel, - icon = if (t.removable) androidx.compose.material.icons.Icons.Outlined.SdCard else androidx.compose.material.icons.Icons.Outlined.Smartphone, - trailing = trailingText, + icon = if (t.folder) androidx.compose.material.icons.Icons.Outlined.Folder else if (t.removable) androidx.compose.material.icons.Icons.Outlined.SdCard else androidx.compose.material.icons.Icons.Outlined.Smartphone, + detail = trailingText, + warning = isLow, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(1 + i).then(if (i == defaultIndex) Modifier.focusRequester(first) else Modifier), + modifier = Modifier.stepItem(1 + i).then(if (i == defaultIndex) Modifier.focusRequester(first) else Modifier), ) { SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath) StoresMotion.markInstall(from, label) @@ -889,6 +903,10 @@ private fun Storefront( SortButton(s, a) ViewButton(s, a) } + if (store == Store.EPIC) SmallTextButton(stringResource(R.string.epic_client_title)) { + if (EpicClient.status.installed) a.onEpicLauncher() + else { EpicClient.install(ctx); StoresState.openDownloads = true } + } SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } val name = StoresState.accounts[store] ?: "" SignOutButton(store, name) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 25d9d0d64..aada7eac0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -457,8 +457,15 @@ خيارات بيئة أخرى لكل لعبة لـ Proton وDXVK وVKD3D-Proton والمزامنة. تم حفظ ملف تعريف %1$s للتشغيل التالي للعبة. بدء اللعبة بدون Steam - لعبة تُغلق فورًا: شغّلها بدون إعداد Steam الخاص بـ Proton. تكون ميزات Steam، مثل الطبقة العلوية، معطّلة لها. + يفتحها في Proton/FEX بدون عميل Steam؛ ميزات Steam معطّلة. تشغيل + يبدأ مع Steam + يبدأ بدون Steam + الإعدادات + إعدادات %1$s + الافتراضي + يعيد كل ما تم تغييره لهذه اللعبة إلى إعدادات التطبيق. + متغيرات مزامنة مخصصة تم حفظ اختيار Proton لـ %1$s وهو متاح في Steam الآن. تم حفظ اختيار Proton لـ %1$s. أعد تشغيل Steam قبل تشغيل اللعبة. لا يمكن لـ %1$s استخدام Proton المحدد على هذا الجهاز. @@ -2056,6 +2063,20 @@ تسجيل الدخول إلى Epic (EOS) التشغيل دون اتصال إصلاح تسجيل الدخول إلى Epic + الإصدار %1$s + ثبّت بيئة تشغيل Linux أولًا + جارٍ التحقق من آخر إصدار… + جارٍ فك الضغط… + لم يستجب خادم التنزيل + يحتاج مشغّل Epic Games إلى FEX الذي يقوم Steam بتنزيله. افتح المشغّل مرة أخرى بعد بدء Steam. + فشل التنزيل: %1$s + تعذّر فك ضغط المُشغِّل: %1$s + لم يقبل Epic Games Launcher تسجيل الدخول. شغّله مرة أخرى من المُشغِّل. + يزيل المشغّل ويحرّر مساحته. تبقى ألعاب Epic مثبتة وتعمل، ويُحتفظ بتسجيل الدخول لحين تثبيته مرة أخرى. + ستُغلق الجلسة الجارية أولاً. + تمت إزالة Epic Games Launcher + تعذّرت إزالة بعض ملفات المشغّل. حاول مرة أخرى. + انتظر حتى ينتهي إلغاء تثبيت Epic Games Launcher ملفات الحفظ السحابية الرفع إلى السحابة التنزيل من السحابة diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index aa3cfed8e..c9f0fb974 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -449,8 +449,15 @@ Други настройки на средата за Proton, DXVK, VKD3D-Proton и синхронизация за отделни игри. Профилът на %1$s е запазен за следващото стартиране на играта. Старт без Steam - За игра, която се затваря веднага: стартира я без настройката на Steam в Proton. Функции на Steam, като оверлея, са изключени за нея. + Отваря го в Proton/FEX без клиента на Steam; функциите на Steam са изключени. Включено + Стартира със Steam + Стартира без Steam + Настройки + Настройки на %1$s + По подразбиране + Връща всичко, променено за тази игра, към настройките на приложението. + Персонализирани променливи за синхронизация Изборът на Proton за %1$s е запазен и вече е достъпен в Steam. Изборът на Proton за %1$s е запазен. Рестартирайте Steam, преди да стартирате играта. %1$s не може да използва избрания Proton на това устройство. @@ -1972,6 +1979,20 @@ Вход в Epic (EOS) Стартирай офлайн Разреши входа в Epic + Версия %1$s + Първо инсталирайте средата на Linux + Проверка за последната версия… + Разархивиране… + Сървърът за изтегляне не отговори + Epic Games Launcher се нуждае от FEX, който Steam изтегля. Отворете отново лаунчъра, след като Steam стартира. + Изтеглянето се провали: %1$s + Разархивирането на програмата не бе успешно: %1$s + Epic Games Launcher не прие влизането. Стартирайте го отново от програмата. + Премахва лаунчера и освобождава мястото му. Игрите ви от Epic остават инсталирани и работят, а входът се запазва за следващото инсталиране. + Текущата сесия първо ще се затвори. + Epic Games Launcher е деинсталиран + Някои файлове на лаунчера не можаха да бъдат премахнати. Опитайте отново. + Изчакайте Epic Games Launcher да се деинсталира Облачни запазвания Качи в облака Изтегли от облака diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 105fa13c9..dae08a4d9 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -453,8 +453,15 @@ Další možnosti prostředí Proton, DXVK, VKD3D-Proton a synchronizace pro jednotlivé hry. Profil %1$s je uložen pro další spuštění hry. Spustit bez Steamu - Pro hru, která se hned zavře: spustí ji bez nastavení Steamu v Protonu. Funkce Steamu, jako je překryvná vrstva, jsou pro ni vypnuté. + Otevře ji v Protonu/FEX bez klienta Steamu; funkce Steamu jsou vypnuté. Zapnuto + Spouští se se Steamem + Spouští se bez Steamu + Nastavení + Nastavení %1$s + Výchozí + Vrátí vše, co se u této hry změnilo, na nastavení aplikace. + Vlastní proměnné synchronizace Výběr Protonu pro %1$s je uložen a hned dostupný ve Steamu. Výběr Protonu pro %1$s je uložen. Před spuštěním hry restartujte Steam. %1$s nemůže na tomto zařízení použít vybraný Proton. @@ -2014,6 +2021,20 @@ Přihlášení do Epic (EOS) Spustit offline Vyřešit přihlášení do Epic + Verze %1$s + Nejprve nainstalujte běhové prostředí Linuxu + Kontrola nejnovější verze… + Rozbaluje se… + Stahovací server neodpověděl + Epic Games Launcher potřebuje FEX, který stáhne Steam. Po spuštění Steamu otevřete launcher znovu. + Stažení se nezdařilo: %1$s + Spouštěč se nepodařilo rozbalit: %1$s + Epic Games Launcher přihlášení nepřijal. Spusťte ho znovu ze spouštěče. + Odebere spouštěč a uvolní jeho místo. Vaše hry z Epic zůstanou nainstalované a funkční a přihlášení zůstane uložené pro příští instalaci. + Nejprve se ukončí běžící relace. + Epic Games Launcher odinstalován + Některé soubory spouštěče nešlo odebrat. Zkuste to znovu. + Počkejte, až se Epic Games Launcher dokončí odinstalaci Cloudové uložené pozice Odeslat do cloudu Stáhnout z cloudu diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b6aef606e..b0609b8d3 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -449,8 +449,15 @@ Andre miljøindstillinger pr. spil for Proton, DXVK, VKD3D-Proton og synkronisering. %1$s’s profil er gemt til næste spilstart. Start uden Steam - Til et spil, der lukker med det samme: starter det uden Protons Steam-opsætning. Steam-funktioner som overlay er slået fra for det. + Åbner det i Proton/FEX uden Steam-klienten; Steam-funktioner er slået fra. Til + Starter med Steam + Starter uden Steam + Indstillinger + Indstillinger for %1$s + Standard + Sætter alt, der er ændret for dette spil, tilbage til appens indstillinger. + Brugerdefinerede synkroniseringsvariabler %1$s’s Proton-valg er gemt og tilgængeligt i Steam nu. %1$s’s Proton-valg er gemt. Genstart Steam, inden du starter spillet. %1$s kan ikke bruge den valgte Proton på denne enhed. @@ -1972,6 +1979,20 @@ Epic-login (EOS) Start offline Løs Epic-login + Version %1$s + Installer Linux-runtimen først + Kontrollerer den nyeste version… + Udpakker… + Downloadserveren svarede ikke + Epic Games Launcher skal bruge FEX, som Steam henter. Åbn launcheren igen, når Steam er startet. + Download mislykkedes: %1$s + Kunne ikke udpakke launcheren: %1$s + Epic Games Launcher accepterede ikke login. Start den igen fra launcheren. + Fjerner launcheren og frigør dens plads. Dine Epic-spil forbliver installeret og virker stadig, og login gemmes til næste installation. + Den kørende session lukkes først. + Epic Games Launcher er afinstalleret + Nogle launcherfiler kunne ikke fjernes. Prøv igen. + Vent, til Epic Games Launcher er færdig med at blive afinstalleret Cloud-gemte spil Upload til cloud Download fra cloud diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 74ca22d28..052a5931c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -446,8 +446,15 @@ Weitere spielspezifische Umgebungsoptionen für Proton, DXVK, VKD3D-Proton und Synchronisation. Das Profil von %1$s ist für den nächsten Spielstart gespeichert. Ohne Steam starten - Für ein Spiel, das sich sofort schließt: startet es ohne Protons Steam-Einrichtung. Steam-Funktionen wie das Overlay sind dafür deaktiviert. + Öffnet es in Proton/FEX ohne den Steam-Client; Steam-Funktionen sind aus. An + Startet mit Steam + Startet ohne Steam + Einstellungen + Einstellungen für %1$s + Standard + Setzt alles, was für dieses Spiel geändert wurde, auf die Einstellungen der App zurück. + Eigene Sync-Variablen Die Proton-Auswahl für %1$s ist gespeichert und jetzt in Steam verfügbar. Die Proton-Auswahl für %1$s ist gespeichert. Starte Steam neu, bevor du das Spiel startest. %1$s kann das gewählte Proton auf diesem Gerät nicht verwenden. @@ -1961,6 +1968,20 @@ Epic-Anmeldung (EOS) Offline starten Epic-Anmeldung klären + Version %1$s + Installiere zuerst die Linux-Laufzeit + Neueste Version wird geprüft… + Wird entpackt… + Der Download-Server hat nicht geantwortet + Der Epic Games Launcher braucht FEX, das Steam herunterlädt. Öffne den Launcher erneut, sobald Steam gestartet ist. + Download fehlgeschlagen: %1$s + Der Launcher konnte nicht entpackt werden: %1$s + Der Epic Games Launcher hat die Anmeldung nicht akzeptiert. Starte ihn über den Launcher erneut. + Entfernt den Launcher und gibt seinen Speicher frei. Deine Epic-Spiele bleiben installiert und funktionieren weiter, und die Anmeldung bleibt für eine erneute Installation erhalten. + Die laufende Sitzung wird zuerst beendet. + Epic Games Launcher deinstalliert + Einige Launcher-Dateien konnten nicht entfernt werden. Versuche es erneut. + Warte, bis der Epic Games Launcher deinstalliert ist Cloud-Spielstände In die Cloud hochladen Aus der Cloud herunterladen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 91df88f93..1da7c29cd 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -449,8 +449,15 @@ Άλλες επιλογές περιβάλλοντος Proton, DXVK, VKD3D-Proton και συγχρονισμού ανά παιχνίδι. Το προφίλ του %1$s αποθηκεύτηκε για την επόμενη εκκίνηση του παιχνιδιού. Εκκίνηση χωρίς το Steam - Για παιχνίδι που κλείνει αμέσως: το εκκινεί χωρίς τη ρύθμιση του Steam στο Proton. Λειτουργίες του Steam, όπως το overlay, είναι απενεργοποιημένες για αυτό. + Το ανοίγει στο Proton/FEX χωρίς το πρόγραμμα Steam· οι λειτουργίες του Steam είναι ανενεργές. Ενεργό + Ξεκινά με το Steam + Ξεκινά χωρίς το Steam + Ρυθμίσεις + Ρυθμίσεις %1$s + Προεπιλογή + Επαναφέρει ό,τι άλλαξε για αυτό το παιχνίδι στις ρυθμίσεις της εφαρμογής. + Προσαρμοσμένες μεταβλητές συγχρονισμού Η επιλογή Proton του %1$s αποθηκεύτηκε και είναι διαθέσιμη στο Steam τώρα. Η επιλογή Proton του %1$s αποθηκεύτηκε. Κάντε επανεκκίνηση του Steam πριν εκκινήσετε το παιχνίδι. Το %1$s δεν μπορεί να χρησιμοποιήσει το επιλεγμένο Proton σε αυτή τη συσκευή. @@ -1972,6 +1979,20 @@ Σύνδεση Epic (EOS) Εκκίνηση εκτός σύνδεσης Επίλυση σύνδεσης Epic + Έκδοση %1$s + Εγκαταστήστε πρώτα το runtime Linux + Έλεγχος για την πιο πρόσφατη έκδοση… + Αποσυμπίεση… + Ο διακομιστής λήψης δεν αποκρίθηκε + Το Epic Games Launcher χρειάζεται το FEX, το οποίο κατεβάζει το Steam. Ανοίξτε ξανά το launcher αφού ξεκινήσει το Steam. + Η λήψη απέτυχε: %1$s + Δεν ήταν δυνατή η αποσυμπίεση του launcher: %1$s + Το Epic Games Launcher δεν δέχτηκε τη σύνδεση. Εκκινήστε το ξανά από το launcher. + Αφαιρεί τον εκκινητή και ελευθερώνει τον χώρο του. Τα παιχνίδια Epic παραμένουν εγκατεστημένα και λειτουργούν, και η σύνδεση διατηρείται για την επόμενη εγκατάσταση. + Η τρέχουσα συνεδρία κλείνει πρώτα. + Το Epic Games Launcher απεγκαταστάθηκε + Δεν ήταν δυνατή η αφαίρεση ορισμένων αρχείων του εκκινητή. Δοκιμάστε ξανά. + Περιμένετε να ολοκληρωθεί η απεγκατάσταση του Epic Games Launcher Αποθηκεύσεις στο cloud Μεταφόρτωση στο cloud Λήψη από το cloud diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8ce20744b..45a7926b5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -448,8 +448,15 @@ Otras opciones de entorno de Proton, DXVK, VKD3D-Proton y sincronización para cada juego. El perfil de %1$s se ha guardado para el próximo inicio del juego. Iniciar sin Steam - Para un juego que se cierra enseguida: lo inicia sin la configuración de Steam de Proton. Las funciones de Steam, como la superposición, están desactivadas para él. + Lo abre en Proton/FEX sin el cliente de Steam; las funciones de Steam quedan desactivadas. Activado + Se inicia con Steam + Se inicia sin Steam + Ajustes + Ajustes de %1$s + Predeterminado + Devuelve todo lo cambiado para este juego a los ajustes de la aplicación. + Variables de sincronización personalizadas La elección de Proton de %1$s se ha guardado y ya está disponible en Steam. La elección de Proton de %1$s se ha guardado. Reinicia Steam antes de iniciar el juego. %1$s no puede usar el Proton elegido en este dispositivo. @@ -1990,6 +1997,20 @@ Inicio de sesión de Epic (EOS) Iniciar sin conexión Solucionar el inicio de sesión de Epic + Versión %1$s + Instala primero el runtime de Linux + Comprobando la versión más reciente… + Descomprimiendo… + El servidor de descargas no respondió + Epic Games Launcher necesita FEX, que descarga Steam. Vuelve a abrir el launcher cuando Steam se haya iniciado. + La descarga falló: %1$s + No se pudo descomprimir el launcher: %1$s + Epic Games Launcher no aceptó el inicio de sesión. Vuelve a iniciarlo desde el launcher. + Quita el launcher y libera su espacio. Tus juegos de Epic siguen instalados y funcionando, y el inicio de sesión se guarda para cuando lo vuelvas a instalar. + Primero se cierra la sesión en curso. + Epic Games Launcher desinstalado + No se pudieron quitar algunos archivos del launcher. Inténtalo de nuevo. + Espera a que termine de desinstalarse Epic Games Launcher Partidas en la nube Subir a la nube Descargar de la nube diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index d014a4e08..f08e3bcdd 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -449,8 +449,15 @@ Muut pelikohtaiset Proton-, DXVK-, VKD3D-Proton- ja synkronointiympäristön asetukset. %1$s:n profiili on tallennettu seuraavaa pelin käynnistystä varten. Käynnistä ilman Steamia - Pelille, joka sulkeutuu heti: käynnistää sen ilman Protonin Steam-asetuksia. Steamin ominaisuudet, kuten overlay, on pois käytöstä sille. + Avaa sen Protonissa/FEXissä ilman Steam-asiakasta; Steamin ominaisuudet ovat pois päältä. Päällä + Käynnistyy Steamin kanssa + Käynnistyy ilman Steamia + Asetukset + %1$s – asetukset + Oletus + Palauttaa kaiken tälle pelille muutetun sovelluksen asetuksiin. + Mukautetut synkronointimuuttujat %1$s:n Proton-valinta on tallennettu ja käytettävissä Steamissa nyt. %1$s:n Proton-valinta on tallennettu. Käynnistä Steam uudelleen ennen pelin käynnistämistä. %1$s ei voi käyttää valittua Protonia tällä laitteella. @@ -1972,6 +1979,20 @@ Epic-kirjautuminen (EOS) Käynnistä offline-tilassa Ratkaise Epic-kirjautuminen + Versio %1$s + Asenna Linux-ajoympäristö ensin + Tarkistetaan uusinta versiota… + Puretaan… + Latauspalvelin ei vastannut + Epic Games Launcher tarvitsee FEXin, jonka Steam lataa. Avaa launcher uudelleen, kun Steam on käynnistynyt. + Lataus epäonnistui: %1$s + Launcherin purkaminen epäonnistui: %1$s + Epic Games Launcher ei hyväksynyt kirjautumista. Käynnistä se uudelleen launcherista. + Poistaa käynnistimen ja vapauttaa sen tilan. Epic-pelisi pysyvät asennettuina ja toimivat, ja kirjautuminen säilyy seuraavaa asennusta varten. + Käynnissä oleva istunto suljetaan ensin. + Epic Games Launcher poistettu + Joitakin käynnistimen tiedostoja ei voitu poistaa. Yritä uudelleen. + Odota, että Epic Games Launcherin asennuksen poisto valmistuu Pilvitallennukset Lähetä pilveen Lataa pilvestä diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1dc49fde9..fec2f05a1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -448,8 +448,15 @@ Autres options d\'environnement Proton, DXVK, VKD3D-Proton et de synchronisation propres à ce jeu. Le profil de %1$s est enregistré pour le prochain lancement du jeu. Démarrer sans Steam - Pour un jeu qui se ferme aussitôt : le démarre sans la configuration Steam de Proton. Les fonctionnalités Steam, comme la surimpression, sont désactivées pour lui. + L\'ouvre dans Proton/FEX sans le client Steam ; les fonctions Steam sont désactivées. Activé + Démarre avec Steam + Démarre sans Steam + Paramètres + Paramètres de %1$s + Par défaut + Remet tout ce qui a été modifié pour ce jeu aux paramètres de l\'application. + Variables de synchronisation personnalisées Le choix de Proton pour %1$s est enregistré et disponible dès maintenant dans Steam. Le choix de Proton pour %1$s est enregistré. Redémarrez Steam avant de lancer le jeu. %1$s ne peut pas utiliser le Proton sélectionné sur cet appareil. @@ -1990,6 +1997,20 @@ Connexion Epic (EOS) Lancer hors ligne Corriger la connexion Epic + Version %1$s + Veuillez d\'abord installer l\'environnement d\'exécution Linux + Vérification de la dernière version… + Décompression… + Le serveur de téléchargement n\'a pas répondu + L\'Epic Games Launcher a besoin de FEX, que Steam télécharge. Rouvrez le launcher une fois Steam démarré. + Échec du téléchargement : %1$s + Impossible de décompresser le launcher : %1$s + Epic Games Launcher n\'a pas accepté la connexion. Relancez-le depuis le launcher. + Supprime le lanceur et libère son espace. Vos jeux Epic restent installés et fonctionnent, et la connexion est conservée pour une prochaine installation. + La session en cours se ferme d’abord. + Epic Games Launcher désinstallé + Certains fichiers du lanceur n’ont pas pu être supprimés. Réessayez. + Attendez la fin de la désinstallation d\'Epic Games Launcher Sauvegardes cloud Envoyer vers le cloud Télécharger depuis le cloud diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ecfdd157c..2872d9245 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -449,8 +449,15 @@ हर गेम के लिए अन्य Proton, DXVK, VKD3D-Proton और सिंक्रोनाइज़ेशन एनवायरनमेंट विकल्प। %1$s की प्रोफ़ाइल अगली बार गेम शुरू होने के लिए सेव है। Steam के बिना शुरू करें - ऐसे गेम के लिए जो तुरंत बंद हो जाता है: इसे Proton के Steam सेटअप के बिना शुरू करता है। इसके लिए ओवरले जैसी Steam सुविधाएं बंद रहती हैं। + इसे Steam क्लाइंट के बिना Proton/FEX में खोलता है; Steam सुविधाएँ बंद रहती हैं। चालू + Steam के साथ शुरू होता है + Steam के बिना शुरू होता है + सेटिंग + %1$s सेटिंग + डिफ़ॉल्ट + इस गेम के लिए बदली गई हर चीज़ को ऐप की सेटिंग पर वापस लाता है। + कस्टम सिंक वेरिएबल %1$s का Proton चुनाव सेव है और अभी Steam में उपलब्ध है। %1$s का Proton चुनाव सेव है। गेम शुरू करने से पहले Steam रीस्टार्ट करें। %1$s इस डिवाइस पर चुना गया Proton इस्तेमाल नहीं कर सकता। @@ -1972,6 +1979,20 @@ Epic साइन-इन (EOS) ऑफ़लाइन लॉन्च करें Epic साइन-इन हल करें + वर्शन %1$s + पहले Linux रनटाइम इंस्टॉल करें + नवीनतम वर्शन जांचा जा रहा है… + अनपैक हो रहा है… + डाउनलोड सर्वर ने जवाब नहीं दिया + Epic Games Launcher को FEX चाहिए, जिसे Steam डाउनलोड करता है। Steam शुरू होने के बाद launcher फिर से खोलें। + डाउनलोड विफल: %1$s + लॉन्चर को अनपैक नहीं किया जा सका: %1$s + Epic Games Launcher ने साइन-इन स्वीकार नहीं किया। इसे लॉन्चर से फिर से शुरू करें। + लॉन्चर हटाता है और उसकी जगह खाली करता है। आपके Epic गेम इंस्टॉल रहते हैं और चलते रहते हैं, और साइन-इन अगली बार इंस्टॉल करने के लिए रखा जाता है। + पहले चल रहा सेशन बंद होगा। + Epic Games Launcher अनइंस्टॉल हो गया + लॉन्चर की कुछ फ़ाइलें हटाई नहीं जा सकीं। फिर से कोशिश करें। + Epic Games Launcher की अनइंस्टॉल पूरी होने तक प्रतीक्षा करें क्लाउड सेव क्लाउड पर अपलोड करें क्लाउड से डाउनलोड करें diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e44f50b33..12bd8128c 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -449,8 +449,15 @@ Egyéb, játékonkénti Proton-, DXVK-, VKD3D-Proton- és szinkronizációs környezeti beállítások. A %1$s profilja mentve a játék következő indítására. Indítás Steam nélkül - Olyan játékhoz, amely azonnal bezáródik: elindítja a Proton Steam-beállítása nélkül. A Steam funkciói, mint az overlay, ki vannak kapcsolva hozzá. + Proton/FEX alatt nyitja meg a Steam kliens nélkül; a Steam funkciói ki vannak kapcsolva. Be + Steammel indul + Steam nélkül indul + Beállítások + %1$s beállításai + Alapértelmezett + Mindent, amit ennél a játéknál módosítottál, visszaállít az alkalmazás beállításaira. + Egyéni szinkronizálási változók A %1$s Proton-választása mentve, és már elérhető a Steamben. A %1$s Proton-választása mentve. Indítsa újra a Steamet a játék elindítása előtt. A %1$s nem tudja használni a kiválasztott Protont ezen az eszközön. @@ -1972,6 +1979,20 @@ Epic bejelentkezés (EOS) Indítás offline Epic bejelentkezés megoldása + %1$s verzió + Előbb telepítse a Linux futtatókörnyezetet + A legújabb verzió ellenőrzése… + Kicsomagolás… + A letöltési szerver nem válaszolt + Az Epic Games Launchernek szüksége van a FEX-re, amelyet a Steam tölt le. Nyisd meg újra a launchert, miután a Steam elindult. + Letöltés sikertelen: %1$s + Nem sikerült kicsomagolni a launchert: %1$s + Az Epic Games Launcher nem fogadta el a bejelentkezést. Indítsa el újra a launcherből. + Eltávolítja az indítót és felszabadítja a helyét. Az Epic-játékaid telepítve maradnak és működnek, a bejelentkezés pedig megmarad a következő telepítéshez. + Előbb bezárul a futó munkamenet. + Az Epic Games Launcher eltávolítva + Az indító néhány fájlját nem sikerült eltávolítani. Próbáld újra. + Várd meg, amíg az Epic Games Launcher eltávolítása befejeződik Felhőmentések Feltöltés a felhőbe Letöltés a felhőből diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6303c8a33..9e362ff9c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -447,8 +447,15 @@ Opsi lingkungan Proton, DXVK, VKD3D-Proton, dan sinkronisasi lain per gim. Profil %1$s tersimpan untuk peluncuran gim berikutnya. Mulai tanpa Steam - Untuk game yang langsung tertutup: menjalankannya tanpa pengaturan Steam dari Proton. Fitur Steam seperti overlay dimatikan untuknya. + Membukanya di Proton/FEX tanpa klien Steam; fitur Steam nonaktif. Aktif + Dimulai dengan Steam + Dimulai tanpa Steam + Setelan + Setelan %1$s + Default + Mengembalikan semua yang diubah untuk game ini ke setelan aplikasi. + Variabel sinkronisasi kustom Pilihan Proton %1$s tersimpan dan sudah tersedia di Steam sekarang. Pilihan Proton %1$s tersimpan. Mulai ulang Steam sebelum meluncurkan gim. %1$s tidak dapat menggunakan Proton yang dipilih di perangkat ini. @@ -1951,6 +1958,20 @@ Masuk Epic (EOS) Jalankan secara offline Selesaikan masuk Epic + Versi %1$s + Instal runtime Linux terlebih dahulu + Memeriksa versi terbaru… + Mengekstrak… + Server unduhan tidak merespons + Epic Games Launcher memerlukan FEX, yang diunduh oleh Steam. Buka launcher lagi setelah Steam dimulai. + Unduhan gagal: %1$s + Tidak dapat mengekstrak launcher: %1$s + Epic Games Launcher tidak menerima proses masuk. Mulai lagi dari launcher. + Menghapus peluncur dan mengosongkan ruangnya. Game Epic Anda tetap terpasang dan berfungsi, dan proses masuk disimpan untuk saat memasangnya lagi. + Sesi yang berjalan ditutup terlebih dahulu. + Epic Games Launcher dicopot + Beberapa file peluncur tidak dapat dihapus. Coba lagi. + Tunggu hingga Epic Games Launcher selesai dicopot Simpanan cloud Unggah ke cloud Unduh dari cloud diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d9eebebd7..a6a1c9988 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -448,8 +448,15 @@ Altre opzioni d\'ambiente per gioco di Proton, DXVK, VKD3D-Proton e sincronizzazione. Il profilo di %1$s è salvato per il prossimo avvio del gioco. Avvia senza Steam - Per un gioco che si chiude subito: lo avvia senza la configurazione Steam di Proton. Le funzioni di Steam, come l\'overlay, sono disattivate per esso. + Lo apre in Proton/FEX senza il client di Steam; le funzioni di Steam sono disattivate. Attivo + Si avvia con Steam + Si avvia senza Steam + Impostazioni + Impostazioni di %1$s + Predefinito + Riporta tutto ciò che è stato cambiato per questo gioco alle impostazioni dell\'app. + Variabili di sincronizzazione personalizzate La scelta di Proton per %1$s è salvata e già disponibile in Steam. La scelta di Proton per %1$s è salvata. Riavvia Steam prima di avviare il gioco. %1$s non può usare il Proton selezionato su questo dispositivo. @@ -1982,6 +1989,20 @@ Accesso Epic (EOS) Avvia offline Risolvi l\'accesso Epic + Versione %1$s + Installa prima il runtime Linux + Controllo della versione più recente… + Estrazione… + Il server di download non ha risposto + Epic Games Launcher ha bisogno di FEX, che viene scaricato da Steam. Riapri il launcher dopo l\'avvio di Steam. + Download non riuscito: %1$s + Impossibile estrarre il launcher: %1$s + Epic Games Launcher non ha accettato l\'accesso. Avvialo di nuovo dal launcher. + Rimuove il launcher e libera il suo spazio. I tuoi giochi Epic restano installati e funzionanti, e l’accesso viene conservato per quando lo reinstalli. + Prima viene chiusa la sessione in corso. + Epic Games Launcher disinstallato + Non è stato possibile rimuovere alcuni file del launcher. Riprova. + Attendi che Epic Games Launcher finisca di disinstallarsi Salvataggi nel cloud Carica nel cloud Scarica dal cloud diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1fc82caa0..35da191bb 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -447,8 +447,15 @@ その他のゲームごとのProton、DXVK、VKD3D-Proton、同期の環境オプション。 %1$sのプロファイルを保存しました。次回のゲーム起動時に有効になります。 Steamなしで起動 - すぐに終了してしまうゲーム用:ProtonのSteamセットアップなしで起動します。オーバーレイなどのSteam機能はこのゲームでは無効になります。 + Steam クライアントなしで Proton/FEX で開きます。Steam の機能はオフになります。 オン + Steam と一緒に起動 + Steam なしで起動 + 設定 + %1$s の設定 + デフォルト + このゲーム用に変更した内容をすべてアプリの設定に戻します。 + カスタム同期変数 %1$sのProtonの選択を保存しました。Steamですぐに使用できます。 %1$sのProtonの選択を保存しました。ゲームを起動する前にSteamを再起動してください。 %1$sは、選択したProtonをこのデバイスで使用できません。 @@ -1951,6 +1958,20 @@ Epicログイン(EOS) オフラインで起動 Epicログインの問題を解決 + バージョン %1$s + 先にLinux実行環境をインストールしてください + 最新バージョンを確認中… + 展開中… + ダウンロードサーバーが応答しませんでした + Epic Games Launcher には FEX が必要です。FEX は Steam がダウンロードします。Steam の起動後にもう一度ランチャーを開いてください。 + ダウンロードに失敗しました:%1$s + ランチャーを展開できませんでした:%1$s + Epic Games Launcherがサインインを受け付けませんでした。ランチャーからもう一度起動してください。 + ランチャーを削除して容量を空けます。Epicのゲームはインストールされたまま動作し、サインインは再インストール時のために保持されます。 + 実行中のセッションを先に終了します。 + Epic Games Launcherをアンインストールしました + ランチャーの一部のファイルを削除できませんでした。もう一度お試しください。 + Epic Games Launcher のアンインストールが終わるまでお待ちください クラウドセーブ クラウドにアップロード クラウドからダウンロード diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5e7d374a8..d613477bb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -447,8 +447,15 @@ 기타 게임별 Proton, DXVK, VKD3D-Proton 및 동기화 환경 옵션입니다. %1$s의 프로필이 저장되었습니다. 다음 게임 실행 시 적용됩니다. Steam 없이 시작 - 바로 닫히는 게임의 경우: Proton의 Steam 설정 없이 실행합니다. 오버레이 같은 Steam 기능은 이 게임에서 꺼집니다. + Steam 클라이언트 없이 Proton/FEX에서 엽니다. Steam 기능은 꺼집니다. 켜짐 + Steam과 함께 시작 + Steam 없이 시작 + 설정 + %1$s 설정 + 기본값 + 이 게임에서 변경한 모든 항목을 앱 설정으로 되돌립니다. + 사용자 지정 동기화 변수 %1$s의 Proton 선택이 저장되었으며 지금 Steam에서 사용할 수 있습니다. %1$s의 Proton 선택이 저장되었습니다. 게임을 실행하기 전에 Steam을 다시 시작하세요. %1$s은(는) 이 기기에서 선택한 Proton을 사용할 수 없습니다. @@ -1951,6 +1958,20 @@ Epic 로그인(EOS) 오프라인으로 실행 Epic 로그인 문제 해결 + 버전 %1$s + 먼저 Linux 런타임을 설치하세요 + 최신 버전 확인 중… + 압축 해제 중… + 다운로드 서버가 응답하지 않았습니다 + Epic Games Launcher에는 Steam이 다운로드하는 FEX가 필요합니다. Steam이 시작된 후 런처를 다시 여세요. + 다운로드 실패: %1$s + 런처 압축을 해제할 수 없습니다: %1$s + Epic Games Launcher가 로그인을 받아들이지 않았습니다. 런처에서 다시 시작하세요. + 런처를 삭제하고 공간을 확보합니다. Epic 게임은 설치된 상태로 계속 작동하며, 로그인은 다시 설치할 때를 위해 유지됩니다. + 실행 중인 세션이 먼저 종료됩니다. + Epic Games Launcher를 제거했습니다 + 일부 런처 파일을 삭제하지 못했습니다. 다시 시도하세요. + Epic Games Launcher 제거가 끝날 때까지 기다려 주세요 클라우드 저장 클라우드에 업로드 클라우드에서 다운로드 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index f4a7dfa86..82059ffd9 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -447,8 +447,15 @@ Pilihan persekitaran Proton, DXVK, VKD3D-Proton dan penyegerakan lain bagi setiap permainan. Profil %1$s disimpan untuk pelancaran permainan seterusnya. Mulakan tanpa Steam - Untuk permainan yang terus tertutup: memulakannya tanpa persediaan Steam oleh Proton. Ciri Steam seperti overlay dimatikan untuknya. + Membukanya dalam Proton/FEX tanpa klien Steam; ciri Steam dimatikan. Hidup + Bermula dengan Steam + Bermula tanpa Steam + Tetapan + Tetapan %1$s + Lalai + Mengembalikan semua yang diubah untuk permainan ini kepada tetapan apl. + Pemboleh ubah segerak tersuai Pilihan Proton %1$s disimpan dan tersedia dalam Steam sekarang. Pilihan Proton %1$s disimpan. Mulakan semula Steam sebelum melancarkan permainan. %1$s tidak boleh menggunakan Proton yang dipilih pada peranti ini. @@ -1951,6 +1958,20 @@ Log masuk Epic (EOS) Lancarkan secara luar talian Selesaikan log masuk Epic + Versi %1$s + Pasang runtime Linux dahulu + Menyemak versi terkini… + Menyahpak… + Pelayan muat turun tidak memberi respons + Epic Games Launcher memerlukan FEX, yang dimuat turun oleh Steam. Buka semula pelancar selepas Steam bermula. + Muat turun gagal: %1$s + Tidak dapat menyahpak pelancar: %1$s + Epic Games Launcher tidak menerima log masuk itu. Mulakan semula daripada pelancar. + Membuang pelancar dan mengosongkan ruangnya. Permainan Epic anda kekal dipasang dan berfungsi, dan log masuk disimpan untuk apabila anda memasangnya semula. + Sesi yang sedang berjalan ditutup dahulu. + Epic Games Launcher dinyahpasang + Sesetengah fail pelancar tidak dapat dibuang. Cuba lagi. + Tunggu sehingga Epic Games Launcher selesai dinyahpasang Simpanan awan Muat naik ke awan Muat turun daripada awan diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 03c5cc3ed..aa1f93e41 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -449,8 +449,15 @@ Andre spillspesifikke Proton-, DXVK-, VKD3D-Proton- og synkroniseringsalternativer. Profilen til %1$s er lagret for neste spilløkt. Start uten Steam - For et spill som lukkes med én gang: starter det uten Protons Steam-oppsett. Steam-funksjoner som overlay er slått av for det. + Åpner det i Proton/FEX uten Steam-klienten; Steam-funksjoner er av. På + Starter med Steam + Starter uten Steam + Innstillinger + Innstillinger for %1$s + Standard + Setter alt som er endret for dette spillet, tilbake til appens innstillinger. + Egendefinerte synkroniseringsvariabler Proton-valget til %1$s er lagret og tilgjengelig i Steam nå. Proton-valget til %1$s er lagret. Start Steam på nytt før du starter spillet. %1$s kan ikke bruke den valgte Protonen på denne enheten. @@ -1972,6 +1979,20 @@ Epic-innlogging (EOS) Start frakoblet Løs Epic-innlogging + Versjon %1$s + Installer Linux-kjøretiden først + Ser etter nyeste versjon… + Pakker ut… + Nedlastingsserveren svarte ikke + Epic Games Launcher trenger FEX, som Steam laster ned. Åpne launcheren igjen når Steam har startet. + Nedlasting mislyktes: %1$s + Kunne ikke pakke ut launcheren: %1$s + Epic Games Launcher godtok ikke innloggingen. Start den på nytt fra launcheren. + Fjerner startprogrammet og frigjør plassen. Epic-spillene dine forblir installert og fungerer, og påloggingen beholdes til neste installasjon. + Den kjørende økten lukkes først. + Epic Games Launcher er avinstallert + Noen filer for startprogrammet kunne ikke fjernes. Prøv igjen. + Vent til Epic Games Launcher er ferdig avinstallert Skylagringer Last opp til skyen Last ned fra skyen diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2b4fcb7d7..475b4d51f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -449,8 +449,15 @@ Overige omgevingsopties per game voor Proton, DXVK, VKD3D-Proton en synchronisatie. Het profiel van %1$s is opgeslagen voor de volgende start van de game. Starten zonder Steam - Voor een game die meteen sluit: start deze zonder Protons Steam-setup. Steam-functies zoals de overlay staan uit voor deze game. + Opent het in Proton/FEX zonder de Steam-client; Steam-functies staan uit. Aan + Start met Steam + Start zonder Steam + Instellingen + Instellingen voor %1$s + Standaard + Zet alles wat voor deze game is gewijzigd terug naar de instellingen van de app. + Aangepaste synchronisatievariabelen De Proton-keuze van %1$s is opgeslagen en nu beschikbaar in Steam. De Proton-keuze van %1$s is opgeslagen. Start Steam opnieuw voordat je de game start. %1$s kan de gekozen Proton op dit apparaat niet gebruiken. @@ -1972,6 +1979,20 @@ Epic-aanmelding (EOS) Offline starten Epic-aanmelding oplossen + Versie %1$s + Installeer eerst de Linux-runtime + Nieuwste versie controleren… + Uitpakken… + De downloadserver reageerde niet + De Epic Games Launcher heeft FEX nodig, dat Steam downloadt. Open de launcher opnieuw zodra Steam is gestart. + Downloaden mislukt: %1$s + Kan de launcher niet uitpakken: %1$s + Epic Games Launcher accepteerde de aanmelding niet. Start het opnieuw vanuit de launcher. + Verwijdert de launcher en maakt zijn ruimte vrij. Je Epic-games blijven geïnstalleerd en werken, en de aanmelding blijft bewaard voor als je hem opnieuw installeert. + De lopende sessie wordt eerst gesloten. + Epic Games Launcher verwijderd + Sommige launcherbestanden konden niet worden verwijderd. Probeer het opnieuw. + Wacht tot Epic Games Launcher klaar is met verwijderen Cloudsaves Uploaden naar cloud Downloaden vanaf cloud diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cf79b13c7..e612170fa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -453,8 +453,15 @@ Inne opcje środowiska Proton, DXVK, VKD3D-Proton i synchronizacji dla poszczególnych gier. Profil gry %1$s zapisano na następne uruchomienie gry. Uruchom bez Steam - Dla gry, która zamyka się od razu: uruchamia ją bez konfiguracji Steam w Protonie. Funkcje Steam, takie jak nakładka, są dla niej wyłączone. + Otwiera w Proton/FEX bez klienta Steam; funkcje Steam są wyłączone. Wł. + Uruchamia się ze Steam + Uruchamia się bez Steam + Ustawienia + Ustawienia: %1$s + Domyślne + Przywraca wszystko, co zmieniono dla tej gry, do ustawień aplikacji. + Własne zmienne synchronizacji Wybór Proton dla %1$s zapisano i jest już dostępny w Steam. Wybór Proton dla %1$s zapisano. Uruchom ponownie Steam przed uruchomieniem gry. %1$s nie może używać wybranego Proton na tym urządzeniu. @@ -2014,6 +2021,20 @@ Logowanie Epic (EOS) Uruchom offline Rozwiąż logowanie Epic + Wersja %1$s + Najpierw zainstaluj środowisko Linux + Sprawdzanie najnowszej wersji… + Rozpakowywanie… + Serwer pobierania nie odpowiedział + Epic Games Launcher wymaga FEX, który pobiera Steam. Otwórz launcher ponownie po uruchomieniu Steam. + Pobieranie nie powiodło się: %1$s + Nie udało się rozpakować launchera: %1$s + Epic Games Launcher nie przyjął logowania. Uruchom go ponownie z launchera. + Usuwa launcher i zwalnia jego miejsce. Twoje gry z Epic pozostają zainstalowane i działają, a logowanie zostaje zachowane na potrzeby ponownej instalacji. + Najpierw zostanie zamknięta bieżąca sesja. + Odinstalowano Epic Games Launcher + Nie udało się usunąć niektórych plików launchera. Spróbuj ponownie. + Poczekaj, aż Epic Games Launcher zostanie odinstalowany Zapisy w chmurze Wyślij do chmury Pobierz z chmury diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 87eaed937..8565d8a01 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -448,8 +448,15 @@ Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e sincronização. O perfil de %1$s foi guardado para o próximo arranque do jogo. Iniciar sem o Steam - Para um jogo que fecha de imediato: inicia-o sem a configuração do Steam no Proton. Funcionalidades do Steam, como a sobreposição, ficam desativadas para ele. + Abre no Proton/FEX sem o cliente do Steam; as funcionalidades do Steam ficam desligadas. Ativado + Inicia com o Steam + Inicia sem o Steam + Definições + Definições de %1$s + Predefinição + Repõe tudo o que foi alterado neste jogo nas definições da aplicação. + Variáveis de sincronização personalizadas A escolha de Proton de %1$s foi guardada e já está disponível no Steam. A escolha de Proton de %1$s foi guardada. Reinicie o Steam antes de iniciar o jogo. %1$s não pode utilizar o Proton selecionado neste dispositivo. @@ -1982,6 +1989,20 @@ Início de sessão Epic (EOS) Iniciar offline Resolver início de sessão Epic + Versão %1$s + Instale primeiro o ambiente de execução Linux + A verificar a versão mais recente… + A descompactar… + O servidor de transferência não respondeu + O Epic Games Launcher precisa do FEX, que o Steam transfere. Abra novamente o launcher depois de o Steam iniciar. + Falha na transferência: %1$s + Não foi possível descompactar o launcher: %1$s + O Epic Games Launcher não aceitou o início de sessão. Inicia-o novamente a partir do launcher. + Remove o launcher e liberta o seu espaço. Os teus jogos da Epic continuam instalados e a funcionar, e a sessão iniciada é guardada para quando o voltares a instalar. + A sessão em curso é fechada primeiro. + Epic Games Launcher desinstalado + Não foi possível remover alguns ficheiros do launcher. Tenta novamente. + Aguarde que o Epic Games Launcher termine de ser desinstalado Dados guardados na nuvem Carregar para a nuvem Transferir da nuvem diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fc1e5fbca..049f6c9f3 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -448,8 +448,15 @@ Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e de sincronização. O perfil de %1$s foi salvo para a próxima vez que o jogo for iniciado. Iniciar sem o Steam - Para um jogo que fecha na hora: inicia ele sem a configuração do Steam no Proton. Recursos do Steam, como a sobreposição, ficam desativados para ele. + Abre no Proton/FEX sem o cliente do Steam; os recursos do Steam ficam desligados. Ativado + Inicia com o Steam + Inicia sem o Steam + Configurações + Configurações de %1$s + Padrão + Volta tudo o que foi alterado neste jogo para as configurações do app. + Variáveis de sincronização personalizadas A escolha de Proton de %1$s foi salva e já está disponível na Steam. A escolha de Proton de %1$s foi salva. Reinicie a Steam antes de iniciar o jogo. %1$s não pode usar o Proton selecionado neste dispositivo. @@ -1982,6 +1989,20 @@ Login da Epic (EOS) Iniciar offline Resolver login da Epic + Versão %1$s + Instale primeiro o runtime do Linux + Verificando a versão mais recente… + Descompactando… + O servidor de download não respondeu + O Epic Games Launcher precisa do FEX, que o Steam baixa. Abra o launcher novamente depois que o Steam iniciar. + Falha no download: %1$s + Não foi possível descompactar o launcher: %1$s + O Epic Games Launcher não aceitou o login. Inicie-o novamente a partir do launcher. + Remove o launcher e libera o espaço dele. Seus jogos da Epic continuam instalados e funcionando, e o login fica guardado para quando você instalar de novo. + A sessão em andamento será fechada primeiro. + Epic Games Launcher desinstalado + Não foi possível remover alguns arquivos do launcher. Tente novamente. + Aguarde o Epic Games Launcher terminar de ser desinstalado Saves na nuvem Enviar para a nuvem Baixar da nuvem diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ae0967c32..c60ac8495 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -451,8 +451,15 @@ Alte opțiuni de mediu Proton, DXVK, VKD3D-Proton și sincronizare, per joc. Profilul %1$s este salvat pentru următoarea lansare a jocului. Pornește fără Steam - Pentru un joc care se închide imediat: îl pornește fără configurarea Steam din Proton. Funcțiile Steam, precum overlay-ul, sunt dezactivate pentru el. + Îl deschide în Proton/FEX fără clientul Steam; funcțiile Steam sunt oprite. Activat + Pornește cu Steam + Pornește fără Steam + Setări + Setări %1$s + Implicit + Readuce tot ce s-a schimbat pentru acest joc la setările aplicației. + Variabile de sincronizare personalizate Alegerea Proton pentru %1$s este salvată și disponibilă în Steam acum. Alegerea Proton pentru %1$s este salvată. Repornește Steam înainte de a lansa jocul. %1$s nu poate folosi Proton-ul selectat pe acest dispozitiv. @@ -1993,6 +2000,20 @@ Autentificare Epic (EOS) Lansează offline Rezolvă autentificarea Epic + Versiunea %1$s + Instalează mai întâi mediul de execuție Linux + Se verifică cea mai recentă versiune… + Se dezarhivează… + Serverul de descărcare nu a răspuns + Epic Games Launcher are nevoie de FEX, pe care îl descarcă Steam. Deschide din nou launcherul după ce pornește Steam. + Descărcarea a eșuat: %1$s + Launcherul nu a putut fi dezarhivat: %1$s + Epic Games Launcher nu a acceptat autentificarea. Pornește-l din nou din launcher. + Elimină lansatorul și îi eliberează spațiul. Jocurile Epic rămân instalate și funcționează, iar autentificarea se păstrează pentru o reinstalare. + Sesiunea în curs se închide mai întâi. + Epic Games Launcher a fost dezinstalat + Unele fișiere ale lansatorului nu au putut fi eliminate. Încearcă din nou. + Așteaptă să se termine dezinstalarea Epic Games Launcher Salvări în cloud Încarcă în cloud Descarcă din cloud diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 96fcb545c..86600fb34 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -450,8 +450,15 @@ Другие параметры среды Proton, DXVK, VKD3D-Proton и синхронизации для отдельной игры. Профиль %1$s сохранен и применится при следующем запуске игры. Запуск без Steam - Для игры, которая сразу закрывается: запускает её без настройки Steam от Proton. Функции Steam, такие как оверлей, для неё отключены. + Открывает в Proton/FEX без клиента Steam; функции Steam отключены. Включено + Запускается со Steam + Запускается без Steam + Настройки + Настройки %1$s + По умолчанию + Возвращает всё, что изменено для этой игры, к настройкам приложения. + Свои переменные синхронизации Выбор Proton для %1$s сохранен и уже доступен в Steam. Выбор Proton для %1$s сохранен. Перезапустите Steam перед запуском игры. %1$s не может использовать выбранный Proton на этом устройстве. @@ -2011,6 +2018,20 @@ Вход в Epic (EOS) Запускать офлайн Устранить проблему входа в Epic + Версия %1$s + Сначала установите среду выполнения Linux + Проверка последней версии… + Распаковка… + Сервер загрузки не ответил + Epic Games Launcher нужен FEX, который скачивает Steam. Откройте лаунчер снова после запуска Steam. + Ошибка загрузки: %1$s + Не удалось распаковать лаунчер: %1$s + Epic Games Launcher не принял вход. Запустите его снова из лаунчера. + Удаляет лаунчер и освобождает место. Ваши игры Epic остаются установленными и работают, а вход сохраняется для повторной установки. + Сначала будет закрыт текущий сеанс. + Epic Games Launcher удалён + Не удалось удалить некоторые файлы лаунчера. Попробуйте ещё раз. + Дождитесь завершения удаления Epic Games Launcher Облачные сохранения Загрузить в облако Скачать из облака diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 939a58bb4..415d25de3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -449,8 +449,15 @@ Övriga miljöalternativ per spel för Proton, DXVK, VKD3D-Proton och synkronisering. %1$s:s profil är sparad för nästa spelstart. Starta utan Steam - För ett spel som stängs direkt: startar det utan Protons Steam-inställning. Steam-funktioner som overlay är avstängda för det. + Öppnar det i Proton/FEX utan Steam-klienten; Steam-funktioner är av. På + Startar med Steam + Startar utan Steam + Inställningar + Inställningar för %1$s + Standard + Återställer allt som ändrats för det här spelet till appens inställningar. + Anpassade synkvariabler %1$s:s Proton-val är sparat och tillgängligt i Steam nu. %1$s:s Proton-val är sparat. Starta om Steam innan du startar spelet. %1$s kan inte använda den valda Proton på den här enheten. @@ -1972,6 +1979,20 @@ Epic-inloggning (EOS) Starta offline Lös Epic-inloggningen + Version %1$s + Installera Linux-körmiljön först + Kontrollerar senaste versionen… + Packar upp… + Nedladdningsservern svarade inte + Epic Games Launcher behöver FEX, som Steam laddar ner. Öppna launchern igen när Steam har startat. + Nedladdningen misslyckades: %1$s + Det gick inte att packa upp launchern: %1$s + Epic Games Launcher accepterade inte inloggningen. Starta den igen från launchern. + Tar bort startprogrammet och frigör utrymmet. Dina Epic-spel förblir installerade och fungerar, och inloggningen sparas tills du installerar det igen. + Den pågående sessionen stängs först. + Epic Games Launcher har avinstallerats + Vissa filer för startprogrammet kunde inte tas bort. Försök igen. + Vänta tills Epic Games Launcher har avinstallerats Molnsparfiler Ladda upp till molnet Hämta från molnet diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 445fe9756..420b5002a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -447,8 +447,15 @@ ตัวเลือกสภาพแวดล้อมอื่น ๆ ของ Proton, DXVK, VKD3D-Proton และการซิงค์เฉพาะเกม บันทึกโปรไฟล์ของ %1$s ไว้สำหรับการเปิดเกมครั้งถัดไป เริ่มโดยไม่ใช้ Steam - สำหรับเกมที่ปิดตัวทันที ใช้เริ่มเกมโดยไม่ตั้งค่า Steam ของ Proton ฟีเจอร์ของ Steam เช่นโอเวอร์เลย์จะถูกปิดสำหรับเกมนี้ + เปิดใน Proton/FEX โดยไม่ใช้ไคลเอนต์ Steam ฟีเจอร์ของ Steam จะปิดอยู่ เปิด + เริ่มพร้อม Steam + เริ่มโดยไม่ใช้ Steam + การตั้งค่า + การตั้งค่า %1$s + ค่าเริ่มต้น + คืนทุกอย่างที่เปลี่ยนสำหรับเกมนี้กลับเป็นการตั้งค่าของแอป + ตัวแปรซิงก์แบบกำหนดเอง บันทึกการเลือก Proton ของ %1$s แล้ว และพร้อมใช้งานใน Steam ตอนนี้ บันทึกการเลือก Proton ของ %1$s แล้ว รีสตาร์ท Steam ก่อนเปิดเกม %1$s ไม่สามารถใช้ Proton ที่เลือกบนอุปกรณ์นี้ได้ @@ -1951,6 +1958,20 @@ ลงชื่อเข้าใช้ Epic (EOS) เปิดแบบออฟไลน์ แก้ไขการลงชื่อเข้าใช้ Epic + เวอร์ชัน %1$s + ติดตั้งรันไทม์ Linux ก่อน + กำลังตรวจสอบเวอร์ชันล่าสุด… + กำลังแตกไฟล์… + เซิร์ฟเวอร์ดาวน์โหลดไม่ตอบสนอง + Epic Games Launcher ต้องใช้ FEX ซึ่ง Steam จะดาวน์โหลดให้ เปิด launcher อีกครั้งหลังจาก Steam เริ่มทำงานแล้ว + ดาวน์โหลดล้มเหลว: %1$s + แตกไฟล์ตัวเรียกใช้งานไม่ได้: %1$s + Epic Games Launcher ไม่ยอมรับการลงชื่อเข้าใช้ เริ่มใหม่อีกครั้งจากตัวเรียกใช้งาน + ลบตัวเปิดเกมและคืนพื้นที่ เกม Epic ของคุณยังติดตั้งอยู่และเล่นได้ และการลงชื่อเข้าใช้จะถูกเก็บไว้สำหรับการติดตั้งครั้งถัดไป + เซสชันที่กำลังทำงานจะถูกปิดก่อน + ถอนการติดตั้ง Epic Games Launcher แล้ว + ไม่สามารถลบไฟล์ของตัวเปิดเกมบางไฟล์ได้ ลองอีกครั้ง + รอให้ถอนการติดตั้ง Epic Games Launcher เสร็จก่อน เซฟบนคลาวด์ อัปโหลดขึ้นคลาวด์ ดาวน์โหลดจากคลาวด์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0879104a3..172966b7b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -446,8 +446,15 @@ Oyuna özel diğer Proton, DXVK, VKD3D-Proton ve senkronizasyon ortam seçenekleri. %1$s profili kaydedildi; oyun bir sonraki başlatıldığında uygulanır. Steam olmadan başlat - Hemen kapanan bir oyun için: Proton\'un Steam kurulumu olmadan başlatır. Overlay gibi Steam özellikleri bu oyun için kapalıdır. + Steam istemcisi olmadan Proton/FEX\'te açar; Steam özellikleri kapalıdır. Açık + Steam ile başlar + Steam olmadan başlar + Ayarlar + %1$s Ayarları + Varsayılan + Bu oyun için değiştirilen her şeyi uygulamanın ayarlarına döndürür. + Özel eşitleme değişkenleri %1$s için Proton seçimi kaydedildi ve şimdi Steam\'de kullanılabilir. %1$s için Proton seçimi kaydedildi. Oyunu başlatmadan önce Steam\'i yeniden başlatın. %1$s bu cihazda seçili Proton\'u kullanamaz. @@ -1961,6 +1968,20 @@ Epic oturum açma (EOS) Çevrimdışı başlat Epic oturum açmayı düzelt + Sürüm %1$s + Önce Linux çalışma zamanını yükleyin + En son sürüm kontrol ediliyor… + Ayıklanıyor… + İndirme sunucusu yanıt vermedi + Epic Games Launcher, Steam\'in indirdiği FEX\'e ihtiyaç duyar. Steam başladıktan sonra başlatıcıyı yeniden açın. + İndirme başarısız oldu: %1$s + Başlatıcı ayıklanamadı: %1$s + Epic Games Launcher oturum açmayı kabul etmedi. Başlatıcıdan yeniden başlatın. + Başlatıcıyı kaldırır ve yerini boşaltır. Epic oyunlarınız yüklü kalır ve çalışmaya devam eder, oturum açma bilgisi yeniden yüklediğinizde kullanılmak üzere saklanır. + Önce çalışan oturum kapatılır. + Epic Games Launcher kaldırıldı + Başlatıcının bazı dosyaları kaldırılamadı. Tekrar deneyin. + Epic Games Launcher\'ın kaldırılmasının bitmesini bekleyin Bulut kayıtları Buluta yükle Buluttan indir diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d047d0101..621246030 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -453,8 +453,15 @@ Інші параметри середовища Proton, DXVK, VKD3D-Proton і синхронізації для окремої гри. Профіль %1$s збережено для наступного запуску гри. Запустити без Steam - Для гри, яка відразу закривається: запускає її без налаштування Steam від Proton. Функції Steam, як-от оверлей, для неї вимкнені. + Відкриває в Proton/FEX без клієнта Steam; функції Steam вимкнено. Увімкнено + Запускається зі Steam + Запускається без Steam + Налаштування + Налаштування %1$s + За замовчуванням + Повертає все, що змінено для цієї гри, до налаштувань застосунку. + Власні змінні синхронізації Вибір Proton для %1$s збережено, і він уже доступний у Steam. Вибір Proton для %1$s збережено. Перезапустіть Steam перед запуском гри. %1$s не може використовувати вибраний Proton на цьому пристрої. @@ -2014,6 +2021,20 @@ Вхід Epic (EOS) Запускати офлайн Виправити вхід Epic + Версія %1$s + Спершу встановіть середовище Linux + Перевірка найновішої версії… + Розпакування… + Сервер завантаження не відповів + Epic Games Launcher потребує FEX, який завантажує Steam. Відкрийте лаунчер знову після запуску Steam. + Помилка завантаження: %1$s + Не вдалося розпакувати лаунчер: %1$s + Epic Games Launcher не прийняв вхід. Запустіть його знову з лаунчера. + Видаляє лаунчер і звільняє місце. Ваші ігри Epic залишаються встановленими й працюють, а вхід зберігається для повторного встановлення. + Спершу буде закрито поточний сеанс. + Epic Games Launcher видалено + Не вдалося видалити деякі файли лаунчера. Спробуйте ще раз. + Зачекайте, доки Epic Games Launcher буде видалено Хмарні збереження Завантажити в хмару Завантажити з хмари diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 58befae92..d28bdedb9 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -447,8 +447,15 @@ Các tùy chọn môi trường Proton, DXVK, VKD3D-Proton và đồng bộ hóa khác cho từng game. Hồ sơ của %1$s được lưu cho lần khởi chạy game kế tiếp. Khởi động không cần Steam - Dành cho game đóng ngay lập tức: khởi động game mà không cần thiết lập Steam của Proton. Các tính năng Steam như overlay sẽ tắt đối với game này. + Mở trong Proton/FEX mà không có ứng dụng Steam; các tính năng Steam bị tắt. Bật + Khởi động cùng Steam + Khởi động không cần Steam + Thiết lập + Thiết lập %1$s + Mặc định + Đưa mọi thứ đã thay đổi cho trò chơi này về thiết lập của ứng dụng. + Biến đồng bộ tùy chỉnh Lựa chọn Proton của %1$s đã được lưu và khả dụng ngay trong Steam. Lựa chọn Proton của %1$s đã được lưu. Khởi động lại Steam trước khi chạy game. %1$s không thể dùng Proton đã chọn trên thiết bị này. @@ -1951,6 +1958,20 @@ Đăng nhập Epic (EOS) Khởi chạy ngoại tuyến Giải quyết đăng nhập Epic + Phiên bản %1$s + Cài runtime Linux trước + Đang kiểm tra phiên bản mới nhất… + Đang giải nén… + Máy chủ tải xuống không phản hồi + Epic Games Launcher cần FEX, do Steam tải xuống. Hãy mở lại launcher sau khi Steam đã khởi động. + Tải xuống thất bại: %1$s + Không thể giải nén launcher: %1$s + Epic Games Launcher không nhận đăng nhập. Khởi động lại từ launcher. + Gỡ trình khởi chạy và giải phóng dung lượng. Các trò chơi Epic vẫn được cài đặt và hoạt động, còn thông tin đăng nhập được giữ lại cho lần cài đặt sau. + Phiên đang chạy sẽ được đóng trước. + Đã gỡ cài đặt Epic Games Launcher + Không thể xóa một số tệp của trình khởi chạy. Hãy thử lại. + Hãy chờ Epic Games Launcher gỡ cài đặt xong Tệp lưu trên cloud Tải lên cloud Tải xuống từ cloud diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7358b987a..22b2a1ddb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -447,8 +447,15 @@ 其他按游戏设置的 Proton、DXVK、VKD3D-Proton 及同步环境选项。 %1$s 的配置档案已保存,将在下次启动游戏时生效。 不启动 Steam 直接开始 - 适用于一开就立即关闭的游戏:不经过 Proton 的 Steam 设置来启动。叠加层等 Steam 功能对该游戏会关闭。 + 在不启动 Steam 客户端的情况下用 Proton/FEX 打开;Steam 功能关闭。 开 + 随 Steam 启动 + 不通过 Steam 启动 + 设置 + %1$s 设置 + 默认 + 将为此游戏更改的所有内容恢复为应用的设置。 + 自定义同步变量 %1$s 的 Proton 选择已保存,现已在 Steam 中可用。 %1$s 的 Proton 选择已保存。启动游戏前请重启 Steam。 %1$s 无法在此设备上使用所选的 Proton。 @@ -2027,6 +2034,20 @@ Epic 登录(EOS) 离线启动 解决 Epic 登录问题 + 版本 %1$s + 请先安装 Linux 运行时 + 正在检查最新版本… + 正在解包… + 下载服务器未响应 + Epic Games Launcher 需要 FEX,FEX 由 Steam 下载。请在 Steam 启动后再次打开启动器。 + 下载失败:%1$s + 无法解包启动器:%1$s + Epic Games Launcher 未接受登录。请从启动器重新启动它。 + 移除启动器并释放其空间。你的 Epic 游戏仍保持安装并可正常运行,登录信息也会保留,以便重新安装时使用。 + 将先关闭正在运行的会话。 + 已卸载 Epic Games Launcher + 部分启动器文件无法删除。请重试。 + 请等待 Epic Games Launcher 卸载完成 云存档 上传到云端 从云端下载 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index cdcc363a1..1dc636dfb 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -447,8 +447,15 @@ 其他個別遊戲的 Proton、DXVK、VKD3D-Proton 及同步環境選項。 已儲存 %1$s 的設定檔,下次啟動遊戲時生效。 不啟動 Steam 直接開始 - 適用於一開就立即關閉的遊戲:不經過 Proton 的 Steam 設定來啟動。疊加層等 Steam 功能對該遊戲會關閉。 + 在不啟動 Steam 用戶端的情況下用 Proton/FEX 開啟;Steam 功能關閉。 開 + 隨 Steam 啟動 + 不經 Steam 啟動 + 設定 + %1$s 設定 + 預設 + 將為此遊戲更改的所有內容還原為應用程式的設定。 + 自訂同步變數 已儲存 %1$s 的 Proton 選擇,現已可在 Steam 中使用。 已儲存 %1$s 的 Proton 選擇。請先重新啟動 Steam,再啟動遊戲。 %1$s 無法在此裝置上使用所選的 Proton。 @@ -1951,6 +1958,20 @@ Epic 登入(EOS) 離線啟動 解決 Epic 登入問題 + 版本 %1$s + 請先安裝 Linux 運行環境 + 正在檢查最新版本… + 正在解壓… + 下載伺服器沒有回應 + Epic Games Launcher 需要 FEX,FEX 由 Steam 下載。請在 Steam 啟動後再次開啟啟動器。 + 下載失敗:%1$s + 無法解壓啟動器:%1$s + Epic Games Launcher 沒有接受登入。請從啟動器重新啟動它。 + 移除啟動器並釋放其空間。你的 Epic 遊戲會保持安裝並可正常運作,登入資料亦會保留,方便重新安裝時使用。 + 會先關閉正在運行的工作階段。 + 已解除安裝 Epic Games Launcher + 部分啟動器檔案無法刪除。請再試一次。 + 請等待 Epic Games Launcher 解除安裝完成 雲端存檔 上載至雲端 從雲端下載 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a3412487b..67bf5274b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -447,8 +447,15 @@ 其他個別遊戲的 Proton、DXVK、VKD3D-Proton 與同步環境選項。 已儲存 %1$s 的設定檔,下次啟動遊戲時生效。 不啟動 Steam 直接開始 - 適用於一開就立即關閉的遊戲:不經過 Proton 的 Steam 設定來啟動。疊加層等 Steam 功能對這款遊戲會關閉。 + 在不啟動 Steam 用戶端的情況下用 Proton/FEX 開啟;Steam 功能關閉。 開 + 隨 Steam 啟動 + 不經 Steam 啟動 + 設定 + %1$s 設定 + 預設 + 將為此遊戲變更的所有內容還原為應用程式的設定。 + 自訂同步變數 已儲存 %1$s 的 Proton 選擇,現在即可在 Steam 中使用。 已儲存 %1$s 的 Proton 選擇。啟動遊戲前請重新啟動 Steam。 %1$s 無法在這部裝置上使用所選的 Proton。 @@ -1951,6 +1958,20 @@ Epic 登入(EOS) 離線啟動 解決 Epic 登入問題 + 版本 %1$s + 請先安裝 Linux 執行環境 + 正在檢查最新版本… + 正在解壓縮… + 下載伺服器沒有回應 + Epic Games Launcher 需要 FEX,FEX 由 Steam 下載。請在 Steam 啟動後再次開啟啟動器。 + 下載失敗:%1$s + 無法解壓縮啟動器:%1$s + Epic Games Launcher 沒有接受登入。請從啟動器重新啟動它。 + 移除啟動器並釋放其空間。你的 Epic 遊戲會保持安裝並可正常運作,登入資訊也會保留,以便重新安裝時使用。 + 會先關閉正在執行的工作階段。 + 已解除安裝 Epic Games Launcher + 部分啟動器檔案無法刪除。請再試一次。 + 請等待 Epic Games Launcher 解除安裝完成 雲端存檔 上傳到雲端 從雲端下載 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bafbf14ca..109f9f55c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -449,8 +449,15 @@ Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options. %1$s\'s profile is saved for the next game launch. Start without Steam - For a game that closes right away: starts it without Proton\'s Steam setup. Steam features such as the overlay are off for it. + Opens it in Proton/FEX without the Steam client; Steam features are off. On + Starts with Steam + Starts without Steam + Settings + %1$s Settings + Default + Puts everything changed for this game back to the app\'s settings. + Custom sync variables %1$s\'s Proton choice is saved and available in Steam now. %1$s\'s Proton choice is saved. Restart Steam before launching the game. %1$s cannot use the selected Proton on this device. @@ -2011,6 +2018,21 @@ Epic sign-in (EOS) Launch offline Resolve Epic sign-in + Epic Games Launcher + Version %1$s + Install the Linux runtime first + Checking for the latest version… + Unpacking… + The download server did not answer + The Epic Games Launcher needs FEX, which Steam downloads. Open the launcher again after Steam has started. + Download failed: %1$s + Could not unpack the launcher: %1$s + The Epic Games Launcher did not take the sign-in. Start it again from the launcher. + Removes the launcher and frees its space. Your Epic games stay installed and keep working, and its sign-in is kept for when you install it again. + The running session closes first. + Epic Games Launcher uninstalled + Some launcher files could not be removed. Try again. + Wait for the Epic Games Launcher to finish uninstalling Cloud saves Upload to cloud Download from cloud diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..5ade7336e --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 127.0.0.1 + localhost + + diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt index cf428d878..4fe40920d 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -122,4 +122,26 @@ class GameEnvironmentTest { assertEquals("value", compatibility.games.getValue("42")["CUSTOM"]) assertEquals("COMPATIBILITY", GameEnvironment.gameFexPreset(compatibility, "42")) } + + @Test fun gameSyncChoiceMatchesTheAppWideChoiceAndCanInheritAgain() { + val base = GameEnvironment.Config(games = mapOf("42" to mapOf("FEX_TSOENABLED" to "1", "PROTON_NO_ESYNC" to "1"))) + assertEquals(GameEnvironment.CUSTOM_SYNC, GameEnvironment.gameSync(base, "42")) + assertNull(GameEnvironment.gameSync(base, "43")) + for (backend in GameEnvironment.syncBackends) { + val chosen = GameEnvironment.withGameSync(base, "42", backend) + assertEquals(backend, GameEnvironment.gameSync(chosen, "42")) + assertEquals("1", chosen.games["42"]!!["FEX_TSOENABLED"]) + } + val ntsync = GameEnvironment.withGameSync(base, "42", "ntsync").games["42"]!! + assertEquals(listOf("1", "0", "1"), listOf(ntsync["BL_SYNC"], ntsync["BL_FSYNC_FIRST"], ntsync["BL_SYNC_FALLBACK"])) + assertFalse(ntsync.containsKey("PROTON_NO_ESYNC")) + val fsync = GameEnvironment.withGameSync(base, "42", "fsync").games["42"]!! + assertEquals(listOf("0", "1", "1", "1"), listOf(fsync["BL_SYNC"], fsync["BL_FSYNC_FIRST"], fsync["BL_SYNC_FALLBACK"], fsync["PROTON_NO_ESYNC"])) + val wineserver = GameEnvironment.withGameSync(base, "42", "wineserver").games["42"]!! + assertEquals(listOf("0", "0", "0"), listOf(wineserver["BL_SYNC"], wineserver["BL_FSYNC_FIRST"], wineserver["BL_SYNC_FALLBACK"])) + val inherited = GameEnvironment.withGameSync(base, "42", null) + assertNull(GameEnvironment.gameSync(inherited, "42")) + assertEquals(mapOf("FEX_TSOENABLED" to "1"), inherited.games["42"]) + assertTrue(GameEnvironment.withGameSync(GameEnvironment.Config(), "42", null).games.isEmpty()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt index e92009812..410d298ac 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesStoreScanTest.kt @@ -49,6 +49,23 @@ class AddedGamesStoreScanTest { assertEquals(Store.EPIC.id, game.source) } + @Test fun anEpicGameAlwaysUsesItsLauncherWrapperEvenAfterAnExeIsPicked() { + val folder = install(Store.EPIC, "picked", "Picked Epic", "Binaries/Game.exe", launcher = true) + AddedExes.put(app, AddedExes.Entry(folder.path, File(folder, "Binaries/Game.exe").path, picked = true)) + val game = AddedGames.scan(app).single { it.folder.absolutePath == folder.absolutePath } + assertEquals("Binaries/Game.exe", game.exe.relativeTo(folder).path) + assertTrue(game.guestExe.endsWith("/" + StoreLaunch.LAUNCHER)) + } + + @Test fun aDifferentPickedExeOnAnEpicGameIsRunDirectly() { + val folder = install(Store.EPIC, "other", "Other Epic", "Binaries/Game.exe", launcher = true) + val tool = File(folder, "Tools/Editor.exe").apply { parentFile!!.mkdirs(); writeText("tool") } + AddedExes.put(app, AddedExes.Entry(folder.path, tool.path, picked = true)) + val game = AddedGames.scan(app).single { it.folder.absolutePath == folder.absolutePath } + assertEquals("Tools/Editor.exe", game.exe.relativeTo(folder).path) + assertTrue(game.guestExe.endsWith("/Tools/Editor.exe")) + } + @Test fun anOlderSidecarsSteamSwitchIsIgnoredEveryInstallIsListed() { val folder = install(Store.AMAZON, "amzn1.adg.product.x", "Yakuza 0", "Yakuza0.exe") val file = StoreGameSidecar.file(folder) diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadMouseTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadMouseTest.kt new file mode 100644 index 000000000..82364a0f8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/PadMouseTest.kt @@ -0,0 +1,226 @@ +package com.droiddeck.launcher.input + +import android.view.KeyEvent +import android.view.MotionEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class PadMouseTest { + private val events = mutableListOf() + private var scrolledX = 0f + private var scrolledY = 0f + private var now = 1000L + private lateinit var pad: PadMouse + + private val listener = object : PadMouse.Listener { + override fun active() = true + override fun onScrollStart() { events += "scroll start" } + override fun onScroll(dx: Float, dy: Float) { + scrolledX += dx + scrolledY += dy + } + override fun onKey(evdev: Int, pressed: Boolean) { events += "key $evdev ${if (pressed) "down" else "up"}" } + override fun onKeyboard() { events += "keyboard" } + override fun onNavigate() {} + override fun scrollSpeed() = 2000f + } + + @Before fun setUp() { + pad = PadMouse(listener) { now } + } + + @Test fun aOpensTheSelection() { + key(KeyEvent.KEYCODE_BUTTON_A, true) + key(KeyEvent.KEYCODE_BUTTON_A, false) + key(KeyEvent.KEYCODE_DPAD_DOWN, true) + key(KeyEvent.KEYCODE_DPAD_DOWN, false) + key(KeyEvent.KEYCODE_BUTTON_A, true) + key(KeyEvent.KEYCODE_BUTTON_A, false) + assertEquals(listOf("key 28 down", "key 28 up", "key 108 down", "key 108 up", "key 28 down", "key 28 up"), events) + } + + @Test fun rightStickScrollsByHowFarItIsPushedThenSettlesTheSelection() { + motion(rightY = 0.5f) + pad.step(0.1f) + assertEquals(33.9f, scrolledY, 0.1f) + motion(rightY = 1f, rightX = -1f) + pad.step(0.1f) + assertEquals(175.3f, scrolledY, 0.1f) + assertEquals(-141.4f, scrolledX, 0.1f) + motion() + pad.step(0.1f) + pad.step(0.1f) + assertEquals(listOf("scroll start", "key 183 down", "key 183 up"), events) + } + + @Test fun eachControllerKeepsItsOwnSticksAndButtons() { + motion(rightY = 1f, device = 1) + motion(device = 2) + pad.step(0.1f) + assertEquals(200f, scrolledY, 0.01f) + key(KeyEvent.KEYCODE_BUTTON_A, true, device = 1) + key(KeyEvent.KEYCODE_BUTTON_A, true, device = 2) + key(KeyEvent.KEYCODE_BUTTON_A, false, device = 2) + assertEquals(listOf("scroll start", "key 28 down"), events) + key(KeyEvent.KEYCODE_BUTTON_A, false, device = 1) + assertEquals(listOf("scroll start", "key 28 down", "key 28 up"), events) + } + + @Test fun resetLetsGoOfAHeldModifierAndTheNextPressWorks() { + key(KeyEvent.KEYCODE_BUTTON_L1, true) + pad.reset() + key(KeyEvent.KEYCODE_BUTTON_L1, false) + key(KeyEvent.KEYCODE_BUTTON_Y, true) + pad.reset() + key(KeyEvent.KEYCODE_BUTTON_Y, true) + assertEquals(listOf("key 29 down", "key 104 down", "key 104 up", "key 29 up", "keyboard", "keyboard"), events) + } + + @Test fun leftStickMovesFromHalfway() { + motion(leftX = 0.45f) + pad.step(0.016f) + motion(leftX = 0.55f) + pad.step(0.016f) + assertEquals(listOf("key 106 down", "key 106 up"), events) + } + + @Test fun otherButtons() { + for (code in listOf(KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1)) { + key(code, true) + key(code, false) + } + assertEquals(listOf("key 127 down", "key 127 up", "key 1 down", "key 158 down", "key 158 up", "key 1 up", "key 28 down", "key 28 up", + "key 29 down", "key 104 down", "key 104 up", "key 29 up", "key 29 down", "key 109 down", "key 109 up", "key 29 up"), events) + } + + @Test fun yOpensTheKeyboardOnce() { + key(KeyEvent.KEYCODE_BUTTON_Y, true) + pad.onKey(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_Y, 1)) + key(KeyEvent.KEYCODE_BUTTON_Y, false) + assertEquals(listOf("keyboard"), events) + } + + @Test fun guideAndSelectStayWithTheGamepad() { + assertFalse(pad.onKey(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_MODE))) + assertFalse(pad.onKey(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_SELECT))) + assertTrue(pad.onKey(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_R2))) + assertEquals(emptyList(), events) + } + + @Test fun dpadKeysAndHatShareOnePress() { + key(KeyEvent.KEYCODE_DPAD_RIGHT, true) + motion(hatX = 1f) + key(KeyEvent.KEYCODE_DPAD_RIGHT, false) + motion() + motion(hatY = -1f) + motion() + assertEquals(listOf("key 106 down", "key 106 up", "key 103 down", "key 103 up"), events) + } + + @Test fun heldDpadRepeatsAtTheMenuPace() { + key(KeyEvent.KEYCODE_DPAD_RIGHT, true) + pad.step(0.016f) + now += 299 + pad.step(0.016f) + now += 1 + pad.step(0.016f) + now += 120 + pad.step(0.016f) + key(KeyEvent.KEYCODE_DPAD_RIGHT, false) + pad.step(0.016f) + now += 1000 + pad.step(0.016f) + assertEquals(List(3) { listOf("key 106 down", "key 106 up") }.flatten(), events) + } + + @Test fun leftStickStepsThroughItemsAndRepeats() { + motion(leftY = 1f) + pad.step(0.016f) + pad.step(0.016f) + now += 400 + pad.step(0.016f) + now += 100 + pad.step(0.016f) + now += 50 + pad.step(0.016f) + motion() + pad.step(0.016f) + motion(leftX = -0.3f) + pad.step(0.016f) + assertEquals(listOf("key 108 down", "key 108 up", "key 108 down", "key 108 up", "key 108 down", "key 108 up"), events) + } + + @Test fun touchPadDrivesTheSameMapping() { + val state = PadState() + state.press(PadState.A, true) + pad.onTouchPad(state) + state.press(PadState.A, false) + state.down = true + pad.onTouchPad(state) + state.down = false + state.rightTrigger = 1f + pad.onTouchPad(state) + pad.step(0.1f) + assertEquals(listOf("key 28 down", "key 28 up", "key 108 down", "key 108 up", "scroll start"), events) + assertEquals(200f, scrolledY, 0.01f) + } + + @Test fun resetReleasesEverythingHeld() { + key(KeyEvent.KEYCODE_BUTTON_X, true) + key(KeyEvent.KEYCODE_DPAD_DOWN, true) + pad.reset() + key(KeyEvent.KEYCODE_DPAD_DOWN, false) + assertEquals(listOf("key 127 down", "key 108 down", "key 108 up", "key 127 up"), events) + } + + @Test fun stickCurveIsGentleNearTheMiddle() { + val (half, _) = PadMouse.curve(0.575f, 0f) + assertEquals(0.25f, half, 0.01f) + assertEquals(0f to 0f, PadMouse.curve(0.1f, 0.1f)) + } + + @Test fun triggersScroll() { + motion(rightTrigger = 1f) + pad.step(0.1f) + assertEquals(200f, scrolledY, 0.01f) + motion(leftTrigger = 1f) + pad.step(0.1f) + assertEquals(0f, scrolledY, 0.01f) + motion() + pad.step(0.1f) + assertEquals(listOf("scroll start", "key 183 down", "key 183 up"), events) + } + + private fun key(code: Int, down: Boolean, device: Int = 0) { + val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP + assertTrue(pad.onKey(KeyEvent(0, 0, action, code, 0, 0, device, 0))) + } + + private fun motion(leftX: Float = 0f, leftY: Float = 0f, rightX: Float = 0f, rightY: Float = 0f, hatX: Float = 0f, hatY: Float = 0f, + leftTrigger: Float = 0f, rightTrigger: Float = 0f, device: Int = 0) { + val properties = arrayOf(MotionEvent.PointerProperties().apply { id = 0; toolType = MotionEvent.TOOL_TYPE_UNKNOWN }) + val coords = arrayOf(MotionEvent.PointerCoords().apply { + setAxisValue(MotionEvent.AXIS_X, leftX) + setAxisValue(MotionEvent.AXIS_Y, leftY) + setAxisValue(MotionEvent.AXIS_Z, rightX) + setAxisValue(MotionEvent.AXIS_RZ, rightY) + setAxisValue(MotionEvent.AXIS_HAT_X, hatX) + setAxisValue(MotionEvent.AXIS_HAT_Y, hatY) + setAxisValue(MotionEvent.AXIS_LTRIGGER, leftTrigger) + setAxisValue(MotionEvent.AXIS_RTRIGGER, rightTrigger) + }) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 1, properties, coords, 0, 0, 1f, 1f, device, 0, + android.view.InputDevice.SOURCE_JOYSTICK, 0) + pad.onMotion(event) + event.recycle() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/ScrollTouchGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/ScrollTouchGesturesTest.kt new file mode 100644 index 000000000..60436eaab --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/ScrollTouchGesturesTest.kt @@ -0,0 +1,190 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ScrollTouchGesturesTest { + private val events = mutableListOf() + private var scrolledX = 0f + private var scrolledY = 0f + private lateinit var gestures: ScrollTouchGestures + private var time = 1000L + private var downTime = 0L + + private val listener = object : ScrollTouchGestures.Listener { + override fun onMove(x: Float, y: Float) { events += "move ${x.toInt()},${y.toInt()}" } + override fun onButton(button: Int, pressed: Boolean) { + val name = if (button == PointerGestures.BTN_RIGHT) "right" else "left" + events += "$name ${if (pressed) "down" else "up"}" + } + override fun onScroll(dx: Float, dy: Float) { + scrolledX += dx + scrolledY += dy + } + override fun onLongPress() { events += "long press" } + } + + @Before fun setUp() { + gestures = ScrollTouchGestures(SLOP, MIN_FLING, listener) + } + + @Test fun tapClicksWhereTheFingerIs() { + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + advance(80) + touch(MotionEvent.ACTION_UP, 400f, 300f) + advance(100) + assertEquals(listOf("move 400,300", "left down", "left up"), events) + } + + @Test fun draggingScrollsWithTheFingerAndNeverClicks() { + touch(MotionEvent.ACTION_DOWN, 400f, 800f) + advance(200) + touch(MotionEvent.ACTION_MOVE, 403f, 600f) + advance(200) + touch(MotionEvent.ACTION_MOVE, 405f, 300f) + advance(200) + touch(MotionEvent.ACTION_UP, 405f, 300f) + advance(100) + assertEquals(500f, scrolledY, 0.01f) + assertEquals(0f, scrolledX, 0.01f) + assertEquals(listOf("move 400,800"), events) + } + + @Test fun sidewaysDragScrollsSideways() { + touch(MotionEvent.ACTION_DOWN, 900f, 400f) + advance(200) + touch(MotionEvent.ACTION_MOVE, 600f, 410f) + advance(200) + touch(MotionEvent.ACTION_UP, 600f, 410f) + assertEquals(300f, scrolledX, 0.01f) + assertEquals(0f, scrolledY, 0.01f) + } + + @Test fun quickSwipeKeepsScrollingAfterLiftingAndATapStopsIt() { + touch(MotionEvent.ACTION_DOWN, 400f, 900f) + advance(16) + touch(MotionEvent.ACTION_MOVE, 400f, 800f) + advance(16) + touch(MotionEvent.ACTION_MOVE, 400f, 700f) + advance(16) + touch(MotionEvent.ACTION_UP, 400f, 600f) + val lifted = scrolledY + repeat(3) { shadowOf(Looper.getMainLooper()).runOneTask() } + assertTrue(scrolledY > lifted) + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + val stopped = scrolledY + advance(80) + touch(MotionEvent.ACTION_UP, 400f, 300f) + advance(500) + assertEquals(stopped, scrolledY, 0.01f) + assertFalse(events.contains("left down")) + } + + @Test fun longPressRightClicks() { + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + advance(600) + touch(MotionEvent.ACTION_UP, 402f, 301f) + advance(100) + assertEquals(listOf("move 400,300", "long press", "right down", "right up"), events) + } + + @Test fun longPressThenMoveDragsWithTheLeftButton() { + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + advance(600) + touch(MotionEvent.ACTION_MOVE, 500f, 300f) + advance(50) + touch(MotionEvent.ACTION_UP, 500f, 300f) + assertEquals(listOf("move 400,300", "long press", "left down", "move 500,300", "left up"), events) + assertEquals(0f, scrolledY, 0.01f) + } + + @Test fun cancelReleasesAHeldDrag() { + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + advance(600) + touch(MotionEvent.ACTION_MOVE, 500f, 300f) + gestures.cancel() + assertEquals("left up", events.last()) + events.clear() + touch(MotionEvent.ACTION_UP, 500f, 300f) + advance(100) + assertEquals(emptyList(), events) + } + + @Test fun aLiftAfterACancelDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 400f, 300f) + advance(50) + gestures.cancel() + touch(MotionEvent.ACTION_UP, 400f, 300f) + advance(100) + assertEquals(listOf("move 400,300"), events) + } + + @Test fun aSecondFingerDoesNotJumpTheScroll() { + touch(MotionEvent.ACTION_DOWN, 400f, 900f) + advance(100) + touch(MotionEvent.ACTION_MOVE, 400f, 700f) + advance(100) + pointers(MotionEvent.ACTION_POINTER_DOWN, 1, 0 to (400f to 700f), 1 to (900f to 200f)) + advance(100) + pointers(MotionEvent.ACTION_POINTER_UP, 0, 0 to (400f to 700f), 1 to (900f to 200f)) + advance(100) + pointers(MotionEvent.ACTION_UP, 0, 1 to (900f to 150f)) + advance(500) + assertEquals(200f, scrolledY, 0.01f) + assertFalse(events.contains("left down")) + } + + @Test fun wheelUnitsLandOnWholePixelsOfWebKitsLineStep() { + for (view in listOf(720, 1280)) { + val step = kotlin.math.floor(Math.pow(view.toDouble(), 2.0 / 3.0)).toFloat() + for (pixels in -300..300) { + val scrolled = ScrollTouchGestures.wheelUnits(pixels, view) / 120f * step + assertEquals(pixels, scrolled.toInt()) + } + } + assertEquals(0, ScrollTouchGestures.wheelUnits(0, 720)) + assertEquals(2, ScrollTouchGestures.wheelUnits(1, 720)) + assertEquals(-2, ScrollTouchGestures.wheelUnits(-1, 720)) + } + + private fun advance(ms: Long) { + time += ms + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) + } + + private fun touch(action: Int, x: Float, y: Float) { + if (action == MotionEvent.ACTION_DOWN) downTime = time + val event = MotionEvent.obtain(downTime, time, action, x, y, 0) + gestures.onTouch(event) + event.recycle() + } + + private fun pointers(action: Int, index: Int, vararg at: Pair>) { + val properties = at.map { (id, _) -> + MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } + }.toTypedArray() + val coords = at.map { (_, xy) -> + MotionEvent.PointerCoords().apply { x = xy.first; y = xy.second; pressure = 1f; size = 1f } + }.toTypedArray() + val event = MotionEvent.obtain(downTime, time, action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), at.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + gestures.onTouch(event) + event.recycle() + } + + private companion object { + const val SLOP = 8f + const val MIN_FLING = 50f + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/AddedGameMoveTest.kt b/app/src/test/java/com/droiddeck/launcher/session/AddedGameMoveTest.kt new file mode 100644 index 000000000..c549adf59 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/AddedGameMoveTest.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGameMoveTest { + private val app get() = RuntimeEnvironment.getApplication() + private val prefs get() = app.getSharedPreferences("session", Context.MODE_PRIVATE) + private val from = "/data/root/Games/Stores/Epic/Fig" + private val to = "/storage/emulated/0/Games/Games/Epic/Fig" + + @Test + fun settingsOfTheOldFolderMoveToTheNewOne() { + val all = mapOf( + "addedAppId:$from" to 3703311242L, + "addedExe:$from" to "$from/Fig.exe", + "addedExeSeen:$from" to 2, + "addedArt:hero:$from" to "https://cdn/hero.png", + "addedAdopted:exe:$from" to from, + "addedAppId:$from-2" to 5L, + "addedName:/other" to "Other", + "theme" to "graphite", + ) + assertEquals( + mapOf( + "addedAppId:$from" to ("addedAppId:$to" to 3703311242L), + "addedExe:$from" to ("addedExe:$to" to "$to/Fig.exe"), + "addedExeSeen:$from" to ("addedExeSeen:$to" to 2), + "addedArt:hero:$from" to ("addedArt:hero:$to" to "https://cdn/hero.png"), + "addedAdopted:exe:$from" to ("addedAdopted:exe:$to" to to), + ), + SessionPrefs.movedAddedGame(all, from, to), + ) + } + + @Test + fun nothingMovesWithoutSettings() { + assertEquals(emptyMap>(), SessionPrefs.movedAddedGame(mapOf("theme" to "x"), from, to)) + } + + @Test + fun aMovedStoreGameKeepsItsAppIdAndRemoval() { + SessionPrefs.followStoreGame(app, "epic:fig", from) { true } + assertEquals(3703311242L, SessionPrefs.addedGameAppId(app, from, 3703311242L)) + SessionPrefs.setAddedGameExe(app, from, "$from/Fig.exe") + SessionPrefs.setAddedGameRemoved(app, from, true) + SessionPrefs.followStoreGame(app, "epic:fig", to) { true } + assertEquals(3703311242L, SessionPrefs.addedGameAppId(app, to, 1L)) + assertEquals("$to/Fig.exe", SessionPrefs.addedGameExe(app, to)) + assertEquals(listOf(to), SessionPrefs.removedAddedGames(app)) + assertEquals(false, prefs.contains("addedAppId:$from")) + } + + @Test + fun aCopyThatIsStillThereKeepsItsOwnSettings() { + SessionPrefs.followStoreGame(app, "epic:fig", from) { false } + SessionPrefs.addedGameAppId(app, from, 7L) + SessionPrefs.followStoreGame(app, "epic:fig", to) { false } + assertEquals(9L, SessionPrefs.addedGameAppId(app, to, 9L)) + assertEquals(7L, SessionPrefs.addedGameAppId(app, from, 1L)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt new file mode 100644 index 000000000..84c995ee2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt @@ -0,0 +1,95 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.stores.epic.EpicClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class NoSteamLaunchTest { + private val context get() = RuntimeEnvironment.getApplication() + + @After fun clean() { + File(context.filesDir, "game-environment.json").delete() + NoSteamLaunch.names.clear() + } + + private val steamGame = Library.SteamGame(413150, "Stardew Valley", null, "internal") + private val shortcut = Library.SteamGame((2733369024L).toInt(), "Hogwarts Legacy", null, Library.ADDED, source = "epic") + private val launcher = Library.SteamGame(EpicClient.LAUNCHER_APP_ID, Library.EPIC_LAUNCHER_NAME, null, Library.EPIC_LAUNCHER, source = "epic") + + @Test fun eachKindOfGameKeepsTheSettingUnderItsOwnId() { + assertEquals("413150", NoSteamLaunch.scope(steamGame)) + assertEquals("2733369024", NoSteamLaunch.scope(shortcut)) + assertEquals(EpicClient.shortcutAppId.toString(), NoSteamLaunch.scope(launcher)) + assertNull(NoSteamLaunch.scope(Library.SteamGame(0, "nothing", null, "internal"))) + } + + @Test fun theSettingIsRememberedPerGame() { + assertFalse(NoSteamLaunch.enabled(context, shortcut)) + GameEnvironmentStore.setGameNoSteam(context, "2733369024", true) + assertTrue(NoSteamLaunch.enabled(context, shortcut)) + assertTrue(NoSteamLaunch.enabled(context, 2733369024L)) + assertFalse(NoSteamLaunch.enabled(context, steamGame)) + assertFalse(NoSteamLaunch.enabled(context, launcher)) + GameEnvironmentStore.setGameNoSteam(context, EpicClient.shortcutAppId.toString(), true) + assertTrue(NoSteamLaunch.enabled(context, launcher)) + GameEnvironmentStore.setGameNoSteam(context, "2733369024", false) + assertFalse(NoSteamLaunch.enabled(context, shortcut)) + assertTrue(GameEnvironment.gameNoSteam(GameEnvironmentStore.read(context), EpicClient.shortcutAppId.toString())) + } + + @Test fun eachGameStartsInItsOwnProtonOrTheLauncherWithoutSteam() { + val game = AddedGames.Game( + File("/x/Hogwarts Legacy"), "Hogwarts Legacy", File("/x/Hogwarts Legacy/.droiddeck-launch.bat"), + "/mnt/droiddeck-sd/Games/Epic/Hogwarts Legacy/.droiddeck-launch.bat", "/mnt/droiddeck-sd/Games/Epic/Hogwarts Legacy", + 2733369024L, 0L, emptyList(), source = "epic", launchOptions = "-windowed", + ) + val added = NoSteamLaunch.command(shortcut, game)!! + assertEquals(NoSteamLaunch.PROTON_RUN, added.program) + assertEquals( + listOf("--app", "2733369024", "--dir", game.guestDir, "--options", "-windowed", "--", game.guestExe), + added.args, + ) + assertNull(NoSteamLaunch.command(shortcut, null)) + val steam = NoSteamLaunch.command(steamGame, null)!! + assertEquals(listOf("--app", "413150"), steam.args) + val epic = NoSteamLaunch.command(launcher, null)!! + assertEquals(NoSteamLaunch.EPIC to listOf("run"), epic.program to epic.args) + } + + @Test fun anAddedGameOwnedOnSteamUsesItsSteamIdForSettingsAndLaunch() { + val routed = AddedGames.Game( + File("/x/Stardew Valley"), "Stardew Valley", File("/x/Stardew Valley/Stardew Valley.exe"), + "/mnt/droiddeck-sd/Games/Stardew Valley/Stardew Valley.exe", "/mnt/droiddeck-sd/Games/Stardew Valley", + 2733369024L, 413150L, emptyList(), steamAppId = 413150, + ) + assertEquals(413150L, NoSteamLaunch.settingsId(routed)) + val entry = Library.SteamGame(413150, "Stardew Valley", null, Library.ADDED) + assertEquals(NoSteamLaunch.settingsId(routed).toString(), NoSteamLaunch.scope(entry)) + val command = NoSteamLaunch.command(entry, routed)!! + assertEquals(NoSteamLaunch.PROTON_RUN to listOf("--app", "413150"), command.program to command.args) + val plain = AddedGames.Game(File("/x/a"), "a", File("/x/a/a.exe"), "/g/a.exe", "/g", 2733369024L, 0L, emptyList()) + assertEquals(2733369024L, NoSteamLaunch.settingsId(plain)) + } + + @Test fun theRunningSessionIsNamedAfterItsGame() { + val command = NoSteamLaunch.command(steamGame, null)!! + assertNull(NoSteamLaunch.nameFor(command.program, command.args)) + NoSteamLaunch.remember(command) + assertEquals("Stardew Valley", NoSteamLaunch.nameFor(command.program, command.args)) + assertEquals(Library.EPIC_LAUNCHER_NAME, NoSteamLaunch.nameFor(NoSteamLaunch.EPIC, listOf("run"))) + assertNull(NoSteamLaunch.nameFor("/usr/bin/retroarch", emptyList())) + assertNull(NoSteamLaunch.nameFor(NoSteamLaunch.PROTON_RUN, listOf("/x/a.exe"))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt index d0f2de6ca..213cbf9a1 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt @@ -113,12 +113,34 @@ class StoreGameSidecarTest { assertFalse(text.contains("DD_AUTH")) } + @Test fun epicLauncherPrefersTheClientsArguments() { + val text = StoreLaunch.launcherText(sample.copy(exe = "Binaries/Win64/Celeste.exe")) + val lines = text.split("\r\n") + val jump = lines.indexOf("if exist \"%~dp0.droiddeck-epic-args\" goto client") + assertTrue(jump in 0 until lines.indexOf("set \"DD_X=\"")) + val client = lines.indexOf(":client") + assertTrue(client > lines.indexOfFirst { it.contains("-AUTH_TYPE=exchangecode") }) + assertEquals("goto :eof", lines[client - 1]) + assertEquals( + listOf( + "set \"DD_A=\"", + "set /p DD_A=<\"%~dp0.droiddeck-epic-args\"", + "del \"%~dp0.droiddeck-epic-args\"", + "if exist \"%~dp0.droiddeck-epic-code\" del \"%~dp0.droiddeck-epic-code\"", + "set \"DD_A=\" & \"%~dp0Binaries\\Win64\\Celeste.exe\" %DD_A%", + ), + lines.subList(client + 1, client + 6), + ) + assertTrue(text.endsWith("%DD_A%\r\n")) + } + @Test fun gogLauncherHasNoEpicBlockAndIsOnlyWrittenWhenNeeded() { val plain = StoreGameSidecar(Store.GOG, "1", "T", "game.exe") val text = StoreLaunch.launcherText(plain.copy(args = listOf("-windowed"))) assertFalse(text.contains("exchangecode")) assertTrue(text.contains("\"%~dp0game.exe\" -windowed\r\n")) assertFalse(text.contains("DD_X")) + assertFalse(text.contains("droiddeck-epic-args")) assertEquals("\"two words\"", StoreLaunch.quoteArg("two words")) assertEquals("-x=\"a b\"", StoreLaunch.quoteArg("-x=\"a b\"")) } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt index de903c80f..c40ef5b20 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt @@ -26,6 +26,18 @@ class StoreInstallRootTest { assertFalse(StoreInstallRoot.targets(app).first().removable) } + @Test fun theSteamLibraryFolderIsOfferedAsAnInstallTarget() { + val library = File(app.filesDir, "Documents/Games").apply { mkdirs() } + SessionPrefs.setGameStorage(app, library.path, "Games") + val targets = StoreInstallRoot.targets(app) + assertEquals(2, targets.size) + assertEquals(File(library, "Games").absolutePath, targets.last().root.absolutePath) + assertTrue(targets.last().folder) + assertTrue(targets.last().label.startsWith("Games")) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + assertEquals(1, StoreInstallRoot.targets(app).size) + } + @Test fun anExistingInstallWinsOverAFreshFolderUnderTheChosenRoot() { SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") val existing = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "Old Name").apply { mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt new file mode 100644 index 000000000..720e122c1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt @@ -0,0 +1,448 @@ +package com.droiddeck.launcher.stores.epic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.security.MessageDigest + +@RunWith(RobolectricTestRunner::class) +class EpicClientTest { + @get:Rule val tmp = TemporaryFolder() + + private fun game(appId: Int, source: String) = + com.droiddeck.launcher.frontend.Library.SteamGame(appId, "g$appId", null, "internal", source = source) + + @Test fun theLauncherSitsRightAboveTheFirstEpicGame() { + val launcher = game(EpicClient.LAUNCHER_APP_ID, "epic") + val games = listOf(game(1, "steam"), game(2, "epic"), game(3, "steam"), game(4, "epic")) + assertEquals(listOf(1, 0, 2, 3, 4), EpicClient.withLauncher(games, launcher).map { it.appId }) + assertEquals(listOf(1, 3, 0), EpicClient.withLauncher(listOf(games[0], games[2]), launcher).map { it.appId }) + assertEquals(listOf(1, 2), EpicClient.withLauncher(games.take(2), null).map { it.appId }) + } + + private fun added(name: String, source: String, storeId: String?) = + com.droiddeck.launcher.frontend.AddedGames.Game(File(tmp.root, name), name, File(tmp.root, "$name/$name.exe"), + "/root/Games/$name/$name.exe", "/root/Games/$name", 3_000_000_000L, 0L, emptyList(), source = source, storeId = storeId) + + private fun inSteamSession(block: (android.app.Application) -> Unit) { + val app = org.robolectric.RuntimeEnvironment.getApplication() + val state = com.droiddeck.launcher.session.SessionState + state.running = true + state.stopRequested = false + val mode = state.mode + state.mode = com.droiddeck.launcher.session.SessionService.MODE_STEAM + try { + block(app) + } finally { + state.running = false + state.mode = mode + } + } + + @Test fun aGameFromTheLauncherIsFoundAsItsSteamShortcut() { + val shortcut = added("Fig", "epic", "Fig") + val games = listOf(added("Gog", "gog", "Fig"), added("Plain", "added", null), shortcut) + assertEquals(shortcut, EpicClient.steamShortcut(games, "Fig")) + assertNull(EpicClient.steamShortcut(games, "Other")) + } + + @Test fun onlyARunningSteamSessionTakesGamesFromTheLauncher() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + assertEquals("no-steam", EpicClient.playInSteam(app, "Fig").getString("reason")) + assertEquals("no-steam", EpicClient.runSteamGame(app, "1091500").getString("reason")) + val state = com.droiddeck.launcher.session.SessionState + val mode = state.mode + state.running = true + state.mode = com.droiddeck.launcher.session.SessionService.MODE_RUN + try { + assertEquals("no-steam", EpicClient.runSteamGame(app, "1091500").getString("reason")) + } finally { + state.running = false + state.mode = mode + } + inSteamSession { + com.droiddeck.launcher.session.SessionState.stopRequested = true + try { + assertEquals("no-steam", EpicClient.runSteamGame(it, "1091500").getString("reason")) + } finally { + com.droiddeck.launcher.session.SessionState.stopRequested = false + } + } + } + + @Test fun aSteamGameTheLauncherListsIsStartedInTheRunningClient() = inSteamSession { app -> + assertEquals("bad-id", EpicClient.runSteamGame(app, "0").getString("reason")) + assertTrue(EpicClient.runSteamGame(app, "1091500").getBoolean("ok")) + val started = org.robolectric.Shadows.shadowOf(app).nextStartedService + assertEquals(com.droiddeck.launcher.session.SessionService.ACTION_LAUNCH_GAME, started.action) + assertEquals("1091500", started.getStringExtra(com.droiddeck.launcher.session.SessionService.EXTRA_GAME_ID)) + } + + @Test fun anEpicGameWithoutAShortcutIsNotHandedToSteam() = inSteamSession { app -> + assertEquals("not-registered", EpicClient.playInSteam(app, "Missing").getString("reason")) + } + + @Test fun theLauncherShortcutHasTheIdSteamGivesIt() { + val crc = java.util.zip.CRC32().apply { update("\"/usr/local/bin/droiddeck-epic-steam\"Epic Games Launcher".toByteArray()) }.value + assertEquals(crc or 0x80000000L, EpicClient.shortcutAppId) + assertTrue(EpicClient.shortcutAppId in 0x80000000L..0xFFFFFFFFL) + assertEquals("12918456320", EpicClient.gameId(3L)) + assertEquals(java.lang.Long.toUnsignedString((EpicClient.shortcutAppId shl 32) or 0x02000000L), EpicClient.gameId(EpicClient.shortcutAppId)) + assertTrue(EpicClient.gameId(EpicClient.shortcutAppId).toBigInteger() > Long.MAX_VALUE.toBigInteger()) + } + + @Test fun theSteamListingCarriesTheLauncherOnlyOnceInstalled() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + assertNull(EpicClient.steamListing(app)) + val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(app, emptyList(), + listOf(org.json.JSONObject().put("name", "A").put("native", true), org.json.JSONObject().put("name", "B"))) + val parsed = org.json.JSONArray(listing.readText()) + assertEquals(2, parsed.length()) + assertTrue(parsed.getJSONObject(0).getBoolean("native")) + assertEquals("B", parsed.getJSONObject(1).getString("name")) + assertEquals(0, org.json.JSONArray(com.droiddeck.launcher.frontend.AddedGames.writeListing(app, emptyList()).readText()).length()) + } + + @Test fun thePadDrivesTheLauncherOnlyWhileSteamShowsIt() = inSteamSession { app -> + val root = com.droiddeck.launcher.runtime.LinuxRuntime.sessionRoot(app).apply { mkdirs() } + val open = File(root, EpicClient.OPEN) + val focused = File(root, EpicClient.FOCUSED_APP) + try { + assertFalse(EpicClient.launcherRunning(app)) + assertFalse(EpicClient.launcherFocused(app)) + open.writeText("1\n") + assertTrue(EpicClient.launcherRunning(app)) + focused.writeText("769\n") + assertFalse(EpicClient.launcherFocused(app)) + focused.writeText("${EpicClient.shortcutAppId}\n") + assertTrue(EpicClient.launcherFocused(app)) + open.writeText("3000000001\n") + assertFalse(EpicClient.launcherFocused(app)) + focused.writeText("3000000001\n") + assertTrue(EpicClient.launcherFocused(app)) + com.droiddeck.launcher.session.SessionState.mode = com.droiddeck.launcher.session.SessionService.MODE_RUN + assertFalse(EpicClient.launcherFocused(app)) + assertFalse(EpicClient.launcherRunning(app)) + } finally { + open.delete() + focused.delete() + } + } + + @Test fun theLauncherEntryNeedsAnInstalledLauncherAndUsesItsIcon() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + assertNull(EpicClient.launcherEntry(app, "Epic Games Launcher", EpicClient.Status(installed = false, version = "1.0"))) + val icon = File(EpicClient.stateDir(app), "app/1.0/epic-games-launcher.png").apply { parentFile!!.mkdirs(); writeBytes(byteArrayOf(1)) } + val entry = EpicClient.launcherEntry(app, "Epic Games Launcher", EpicClient.Status(installed = true, version = "1.0"))!! + assertEquals(com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER, entry.library) + assertEquals("epic", entry.source) + assertEquals(icon, entry.art) + assertNull(EpicClient.launcherEntry(app, "x", EpicClient.Status(installed = true, version = "2.0"))!!.art) + } + + @Test fun onlyAnInstalledLauncherIsUpdatedAutomatically() { + assertFalse(EpicClient.updateWanted(EpicClient.Status(installed = false), "1.0")) + assertFalse(EpicClient.updateWanted(EpicClient.Status(installed = false, version = "0.9"), "1.0")) + assertTrue(EpicClient.updateWanted(EpicClient.Status(installed = true, version = "0.9"), "1.0")) + assertFalse(EpicClient.updateWanted(EpicClient.Status(installed = true, version = "1.0"), "1.0")) + assertFalse(EpicClient.updateWanted(EpicClient.Status(installed = true, version = "0.9"), null)) + } + + @Test fun anAutomaticSignInSkipsTheAccountPicker() { + val url = "https://www.epicgames.com/id/authorize?client_id=c85f&prompt=select_account&redirect_uri=http%3A%2F%2F127.0.0.1%3A4%2Fa" + assertEquals("https://www.epicgames.com/id/authorize?client_id=c85f&redirect_uri=http%3A%2F%2F127.0.0.1%3A4%2Fa", EpicClient.withoutAccountPrompt(url)) + assertEquals("https://www.epicgames.com/id/authorize", EpicClient.withoutAccountPrompt("https://www.epicgames.com/id/authorize?prompt=select_account")) + val other = "https://www.epicgames.com/id/authorize?prompt=login&x=prompt%3Dselect_account" + assertEquals(other, EpicClient.withoutAccountPrompt(other)) + assertEquals("https://www.epicgames.com/id/authorize", EpicClient.withoutAccountPrompt("https://www.epicgames.com/id/authorize")) + } + + @Test fun noDroidDeckAccountMeansNoHandOff() { + assertFalse(EpicClient.canHandOff(org.robolectric.RuntimeEnvironment.getApplication())) + } + + @Test fun signingOutDropsTheLaunchersSignInAndTellsARunningLauncher() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + val auth = File(EpicClient.dataDir(app), "auth/auth.json").apply { parentFile?.mkdirs() } + val marker = File(EpicClient.stateDir(app), "signed-out") + val auto = File(EpicClient.stateDir(app), "auto-sign-in").apply { parentFile?.mkdirs(); writeText("1\n") } + try { + auth.writeText("""{"prod":{"refresh_token":"t"}}""") + assertTrue(EpicClient.signOut(app)) + assertFalse(auth.exists()) + assertFalse(auto.exists()) + assertTrue(marker.isFile) + marker.delete() + auth.writeText("""{"prod":{"refresh_token":""}}""") + assertFalse(EpicClient.signOut(app)) + assertFalse(auth.exists()) + assertFalse(marker.exists()) + assertFalse(EpicClient.signOut(app)) + } finally { + auth.delete(); marker.delete(); auto.delete() + } + } + + @Test fun uninstallingRemovesTheProgramButKeepsGamesRecordsAndSignIn() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + val state = EpicClient.stateDir(app) + val runtime = File(state, "runtime/4.0").apply { mkdirs() } + File(runtime, "files").mkdirs() + val loop = File(runtime, "files/usr") + java.nio.file.Files.createSymbolicLink(loop.toPath(), java.nio.file.Paths.get(".")) + val outside = File(app.filesDir, "outside-keep").apply { mkdirs() } + java.nio.file.Files.createSymbolicLink(File(runtime, "files/elsewhere").toPath(), outside.toPath()) + File(outside, "keep.txt").writeText("x") + File(state, "app/0.97.0").mkdirs() + File(state, "app/0.97.0/AppRun").writeText("x") + File(state, "current.json").writeText("""{"app":"0.97.0","runtime":"4.0"}""") + val imports = File(state, "client-imports.json").apply { writeText("{}") } + val auth = File(EpicClient.dataDir(app), "auth/auth.json").apply { parentFile?.mkdirs(); writeText("{}") } + val records = File(EpicClient.dataDir(app), "installations.jsonl").apply { writeText("{}\n") } + try { + assertTrue(EpicClient.uninstall(app)) + assertFalse(File(state, "app").exists()) + assertFalse(File(state, "runtime").exists()) + assertFalse(File(state, "current.json").exists()) + assertTrue(imports.isFile) + assertTrue(auth.isFile) + assertTrue(records.isFile) + assertTrue(File(outside, "keep.txt").isFile) + } finally { + imports.delete(); auth.delete(); records.delete(); outside.deleteRecursively() + } + } + + @Test fun theWebSignInIsExpiredForEveryCookieOnBothPathsAndTheDomain() { + assertEquals(emptyList(), EpicClient.expiredCookies(null)) + assertEquals(emptyList(), EpicClient.expiredCookies(" ; ")) + val lines = EpicClient.expiredCookies("EPIC_SSO=abc; EPIC_SSO_RM=1; EPIC_SSO=again;__Host-x=y") + assertEquals(12, lines.size) + assertTrue(lines.contains("EPIC_SSO=; Max-Age=0; Path=/; Secure")) + assertTrue(lines.contains("EPIC_SSO_RM=; Max-Age=0; Path=/id; Secure; Domain=.epicgames.com")) + assertTrue(lines.contains("__Host-x=; Max-Age=0; Path=/; Secure")) + assertTrue(lines.none { "abc" in it }) + val www = EpicClient.expiredCookies("__cf_bm=1", "www.epicgames.com") + assertEquals(6, www.size) + assertTrue(www.contains("__cf_bm=; Max-Age=0; Path=/; Secure; Domain=www.epicgames.com")) + assertEquals(4, EpicClient.expiredCookies("a=1", "example.com").size) + } + + @Test fun launcherVersionComesFromTheRedirect() { + val location = "https://content-download-egs.distro.on.epicgames.com/Builds/EGLv2/0.95.2%2Bgfa9ad49/Linux/EpicGamesLauncher-x86_64.AppImage?launcherfilename=EpicGamesLauncher-x86_64.AppImage" + assertEquals("0.95.2+gfa9ad49", EpicClientSources.launcherVersion(location)) + assertNull(EpicClientSources.launcherVersion("http://content-download-egs.distro.on.epicgames.com/Builds/EGLv2/1.0/Linux/a")) + assertNull(EpicClientSources.launcherVersion("https://example.com/Builds/EGLv2/..%2F..%2Fetc/Linux/a")) + assertNull(EpicClientSources.launcherVersion("https://example.com/other/path")) + } + + @Test fun runtimeChecksumIsReadFromTheSumsFile() { + val sums = """ + 0f3e16b00ed7256db38733d50995b82484ef730167cbf5693e19634dcb04f39a *com.valvesoftware.SteamRuntime.Platform-amd64,i386-steamrt4-runtime-debug.tar.gz + A0F3E16B00ED7256DB38733D50995B82484EF730167CBF5693E19634DCB04F39 *com.valvesoftware.SteamRuntime.Platform-amd64,i386-steamrt4-runtime.tar.gz + """.trimIndent() + assertEquals("a0f3e16b00ed7256db38733d50995b82484ef730167cbf5693e19634dcb04f39", EpicClientSources.sha256For(sums, EpicClientSources.RUNTIME_FILE)) + assertNull(EpicClientSources.sha256For(sums, "missing.tar.gz")) + } + + private fun md5(bytes: ByteArray) = MessageDigest.getInstance("MD5").digest(bytes) + private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + + @Test fun multipartEtagIsChecked() { + val part = 8 shl 20 + val data = ByteArray(part * 2 + 123) { (it * 31).toByte() } + val file = File(tmp.root, "image").apply { writeBytes(data) } + val digest = hex(md5(md5(data.copyOfRange(0, part)) + md5(data.copyOfRange(part, 2 * part)) + md5(data.copyOfRange(2 * part, data.size)))) + assertEquals(true, EpicClientSources.etagMatches(file, "\"$digest-3\"")) + assertEquals(false, EpicClientSources.etagMatches(file, "\"${"0".repeat(32)}-3\"")) + assertNull(EpicClientSources.etagMatches(file, "\"$digest-99\"")) + assertNull(EpicClientSources.etagMatches(file, "W/\"$digest-3\"")) + assertEquals(true, EpicClientSources.etagMatches(file, hex(md5(data)))) + } + + @Test fun appImageMagicIsRequired() { + val good = File(tmp.root, "a").apply { writeBytes(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1, 1, 0, 'A'.code.toByte(), 'I'.code.toByte(), 2, 0)) } + val bad = File(tmp.root, "b").apply { writeBytes(ByteArray(16)) } + assertTrue(EpicClientSources.isAppImage(good)) + assertFalse(EpicClientSources.isAppImage(bad)) + } + + @Test fun signInUrlsMustRedirectToLoopback() { + val good = "https://www.epicgames.com/id/authorize?client_id=c85f4c6238b04d3399bc0080e83b83fd&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A41234%2Flauncher%2Fauthorized&state=x" + assertEquals("http://127.0.0.1:41234/launcher/authorized", EpicClient.redirectOf(good)) + assertNull(EpicClient.redirectOf(good.replace("www.epicgames.com", "evil.example"))) + assertNull(EpicClient.redirectOf(good.replace("https://", "http://"))) + assertNull(EpicClient.redirectOf(good.replace("127.0.0.1%3A41234", "example.com%3A80"))) + assertNull(EpicClient.redirectOf(good.replace("%3A41234", ""))) + assertFalse(EpicClient.loopback("http://user@127.0.0.1:1/x")) + assertTrue(EpicClient.loopback("http://localhost:5/launcher/authorized?code=1")) + } + + @Test fun itemsDescribeTheInstallForTheLauncher() { + val install = EpicClientLibrary.Install( + "Fig", "ns1", "item1", "Fig Game", "1.0.5", "Binaries/Win64/Fig.exe", "/root/Games/Stores/Epic/Fig Game", + File(tmp.root, "Fig Game"), "dep1", 1234L, + ) + val item = EpicClientLibrary.item(install, listOf("en"), ownership = true) + val guid = EpicClientLibrary.guid("Fig") + assertEquals(32, guid.length) + assertEquals(guid, EpicClientLibrary.guid("Fig")) + assertTrue(guid.all { it in "0123456789ABCDEF" }) + assertEquals(guid, item.getString("InstallationGuid")) + assertEquals("/root/Games/Stores/Epic/Fig Game", item.getString("InstallLocation")) + assertEquals("/root/Games/Stores/Epic/Fig Game/.egstore", item.getString("ManifestLocation")) + assertEquals("Binaries/Win64/Fig.exe", item.getString("LaunchExecutable")) + assertEquals("Fig", item.getString("AppName")) + assertEquals("Fig", item.getString("MainGameAppName")) + assertEquals("ns1", item.getString("CatalogNamespace")) + assertEquals("item1", item.getString("CatalogItemId")) + assertEquals("1.0.5", item.getString("AppVersionString")) + assertEquals("true", item.getString("OwnershipToken")) + assertEquals("dep1", item.getString("SidecarDeploymentId")) + assertEquals("Fig Game", item.getString("MandatoryAppFolderName")) + assertFalse(item.getBoolean("bIsIncompleteInstall")) + val list = EpicClientLibrary.aggregateFromManifests(File(tmp.root, "none"), listOf(install)).getJSONArray("InstallationList") + assertEquals(1, list.length()) + assertEquals("Fig", list.getJSONObject(0).getString("ArtifactId")) + assertEquals("ns1", list.getJSONObject(0).getString("NamespaceId")) + assertEquals("item1", list.getJSONObject(0).getString("ItemId")) + } + + @Test fun clientManifestsRemainInTheAggregateAndSupplyDroidDeckShortcuts() { + val manifests = File(tmp.root, "Manifests").apply { mkdirs() } + File(manifests, "client.item").writeText( + """{"AppName":"ClientGame","CatalogNamespace":"ns2","CatalogItemId":"item2","InstallLocation":"/root/Games/ClientGame","LaunchExecutable":"Binaries/ClientGame.exe","AppVersionString":"2.0","DisplayName":"Client Game","bIsIncompleteInstall":false}""", + ) + File(manifests, "partial.item").writeText( + """{"AppName":"Partial","CatalogNamespace":"ns","CatalogItemId":"partial","InstallLocation":"/root/partial","bIsIncompleteInstall":true}""", + ) + val managed = EpicClientLibrary.Install( + "RustGame", "ns1", "item1", "Rust Game", "1.0", "Game.exe", "/root/Games/RustGame", File(tmp.root, "RustGame"), "dep", 10, + ) + val old = org.json.JSONObject().put("InstallationList", org.json.JSONArray().put( + org.json.JSONObject().put("AppName", "InstallingGame").put("ArtifactId", "InstallingGame").put("InstallLocation", "/root/Games/InstallingGame"), + )) + val list = EpicClientLibrary.aggregateFromManifests(manifests, listOf(managed), old).getJSONArray("InstallationList") + assertEquals(3, list.length()) + val names = (0 until list.length()).map { list.getJSONObject(it).getString("AppName") } + assertTrue("ClientGame" in names) + assertTrue("RustGame" in names) + assertTrue("InstallingGame" in names) + assertEquals("Binaries/ClientGame.exe", EpicClientLibrary.executableRelative("/root/Games/ClientGame", "Binaries/ClientGame.exe")) + assertNull(EpicClientLibrary.executableRelative("/root/Games/ClientGame", "../outside.exe")) + assertNull(EpicClientLibrary.executableRelative("/root/Games/ClientGame", "C:/outside.exe")) + } + + @Test fun gamesInstalledByTheClientBecomeEpicLibraryShortcuts() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val game = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/Games/ClientGame") + File(game, "Binaries").mkdirs() + File(game, "Binaries/ClientGame.exe").writeBytes(byteArrayOf(1)) + val manifests = EpicClientLibrary.manifestsDir(context).apply { mkdirs() } + File(manifests, "client.item").writeText( + """{"AppName":"ClientGame","CatalogNamespace":"ns2","CatalogItemId":"item2","InstallLocation":"/root/Games/ClientGame","LaunchExecutable":"Binaries/ClientGame.exe","AppVersionString":"2.0","DisplayName":"Client Game","bIsIncompleteInstall":false}""", + ) + + assertTrue(EpicClientLibrary.importClientGames(context)) + val sidecar = com.droiddeck.launcher.stores.StoreGameSidecar.read(game)!! + assertEquals(com.droiddeck.launcher.stores.Store.EPIC, sidecar.store) + assertEquals("ClientGame", sidecar.id) + assertEquals("Binaries/ClientGame.exe", sidecar.exe) + assertEquals("true", sidecar.extra["clientManaged"]) + assertTrue(File(game, sidecar.launcher!!).isFile) + assertEquals(File(game, "Binaries/ClientGame.exe").path, + com.droiddeck.launcher.frontend.AddedExes.list(context).first { it.folder == game.path }.exe) + } + + @Test fun gamesTheLauncherInstallsItselfAreImportedFromItsInstallationList() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val game = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/Games/Stores/Epic/Luftrausers51924") + File(game, "bin").mkdirs() + File(game, "bin/Luftrausers.exe").writeBytes(byteArrayOf(1)) + val list = EpicClientLibrary.installationsFile(context).apply { parentFile!!.mkdirs() } + list.writeText(listOf( + """{"version":1,"entries":2}""", + """{"installationId":"F00B","complete":false,"dir":"/root/Games/Stores/Epic/Luftrausers51924","platform":"Windows","namespace":"ns","itemId":"it","artifactId":"luft","launchExecutable":"bin/Luftrausers.exe"}""", + """{"installationId":"F00B","complete":true,"dir":"/root/Games/Stores/Epic/Luftrausers51924","platform":"Windows","namespace":"ns","itemId":"it","artifactId":"luft","version":"1.0","sidecar":{"deploymentId":"dep"},"launchExecutable":"bin/Luftrausers.exe"}""", + ).joinToString("\n")) + + assertTrue(EpicClientLibrary.importClientGames(context)) + val sidecar = com.droiddeck.launcher.stores.StoreGameSidecar.read(game)!! + assertEquals(listOf("luft", "Luftrausers51924", "bin/Luftrausers.exe", "1.0", "dep", "true"), + listOf(sidecar.id, sidecar.title, sidecar.exe, sidecar.installVersion, sidecar.extra["deploymentId"], sidecar.extra["clientManaged"])) + assertTrue(com.droiddeck.launcher.frontend.AddedGames.scan(context).any { it.folder.path == game.path && it.source == "epic" }) + assertFalse(EpicClientLibrary.importClientGames(context)) + + list.appendText("\n" + """{"installationId":"F00B","deleted":true}""") + assertTrue(EpicClientLibrary.importClientGames(context)) + assertNull(com.droiddeck.launcher.stores.StoreGameSidecar.read(game)) + assertTrue(com.droiddeck.launcher.frontend.AddedExes.list(context).none { it.folder == game.path }) + } + + @Test fun anUnfinishedOrLinuxInstallIsNotImported() { + val (records, readable) = EpicClientLibrary.clientInstallations(tmp.newFile().apply { + writeText("""{"installationId":"a","complete":true,"platform":"Linux","artifactId":"x","dir":"/root/Games/X"}""" + "\nnot json\n") + }) + assertFalse(readable) + assertTrue(EpicClientLibrary.manifestOf(records.single(), null).getBoolean("bIsIncompleteInstall")) + assertEquals("X", EpicClientLibrary.manifestOf(records.single(), " ").getString("DisplayName")) + assertEquals(emptyList() to true, EpicClientLibrary.clientInstallations(File(tmp.root, "missing"))) + } + + @Test fun theLauncherWaitsForTheFexToolSteamInstalls() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + com.droiddeck.launcher.session.SessionPrefs.setGameStorage(context, com.droiddeck.launcher.session.SessionPrefs.GAME_STORAGE_OFF, "") + val steamapps = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").apply { mkdirs() } + assertFalse(EpicClient.fexReady(context)) + EpicClient.requestFex(context) + assertTrue(File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/fex-wanted").isFile) + File(steamapps, "appmanifest_3127680.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"3127680\"\n\t\"installdir\"\t\t\"FEX-Emu\"\n}\n") + assertFalse(EpicClient.fexReady(context)) + File(steamapps, "common/FEX-Emu/usr/bin").mkdirs() + assertFalse(EpicClient.fexReady(context)) + File(steamapps, "common/FEX-Emu/usr/bin/FEXInterpreter").writeText("") + assertTrue(EpicClient.fexReady(context)) + } + + @Test fun fexOnASecondLibraryIsFoundInItsPrivateToolCopy() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val library = File(context.cacheDir, "card-library").apply { mkdirs() } + com.droiddeck.launcher.session.SessionPrefs.setGameStorage(context, library.path, "SD Card") + try { + val steamapps = File(library, "steamapps").apply { mkdirs() } + File(steamapps, "appmanifest_3127680.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"3127680\"\n\t\"installdir\"\t\t\"FEX-Emu\"\n}\n") + File(steamapps, "common/FEX-Emu").mkdirs() + assertFalse(EpicClient.fexReady(context)) + val private = com.droiddeck.launcher.session.SecondaryLibrary.privateRoot(context.filesDir, library) + File(private, "steamapps/common/FEX-Emu/usr/bin").mkdirs() + File(private, "steamapps/common/FEX-Emu/usr/bin/FEX").writeText("") + assertTrue(EpicClient.fexReady(context)) + } finally { + com.droiddeck.launcher.session.SessionPrefs.setGameStorage(context, com.droiddeck.launcher.session.SessionPrefs.GAME_STORAGE_OFF, "") + library.deleteRecursively() + } + } + + @Test fun theLauncherInstallsWhereDroidDeckInstallsEpicGames() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val settings = File(EpicClient.dataDir(context), "settings/app-settings.json").apply { parentFile!!.mkdirs() } + settings.writeText("""{"defaultInstallDir":"/root/Games","verboseLogs":false}""") + assertEquals("/root/Games/Stores/Epic", EpicClient.prepareInstallDir(context)) + val written = org.json.JSONObject(settings.readText()) + assertEquals("/root/Games/Stores/Epic", written.getString("defaultInstallDir")) + assertFalse(written.getBoolean("verboseLogs")) + assertTrue(File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/Games/Stores/Epic").isDirectory) + settings.writeText("""{"defaultInstallDir":"/root/MyGames"}""") + assertEquals("/root/MyGames", EpicClient.prepareInstallDir(context)) + assertEquals("/root/MyGames", org.json.JSONObject(settings.readText()).getString("defaultInstallDir")) + settings.writeText("""{"defaultInstallDir":"/mnt/droiddeck-sd/Stores/Epic"}""") + assertEquals("/root/Games/Stores/Epic", EpicClient.prepareInstallDir(context)) + } +} diff --git a/tools/linuxfs/build-x86-preloads.sh b/tools/linuxfs/build-x86-preloads.sh index 58e0dcefe..8a43941af 100755 --- a/tools/linuxfs/build-x86-preloads.sh +++ b/tools/linuxfs/build-x86-preloads.sh @@ -41,6 +41,14 @@ for target in x86_64/faultreport i386/faultreport x86_64/thunkaudit; do exit 1 fi done +lib=$out/x86_64/libwebkitinput.so +gcc -m64 -shared -fPIC -O2 -Wall -Wextra -nostdlib -fno-stack-protector -fno-builtin -fvisibility=hidden \ + -o "$lib" tools/linuxfs/fex/webkitinput.c +strip --strip-unneeded "$lib" +if objdump -p "$lib" | grep -q NEEDED || [ "$(nm -D --undefined-only "$lib" | awk '{print $1 $2}' | LC_ALL=C sort | tr '\n' ' ')" != "wdlsym wg_object_get_data wg_object_set wg_object_set_data wg_type_check_instance_is_a wgtk_container_forall wgtk_container_get_type wgtk_key_snooper_install wgtk_widget_get_toplevel wwebkit_user_script_new wwebkit_user_script_unref wwebkit_web_view_get_type wwebkit_web_view_get_uri wwebkit_web_view_run_javascript " ]; then + echo "$lib must need nothing but weak GLib, GTK and WebKit calls" >&2 + exit 1 +fi stub=$(mktemp -d) gcc -m64 -shared -nostdlib -Wl,-soname,/usr/lib/x86_64-linux-gnu/libvulkan.so.1 -o "$stub/libvulkan.so.1" -x c /dev/null gcc -m64 -shared -nostdlib -Wl,-z,nodelete -Wl,--no-as-needed \ diff --git a/tools/linuxfs/fex/webkitinput.c b/tools/linuxfs/fex/webkitinput.c new file mode 100644 index 000000000..fd39bd6ab --- /dev/null +++ b/tools/linuxfs/fex/webkitinput.c @@ -0,0 +1,198 @@ +#if !defined(__x86_64__) +#error webkitinput is built for x86_64 guests only +#endif + +typedef struct _WebKitSettings WebKitSettings; +typedef struct _WebKitUserContentManager WebKitUserContentManager; +typedef struct _WebKitUserScript WebKitUserScript; + +extern void g_object_set(void *object, const char *first_property, ...) __attribute__((weak)); +extern void *g_object_get_data(void *object, const char *key) __attribute__((weak)); +extern void g_object_set_data(void *object, const char *key, void *data) __attribute__((weak)); +extern void *dlsym(void *handle, const char *symbol) __attribute__((weak)); +extern unsigned gtk_key_snooper_install(int (*snooper)(void *, void *, void *), void *data) __attribute__((weak)); +extern void *gtk_widget_get_toplevel(void *widget) __attribute__((weak)); +extern void gtk_container_forall(void *container, void (*callback)(void *, void *), void *data) __attribute__((weak)); +extern unsigned long gtk_container_get_type(void) __attribute__((weak)); +extern unsigned long webkit_web_view_get_type(void) __attribute__((weak)); +extern int g_type_check_instance_is_a(void *instance, unsigned long type) __attribute__((weak)); +extern const char *webkit_web_view_get_uri(void *view) __attribute__((weak)); +extern void webkit_web_view_run_javascript(void *view, const char *script, void *cancellable, void *callback, + void *data) __attribute__((weak)); +extern WebKitUserScript *webkit_user_script_new(const char *source, int frames, int time, + const char *const *allow, const char *const *block) __attribute__((weak)); +extern void webkit_user_script_unref(WebKitUserScript *script) __attribute__((weak)); + +static const char NAV_SCRIPT[] = + "(()=>{let open=-1e9,arrowAt=-1e9,ringed=null,ringOutline='',ringOffset='',settleTimer=0,settling=false,redirecting=false;" + "const shown=()=>[...document.querySelectorAll('[role=dialog],[role=alertdialog],[aria-modal=true],[role=menu]')]" + ".some(e=>e.getClientRects().length);" + "const hidden=e=>{const r=e.getBoundingClientRect();if(!r.width||!r.height)return true;" + "for(let n=e;n&&n.nodeType===1;n=n.parentElement){const s=getComputedStyle(n);" + "if(s.visibility==='hidden'||+s.opacity<0.1)return true;}return false;};" + "const windowButton=e=>/^(Minimize|Maximize|Restore|Close) window$/.test(e.getAttribute('aria-label')||'');" + "const editing=e=>!!e&&e.nodeType===1&&(e.isContentEditable||" + "e.matches('input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]),textarea,select'));" + "const mainNav=()=>[...document.querySelectorAll('nav')].filter(n=>n.querySelectorAll('a[href]').length>1&&!hidden(n))" + ".sort((a,b)=>a.getBoundingClientRect().y-b.getBoundingClientRect().y)[0];" + "const first=nav=>{const below=nav?nav.getBoundingClientRect().bottom:0,all=[];" + "for(const e of document.querySelectorAll('a[href],button,input,[role=button],[tabindex]')){" + "if(e.tabIndex<0||(nav&&nav.contains(e)))continue;const r=e.getBoundingClientRect();" + "if(r.topinnerHeight||windowButton(e)||hidden(e))continue;all.push({e,r});}" + "const big=all.filter(c=>c.r.width*c.r.height>=12000),list=big.length?big:all;" + "const key=c=>Math.round(c.r.top/8)*1e5+c.r.left;" + "list.sort((a,b)=>key(a)-key(b));return list.length?list[0].e:null;};" + "const big=e=>{const r=e.getBoundingClientRect();return r.width*r.height>=12000;};" + "const toward=(from,key,nav,small)=>{const r=from.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;let best=null,score=1e9;" + "for(const c of document.querySelectorAll('a[href],button,[role=button],[tabindex]')){if(c===from||c.tabIndex<0||(nav&&nav.contains(c))||from.contains(c)||c.contains(from))continue;" + "const q=c.getBoundingClientRect();if(!small&&q.width*q.height<12000)continue;const qx=q.left+q.width/2,qy=q.top+q.height/2;let along,across;" + "if(key==='ArrowDown'){along=q.top-r.bottom;across=qx-cx;}else if(key==='ArrowUp'){along=r.top-q.bottom;across=qx-cx;}" + "else if(key==='ArrowRight'){along=q.left-r.right;across=qy-cy;}else{along=r.left-q.right;across=qy-cy;}" + "if(along<-4)continue;const d=Math.max(along,0)+2*Math.abs(across);if(d{const a=document.activeElement;if(editing(a)||shown())return;const nav=mainNav(),list=[];" + "for(const c of document.querySelectorAll('a[href],button,[role=button],[tabindex]')){if(c.tabIndex<0||(nav&&nav.contains(c)))continue;" + "const r=c.getBoundingClientRect();if(r.width*r.height>=12000&&r.top>=0&&r.left>=0&&r.bottom<=innerHeight&&r.right<=innerWidth)list.push({c,r});}" + "const key=x=>Math.round(x.r.top/8)*1e5+x.r.left;list.sort((x,y)=>key(x)-key(y));" + "for(const {c,r} of list){const hit=document.elementFromPoint(r.left+r.width/2,r.top+8);" + "if(hit&&(hit===c||c.contains(hit))&&!hidden(c)){arrowAt=performance.now();c.focus({preventScroll:true});return;}}};" + "const arm=()=>{clearTimeout(settleTimer);settleTimer=setTimeout(()=>{settling=false;settle();},200);};" + "addEventListener('scroll',()=>{if(settling)arm();},true);" + "const unring=()=>{if(ringed){ringed.style.outline=ringOutline;ringed.style.outlineOffset=ringOffset;ringed=null;}};" + "addEventListener('keydown',e=>{const k=e.key||'';if(k.startsWith('Arrow')){arrowAt=e.timeStamp;const a=document.activeElement;" + "if(!editing(a)&&!shown()){const nav=mainNav();" + "if(!a||a===document.body||hidden(a)||(k==='ArrowDown'&&nav&&nav.contains(a))){const f=first(nav);" + "if(f){e.preventDefault();e.stopImmediatePropagation();f.focus();f.scrollIntoView({block:'nearest'});}}" + "else if(big(a)&&!e.ctrlKey&&!e.altKey){let t=toward(a,k,nav);" + "if(!t&&(k==='ArrowUp'||k==='ArrowDown'))t=toward(a,k,nav,true);" + "if(t){e.preventDefault();e.stopImmediatePropagation();t.focus({preventScroll:true});t.scrollIntoView({block:'nearest',inline:'nearest'});}}}}" + "if(k==='Escape')open=shown()?e.timeStamp:-1e9;" + "if(e.code==='F13'){e.preventDefault();e.stopImmediatePropagation();settling=true;arm();}},true);" + "addEventListener('keydown',e=>{if(e.key!=='BrowserBack')return;e.preventDefault();e.stopImmediatePropagation();" + "const a=document.activeElement;if(editing(a)){a.blur();return;}" + "if(e.timeStamp-open>500)history.back();},true);" + "window.droiddeckTab=step=>{" + "const nav=mainNav();if(!nav)return;" + "const tabs=[...nav.querySelectorAll('a[href]')].filter(a=>!hidden(a)),part=p=>p.split('/')[1]||'';" + "const at=tabs.findIndex(a=>part(new URL(a.href).pathname)===part(location.pathname));" + "const to=tabs[Math.min(tabs.length-1,Math.max(0,at+step))];" + "if(at>=0&&to!==tabs[at]){to.click();to.blur();}};" + "const redirect=n=>{redirecting=true;try{n.focus();}finally{redirecting=false;}};" + "addEventListener('focusout',unring,true);" + "addEventListener('focusin',e=>{const t=e.target;if(!(t instanceof Element)||e.timeStamp-arrowAt>1000)return;" + "const back=e.relatedTarget instanceof Element&&e.relatedTarget.focus?e.relatedTarget:null;" + "if(!redirecting&&windowButton(t)){if(back)redirect(back);return;}" + "if(!redirecting&&hidden(t)){let a=t.parentElement;" + "while(a&&!(a.matches('a[href],button,[role=button],[tabindex]')&&!hidden(a)))a=a.parentElement;" + "if(a)redirect(a);else if(back)redirect(back);return;}" + "unring();ringed=(t.matches('input,textarea')&&t.closest('label'))||t;" + "ringOutline=ringed.style.outline;ringOffset=ringed.style.outlineOffset;" + "ringed.style.outline='3px solid #fff';ringed.style.outlineOffset='2px';},true);})();"; + +typedef struct { + int type; + void *window; + signed char send_event; + unsigned time; + unsigned state; + unsigned keyval; +} KeyEvent; + +static void *launcher_view; + +static void find_launcher_view(void *widget, void *data) +{ + (void)data; + if (launcher_view) + return; + if (g_type_check_instance_is_a(widget, webkit_web_view_get_type())) { + const char *uri = webkit_web_view_get_uri(widget); + const char *prefix = "tauri:"; + while (uri && *prefix && *uri == *prefix) { + uri++; + prefix++; + } + if (uri && !*prefix) + launcher_view = widget; + } else if (g_type_check_instance_is_a(widget, gtk_container_get_type())) { + gtk_container_forall(widget, find_launcher_view, 0); + } +} + +static int switch_tab(void *grab, void *event_data, void *data) +{ + KeyEvent *event = event_data; + (void)data; + if (!grab || event->type != 8 || !(event->state & 4) || (event->keyval != 0xff55 && event->keyval != 0xff56)) + return 0; + launcher_view = 0; + find_launcher_view(gtk_widget_get_toplevel(grab), 0); + if (!launcher_view) + return 0; + webkit_web_view_run_javascript(launcher_view, event->keyval == 0xff56 ? "window.droiddeckTab&&droiddeckTab(1)" + : "window.droiddeckTab&&droiddeckTab(-1)", + 0, 0, 0); + return 1; +} + +static void watch_tab_keys(void) +{ + static int watching; + if (watching || !gtk_key_snooper_install || !gtk_widget_get_toplevel || !gtk_container_forall || + !gtk_container_get_type || !webkit_web_view_get_type || !g_type_check_instance_is_a || + !webkit_web_view_get_uri || !webkit_web_view_run_javascript) + return; + watching = 1; + gtk_key_snooper_install(switch_tab, 0); +} + +static void set(WebKitSettings *settings, const char *setter, const char *property, int value) +{ + static int setting; + if (setting) { + void (*next)(WebKitSettings *, int) = dlsym ? (void (*)(WebKitSettings *, int))dlsym((void *)-1l, setter) : 0; + if (next) + next(settings, value); + return; + } + if (!g_object_set) + return; + setting = 1; + g_object_set(settings, property, value, "enable-smooth-scrolling", 0, "enable-spatial-navigation", 1, (const char *)0); + setting = 0; +} + +#define SETTER(name, property) \ + __attribute__((visibility("default"))) void webkit_settings_set_##name(WebKitSettings *settings, int value) \ + { \ + set(settings, "webkit_settings_set_" #name, property, value); \ + } + +SETTER(enable_javascript, "enable-javascript") +SETTER(enable_developer_extras, "enable-developer-extras") +SETTER(enable_webgl, "enable-webgl") +SETTER(enable_webaudio, "enable-webaudio") +SETTER(enable_page_cache, "enable-page-cache") +SETTER(javascript_can_access_clipboard, "javascript-can-access-clipboard") +SETTER(javascript_can_open_windows_automatically, "javascript-can-open-windows-automatically") +SETTER(enable_back_forward_navigation_gestures, "enable-back-forward-navigation-gestures") + +__attribute__((visibility("default"))) void webkit_user_content_manager_add_script(WebKitUserContentManager *manager, + WebKitUserScript *script) +{ + static void (*next)(WebKitUserContentManager *, WebKitUserScript *); + if (!next && dlsym) + next = (void (*)(WebKitUserContentManager *, WebKitUserScript *))dlsym((void *)-1l, "webkit_user_content_manager_add_script"); + if (!next) + return; + watch_tab_keys(); + if (g_object_get_data && g_object_set_data && webkit_user_script_new && webkit_user_script_unref && + !g_object_get_data(manager, "droiddeck-nav")) { + g_object_set_data(manager, "droiddeck-nav", (void *)1); + WebKitUserScript *nav = webkit_user_script_new(NAV_SCRIPT, 1, 0, 0, 0); + if (nav) { + next(manager, nav); + webkit_user_script_unref(nav); + } + } + next(manager, script); +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 631eb9ad5..2ae3016a8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -581,6 +581,31 @@ def wait_for_steam_shortcut_overview(agent_dir, game_id, timeout=45): raise AgentError("STEAM_OVERVIEW_TIMEOUT", "Steam did not load shortcut app overview %d" % app_id) +def steam_shortcut_state(agent_dir, game_id, timeout=6): + if steam_shortcut_app_id(game_id) is None: + return "not-shortcut" + timeout = request_timeout({"timeout": timeout}, 6) + deadline = time.monotonic() + timeout + expression = """(() => { + const store = globalThis.appStore; + if (typeof store?.GetAppOverviewByGameID !== 'function' || !store.m_bIsInitialized) return 'loading'; + if (store.GetAppOverviewByGameID(%s) != null) return 'ready'; + return store.allApps.some(app => app.app_type == 1073741824) ? 'missing' : 'loading'; + })()""" % json.dumps(game_id) + state = "loading" + while True: + try: + state = cdp_eval(agent_dir, expression, await_promise=False, + timeout=min(5, max(0.25, deadline - time.monotonic()))).get("value") or "loading" + except AgentError as error: + if error.code not in ("NO_DEVTOOLS", "DEVTOOLS_UNREACHABLE", "NO_TARGET", "TIMEOUT"): + raise + state = "loading" + if state == "ready" or time.monotonic() >= deadline: + return state + time.sleep(min(0.5, max(0, deadline - time.monotonic()))) + + def game_pids(app_id): """Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their environment, and Proton's wine processes inherit it).""" @@ -858,6 +883,28 @@ def main(argv): return 3 print("droiddeck-agent: Steam overview is ready for shortcut app %d" % prepared["appId"]) return 0 + if argv and argv[0] == "--steam-asleep": + expression = """(() => { + const store = globalThis.SuspendResumeStore; + return store ? (store.m_bSuspending ? 'yes' : 'no') : 'unknown'; + })()""" + try: + state = cdp_eval(Path(base) / "agent", expression, await_promise=False, timeout=3).get("value") or "unknown" + except (AgentError, OSError, ValueError): + state = "unknown" + print(state) + return {"yes": 0, "no": 5}.get(state, 1) + if argv and argv[0] == "--steam-shortcut-state": + if len(argv) not in (2, 3): + print("usage: droiddeck-agent --steam-shortcut-state [timeout-seconds]", file=sys.stderr) + return 2 + try: + state = steam_shortcut_state(Path(base) / "agent", argv[1], argv[2] if len(argv) == 3 else 6) + except (AgentError, OSError, ValueError) as error: + print("droiddeck-agent: could not ask the client about the shortcut: %s" % error, file=sys.stderr) + return 1 + print(state) + return {"ready": 0, "missing": 4, "not-shortcut": 3}.get(state, 1) serve(Path(base) / "agent", focus="--no-focus" not in argv) return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic b/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic new file mode 100644 index 000000000..45181cc3b --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic @@ -0,0 +1,1851 @@ +#!/usr/bin/python3 +import contextlib +import collections +import fcntl +import filecmp +import http.client +import io +import json +import os +import re +import runpy +import select +import shutil +import signal +import socket +import subprocess +import sys +import tarfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +HOME = os.environ.get("HOME") or "/root" +SELF = os.path.abspath(__file__) +BIN_DIR = os.path.dirname(SELF) +STATE = os.path.join(HOME, ".local", "share", "droiddeck-epic") +CURRENT = os.path.join(STATE, "current.json") +APPS = os.path.join(STATE, "app") +RUNTIMES = os.path.join(STATE, "runtime") +LOGS = os.path.join(STATE, "logs") +SHIMS = os.path.join(STATE, "bin") +FEX_CONFIG = os.path.join(STATE, "fex") +FEX_CACHE = os.path.join(HOME, ".cache", "droiddeck-epic") +DATA = os.path.join(HOME, ".local", "share", "com.epicgames.eglv2") +SETTINGS = os.path.join(DATA, "settings", "app-settings.json") +API_FILE = os.path.join(DATA, "api.json") +INSTALLATIONS = os.path.join(DATA, "installations.jsonl") +STEAM_RUN = re.compile(r"^steam://(?:rungameid|run)/([1-9][0-9]{0,19})(?:[/?].*)?$") +V1_MANIFESTS = os.path.join(HOME, ".local", "share", "Epic", "EpicGamesLauncher", "Data", "Manifests") +AUTH_FILE = os.path.join(DATA, "auth", "auth.json") +AUTH_ENV = "prod" +AUTO_SIGN_IN = os.path.join(STATE, "auto-sign-in") +SIGNED_OUT = os.path.join(STATE, "signed-out") +WEB_COOKIES = os.path.join(DATA, "cookies") +A11Y_OFF = ("NO_AT_BRIDGE", "GTK_A11Y") +A11Y_WEB_ROOT = "/org/a11y/webkit/accessible" +A11Y_LIMIT = 4000 +A11Y_BUDGET = 3.0 +A11Y_ACCESSIBLE = "org.a11y.atspi.Accessible" +FIRST_RUN_LABEL = "Get started" +SIGN_IN_LABEL = "Sign in" +PROTON_ROOT = os.path.join(DATA, "compat", "runtime") +PROTON_PIN = "GE-Proton10-34" +PROTON_RUN = os.path.join(BIN_DIR, "droiddeck-proton-run") +LAUNCH_DIR_FILE = "/tmp/droiddeck-browser/launch-dir" +WEBKIT_INPUT = "libwebkitinput.so" +WEBKIT_INPUT_SOURCE = "/usr/local/lib/droiddeck-fex/x86_64/" + WEBKIT_INPUT +WEBKIT_INPUT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + WEBKIT_INPUT +PLAYING = "epic-playing" +OPEN = "epic-open" +STEAM_PRELOAD = "DROIDDECK_STEAM_PRELOAD" +STEAM_ANCESTORS = 32 +READY = ".droiddeck-ready" +SIDECAR = ".droiddeck-store.json" +LAUNCHER = ".droiddeck-launch.bat" +ARGS_FILE = ".droiddeck-epic-args" +HANDLED = "DROIDDECK_EPIC_HANDLED" +BROKER_ENV = "DROIDDECK_EPIC_BROKER" +APP_ID = "com.epicgames.eglv2" +DBUS_NAME = APP_ID + ".SingleInstance" +DBUS_PATH = "/" + APP_ID.replace(".", "/") + "/SingleInstance" +DBUS_METHOD = "org.SingleInstance.DBus.ExecuteCallback" +SKIPPED = ("lib/i386-linux-gnu", "lib32", "libx32", "i686-pc-linux-gnu", "share/doc", "include") +SETTINGS_SEED = { + "minimizeToTray": False, + "startOnBoot": False, + "cloudSavesEnabled": False, + "automaticDownloadsEnabled": False, + "autoLaunchOnDownloadComplete": False, + "showWhatsNewOnStartup": False, + "suppressNotificationsWhileGaming": True, + "verboseLogs": False, + "v1Migrated": True, +} +CLIENT_ENV = { + "EGL_DISABLE_TELEMETRY": "1", + "EGL_DISABLE_SENTRY": "1", + "EGL_DISABLE_SELF_UPDATE": "1", + "EGL_DISABLE_SOCIAL_PANEL": "1", + "EGL_PROTON_PIN": PROTON_PIN, + "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS": "1", + "WEBKIT_DISABLE_COMPOSITING_MODE": "1", + "WEBKIT_DISABLE_DMABUF_RENDERER": "1", + "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "1", + "NO_AT_BRIDGE": "1", + "GTK_A11Y": "none", + "LIBGL_ALWAYS_SOFTWARE": "1", + "JSC_useDFGJIT": "0", + "JSC_useFTLJIT": "0", +} +KEPT_ENV = ("HOME", "USER", "LOGNAME", "LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "TZ", "XDG_RUNTIME_DIR", "BL_LAUNCH_DIR") +LAUNCH_VERBS = ("waitforexitandrun", "run", "runinprefix") +EPIC_ARGS = ("-auth_", "-epic") +CMD_SPECIAL = "&|<>^" +WIDTH, HEIGHT = 1280, 720 +LAUNCH_TIMEOUT = 150.0 +HANDOFF_WAIT = 30.0 +STEAM_WAIT = 20.0 +STEAM_CLAIM_WAIT = 90.0 +CLAIM_PEEK = 0.5 +SIGN_IN_WAIT = 90.0 +REGISTER_WAIT = 30.0 +AUTO_SIGN_IN_FRESH = 120.0 +FIRST_RUN_PRESSES = 3 +BROKER_WAIT = 60.0 +HIDDEN_IDLE = 240.0 +EXIT_GRACE = 6.0 +SIGN_OUT_GRACE = 4.0 +STOP_WAIT = 8.0 +LOG_CAP = 8 << 20 +DISPLAY_PROBE = ("import os, sys, time\nparent = os.getppid()\n" + "with open(sys.argv[1] + '.tmp', 'w') as f:\n f.write(os.environ.get('DISPLAY', ''))\n" + "os.replace(sys.argv[1] + '.tmp', sys.argv[1])\n" + "while os.getppid() == parent:\n time.sleep(1)\n") +SHIM = "#!/bin/sh\nunset LD_PRELOAD LD_LIBRARY_PATH\nexec /usr/bin/python3 -I %s %s \"$@\"\n" + + +class Failure(Exception): + pass + + +def say(message): + print("droiddeck-epic: %s" % message, file=sys.stderr, flush=True) + + +def read_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return default + + +def write_json(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp, path) + + +def install_preload(files): + target = os.path.join(files, "lib", "x86_64-linux-gnu", "droiddeck", WEBKIT_INPUT) + line = "" + if os.path.isfile(WEBKIT_INPUT_SOURCE): + if not (os.path.isfile(target) and filecmp.cmp(WEBKIT_INPUT_SOURCE, target, shallow=False)): + os.makedirs(os.path.dirname(target), exist_ok=True) + tmp = "%s.%d.tmp" % (target, os.getpid()) + shutil.copyfile(WEBKIT_INPUT_SOURCE, tmp) + os.chmod(tmp, 0o644) + os.replace(tmp, target) + line = WEBKIT_INPUT_GUEST + "\n" + else: + remove(target) + write_text(os.path.join(files, "etc", "ld.so.preload"), line) + + +def write_text(path, text, mode=0o644): + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + with open(path, encoding="utf-8") as f: + if f.read() == text: + os.chmod(path, mode) + return False + except (OSError, UnicodeDecodeError): + pass + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + os.chmod(tmp, mode) + os.replace(tmp, path) + return True + + +def remove(path): + if not os.path.lexists(path): + return + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path, ignore_errors=True) + else: + os.unlink(path) + + +def version_name(value): + return re.sub(r"[^A-Za-z0-9._+-]", "_", value or "")[:80] + + +@contextlib.contextmanager +def locked(name, wait=True): + os.makedirs(STATE, exist_ok=True) + with open(os.path.join(STATE, name), "a") as handle: + fcntl.flock(handle, fcntl.LOCK_EX if wait else fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def run_dir(env=None): + env = os.environ if env is None else env + runtime = env.get("XDG_RUNTIME_DIR") + if runtime and os.path.isdir(runtime) and os.access(runtime, os.W_OK): + return os.path.join(runtime, "droiddeck-epic") + return os.path.join(STATE, "run") + + +def instance_file(): + return os.path.join(STATE, "instance.json") + + +def broker_path(): + return os.path.join(run_dir(), "broker.sock") + + +def safe_name(name): + parts = [part for part in name.replace("\\", "/").split("/") if part not in ("", ".")] + if not parts or ".." in parts: + return None + return "/".join(parts) + + +def skipped(relative): + return any(relative == s or relative.startswith(s + "/") for s in SKIPPED) + + +def rooted_link(files_root, link, target): + if not target.startswith("/"): + return target + relative = target.lstrip("/") + if relative == "usr" or relative.startswith("usr/"): + relative = relative[4:] + return os.path.relpath(os.path.join(files_root, relative), os.path.dirname(link)) + + +def inside(root, path): + real = os.path.realpath(path) + return real == root or real.startswith(root + os.sep) + + +def extract_runtime(tarball, dest): + staging = dest + ".staging" + remove(staging) + os.makedirs(staging) + root = os.path.realpath(staging) + files_root = os.path.join(staging, "files") + count = 0 + with tarfile.open(tarball, "r:*") as archive: + for member in archive: + name = safe_name(member.name) + if name is None: + continue + top, _, rest = name.partition("/") + if top not in ("files", "metadata"): + continue + if top == "files" and rest and skipped(rest): + continue + target = os.path.join(staging, name) + if not inside(root, os.path.dirname(target)): + continue + if member.isdir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + if not inside(root, os.path.dirname(target)): + continue + if member.issym(): + remove(target) + os.symlink(rooted_link(files_root, target, member.linkname) if top == "files" else member.linkname, target) + elif member.islnk(): + source = safe_name(member.linkname) + source = os.path.join(staging, source) if source else None + if source and os.path.isfile(source) and inside(root, source): + remove(target) + shutil.copy2(source, target) + elif member.isfile(): + remove(target) + with archive.extractfile(member) as src, open(target, "wb") as out: + shutil.copyfileobj(src, out, 1 << 20) + os.chmod(target, (member.mode & 0o777) | 0o600) + else: + continue + count += 1 + if not os.path.isfile(os.path.join(files_root, "lib", "x86_64-linux-gnu", "libc.so.6")) and \ + not os.path.islink(os.path.join(files_root, "lib", "x86_64-linux-gnu", "libc.so.6")): + remove(staging) + raise Failure("runtime-incomplete") + if not os.path.lexists(os.path.join(files_root, "usr")): + os.symlink(".", os.path.join(files_root, "usr")) + return staging, count + + +def ar_members(path): + with open(path, "rb") as f: + if f.read(8) != b"!\n": + raise Failure("gles-format") + while True: + header = f.read(60) + if len(header) < 60: + return + name = header[:16].decode("ascii", "replace").strip().rstrip("/") + size = int(header[48:58].decode("ascii").strip()) + data = f.read(size) + if size % 2: + f.read(1) + yield name, data + + +def install_gles(deb, files_root): + libdir = os.path.join(files_root, "lib", "x86_64-linux-gnu") + os.makedirs(libdir, exist_ok=True) + for name, data in ar_members(deb): + if not name.startswith("data.tar"): + continue + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive: + for member in archive: + base = os.path.basename(member.name) + if not base.startswith("libGLESv2.so"): + continue + target = os.path.join(libdir, base) + remove(target) + if member.issym(): + os.symlink(os.path.basename(member.linkname), target) + elif member.isfile(): + with archive.extractfile(member) as src, open(target, "wb") as out: + shutil.copyfileobj(src, out) + os.chmod(target, 0o644) + if os.path.exists(os.path.join(libdir, "libGLESv2.so.2")): + return + raise Failure("gles-missing") + + +def extract_app(image, dest): + staging = dest + ".staging" + remove(staging) + os.makedirs(staging) + status = subprocess.call([os.path.join(BIN_DIR, "droiddeck-fex"), "extract", os.path.abspath(image)], + cwd=staging, stdout=subprocess.DEVNULL) + root = os.path.realpath(os.path.join(staging, "squashfs-root")) + if status != 0 or not app_complete(root): + remove(staging) + raise Failure("app-extract") + remove(dest) + os.rename(root, dest) + remove(staging) + + +def app_complete(root): + return os.path.isfile(os.path.join(root, "AppRun")) and os.path.isfile(os.path.join(root, "usr", "bin", "EpicGamesLauncher")) + + +def ready(path): + return os.path.isfile(os.path.join(path, READY)) + + +def mark_ready(path): + write_text(os.path.join(path, READY), "1\n") + + +def prune(parent, keep): + try: + names = os.listdir(parent) + except OSError: + return + for name in names: + if name != keep: + remove(os.path.join(parent, name)) + + +def install(options): + with locked("install.lock"): + current = read_json(CURRENT, {}) or {} + app = version_name(options.get("app-version") or current.get("app")) + runtime = version_name(options.get("runtime-version") or current.get("runtime")) + if not app or not runtime: + raise Failure("no-version") + app_dir = os.path.join(APPS, app) + runtime_dir = os.path.join(RUNTIMES, runtime) + if not ready(app_dir): + image = options.get("app") + if not image or not os.path.isfile(image): + raise Failure("no-app-image") + say("unpacking the Epic Games Launcher %s" % app) + extract_app(image, app_dir) + mark_ready(app_dir) + if not ready(runtime_dir): + tarball, deb = options.get("runtime"), options.get("gles") + if not tarball or not os.path.isfile(tarball) or not deb or not os.path.isfile(deb): + raise Failure("no-runtime-archive") + say("unpacking the x86 runtime %s" % runtime) + staging, count = extract_runtime(tarball, runtime_dir) + install_gles(deb, os.path.join(staging, "files")) + write_text(os.path.join(staging, "files", "etc", "ld.so.preload"), "") + remove(runtime_dir) + os.rename(staging, runtime_dir) + mark_ready(runtime_dir) + say("x86 runtime ready (%d entries)" % count) + with instance_guard() as free: + write_json(CURRENT, {"app": app, "runtime": runtime}) + if free: + prune(APPS, app) + prune(RUNTIMES, runtime) + report = status() + print(json.dumps(report)) + return 0 if report["installed"] else 1 + + +@contextlib.contextmanager +def instance_guard(): + os.makedirs(STATE, exist_ok=True) + with open(os.path.join(STATE, "instance.lock"), "a") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + yield False + return + try: + yield True + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def current_dirs(): + current = read_json(CURRENT, {}) or {} + app = version_name(current.get("app")) + runtime = version_name(current.get("runtime")) + app_dir = os.path.join(APPS, app) if app else None + runtime_dir = os.path.join(RUNTIMES, runtime) if runtime else None + if app_dir and ready(app_dir) and app_complete(app_dir) and runtime_dir and ready(runtime_dir): + return app_dir, os.path.join(runtime_dir, "files"), current + return None, None, current + + +def signed_in(): + auth = read_json(AUTH_FILE) + entry = auth.get(AUTH_ENV) if isinstance(auth, dict) else None + return isinstance(entry, dict) and isinstance(entry.get("refresh_token"), str) and bool(entry["refresh_token"]) + + +def forget_sign_in(): + if not os.path.exists(SIGNED_OUT): + return False + remove(AUTH_FILE) + remove(WEB_COOKIES) + remove(SIGNED_OUT) + say("DroidDeck's Epic store was signed out; the launcher starts signed out") + return True + + +def utc_now(): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def read_installations(): + header, entries = None, [] + try: + with open(INSTALLATIONS, encoding="utf-8") as f: + lines = [line for line in f.read().splitlines() if line.strip()] + except OSError: + return None, [] + for index, line in enumerate(lines): + try: + value = json.loads(line) + except ValueError: + continue + if not isinstance(value, dict): + continue + if index == 0 and "entries" in value and "installationId" not in value: + header = value + else: + entries.append(value) + return header, entries + + +def droiddeck_dir(path): + sidecar = read_json(os.path.join(path, SIDECAR)) if path else None + if not isinstance(sidecar, dict) or sidecar.get("store") != "epic": + return False + extra = sidecar.get("extra") + return not (isinstance(extra, dict) and extra.get("clientManaged") == "true") + + +def droiddeck_installs(): + found = [] + try: + names = sorted(os.listdir(V1_MANIFESTS)) + except OSError: + return found + for name in names: + if not name.endswith(".item"): + continue + item = read_json(os.path.join(V1_MANIFESTS, name)) + if not isinstance(item, dict) or item.get("bIsIncompleteInstall"): + continue + if not droiddeck_dir(item.get("InstallLocation")): + continue + if not all(item.get(key) for key in ("InstallationGuid", "CatalogNamespace", "CatalogItemId", "AppName")): + continue + found.append(item) + return found + + +def installation_record(item): + tags = [""] + [tag for tag in item.get("InstallTags") or [] if isinstance(tag, str) and tag] + sidecar = {"revision": int(item.get("SidecarConfigRevision") or 0)} + if item.get("SidecarDeploymentId"): + sidecar["deploymentId"] = item["SidecarDeploymentId"] + return { + "installationId": item["InstallationGuid"], + "complete": True, + "dir": item["InstallLocation"], + "platform": "Windows", + "namespace": item["CatalogNamespace"], + "itemId": item["CatalogItemId"], + "artifactId": item["AppName"], + "version": item.get("AppVersionString") or "", + "tags": tags, + "sidecar": sidecar, + "launchCommand": item.get("LaunchCommand") or "", + "launchExecutable": item.get("LaunchExecutable") or "", + "writtenAt": utc_now(), + } + + +def sync_installations(): + installs = droiddeck_installs() + header, entries = read_installations() + latest = {} + for entry in entries: + key = entry.get("installationId") or entry.get("artifactId") + if key: + latest[key] = entry + changed = 0 + moved = {} + for item in installs: + key = item["InstallationGuid"] if item["InstallationGuid"] in latest else \ + next((k for k, e in latest.items() if e.get("artifactId") == item["AppName"]), None) + if key is None: + record = installation_record(item) + entries.append(record) + latest[record["installationId"]] = record + changed += 1 + elif latest[key].get("dir") != item["InstallLocation"] and not os.path.lexists(latest[key].get("dir") or ""): + moved[key] = item["InstallLocation"] + for key, current in latest.items(): + fix = {} + if key in moved: + fix["dir"] = moved[key] + if current.get("platform") != "Windows" and droiddeck_dir(moved.get(key, current.get("dir"))): + fix["platform"] = "Windows" + if not fix: + continue + for entry in entries: + if (entry.get("installationId") or entry.get("artifactId")) == key: + entry.update(fix) + changed += 1 + if not changed: + return False + head = dict(header or {"version": 1}) + head["writtenAt"] = utc_now() + head["entries"] = len(entries) + tmp = "%s.%d.tmp" % (INSTALLATIONS, os.getpid()) + os.makedirs(DATA, exist_ok=True) + with open(tmp, "w", encoding="utf-8") as f: + for value in [head] + entries: + f.write(json.dumps(value, separators=(",", ":")) + "\n") + os.replace(tmp, INSTALLATIONS) + say("updated %d DroidDeck Epic installs in the launcher" % changed) + return True + + +def parent_pid(pid): + try: + with open("/proc/%d/stat" % pid, encoding="utf-8", errors="replace") as f: + return int(f.read().rsplit(")", 1)[1].split()[1]) + except (OSError, IndexError, ValueError): + return 0 + + +def steam_app_id(pid=None): + pid = os.getppid() if pid is None else pid + for _ in range(STEAM_ANCESTORS): + if pid <= 1: + break + try: + with open("/proc/%d/cmdline" % pid, "rb") as f: + args = f.read().split(b"\0") + except OSError: + break + if b"SteamLaunch" in args: + ids = [a[6:].decode("ascii", "replace") for a in args if a.startswith(b"AppId=")] + return ids[0] if ids and ids[0].isdigit() else "" + pid = parent_pid(pid) + return "" + + +@contextlib.contextmanager +def marked(name, value="1"): + folder = launch_dir() + path = os.path.join(folder, name) if folder else "" + if path: + write_text(path, value + "\n") + try: + yield + finally: + if path: + remove(path) + + +def playing(): + return marked(PLAYING) + + +def clear_playing(): + folder = launch_dir() + if folder: + remove(os.path.join(folder, PLAYING)) + + +def launch_dir(): + found = os.environ.get("BL_LAUNCH_DIR", "") + if not found: + try: + with open(LAUNCH_DIR_FILE, encoding="utf-8") as f: + found = f.read().strip() + except OSError: + found = "" + return found + + +_fex = None + + +def fex_module(): + global _fex + if _fex is None: + _fex = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-fex")) + return _fex + + +def fex_tool(request=False): + try: + module = fex_module() + found = module["fex_install"]() + if found and found.get("server"): + return found + if request: + module["request_install"]() + except Exception as error: + say("FEX lookup failed (%s)" % error) + return None + + +def status(): + app_dir, files, current = current_dirs() + fex = fex_tool() + report = { + "app": current.get("app"), + "runtime": current.get("runtime"), + "installed": app_dir is not None, + "fex": bool(fex), + "signedIn": signed_in(), + } + return report + + +def seed_settings(): + current = read_json(SETTINGS) + settings = current if isinstance(current, dict) else {} + changed = False + for key, value in SETTINGS_SEED.items(): + if settings.get(key) != value: + settings[key] = value + changed = True + if changed: + write_json(SETTINGS, settings) + return changed + + +def install_shims(): + write_text(os.path.join(SHIMS, "xdg-open"), SHIM % (SELF, "xdg-open"), 0o755) + proton = SHIM % (SELF, "proton") + os.makedirs(PROTON_ROOT, exist_ok=True) + names = {PROTON_PIN} + try: + names |= {name for name in os.listdir(PROTON_ROOT) if name.startswith("GE-Proton") and not name.startswith(".")} + except OSError: + pass + for name in sorted(names): + folder = os.path.join(PROTON_ROOT, name) + entry = os.path.join(folder, "proton") + if os.path.isdir(entry) and not os.path.islink(entry): + shutil.rmtree(entry, ignore_errors=True) + write_text(entry, proton, 0o755) + write_text(os.path.join(folder, "version"), "%d %s\n" % (0, name)) + + +def fex_environment(fex, files, env): + fex_data = os.path.join(run_dir(env), "fex") + for directory in (FEX_CONFIG, fex_data, FEX_CACHE): + os.makedirs(directory, exist_ok=True) + write_text(os.path.join(FEX_CONFIG, "Config.json"), json.dumps({"Config": {"RootFS": files}}, indent=2) + "\n") + result = { + "FEX_APP_CONFIG_LOCATION": FEX_CONFIG + "/", + "FEX_APP_DATA_LOCATION": fex_data + "/", + "FEX_APP_CACHE_LOCATION": FEX_CACHE + "/", + "FEX_ROOTFS": files, + "FEX_SILENTLOG": "0", + "FEX_OUTPUTLOG": "", + } + if fex.get("portable"): + result["FEX_PORTABLE"] = "1" + return result + + +def client_environment(base, fex, files, display, bus, broker, a11y=None): + env = {name: base[name] for name in KEPT_ENV if base.get(name)} + env.setdefault("HOME", HOME) + env.setdefault("LANG", "C.UTF-8") + env["PATH"] = SHIMS + ":/usr/bin:/bin" + env["DISPLAY"] = display + env["DBUS_SESSION_BUS_ADDRESS"] = bus + env[BROKER_ENV] = broker + env.update(CLIENT_ENV) + if a11y: + for name in A11Y_OFF: + env.pop(name, None) + env["AT_SPI_BUS_ADDRESS"] = a11y + env.update(fex_environment(fex, files, base)) + return env + + +def windows_path(path): + return "Z:" + path.replace("/", "\\") + + +def native_path(path): + if len(path) >= 2 and path[1] == ":" and path[0].upper() == "Z": + return path[2:].replace("\\", "/") or "/" + return path.replace("\\", "/") + + +ARG_PATH = re.compile(r'^(-[^=\s]+=)"?(/[^"]*)"?$') + + +def path_like(path): + return os.path.exists(path) or os.path.isdir(os.path.dirname(path)) + + +def convert_arg(arg): + if arg.startswith("/") and path_like(arg): + return windows_path(arg) + match = ARG_PATH.match(arg) + if match and path_like(match.group(2)): + return '%s"%s"' % (match.group(1), windows_path(match.group(2))) + return arg + + +def epic_managed(arg): + low = arg.lower() + return low.startswith(EPIC_ARGS) + + +def merge_args(client, own): + merged = [convert_arg(arg) for arg in client if arg] + for arg in own or []: + if arg and not epic_managed(arg) and arg not in merged: + merged.append(arg) + return merged + + +def quote_arg(arg): + clean = "".join(ch for ch in arg if ch >= " " and ch != "\x7f") + if clean.count('"') % 2: + clean = clean.replace('"', "") + if (" " in clean or "\t" in clean) and '"' not in clean: + return '"%s"' % clean + return clean + + +def cmd_escape(line): + out, quoted = [], False + for ch in line: + if ch == '"': + quoted = not quoted + elif ch in CMD_SPECIAL and not quoted: + out.append("^") + out.append(ch) + return "".join(out) + + +def command_line(args): + return cmd_escape(" ".join(quote_arg(arg) for arg in args if arg)) + + +def find_store_game(path): + folder = os.path.dirname(os.path.abspath(path)) if not os.path.isdir(path) else os.path.abspath(path) + for _ in range(8): + sidecar = read_json(os.path.join(folder, SIDECAR)) + if isinstance(sidecar, dict) and sidecar.get("store") and sidecar.get("id"): + return folder, sidecar + parent = os.path.dirname(folder) + if parent == folder: + break + folder = parent + return None + + +def write_args(folder, sidecar, client_args): + line = command_line(merge_args(client_args, sidecar.get("args") if isinstance(sidecar.get("args"), list) else [])) + path = os.path.join(folder, ARGS_FILE) + tmp = "%s.%d.tmp" % (path, os.getpid()) + remove(tmp) + with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8", newline="") as f: + f.write(line + "\r\n") + os.replace(tmp, path) + return line + + +def launcher_takes_args(folder): + try: + with open(os.path.join(folder, LAUNCHER), encoding="utf-8", errors="replace") as f: + return ARGS_FILE in f.read() + except OSError: + return False + + +def send(sock, message): + sock.sendall((json.dumps(message) + "\n").encode("utf-8")) + + +def receive(sock, timeout=None): + sock.settimeout(timeout) + data = b"" + while b"\n" not in data: + chunk = sock.recv(65536) + if not chunk: + raise ConnectionError("closed") + data += chunk + return json.loads(data.split(b"\n", 1)[0].decode("utf-8")) + + +def ask_broker(message, timeout=5.0, path=None): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + sock.connect(path or broker_path()) + send(sock, message) + return receive(sock, timeout) + + +def pid_alive(pid): + try: + os.kill(int(pid), 0) + return True + except (OSError, ValueError, TypeError): + return False + + +def live_instance(): + info = read_json(instance_file()) + if not isinstance(info, dict) or not pid_alive(info.get("pid")): + return None + try: + answer = ask_broker({"op": "status"}, timeout=3.0, path=info.get("broker")) + except (OSError, ValueError): + return None + return dict(info, **answer) if isinstance(answer, dict) else None + + +class Launch: + def __init__(self, request): + self.request = request + self.done = threading.Event() + self.code = 0 + self.claimed = False + + +class Broker: + def __init__(self, path, mode, session_env): + self.path = path + self.mode = mode + self.session_env = session_env + self.cond = threading.Condition() + self.launches = [] + self.running = [] + self.waiters = 0 + self.awaiting = collections.Counter() + self.game = None + self.owner = None + self.closing = False + self.exited_at = None + self.last_contact = time.monotonic() + remove(path) + os.makedirs(os.path.dirname(path), exist_ok=True) + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.bind(path) + os.chmod(path, 0o600) + self.server.listen(16) + threading.Thread(target=self.accept, name="broker", daemon=True).start() + + def accept(self): + while True: + try: + conn, _ = self.server.accept() + except OSError: + return + threading.Thread(target=self.serve, args=(conn,), daemon=True).start() + + def serve(self, conn): + with conn: + try: + request = receive(conn, 30.0) + op = request.get("op") if isinstance(request, dict) else None + if op == "status": + send(conn, {"mode": self.mode, "pid": os.getpid(), "game": self.game is not None}) + elif op == "await": + self.wait_for_launch(conn, request) + elif op == "launch": + self.launched(conn, request) + elif op == "exited": + self.exited(int(request.get("code") or 0)) + send(conn, {"ok": True}) + elif op == "stop": + self.close() + send(conn, {"ok": True}) + else: + send(conn, {"ok": False, "reason": "unknown-op"}) + except (OSError, ValueError, TypeError) as error: + say("broker: %s" % type(error).__name__) + + def wait_for_launch(self, conn, request): + app = request.get("app") or "" + deadline = time.monotonic() + float(request.get("timeout") or LAUNCH_TIMEOUT) + with self.cond: + self.waiters += 1 + self.awaiting[app.lower()] += 1 + self.last_contact = time.monotonic() + try: + while True: + found = next((item for item in self.launches if not item.claimed and is_game(item.request, app)), None) + if found is not None: + found.claimed = True + self.launches.remove(found) + self.cond.notify_all() + self.game = found + self.owner = request.get("owner") + self.exited_at = None + break + left = deadline - time.monotonic() + if self.closing or left <= 0: + send(conn, {"ok": False, "reason": "closing" if self.closing else "timeout"}) + return + self.cond.wait(min(left, 1.0)) + finally: + self.waiters -= 1 + self.awaiting[app.lower()] -= 1 + if self.awaiting[app.lower()] <= 0: + del self.awaiting[app.lower()] + self.last_contact = time.monotonic() + try: + send(conn, {"ok": True, "exe": found.request.get("exe"), "args": found.request.get("args") or []}) + except OSError: + self.exited(1) + + def launched(self, conn, request): + exe = request.get("exe") or "" + say("the client starts %s (%d arguments)" % (os.path.basename(exe), len(request.get("args") or []))) + if not is_game(request): + send(conn, {"code": 0}) + return + item = Launch(request) + if self.mode == "visible" and not self.awaited(request): + with self.cond: + self.launches.append(item) + self.cond.notify_all() + if not play_in_steam(request): + with self.cond: + unclaimed = not item.claimed + if unclaimed and item in self.launches: + self.launches.remove(item) + if unclaimed: + with playing(): + code = run_game(request, self.session_env, self.running) + send(conn, {"code": code}) + return + send(conn, {"code": self.hand_over(item, STEAM_CLAIM_WAIT)}) + return + send(conn, {"code": self.hand_over(item, 20.0)}) + + def awaited(self, request): + with self.cond: + return any(is_game(request, app) for app in self.awaiting if app) + + def hand_over(self, item, wait): + deadline = time.monotonic() + wait + with self.cond: + if item not in self.launches and not item.claimed: + self.launches.append(item) + self.cond.notify_all() + while not item.claimed and not self.closing: + left = deadline - time.monotonic() + if left <= 0: + break + self.cond.wait(left) + if not item.claimed and item in self.launches: + self.launches.remove(item) + if not item.claimed: + return 0 + while not item.done.wait(1.0): + if self.closing: + break + return item.code + + def exited(self, code): + with self.cond: + game = self.game + if game is None: + return + game.code = code + game.done.set() + self.game = None + self.owner = None + self.exited_at = time.monotonic() + self.cond.notify_all() + + def close(self): + with self.cond: + self.closing = True + for item in self.launches: + item.done.set() + if self.game is not None: + self.game.done.set() + self.cond.notify_all() + + def shutdown(self): + self.close() + with contextlib.suppress(OSError): + self.server.close() + remove(self.path) + + +def is_game(request, app=None): + args = [str(arg).lower() for arg in request.get("args") or []] + if app: + return "-epicapp=" + app.lower() in args + return any(arg.startswith("-epicapp=") for arg in args) + + +def client_artifact(exe): + latest = {} + for entry in read_installations()[1]: + key = entry.get("installationId") or entry.get("artifactId") + if key: + latest[key] = entry + target = os.path.abspath(exe) + for entry in latest.values(): + folder = entry.get("dir") + if entry.get("deleted") or not isinstance(folder, str) or not folder: + continue + folder = os.path.abspath(folder) + if (target == folder or target.startswith(folder + os.sep)) and entry.get("artifactId"): + return str(entry["artifactId"]) + return None + + +def play_in_steam(request): + exe = native_path(request.get("exe") or "") + found = find_store_game(exe) + if found and found[1].get("store") == "epic" and found[1].get("id"): + game = str(found[1]["id"]) + elif not found: + game = client_artifact(exe) + if not game: + return False + else: + return False + reply = ask_app({"op": "epic-play-in-steam", "id": game}, STEAM_WAIT) + if not isinstance(reply, dict): + reply = {} + if reply.get("ok"): + say("handed %s to Steam" % game) + return True + say("Steam could not take %s (%s); running it here" % (game, reply.get("reason") or "no answer")) + return False + + +def run_game(request, session_env, running=None): + exe = native_path(request.get("exe") or "") + args = request.get("args") or [] + env = {k: v for k, v in session_env.items() if not k.startswith(("FEX_", "DROIDDECK_EPIC", "EGL_", "WEBKIT_"))} + preload = env.pop(STEAM_PRELOAD, "") + if preload: + env["LD_PRELOAD"] = preload + found = find_store_game(exe) + if found and found[1].get("store") == "epic" and launcher_takes_args(found[0]): + folder, sidecar = found + write_args(folder, sidecar, args) + env[HANDLED] = "1" + command = [PROTON_RUN, os.path.join(folder, LAUNCHER)] + else: + command = [PROTON_RUN, exe] + [convert_arg(arg) for arg in args] + say("running %s" % os.path.basename(command[1])) + try: + proc = subprocess.Popen(command, env=env, cwd=os.path.dirname(command[1]) or "/", start_new_session=True) + except OSError as error: + say("could not run the game (%s)" % error) + return 1 + if running is not None: + running.append(proc) + try: + return proc.wait() + finally: + if running is not None and proc in running: + running.remove(proc) + + +def wait_for(check, timeout, step=0.1): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if check(): + return True + time.sleep(step) + return check() + + +def capped_log(path): + os.makedirs(os.path.dirname(path), exist_ok=True) + handle = open(path, "wb") + read, write = os.pipe() + + def pump(): + written = 0 + with os.fdopen(read, "rb") as source: + while True: + chunk = source.read1(65536) if hasattr(source, "read1") else source.read(65536) + if not chunk: + break + if written < LOG_CAP: + handle.write(chunk[:LOG_CAP - written]) + handle.flush() + written += len(chunk) + handle.close() + + threading.Thread(target=pump, daemon=True).start() + return write + + +class Session: + def __init__(self, mode): + self.mode = mode + self.carried = False + self.a11y = None + self.press_at = 0.0 + self.first_run_presses = 0 + self.sign_in_pressed = False + self.was_signed_in = False + self.signed_out_at = None + self.procs = [] + self.client = None + self.command = None + self.env = None + self.broker = None + self.bus = None + self.rundir = run_dir() + + def spawn(self, command, env, log=None, group=False): + out = capped_log(log) if log else subprocess.DEVNULL + try: + proc = subprocess.Popen(command, env=env, stdin=subprocess.DEVNULL, stdout=out, stderr=out, + start_new_session=group, cwd="/") + finally: + if log: + os.close(out) + self.procs.append(proc) + return proc + + def start_bus(self, env, name="bus"): + path = os.path.join(self.rundir, name) + remove(path) + address = "unix:path=" + path + proc = self.spawn(["dbus-daemon", "--session", "--nofork", "--nopidfile", "--address=" + address], env) + if not wait_for(lambda: os.path.exists(path) or proc.poll() is not None, 10) or not os.path.exists(path): + raise Failure("no-session-bus") + return address + + def start_display(self, env, bus): + marker = os.path.join(self.rundir, "display") + remove(marker) + scope_env = dict(env, DBUS_SESSION_BUS_ADDRESS=bus) + for name in ("DISPLAY", "WAYLAND_DISPLAY", "GAMESCOPE_WAYLAND_DISPLAY"): + scope_env.pop(name, None) + size = ["-W", str(WIDTH), "-H", str(HEIGHT), "-w", str(WIDTH), "-h", str(HEIGHT)] + self.spawn(["gamescope", "--backend", "headless"] + size + ["--", sys.executable, "-I", "-c", DISPLAY_PROBE, marker], + scope_env, os.path.join(LOGS, "gamescope.log")) + display = {} + + def ready(): + try: + with open(marker, encoding="utf-8") as f: + display["name"] = f.read().strip() + except OSError: + return False + return bool(display["name"]) + + if not wait_for(ready, 30): + raise Failure("no-display") + return display["name"] + + def run(self): + app_dir, files, current = current_dirs() + if app_dir is None: + raise Failure("not-installed") + fex = fex_tool(request=True) + if fex is None: + raise Failure("no-fex") + os.makedirs(self.rundir, exist_ok=True) + os.makedirs(LOGS, exist_ok=True) + base = dict(os.environ) + base.setdefault("XDG_RUNTIME_DIR", self.rundir) + seed_settings() + install_shims() + install_preload(files) + clear_playing() + sync_installations() + forget_sign_in() + self.carried = carry_sign_in() + bus = self.start_bus(base) + self.bus = bus + if self.carried: + self.a11y = self.start_bus(base, "a11y") + if self.mode == "hidden": + display = self.start_display(base, bus) + else: + display = base.get("DISPLAY") or ":0" + path = broker_path() + self.broker = Broker(path, self.mode, base) + write_json(instance_file(), {"pid": os.getpid(), "mode": self.mode, "bus": bus, "broker": path}) + env = client_environment(base, fex, files, display, bus, path, self.a11y) + self.command = [fex["bin"], os.path.join(app_dir, "AppRun")] + self.env = env + say("starting the Epic Games Launcher %s (%s, display %s)" % (current.get("app"), self.mode, display)) + self.start_client() + return self.watch() + + def start_client(self): + self.client = self.spawn(self.command, self.env, os.path.join(LOGS, "client-%s.log" % self.mode), group=True) + + def restart_signed_out(self): + say("DroidDeck's Epic store was signed out; restarting the launcher signed out") + self.end_client() + forget_sign_in() + self.carried = False + self.was_signed_in = False + self.signed_out_at = None + self.start_client() + + def carry_on(self, now): + if signed_in(): + self.carried = False + self.was_signed_in = True + remove(AUTO_SIGN_IN) + say("the launcher is signed in") + return + if now < self.press_at or self.sign_in_pressed: + return + self.press_at = now + 2.0 + buttons = a11y_buttons(self.a11y) + if SIGN_IN_LABEL in buttons: + write_text(AUTO_SIGN_IN, "%d\n" % time.time()) + if a11y_press(self.a11y, buttons[SIGN_IN_LABEL]): + self.sign_in_pressed = True + say("asked the launcher to sign in") + elif FIRST_RUN_LABEL in buttons and self.first_run_presses < FIRST_RUN_PRESSES: + self.first_run_presses += 1 + if a11y_press(self.a11y, buttons[FIRST_RUN_LABEL]): + say("moved past the launcher's welcome screen") + + def follow_sign_out(self, now): + if signed_in(): + self.was_signed_in = True + self.signed_out_at = None + return + if not self.was_signed_in: + return + if self.signed_out_at is None: + self.signed_out_at = now + return + if now - self.signed_out_at < SIGN_OUT_GRACE: + return + self.was_signed_in = False + self.signed_out_at = None + channel = launch_dir() + if channel: + post(channel, {"op": "epic-signed-out"}) + say("the launcher was signed out; signing DroidDeck's Epic store out too") + + def watch(self): + started = time.monotonic() + broker = self.broker + while True: + if self.client.poll() is not None: + say("the Epic Games Launcher exited (%s)" % self.client.returncode) + return 0 if self.mode == "visible" else 1 + now = time.monotonic() + if broker.closing: + return 0 + if broker.game is None and os.path.exists(SIGNED_OUT): + if self.mode != "visible": + say("DroidDeck's Epic store was signed out; closing") + return 0 + self.restart_signed_out() + continue + if self.carried: + self.carry_on(now) + elif self.mode == "visible": + self.follow_sign_out(now) + owner = broker.owner + if broker.game is not None and owner and not pid_alive(owner): + say("the game's launcher process is gone") + broker.exited(0) + if self.mode == "hidden": + idle = broker.game is None and broker.waiters == 0 + if idle and broker.exited_at is not None: + if now - broker.exited_at >= EXIT_GRACE: + return 0 + elif idle and now - max(started, broker.last_contact) > HIDDEN_IDLE: + say("no launch was asked for; closing") + return 0 + elif idle and broker.last_contact > started and now - broker.last_contact > 5.0: + say("the launch was given up; closing") + return 0 + time.sleep(0.5) + + def end_client(self): + if self.client is None: + return + if self.client.poll() is None: + with contextlib.suppress(OSError): + os.killpg(self.client.pid, signal.SIGTERM) + try: + self.client.wait(STOP_WAIT) + except subprocess.TimeoutExpired: + with contextlib.suppress(OSError): + os.killpg(self.client.pid, signal.SIGKILL) + with contextlib.suppress(subprocess.TimeoutExpired): + self.client.wait(5) + with contextlib.suppress(OSError): + os.killpg(self.client.pid, signal.SIGKILL) + + def stop(self): + if self.broker is not None: + self.broker.shutdown() + for game in list(self.broker.running): + stop_group(game) + clear_playing() + info = read_json(instance_file()) + if isinstance(info, dict) and info.get("pid") == os.getpid(): + remove(instance_file()) + self.end_client() + forget_sign_in() + for proc in reversed(self.procs): + if proc is self.client or proc.poll() is not None: + continue + proc.terminate() + try: + proc.wait(3) + except subprocess.TimeoutExpired: + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(2) + + +def terminate(*_): + raise SystemExit(0) + + +def retire_hidden(): + info = read_json(instance_file()) + if not isinstance(info, dict) or info.get("mode") != "hidden" or not pid_alive(info.get("pid")): + return + say("closing the hidden launcher first") + with contextlib.suppress(OSError, ValueError): + ask_broker({"op": "stop"}, timeout=3.0, path=info.get("broker")) + wait_for(lambda: not pid_alive(info.get("pid")), STOP_WAIT + 10, 0.25) + + +def serve(mode): + if mode == "visible": + retire_hidden() + with instance_guard() as free: + if not free: + say("the Epic Games Launcher is already running") + return 3 + session = Session(mode) + signal.signal(signal.SIGTERM, terminate) + signal.signal(signal.SIGHUP, terminate) + try: + with marked(OPEN, steam_app_id() or "1") if mode == "visible" else contextlib.nullcontext(): + return session.run() + except (Failure, OSError) as failure: + say("could not start: %s" % failure) + return 2 + finally: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + session.stop() + + +def api(): + info = read_json(API_FILE) + if not isinstance(info, dict) or not info.get("port") or not info.get("machineSecret"): + return None + return info + + +def api_get(info, path, timeout=5.0): + request = urllib.request.Request("http://127.0.0.1:%d%s" % (int(info["port"]), path), + headers={"x-epic-machine-secret": str(info["machineSecret"])}) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=timeout) as reply: + return json.loads(reply.read().decode("utf-8")) + + +def holds(value, wanted): + if isinstance(value, dict): + return any(holds(v, wanted) for v in value.values()) + if isinstance(value, list): + return any(holds(v, wanted) for v in value) + return isinstance(value, str) and value == wanted + + +def installed(info, app): + try: + listing = api_get(info, "/api/v1/installation/installations") + except (OSError, ValueError, urllib.error.URLError, http.client.HTTPException): + return None + return holds(listing.get("installations") if isinstance(listing, dict) else listing, app) + + +def deep_link(namespace, item, app): + target = ":".join((namespace, item, app)) + return "com.epicgames.launcher://apps/%s?action=launch&silent=true" % urllib.parse.quote(target, safe="") + + +def forward(bus, url): + command = ["dbus-send", "--session", "--print-reply", "--type=method_call", "--dest=" + DBUS_NAME, DBUS_PATH, + DBUS_METHOD, "array:string:EpicGamesLauncher,%s" % url, "string:/"] + env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=bus) + try: + return subprocess.call(command, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def parse_options(args, flags=()): + options, rest = {}, [] + while args: + arg = args.pop(0) + if arg.startswith("--") and arg[2:] in flags: + options[arg[2:]] = True + elif arg.startswith("--") and args: + options[arg[2:]] = args.pop(0) + else: + rest.append(arg) + return options, rest + + +def answer(ok, **fields): + print(json.dumps(dict(fields, ok=ok)), flush=True) + return 0 if ok else 1 + + +def launch(options): + app = options.get("app") or "" + namespace = options.get("namespace") or "" + item = options.get("item") or "" + folder = os.path.abspath(options.get("folder") or "") + try: + timeout = float(options.get("timeout") or LAUNCH_TIMEOUT) + except ValueError: + timeout = LAUNCH_TIMEOUT + if not 0 < timeout < float("inf"): + timeout = LAUNCH_TIMEOUT + deadline = time.monotonic() + timeout + if not (app and namespace and item and options.get("folder")): + return answer(False, reason="missing-game") + sidecar = read_json(os.path.join(folder, SIDECAR)) + if not isinstance(sidecar, dict) or sidecar.get("id") != app: + return answer(False, reason="no-sidecar") + if not launcher_takes_args(folder): + return answer(False, reason="old-launcher") + report = status() + if not report["installed"]: + return answer(False, reason="not-installed") + if not report["fex"]: + return answer(False, reason="no-fex") + if not report["signedIn"] and not handoff_ready(): + return answer(False, reason="signed-out") + remove(os.path.join(folder, ARGS_FILE)) + info = live_instance() + spawned = None + if info is None: + log = os.path.join(LOGS, "hidden.log") + os.makedirs(LOGS, exist_ok=True) + with contextlib.suppress(OSError): + os.replace(log, log + ".1") + with open(log, "wb") as out: + spawned = subprocess.Popen([sys.executable, SELF, "serve", "--mode", "hidden"], stdin=subprocess.DEVNULL, + stdout=out, stderr=out, start_new_session=True) + if not wait_for(lambda: live_instance() is not None or spawned.poll() is not None, BROKER_WAIT, 0.25) \ + or spawned.poll() is not None: + stop_spawned(spawned, read_json(instance_file())) + return answer(False, reason="client-start") + info = live_instance() + try: + reply = hand_off(info, spawned, app, namespace, item, options.get("owner"), deadline) + except (OSError, ValueError, subprocess.SubprocessError) as error: + reply = {"ok": False, "reason": "error-" + type(error).__name__} + if not reply.get("ok"): + stop_spawned(spawned, info) + return answer(False, reason=reply.get("reason") or "no-launch") + write_args(folder, sidecar, reply.get("args") or []) + say("the client handed over %s with %d arguments" % (os.path.basename(reply.get("exe") or ""), len(reply.get("args") or []))) + return answer(True) + + +def hand_off(info, spawned, app, namespace, item, owner, deadline): + def left(): + return max(0.0, deadline - time.monotonic()) + + def gone(): + return (spawned is not None and spawned.poll() is not None) or not pid_alive(info.get("pid")) + + if info is None: + return {"ok": False, "reason": "client-start"} + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn: + conn.connect(info["broker"]) + send(conn, {"op": "await", "app": app, "owner": owner, "timeout": max(1.0, left())}) + if info.get("mode") == "visible" and select.select([conn], [], [], CLAIM_PEEK)[0]: + try: + reply = receive(conn, max(1.0, left())) + except (OSError, ValueError): + return {"ok": False, "reason": "client-exited" if gone() else "timeout"} + if isinstance(reply, dict) and reply.get("ok"): + say("took the launch the client already started for %s" % app) + return reply if isinstance(reply, dict) else {"ok": False, "reason": "bad-reply"} + state = {} + + def ready(): + current = api() + if current is not None and installed(current, app) is not None: + state["api"] = current + return gone() or "api" in state + + if not wait_for(ready, left(), 1.0) or gone(): + return {"ok": False, "reason": "client-exited" if gone() else "client-not-ready"} + if not wait_for(lambda: gone() or installed(state["api"], app) is True, min(REGISTER_WAIT, left()), 1.0) or gone(): + return {"ok": False, "reason": "client-exited" if gone() else "not-registered"} + if not wait_for(lambda: gone() or signed_in(), min(SIGN_IN_WAIT, left()), 1.0) or gone(): + return {"ok": False, "reason": "client-exited" if gone() else "signed-out"} + url = deep_link(namespace, item, app) + if not wait_for(lambda: gone() or forward(info["bus"], url), min(20.0, left()), 1.0) or gone(): + return {"ok": False, "reason": "client-exited" if gone() else "deep-link"} + say("asked the client to launch %s" % app) + try: + reply = receive(conn, max(1.0, left())) + except (OSError, ValueError): + return {"ok": False, "reason": "client-exited" if gone() else "timeout"} + return reply if isinstance(reply, dict) else {"ok": False, "reason": "bad-reply"} + + +def stop_group(proc, wait=STOP_WAIT): + if proc.poll() is None: + with contextlib.suppress(OSError): + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(wait) + except subprocess.TimeoutExpired: + pass + with contextlib.suppress(OSError): + os.killpg(proc.pid, signal.SIGKILL) + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(5) + + +def stop_spawned(proc, info=None): + if proc is None: + return + path = info.get("broker") if isinstance(info, dict) and info.get("pid") == proc.pid else None + asked = False + if path: + with contextlib.suppress(OSError, ValueError): + ask_broker({"op": "stop"}, timeout=3.0, path=path) + asked = True + try: + proc.wait(STOP_WAIT + 10 if asked else 0) + except subprocess.TimeoutExpired: + stop_group(proc) + + +def exited(options): + info = read_json(instance_file()) + if not isinstance(info, dict) or info.get("mode") not in ("hidden", "visible") or not pid_alive(info.get("pid")): + return 0 + with contextlib.suppress(OSError, ValueError): + ask_broker({"op": "exited", "code": int(options.get("code") or 0)}, timeout=3.0, path=info.get("broker")) + return 0 + + +def sign_in_url(url): + try: + parts = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parts.query) + redirect = urllib.parse.urlsplit((query.get("redirect_uri") or [""])[0]) + except ValueError: + return False + return parts.scheme == "https" and parts.hostname in ("www.epicgames.com", "epicgames.com") \ + and parts.path.startswith("/id/authorize") and redirect.scheme == "http" \ + and redirect.hostname in ("127.0.0.1", "localhost") and redirect.port is not None + + +def post(channel, request): + req = os.path.join(channel, "stores", "req") + os.makedirs(req, exist_ok=True) + rid = "epic-%d-%d" % (os.getpid(), time.time_ns()) + tmp = os.path.join(req, rid + ".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(request, f) + os.rename(tmp, os.path.join(req, rid + ".json")) + return rid + + +def hand_over(op, url, launch_dir, auto=False): + request = {"op": op, "url": url} + if auto: + request["auto"] = True + post(launch_dir, request) + + +def ask_app(request, timeout): + channel = launch_dir() + if not channel: + return None + rid = post(channel, request) + reply = os.path.join(channel, "stores", "resp", rid + ".json") + value = {} + + def answered(): + found = read_json(reply) + if isinstance(found, dict): + value.update(found) + return True + return False + + if not wait_for(answered, timeout, 0.1): + remove(os.path.join(channel, "stores", "req", rid + ".json")) + return None + remove(reply) + return value + + +def handoff_ready(): + reply = ask_app({"op": "epic-client-handoff"}, HANDOFF_WAIT) + if isinstance(reply, dict) and reply.get("ok"): + return True + say("no DroidDeck Epic account to sign the launcher in with (%s)" % ((reply or {}).get("reason") or "no answer")) + return False + + +def carry_sign_in(): + remove(AUTO_SIGN_IN) + if signed_in() or not handoff_ready(): + return False + say("signing the launcher in with DroidDeck's Epic account") + return True + + +def a11y_connect(address): + try: + from gi.repository import Gio + flags = Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION + return Gio.DBusConnection.new_for_address_sync(address, flags, None, None) + except Exception: + return None + + +def a11y_call(conn, name, path, interface, method, args=None, reply=None): + from gi.repository import GLib + answer = conn.call_sync(name, path, interface, method, args, GLib.VariantType(reply) if reply else None, + 0, 2000, None) + return answer.unpack() + + +def a11y_buttons(address): + conn = a11y_connect(address) if address else None + if conn is None: + return {} + from gi.repository import GLib + found = {} + try: + names = a11y_call(conn, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "ListNames", None, "(as)")[0] + queue = collections.deque() + for name in names: + if not name.startswith(":"): + continue + try: + xml = a11y_call(conn, name, A11Y_WEB_ROOT, "org.freedesktop.DBus.Introspectable", "Introspect", None, "(s)")[0] + except Exception: + continue + queue.extend((name, A11Y_WEB_ROOT + "/" + node) for node in re.findall(r' @@ -80,6 +84,7 @@ class LoginManager: if name == "CanSuspend": reply.return_value(GLib.Variant("(s)", ("yes",))) elif name in ("Suspend", "SuspendWithFlags"): + print("== login1: %s.%s asked by %s" % (interface.rsplit(".", 1)[-1], name, sender), flush=True) if self.pending: reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing") return @@ -149,7 +154,7 @@ class LoginManager: if file.get_basename() == self.wake.name or (other and other.get_basename() == self.wake.name): if self.wake.exists(): self.wake.unlink(missing_ok=True) - self.resume(force=True) + self.woken() if file.get_basename() == self.state.name or (other and other.get_basename() == self.state.name): self.check_state() @@ -171,6 +176,19 @@ class LoginManager: self.resume() return False + def woken(self): + if self.pending is not None: + self.resume(force=True) + return + threading.Thread(target=lambda: GLib.idle_add(self.replay_unless_awake, steam_asleep()), daemon=True).start() + + def replay_unless_awake(self, asleep): + if asleep is False and self.pending is None: + print("== login1: Steam is awake; no sleep/wake replay", flush=True) + return False + self.resume(force=True) + return False + def resume(self, force=False): if self.pending is None: if force: @@ -195,6 +213,15 @@ class LoginManager: self.notify(False) +def steam_asleep(): + try: + code = subprocess.run([str(AGENT), "--steam-asleep"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=ASLEEP_WAIT).returncode + except (OSError, subprocess.SubprocessError): + return None + return {0: True, 5: False}.get(code) + + def load_netmanager(): path = Path(__file__).resolve().with_name("droiddeck-netmanager") loader = importlib.machinery.SourceFileLoader("droiddeck_netmanager", str(path)) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run index 8ff6f14ee..5d59829bf 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run @@ -2,6 +2,7 @@ """Runs a Windows program with the Proton the Steam client has chosen for it. droiddeck-proton-run [--window] PROGRAM [ARGS...] + droiddeck-proton-run --app ID [--dir FOLDER] [--options TEXT] [PROGRAM [ARGS...]] The program belongs to an installed Steam game when it sits in one's folder, to a prefix when it sits in one's drive_c, or to a non-Steam shortcut that names it; any other program gets a prefix of @@ -10,11 +11,15 @@ config.vdf picks the tool, else the client's default for all other titles ("0"). started as the client starts it - its toolmanifest's command line, the waitforexitandrun verb and the STEAM_COMPAT_* environment - so its wrapper applies the game's settings, components and audio. --window keeps the program in the desktop's own window, drawn with WineD3D. +--app starts one game without the Steam client (its Start without Steam): a Steam game's default +Windows launch option from appinfo.vdf when no program is given, else the program in that +shortcut's own prefix, started in --dir with the shortcut's launch options (--options). """ import json import os import re import runpy +import shlex import struct import sys import zlib @@ -249,6 +254,81 @@ def locate(program, root): os.path.dirname(program), root, "own") +def split_words(text): + try: + return shlex.split(text or "") + except ValueError: + return (text or "").split() + + +def split_options(text): + """A shortcut's launch options: NAME=value words before %command% go to the environment, the words + after it - or all of them, without %command% - are the program's arguments.""" + words = split_words(text) + if "%command%" not in words: + return {}, words + at = words.index("%command%") + env = {} + for word in words[:at]: + name, equals, value = word.partition("=") + if equals and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + env[name] = value + return env, words[at + 1:] + + +def find_file(base, relative): + """base/relative, matching each part without regard to case as Windows does.""" + path = base + for part in [p for p in relative.replace("\\", "/").split("/") if p and p != "."]: + exact = os.path.join(path, part) + if part == ".." or os.path.exists(exact): + path = exact + continue + try: + match = next((name for name in sorted(os.listdir(path)) if name.lower() == part.lower()), None) + except OSError: + return None + if match is None: + return None + path = os.path.join(path, match) + return os.path.normpath(path) + + +def steam_install(appid, root): + for library in libraries(root): + found = re.search(r'"installdir"\s+"([^"]+)"', + read_text(os.path.join(library, "steamapps", "appmanifest_%s.acf" % appid)) or "") + if found: + return library, os.path.join(library, "steamapps", "common", found.group(1)) + return None, None + + +def steam_launch(appid, root): + """(target, program, arguments) for a Steam game's default Windows launch option, or None.""" + library, install = steam_install(appid, root) + if not install or not os.path.isdir(install): + return None + entry = compat()["appinfo_entries"](os.path.join(root, "appcache", "appinfo.vdf")).get(str(appid), {}) + options = [option for option in entry.get("launch_options", ()) + if not option["betakey"] and (not option["oslist"] or "windows" in option["oslist"])] + options.sort(key=lambda option: (option["type"] not in ("", "default", "none"), + int(option["key"]) if option["key"].isdigit() else 0)) + for option in options: + program = find_file(install, option["executable"]) + if not program or not os.path.isfile(program): + continue + start = find_file(install, option["workingdir"]) if option["workingdir"] else None + target = Target(appid, os.path.join(library, "steamapps", "compatdata", str(appid)), install, library, "steam", + start if start and os.path.isdir(start) else os.path.dirname(program)) + return target, program, split_words(option["arguments"]) + return None + + +def shortcut_target(appid, program, start, root): + return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)), os.path.dirname(program), root, + "shortcut", start if start and os.path.isdir(start) else None) + + def steam_launches(target, program, root): """Whether a launch option of the Steam game starts [program]: then Steam should start it.""" if target.kind != "steam": @@ -292,24 +372,52 @@ def arguments(program, args): def main(argv): window = False + app, start, options = None, None, "" while argv and argv[0].startswith("--"): option = argv.pop(0) if option == "--window": window = True + elif option in ("--app", "--dir", "--options") and argv: + value = argv.pop(0) + if option == "--app": + app = value + elif option == "--dir": + start = value + else: + options = value elif option == "--": break else: say("unknown option %s" % option) return 2 - if not argv: + if app is not None and not (app.isdigit() and 0 < int(app) < 2 ** 32): + say("%s is not an app id" % app) + return 2 + if not argv and app is None: say("usage: droiddeck-proton-run [--window] PROGRAM [ARGS...]") return 2 - program = os.path.abspath(argv[0]) - if not os.path.isfile(program): - say("%s does not exist" % program) - return 2 root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or STEAM_ROOT - target = locate(program, root) + extra, args = split_options(options) + if not argv: + if int(app) >= STEAM_APP_LIMIT: + say("shortcut %s needs its program" % app) + return 2 + found = steam_launch(app, root) + if found is None: + say("Steam game %s is not installed, or has no Windows launch option" % app) + return 2 + target, program, own = found + args = own + args + else: + program = os.path.abspath(argv[0]) + if not os.path.isfile(program): + say("%s does not exist" % program) + return 2 + args = args + argv[1:] + if app is not None and int(app) >= STEAM_APP_LIMIT: + target = shortcut_target(app, program, start, root) + else: + target = locate(program, root) available = tools(root) name, why = choose_tool(target.appid, mapping(root), available) if name is None: @@ -318,13 +426,15 @@ def main(argv): folder, command = available[name] os.makedirs(target.compatdata, exist_ok=True) env = environment(os.environ, target, folder, root, window) - say("%s with %s (%s), %s prefix %s" % (program, name, why, target.kind, target.compatdata)) + env.update(extra) + say("%s with %s (%s), %s prefix %s%s" % (program, name, why, target.kind, target.compatdata, + ", without the Steam client" if app is not None else "")) try: os.chdir(target.start) except OSError: os.chdir(os.path.dirname(program)) entry = os.path.join(folder, command) - os.execve(entry, [entry, VERB] + arguments(program, argv[1:]), env) + os.execve(entry, [entry, VERB] + arguments(program, args), env) if __name__ == "__main__": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 65e835686..fbc49e790 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -301,6 +301,24 @@ case $mode in taskset -cp "$cores" $$ >/dev/null 2>&1 || true fi python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 || true + run_program() { + local rc=0 child sig forwarded=0 + { trap - INT QUIT; exec "$@"; } <&0 & + child=$! + for sig in TERM HUP INT; do + trap "kill -$sig $child 2>/dev/null; forwarded=1" "$sig" + done + while :; do + forwarded=0 + rc=0 + wait "$child" || rc=$? + [ "$forwarded" = 1 ] || break + done + if [ -n "${BL_LAUNCH_DIR:-}" ]; then + printf '%s\n' "$rc" > "$BL_LAUNCH_DIR/run-status.tmp" && mv -f "$BL_LAUNCH_DIR/run-status.tmp" "$BL_LAUNCH_DIR/run-status" + fi + exit "$rc" + } fex=() if [ -x /usr/local/bin/droiddeck-fex ]; then case $(od -An -tu2 -j18 -N2 "$target" 2>/dev/null | tr -d ' ') in @@ -311,17 +329,17 @@ case $mode in *.appimage) export APPIMAGE_EXTRACT_AND_RUN=1 chmod +x "$target" 2>/dev/null - exec "${fex[@]}" "$target" "$@" + run_program "${fex[@]}" "$target" "$@" ;; *.sh) if [ -x /usr/local/bin/droiddeck-fex ]; then - exec /usr/local/bin/droiddeck-fex run --for "$target" -- bash "$target" "$@" + run_program /usr/local/bin/droiddeck-fex run --for "$target" -- bash "$target" "$@" fi - exec bash "$target" "$@" + run_program bash "$target" "$@" ;; *) chmod +x "$target" 2>/dev/null - cd "$(dirname "$target")" && exec "${fex[@]}" "$target" "$@" + cd "$(dirname "$target")" && run_program "${fex[@]}" "$target" "$@" ;; esac ;; @@ -495,6 +513,9 @@ case $mode in if [ "$pv" != "$app" ]; then app=$pv echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)" + if [ -n "${BL_LAUNCH_DIR:-}" ]; then + printf '%s\n' "$app" > "$BL_LAUNCH_DIR/steam-focused-app.tmp" && mv -f "$BL_LAUNCH_DIR/steam-focused-app.tmp" "$BL_LAUNCH_DIR/steam-focused-app" + fi fi [ "$restore" = 1 ] || continue case $pv in ''|*[!0-9]*) prev=; continue ;; esac @@ -514,6 +535,44 @@ case $mode in esac done < <(xprop -spy -root "${atoms[@]}" 2>/dev/null) ) & + steam_hold_launch() { + _bl_launch_ready= + _bl_end=$((SECONDS + 120)) + until [ "$(xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p')" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do + sleep 1 + done + # With a large library the overview can stay missing for 30+ minutes while Steam already takes the launch URL. + _bl_overview_status=1 + _bl_overview_tries=0 + while [ "$_bl_overview_tries" -lt 2 ]; do + if /usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$1" 45; then + _bl_overview_status=0 + else + _bl_overview_status=$? + fi + if [ "$_bl_overview_status" -ne 1 ]; then + break + fi + _bl_overview_tries=$((_bl_overview_tries + 1)) + echo "== steam: shortcut overview not ready yet (attempt $_bl_overview_tries of 2)" + sleep 2 + done + if [ "$_bl_overview_status" -eq 0 ]; then + echo "== steam: shortcut overview ready; launching the requested game $1" + _bl_launch_ready=1 + elif [ "$_bl_overview_status" -eq 3 ]; then + echo "== steam: not a shortcut CGameID; keeping the existing launch path" + sleep 4 + _bl_launch_ready=1 + else + echo "== steam: shortcut overview not ready after about 90 s; launching the requested game anyway $1" + _bl_launch_ready=1 + fi + if [ "$_bl_launch_ready" = 1 ]; then + echo "== steam: interface up; launching the requested game $1" + printf '%s\n' "$1" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game" + fi + } if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then _bl_held= _bl_uninstall= @@ -539,44 +598,7 @@ case $mode in fi if [ -n "$_bl_held" ]; then set -- "${_bl_rest[@]}" - ( - _bl_launch_ready= - _bl_end=$((SECONDS + 120)) - until [ "$(xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p')" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do - sleep 1 - done - # With a large library the overview can stay missing for 30+ minutes while Steam already takes the launch URL. - _bl_overview_status=1 - _bl_overview_tries=0 - while [ "$_bl_overview_tries" -lt 2 ]; do - if /usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$_bl_held" 45; then - _bl_overview_status=0 - else - _bl_overview_status=$? - fi - if [ "$_bl_overview_status" -ne 1 ]; then - break - fi - _bl_overview_tries=$((_bl_overview_tries + 1)) - echo "== steam: shortcut overview not ready yet (attempt $_bl_overview_tries of 2)" - sleep 2 - done - if [ "$_bl_overview_status" -eq 0 ]; then - echo "== steam: shortcut overview ready; launching the requested game $_bl_held" - _bl_launch_ready=1 - elif [ "$_bl_overview_status" -eq 3 ]; then - echo "== steam: not a shortcut CGameID; keeping the existing launch path" - sleep 4 - _bl_launch_ready=1 - else - echo "== steam: shortcut overview not ready after about 90 s; launching the requested game anyway $_bl_held" - _bl_launch_ready=1 - fi - if [ "$_bl_launch_ready" = 1 ]; then - echo "== steam: interface up; launching the requested game $_bl_held" - printf '%s\n' "$_bl_held" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game" - fi - ) & + steam_hold_launch "$_bl_held" & fi fi if [ -s "$HOME/.bl-autolaunch" ]; then @@ -727,6 +749,81 @@ BUSCONF echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-ozone-platform=x11 ${_bl_cef[*]} $*" steam_updates=0 rm -f "$HOME/.bl-stop-requested" "$STEAMOS_STEAM_SHUTDOWN_SENTINEL" "$STEAMOS_STEAM_REBOOT_SENTINEL" + steam_other_game_running() { + local line value n open + open=$(cat "$BL_LAUNCH_DIR/epic-open" 2>/dev/null) + case $open in ?|*[!0-9]*) open=${BL_EPIC_APP_ID:-$open} ;; esac + line=$(xprop -root GAMESCOPE_FOCUSABLE_APPS 2>/dev/null) || return 1 + case $line in *=*) value=${line#*= } ;; *) return 1 ;; esac + for n in ${value//,/ }; do + [ "$n" = 769 ] || [ "$n" = "$open" ] || return 0 + done + return 1 + } + steam_restart_for() { + if [ "$(cat "$HOME/.bl-restarted-for" 2>/dev/null)" = "$1" ]; then + echo "== steam: $2, but Steam was already restarted for it; launching it anyway" + return 1 + fi + printf '%s\n' "$1" > "$HOME/.bl-restarted-for" + echo "== steam: $2; restarting Steam to launch it" + printf '%s\n' "$1" > "$HOME/.bl-restart-game" + "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 + } + steam_launch_game() { + local state=0 tries + /usr/local/bin/droiddeck-agent --steam-shortcut-state "$1" 6 >/dev/null 2>&1 || state=$? + [ "$state" -eq 0 ] && rm -f "$HOME/.bl-restarted-for" + if [ "$state" -eq 4 ]; then + if steam_other_game_running; then + echo "== steam: the client lost shortcut $1, but a game is running; launching it anyway" + elif steam_restart_for "$1" "the client lost shortcut $1"; then + return + fi + fi + "$steam_root/steamrtarm64/steam" "steam://rungameid/$1" >/dev/null 2>&1 + [ "$state" -eq 3 ] && return + for tries in 1 2 3 4 5 6 7 8; do + nap 1 + state=0 + /usr/local/bin/droiddeck-agent --steam-shortcut-state "$1" 1 >/dev/null 2>&1 || state=$? + [ "$state" -eq 4 ] || continue + if steam_other_game_running; then + echo "== steam: the client dropped shortcut $1 while launching it, but a game is running" + else + steam_restart_for "$1" "the client dropped shortcut $1 while launching it" + fi + return 0 + done + } + steam_check_exits() { + local ran n state lost= + ran=$(pgrep -a -f 'SteamLaunch AppId=[0-9]' 2>/dev/null | sed -n 's/.* SteamLaunch AppId=\([0-9]*\).*/\1/p') + if [ -n "$ran" ]; then + for n in $ran; do + [ "$n" -ge 2147483648 ] && _bl_ran[$n]=1 + done + _bl_exited= + return + fi + [ "${#_bl_ran[@]}" -gt 0 ] || return 0 + if [ -z "$_bl_exited" ]; then + _bl_exited=1 + return + fi + _bl_exited= + for n in "${!_bl_ran[@]}"; do + state=0 + /usr/local/bin/droiddeck-agent --steam-shortcut-state "$(printf '%u' $(((n << 32) | 0x02000000)))" 3 >/dev/null 2>&1 || state=$? + [ "$state" -eq 4 ] && lost="$lost $n" + done + _bl_ran=() + [ -n "$lost" ] || return 0 + pgrep -f 'SteamLaunch AppId=[0-9]' >/dev/null 2>&1 && return 0 + echo "== steam: the client dropped shortcut(s)$lost when they exited; restarting Steam so it lists them again" + : > "$HOME/.bl-restart-lost" + "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 + } steam_bring_forward() { local end=$((SECONDS + 600)) behind=0 line name value focused other n while [ "$SECONDS" -lt "$end" ]; do @@ -766,6 +863,8 @@ BUSCONF rm -f "$lossless_asked" [ "${BL_LOSSLESS_OWNED:-0}" != 1 ] && lossless_left=60 declare -A _bl_pinned=() + declare -A _bl_ran=() + _bl_exited= compat_stamp=$HOME/.bl-compat-beat seed_stamp=$HOME/.bl-seed-beat rm -f "$compat_stamp" "$seed_stamp" @@ -827,7 +926,7 @@ BUSCONF [ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]]; }; then echo "== steam: launching requested game $game_id in the running client" - "$steam_root/steamrtarm64/steam" "steam://rungameid/$game_id" >/dev/null 2>&1 & + steam_launch_game "$game_id" & [ -n "${forward_pid:-}" ] && kill "$forward_pid" 2>/dev/null steam_bring_forward & forward_pid=$! @@ -842,6 +941,7 @@ BUSCONF tick=$((tick + 1)) [ "$((tick % 10))" -eq 0 ] || continue beat=$((beat + 1)) + steam_check_exits if [ "$beat" -le 720 ] && [ -n "${BL_CLIENT_CPUS:-}" ]; then for _d in /proc/[0-9]*; do IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue @@ -887,8 +987,9 @@ BUSCONF done ) & stop_watcher=$! + rm -f "$HOME/.bl-restart-game" "$HOME/.bl-restarted-for" while :; do - rm -f "$HOME/.bl-restart-once" + rm -f "$HOME/.bl-restart-once" "$HOME/.bl-restart-lost" if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta" echo "$steam_channel" > "$steam_beta" @@ -939,6 +1040,25 @@ BUSCONF set -- continue fi + if [ -s "$HOME/.bl-restart-game" ]; then + _bl_again=$(cat "$HOME/.bl-restart-game" 2>/dev/null) + rm -f "$HOME/.bl-restart-game" + case $_bl_again in + ''|0*|*[!0-9]*) ;; + *) + echo "== steam: starting the client again to launch $_bl_again" + set -- + steam_hold_launch "$_bl_again" & + continue + ;; + esac + fi + if [ -f "$HOME/.bl-restart-lost" ]; then + rm -f "$HOME/.bl-restart-lost" + echo "== steam: starting the client again so it lists every shortcut" + set -- + continue + fi if [ -f "$HOME/.bl-restart-once" ]; then echo "== steam: restarting once so the new compatibility layer is in the client's lists" set -- diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 507b1cebe..58022a267 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -184,7 +184,7 @@ def unimported(steam_root, snapshot, candidates): def watch(steam_root, listing): - games = [game for game in read_json(listing, []) if not store_game(game)] + games = [game for game in read_json(listing, []) if not store_game(game) and not game.get("native")] if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: return candidates, sources = {}, {} @@ -408,6 +408,9 @@ def route(steam_root, acct, games): print(f"droiddeck-steam-games: store import not undone ({error})") shortcuts.append(game) continue + if game.get("native"): + shortcuts.append(game) + continue folder = game.get("folder", "") appid = candidates.get(folder, candidates.get(legacy_path(folder))) stamp = source_stamp(game) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts index d8e24069e..223edc01f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts @@ -180,12 +180,14 @@ def merge(fresh, current, state, seen): def reconcile(existing, wanted, seen, record): games, away = record["games"], dict(record["away"]) kept, ours, placed = [], [], {} + claimed = {(unquoted(fresh["Exe"]), fresh["AppName"]) for fresh in wanted.values()} for value in existing.values(): if not isinstance(value, dict): continue tags = value.get("tags") if not isinstance(tags, dict) or TAG not in tags.values(): - kept.append(value) + if (unquoted(value.get("Exe")), value.get("AppName")) not in claimed: + kept.append(value) continue appid = value.get("appid") if not isinstance(appid, int) or appid in placed: @@ -342,7 +344,7 @@ def main(): spec.loader.exec_module(compat) cfg = os.path.join(steam_root, "config", "config.vdf") if os.path.isfile(cfg) and (ours or routes): - compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())], + compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe") and not g.get("native")] + [str(appid) for appid in set(routes.values())], protect=compat.adopt_extras(os.path.join(steam_root, "compatibilitytools.d"))) print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL) except Exception as e: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index ba1415112..0b6f64ee4 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -6,8 +6,10 @@ whoever started it: the Games tab, a pinned shortcut, or the Steam client's own looks for the store launcher DroidDeck wrote (.droiddeck-launch.bat) among the arguments; beside it .droiddeck-store.json says which store and game it is. -For an Epic game the game needs a fresh exchange code to sign in, which only the app can mint. The -request goes through files under $BL_LAUNCH_DIR/stores (the session root, the same path on both +For an Epic game, once the Epic Games Launcher is installed (droiddeck-epic), the game is started +through it: the hidden launcher signs the game in and hands its arguments back in +.droiddeck-epic-args. Without the launcher, or when it cannot, the game needs a fresh exchange code +to sign in, which only the app can mint. The request goes through files under $BL_LAUNCH_DIR/stores (the session root, the same path on both sides): req/.json {"op": "epic-code", "store": "epic", "id": ""} (tmp + rename) @@ -38,6 +40,11 @@ from pathlib import Path LAUNCHER = ".droiddeck-launch.bat" SIDECAR = ".droiddeck-store.json" EPIC_CODE = ".droiddeck-epic-code" +EPIC_ARGS = ".droiddeck-epic-args" +EPIC_CLIENT = "/usr/local/bin/droiddeck-epic" +EPIC_CLIENT_CURRENT = ".local/share/droiddeck-epic/current.json" +EPIC_HANDLED = "DROIDDECK_EPIC_HANDLED" +CLIENT_TIMEOUT = 150.0 TIMEOUT = 5.0 STALE_SECONDS = 300 SERVER_WAIT = 8.0 @@ -98,6 +105,30 @@ def drop_stale(folder, now=None): return False +def client_wanted(): + home = os.environ.get("HOME") or "/root" + return os.access(EPIC_CLIENT, os.X_OK) and os.path.isfile(os.path.join(home, EPIC_CLIENT_CURRENT)) + + +def through_client(folder, sidecar, timeout=CLIENT_TIMEOUT): + extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {} + namespace, item = str(extra.get("namespace") or ""), str(extra.get("catalogItemId") or "") + if not namespace or not item: + return "no-catalog-ids" + command = [EPIC_CLIENT, "launch", "--app", str(sidecar.get("id")), "--namespace", namespace, "--item", item, + "--folder", str(folder), "--owner", str(os.getppid()), "--timeout", str(int(timeout))] + try: + run = subprocess.run(command, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, timeout=timeout + 30) + answer = json.loads(run.stdout.decode("utf-8", "replace").strip().splitlines()[-1]) + except (OSError, ValueError, IndexError, subprocess.TimeoutExpired) as error: + return "error-" + type(error).__name__ + if not isinstance(answer, dict): + return "no-answer" + if answer.get("ok") and (folder / EPIC_ARGS).is_file(): + return "ok" + return str(answer.get("reason") or "failed") + + def ask(channel, request, timeout=TIMEOUT): """Writes [request] under channel/req and waits for channel/resp; the answer, or None on timeout.""" req_dir, resp_dir = channel / "req", channel / "resp" @@ -247,10 +278,16 @@ def cloud_on(sidecar): return str(sidecar.get("store")) in CLOUD_STORES and sidecar.get("cloud", True) is not False -def exited(argv): +def exited(argv, code=0): """After the game: ask the app to upload what changed, without waiting for it.""" found = find_store_game(argv) launch_dir = os.environ.get("BL_LAUNCH_DIR", "") + if found is not None and str(found[1].get("store")) == "epic" and os.environ.get(EPIC_HANDLED) != "1" \ + and os.access(EPIC_CLIENT, os.X_OK): + try: + subprocess.run([EPIC_CLIENT, "exited", "--code", str(code)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=10) + except (OSError, subprocess.TimeoutExpired): + pass if found is None or not launch_dir or not cloud_on(found[1]): return 0 store, game = str(found[1].get("store")), str(found[1].get("id")) @@ -329,7 +366,14 @@ def prepare_gog(sidecar, compat_data, launch_dir): def main(argv): if argv and argv[0] == "--exited": - return exited(argv[1:]) + rest, code = argv[1:], 0 + if rest[:1] == ["--code"] and len(rest) > 1: + try: + code = int(rest[1]) + except ValueError: + pass + rest = rest[2:] + return exited(rest, code) found = find_store_game(argv) if found is None: return 0 @@ -341,9 +385,27 @@ def main(argv): if store == "gog": prepare_gog(sidecar, compat_data, launch_dir) if store == "epic": - drop_stale(folder) + handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() + if not handled: + drop_stale(folder) + try: + (folder / EPIC_ARGS).unlink() + except OSError: + pass choice = options(sidecar) - if not (choice["eos"] and not choice["offline"]): + client = None + if handled: + client = "ok" + elif choice["eos"] and not choice["offline"] and client_wanted(): + client = through_client(folder, sidecar) + log("epic launch id=%s client=%s" % (game, client)) + if client == "ok": + try: + (folder / EPIC_CODE).unlink() + except OSError: + pass + log("epic launch id=%s through=client" % game) + elif not (choice["eos"] and not choice["offline"]): try: (folder / EPIC_CODE).unlink() except OSError: diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 391497517..ef1147c87 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -73,7 +73,7 @@ bl_run() { if [ -n "${BL_STORE_EXIT:-}" ]; then "$@" rc=$? - /usr/local/bin/droiddeck-store-launch --exited "${BL_STORE_ARGS[@]}" || true + /usr/local/bin/droiddeck-store-launch --exited --code "$rc" "${BL_STORE_ARGS[@]}" || true exit $rc fi exec "$@" @@ -660,6 +660,26 @@ def launch_programs(config): return found +def launch_options(config): + """An app's launch options as Steam lists them: executable, arguments, working folder, type and oslist.""" + launch = config.get("launch") if isinstance(config, dict) else None + found = [] + for key, option in launch.items() if isinstance(launch, dict) else (): + if not isinstance(option, dict) or not option.get("executable"): + continue + settings = option.get("config") if isinstance(option.get("config"), dict) else {} + found.append({ + "key": str(key), + "executable": str(option["executable"]), + "arguments": str(option.get("arguments", "")), + "workingdir": str(option.get("workingdir", "")), + "type": str(option.get("type", "")).lower(), + "oslist": str(settings.get("oslist", "")).lower(), + "betakey": str(settings.get("betakey", "")), + }) + return found + + def appinfo_entries(path): import struct try: @@ -751,6 +771,7 @@ def appinfo_entries(path): "type": str(common.get("type", "")).lower(), "recommended": str(recommended), "launch": launch_programs(info.get("config")), + "launch_options": launch_options(info.get("config")), } if app == STEAM_PLAY_MANIFESTS: extended = info.get("extended", {}) diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index 19e83c13e..09c80f0af 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -70,7 +70,7 @@ def required(): for arch in ['x86_64', 'i386']: paths += ['assets/linuxfs/' + arch + '/' + lib for lib in ['libfakeinput.so', 'libblsession.so']] paths += ['assets/linuxfs/x86_64/' + lib for lib in - ['libfaultreport.so', 'libthunkaudit.so', 'libvulkan-thunk.so']] + ['libfaultreport.so', 'libthunkaudit.so', 'libvulkan-thunk.so', 'libwebkitinput.so']] paths += ['assets/linuxfs/i386/libfaultreport.so'] paths += ['assets/linuxfs/usr/local/lib/droiddeck-msitools/' + name for name in ['msiinfo', 'cabextract', '7z', '7z.so', 'libmsi-1.0.so.0', 'libgsf-1.so.114', 'libgcab-1.0.so.0']] diff --git a/tools/tests/test_desktop_open.py b/tools/tests/test_desktop_open.py index a766a2467..de98e75c6 100644 --- a/tools/tests/test_desktop_open.py +++ b/tools/tests/test_desktop_open.py @@ -516,6 +516,65 @@ class ProtonRunTest(Temp): self.assertEqual(env["SteamAppId"], "413150") self.assertTrue((self.root / "steamapps/compatdata/413150").is_dir()) + def run_main(self, argv): + calls, dirs = [], [] + with mock.patch.dict(os.environ, {"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(self.root)}), \ + mock.patch.object(RUN["os"], "execve", lambda *a: calls.append(a)), \ + mock.patch.object(RUN["os"], "chdir", dirs.append), mock.patch.dict(RUN, {"say": lambda message: None}): + code = RUN["main"](argv) + return code, calls, dirs + + def test_without_steam_a_steam_game_starts_its_default_windows_launch(self): + info = {"common": {"type": "Game", "oslist": "windows,linux"}, "config": {"launch": { + "0": {"executable": "Bin\\Game.exe", "arguments": "-dx11 \"-name=Two Words\"", "workingdir": "bin", + "type": "default", "config": {"oslist": "windows"}}, + "1": {"executable": "game.sh", "type": "default", "config": {"oslist": "linux"}}, + "2": {"executable": "Beta.exe", "type": "default", "config": {"oslist": "windows", "betakey": "beta"}}, + "3": {"executable": "Tools\\Editor.exe", "type": "option1"}, + }}} + self.write("Steam/appcache/appinfo.vdf", appinfo({413150: info})) + program = self.game("413150", "Some Game", "bin/game.exe") + self.game("413150", "Some Game", "Tools/Editor.exe") + code, calls, dirs = self.run_main(["--app", "413150", "--options", "-windowed"]) + self.assertIsNone(code) + entry, argv, env = calls[0] + self.assertEqual(argv[1:], ["waitforexitandrun", str(program), "-dx11", "-name=Two Words", "-windowed"]) + self.assertEqual((env["SteamAppId"], env["STEAM_COMPAT_DATA_PATH"]), + ("413150", str(self.root / "steamapps/compatdata/413150"))) + self.assertEqual(dirs, [str(program.parent)]) + + def test_without_steam_a_game_steam_cannot_start_is_refused(self): + self.write("Steam/appcache/appinfo.vdf", appinfo({413150: STARDEW})) + self.assertEqual(self.run_main(["--app", "413150"])[0], 2) + self.assertEqual(self.run_main(["--app", "3384071994"])[0], 2) + self.assertEqual(self.run_main(["--app", "0"])[0], 2) + self.assertEqual(self.run_main(["--app", "game"])[0], 2) + linux = {"common": {"type": "Game"}, "config": {"launch": {"0": {"executable": "run.sh", "config": {"oslist": "linux"}}}}} + self.write("Steam/appcache/appinfo.vdf", appinfo({70: linux})) + self.game("70", "Linux Game", "run.sh") + self.assertEqual(self.run_main(["--app", "70"])[0], 2) + + def test_without_steam_a_shortcut_keeps_its_prefix_folder_and_launch_options(self): + bat = self.write("Games/Epic/Game/.droiddeck-launch.bat", b"@echo off") + start = self.tmp / "Games/Epic/Game/Binaries" + start.mkdir() + code, calls, dirs = self.run_main(["--app", "2733369024", "--dir", str(start), "--options", + "PROTON_LOG=1 %command% -epicportal", "--", str(bat)]) + self.assertIsNone(code) + entry, argv, env = calls[0] + self.assertEqual(argv[1:], ["waitforexitandrun", str(bat), "-epicportal"]) + self.assertEqual((env["STEAM_COMPAT_DATA_PATH"], env["SteamAppId"], env["SteamGameId"], env["PROTON_LOG"]), + (str(self.root / "steamapps/compatdata/2733369024"), "0", str(2733369024 << 32 | 0x02000000), "1")) + self.assertTrue((self.root / "steamapps/compatdata/2733369024").is_dir()) + self.assertEqual(dirs, [str(start)]) + + def test_launch_options_split_like_steam_does(self): + split = RUN["split_options"] + self.assertEqual(split(""), ({}, [])) + self.assertEqual(split("-windowed -w 1280"), ({}, ["-windowed", "-w", "1280"])) + self.assertEqual(split("A=1 B=\"x y\" gamemoderun %command% -dx12"), ({"A": "1", "B": "x y"}, ["-dx12"])) + self.assertEqual(split("\"unterminated -x"), ({}, ["\"unterminated", "-x"])) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 3d7fdae5a..986931a84 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -291,6 +291,53 @@ class AgentTest(unittest.TestCase): self.assertFalse(result["supported"]) evaluate.assert_not_called() + def test_shortcut_state_tells_a_lost_shortcut_from_a_loading_library(self): + game_id = str((3333273269 << 32) | 0x02000000) + self.assertEqual("not-shortcut", agent.steam_shortcut_state(self.dir, "413150")) + for values, wanted, calls in ((["loading", "ready"], "ready", 2), (["loading", "missing"], "missing", 3), + ([None], "loading", 3)): + clock = [0.0] + replies = [{"value": v} for v in values] + [{"value": values[-1]}] * 10 + with self.subTest(values=values), patch.object(agent, "cdp_eval", side_effect=replies) as evaluate, \ + patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \ + patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)): + self.assertEqual(wanted, agent.steam_shortcut_state(self.dir, game_id, timeout=1)) + self.assertEqual(calls, evaluate.call_count) + self.assertIn("GetAppOverviewByGameID(%s)" % json.dumps(game_id), evaluate.call_args_list[0].args[1]) + + def test_shortcut_state_waits_out_an_unreachable_client_but_not_other_errors(self): + game_id = str((3333273269 << 32) | 0x02000000) + clock = [0.0] + with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")), \ + patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \ + patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)): + self.assertEqual("loading", agent.steam_shortcut_state(self.dir, game_id, timeout=1)) + with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("CDP_EXCEPTION", "boom")): + with self.assertRaises(agent.AgentError): + agent.steam_shortcut_state(self.dir, game_id, timeout=1) + + def test_shortcut_state_command_exits_with_the_state(self): + game_id = str((3333273269 << 32) | 0x02000000) + launch = self.dir.parent + with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}): + for state, code in (("ready", 0), ("missing", 4), ("not-shortcut", 3), ("loading", 1)): + with self.subTest(state=state), patch.object(agent, "steam_shortcut_state", return_value=state) as ask: + self.assertEqual(code, agent.main(["--steam-shortcut-state", game_id, "2"])) + self.assertEqual((launch / "agent", game_id, "2"), ask.call_args.args) + with patch.object(agent, "steam_shortcut_state", side_effect=agent.AgentError("CDP_EXCEPTION", "boom")): + self.assertEqual(1, agent.main(["--steam-shortcut-state", game_id])) + self.assertEqual(2, agent.main(["--steam-shortcut-state"])) + + def test_asleep_command_reports_steams_own_sleep_state(self): + with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}): + for value, code in (("yes", 0), ("no", 5), ("unknown", 1), (None, 1)): + with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask: + self.assertEqual(code, agent.main(["--steam-asleep"])) + self.assertIn("m_bSuspending", ask.call_args.args[1]) + self.assertFalse(ask.call_args.kwargs["await_promise"]) + with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")): + self.assertEqual(1, agent.main(["--steam-asleep"])) + def test_cdp_exception(self): server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": { "text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}}) diff --git a/tools/tests/test_droiddeck_epic.py b/tools/tests/test_droiddeck_epic.py new file mode 100644 index 000000000..9e08abe6c --- /dev/null +++ b/tools/tests/test_droiddeck_epic.py @@ -0,0 +1,1205 @@ +import http.client +import io +import json +import os +import runpy +import shutil +import socket +import signal +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +EPIC = runpy.run_path(str(BIN / "droiddeck-epic")) +G = EPIC["main"].__globals__ +STORE = runpy.run_path(str(BIN / "droiddeck-store-launch")) +S = STORE["main"].__globals__ + + +def tar_add(archive, name, data=None, link=None, kind=None, mode=0o644): + info = tarfile.TarInfo(name) + if kind == "dir": + info.type = tarfile.DIRTYPE + info.mode = 0o755 + archive.addfile(info) + elif link is not None: + info.type = tarfile.SYMTYPE if kind != "hard" else tarfile.LNKTYPE + info.linkname = link + archive.addfile(info) + else: + info.size = len(data) + info.mode = mode + archive.addfile(info, io.BytesIO(data)) + + +def ar(members): + out = bytearray(b"!\n") + for name, data in members: + out += ("%-16s%-12s%-6s%-6s%-8s%-10d`\n" % (name + "/", 0, 0, 0, "100644", len(data))).encode() + out += data + if len(data) % 2: + out += b"\n" + return bytes(out) + + +class EpicTestCase(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.home = self.tmp / "home" + state = self.home / ".local/share/droiddeck-epic" + data = self.home / ".local/share/com.epicgames.eglv2" + runtime = self.tmp / "run" + runtime.mkdir() + patches = { + "HOME": str(self.home), + "STATE": str(state), + "CURRENT": str(state / "current.json"), + "APPS": str(state / "app"), + "RUNTIMES": str(state / "runtime"), + "LOGS": str(state / "logs"), + "SHIMS": str(state / "bin"), + "FEX_CONFIG": str(state / "fex"), + "FEX_CACHE": str(self.home / ".cache/droiddeck-epic"), + "DATA": str(data), + "SETTINGS": str(data / "settings/app-settings.json"), + "API_FILE": str(data / "api.json"), + "AUTH_FILE": str(data / "auth" / "auth.json"), + "PROTON_ROOT": str(data / "compat/runtime"), + "INSTALLATIONS": str(data / "installations.jsonl"), + "V1_MANIFESTS": str(self.home / ".local/share/Epic/EpicGamesLauncher/Data/Manifests"), + "AUTO_SIGN_IN": str(state / "auto-sign-in"), + "SIGNED_OUT": str(state / "signed-out"), + "WEB_COOKIES": str(data / "cookies"), + } + for name, value in patches.items(): + patcher = mock.patch.dict(G, {name: value}) + patcher.start() + self.addCleanup(patcher.stop) + env = mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(runtime)}) + env.start() + self.addCleanup(env.stop) + self.state = state + self.data = data + + +class PreloadTest(EpicTestCase): + def test_launcher_runtime_preloads_the_webkit_input_fix(self): + source = self.tmp / "libwebkitinput.so" + source.write_bytes(b"scroll") + files = self.tmp / "files" + with mock.patch.dict(G, {"WEBKIT_INPUT_SOURCE": str(source)}): + G["install_preload"](str(files)) + self.assertEqual((files / "lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so").read_bytes(), b"scroll") + self.assertEqual((files / "etc/ld.so.preload").read_text(), "/usr/lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so\n") + with mock.patch.dict(G, {"WEBKIT_INPUT_SOURCE": str(self.tmp / "missing.so")}): + G["install_preload"](str(files)) + self.assertFalse((files / "lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so").exists()) + self.assertEqual((files / "etc/ld.so.preload").read_text(), "") + + +class RuntimeExtractTest(EpicTestCase): + def test_runtime_extract_rewrites_links_and_skips_i386(self): + tarball = self.tmp / "rt.tar.gz" + with tarfile.open(tarball, "w:gz") as archive: + tar_add(archive, "metadata", b"[Runtime]\n") + tar_add(archive, "files", kind="dir") + tar_add(archive, "files/lib/x86_64-linux-gnu/libc.so.6", b"libc", mode=0o755) + tar_add(archive, "files/lib/ld-linux-x86-64.so.2", link="/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2") + tar_add(archive, "files/etc/alias", link="/etc/real") + tar_add(archive, "files/bin/rel", link="../lib/x86_64-linux-gnu/libc.so.6") + tar_add(archive, "files/lib/i386-linux-gnu/libc.so.6", b"i386") + tar_add(archive, "files/share/doc/readme", b"doc") + tar_add(archive, "files/lib/hard", link="files/lib/x86_64-linux-gnu/libc.so.6", kind="hard") + tar_add(archive, "../escape", b"x") + tar_add(archive, "other/file", b"x") + staging, count = G["extract_runtime"](str(tarball), str(self.tmp / "rt")) + files = Path(staging) / "files" + self.assertEqual(b"libc", (files / "lib/x86_64-linux-gnu/libc.so.6").read_bytes()) + self.assertTrue(os.access(files / "lib/x86_64-linux-gnu/libc.so.6", os.X_OK)) + self.assertEqual("x86_64-linux-gnu/ld-linux-x86-64.so.2", os.readlink(files / "lib/ld-linux-x86-64.so.2")) + self.assertEqual("real", os.readlink(files / "etc/alias")) + self.assertEqual("../lib/x86_64-linux-gnu/libc.so.6", os.readlink(files / "bin/rel")) + self.assertEqual(".", os.readlink(files / "usr")) + self.assertEqual(b"libc", (files / "lib/hard").read_bytes()) + self.assertFalse((files / "lib/i386-linux-gnu").exists()) + self.assertFalse((files / "share/doc").exists()) + self.assertFalse((self.tmp / "escape").exists()) + self.assertFalse((Path(staging) / "other").exists()) + self.assertTrue((Path(staging) / "metadata").is_file()) + self.assertEqual(6, count) + + def test_runtime_without_libc_is_refused(self): + tarball = self.tmp / "rt.tar.gz" + with tarfile.open(tarball, "w:gz") as archive: + tar_add(archive, "files/bin/sh", b"sh") + with self.assertRaises(G["Failure"]): + G["extract_runtime"](str(tarball), str(self.tmp / "rt")) + self.assertFalse((self.tmp / "rt.staging").exists()) + + def test_gles_comes_out_of_the_deb(self): + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:xz") as archive: + tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2.1.0", b"gles") + tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2", link="libGLESv2.so.2.1.0") + tar_add(archive, "./usr/share/doc/libgles2/copyright", b"c") + deb = self.tmp / "gles.deb" + deb.write_bytes(ar([("debian-binary", b"2.0\n"), ("control.tar.xz", b"x"), ("data.tar.xz", data.getvalue())])) + files = self.tmp / "files" + G["install_gles"](str(deb), str(files)) + lib = files / "lib/x86_64-linux-gnu" + self.assertEqual(b"gles", (lib / "libGLESv2.so.2").read_bytes()) + self.assertEqual("libGLESv2.so.2.1.0", os.readlink(lib / "libGLESv2.so.2")) + self.assertFalse((files / "share").exists()) + bad = self.tmp / "bad.deb" + bad.write_bytes(b"not an archive") + with self.assertRaises(G["Failure"]): + G["install_gles"](str(bad), str(files)) + + def test_install_marks_ready_and_prunes_old_versions(self): + tarball = self.tmp / "rt.tar.gz" + with tarfile.open(tarball, "w:gz") as archive: + tar_add(archive, "files/lib/x86_64-linux-gnu/libc.so.6", b"libc") + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:gz") as archive: + tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2", b"gles") + deb = self.tmp / "gles.deb" + deb.write_bytes(ar([("data.tar.gz", data.getvalue())])) + old = self.state / "app/0.1" + old.mkdir(parents=True) + + def extract_app(image, dest): + os.makedirs(os.path.join(dest, "usr/bin")) + Path(dest, "AppRun").write_text("run") + Path(dest, "usr/bin/EpicGamesLauncher").write_text("bin") + + with mock.patch.dict(G, {"extract_app": extract_app, "fex_tool": lambda request=False: {"bin": "/fex", "server": "/s"}}), \ + mock.patch("sys.stdout", new_callable=io.StringIO) as out: + image = self.tmp / "image" + image.write_bytes(b"x") + code = G["install"]({"app": str(image), "app-version": "0.95.2+g1", "runtime": str(tarball), + "runtime-version": "4.0.1", "gles": str(deb)}) + self.assertEqual(0, code) + report = json.loads(out.getvalue()) + self.assertTrue(report["installed"]) + self.assertEqual({"app": "0.95.2+g1", "runtime": "4.0.1"}, json.loads((self.state / "current.json").read_text())) + self.assertFalse(old.exists()) + files = self.state / "runtime/4.0.1/files" + self.assertEqual("", (files / "etc/ld.so.preload").read_text()) + self.assertTrue((self.state / "runtime/4.0.1/.droiddeck-ready").is_file()) + self.assertTrue((self.state / "app/0.95.2+g1/.droiddeck-ready").is_file()) + + +class ArgumentsTest(EpicTestCase): + def test_client_arguments_win_and_paths_become_windows_paths(self): + ovt = self.tmp / "ovt" + ovt.mkdir() + client = ["-AUTH_LOGIN=unused", "-AUTH_PASSWORD=abc", "-AUTH_TYPE=exchangecode", "-epicapp=Fig", + "-epicovt=%s/token.ovt" % ovt, '-epicusername="Some One"', "-nosplash"] + own = ["-EpicPortal", "-epicapp=Fig", "-nosplash", "-windowed"] + merged = G["merge_args"](client, own) + self.assertIn('-epicovt="Z:%s\\token.ovt"' % str(ovt).replace("/", "\\"), merged) + self.assertEqual(1, merged.count("-nosplash")) + self.assertNotIn("-EpicPortal", merged) + self.assertEqual("-windowed", merged[-1]) + self.assertEqual("-AUTH_PASSWORD=abc", merged[1]) + + def test_command_line_escapes_cmd_specials_outside_quotes(self): + line = G["command_line"](["-a=b&c", '-n="A & B"', "two words", "-p=50%", "^x", "-z=a|b>cc^&2\n" + "echo 'write_xattr: could not write xattr security.selinux for file ./AppDir/.DirIcon because you are not superuser!' >&2\n" + "echo 'Further error messages of this type are suppressed!' >&2\n" + "exit 2\n") + with mock.patch.dict(G, {"URUNTIME": tool, "fex_install": lambda: None, "steam_running": lambda: False}): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertEqual(status, 0) + self.assertIn("could not keep the image's extended attributes", said) + self.assertTrue(Path("squashfs-root/AppRun").is_file()) + + def test_an_attribute_only_failure_without_apprun_is_not_taken_as_unpacked(self): + tool = self.uruntime( + "mkdir -p AppDir\n" + "echo 'write_xattr: could not write xattr security.selinux for file ./AppDir/.DirIcon because you are not superuser!' >&2\n" + "exit 2\n") + with mock.patch.dict(G, {"URUNTIME": tool, "fex_install": lambda: None, "steam_running": lambda: False, "prepare": lambda: None}): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertIn("uruntime could not extract it (status 2)", said) + self.assertNotIn("could not keep the image's extended attributes", said) + self.assertFalse(os.path.lexists("squashfs-root/AppRun")) + + def test_other_uruntime_errors_are_not_taken_as_unpacked(self): + self.assertFalse(FEX["only_xattrs_failed"]("write_xattr: could not write xattr\nwrite_file: failed to create file x\n")) + self.assertFalse(FEX["only_xattrs_failed"]("")) + self.assertTrue(FEX["only_xattrs_failed"]("proot error: execveat() failed\nwrite_xattr: could not write xattr\n")) + def test_an_arm64_image_does_not_ask_for_fex(self): self.write(self.image, elf(183) + b"payload", 0o755) tool = self.uruntime("mkdir -p squashfs-root\n") @@ -335,6 +364,33 @@ class ExtractTest(FexTestCase): self.assertIn("no AppImage runtime for ARM64, no FEX yet and no unsquashfs", said) self.assertFalse(os.path.lexists("squashfs-root")) + def test_unsquashfs_fallback_disables_unwritable_xattrs(self): + tool = self.uruntime("exit 1\n") + commands = [] + + def call(argv, **kwargs): + commands.append(argv) + if argv[0] == tool: + return 1 + (self.work / "squashfs-root").mkdir() + (self.work / "squashfs-root/AppRun").touch() + return 0 + + with mock.patch.dict(G, { + "URUNTIME": tool, + "prepare": lambda: None, + "fex_install": lambda: None, + "steam_running": lambda: False, + "want_fex": lambda: None, + }), mock.patch("shutil.which", return_value="/usr/bin/unsquashfs"), \ + mock.patch("subprocess.call", side_effect=call): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertEqual(status, 0) + self.assertIn("uruntime could not extract it (status 1)", said) + self.assertEqual(commands[-1][0], "/usr/bin/unsquashfs") + self.assertIn("-no-xattrs", commands[-1]) + self.assertTrue(Path("squashfs-root/AppRun").is_file()) + class LaunchTest(FexTestCase): def test_the_guest_environment(self): diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 40f2eef84..26612ec38 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -90,6 +90,12 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(env["REMOVE"], "inherited") self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared") + def test_a_games_wine_sync_choice_also_reaches_its_setup_runs(self): + env = {"BL_SYNC": "1", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "0", "OTHER": "kept"} + config = {"version": 1, "games": {"42": {"BL_SYNC": "0", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "FEX_TSOENABLED": "1"}}} + chosen = MODULE["apply_config"](env, MODULE["sync_config"](config, "42"), "42") + self.assertEqual(chosen, {"BL_SYNC": "0", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "OTHER": "kept"}) + def test_component_profiles_override_general_and_restore_it_for_the_next_game(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -947,28 +953,6 @@ if __name__ == "__main__": unittest.main() -class StartWithoutSteamTest(unittest.TestCase): - def test_start_without_steam_uses_umu_or_runinprefix_and_only_when_asked(self): - with tempfile.TemporaryDirectory() as tmp: - ge = Path(tmp) / "ge/proton" - ge.parent.mkdir() - ge.write_text('if "UMU_ID" in os.environ:\n argv = [wine, "c:\\\\windows\\\\system32\\\\umu.exe"]\n') - valve = Path(tmp) / "valve/proton" - valve.parent.mkdir() - valve.write_text('argv = [wine, "c:\\\\windows\\\\system32\\\\steam.exe"]\n') - start = MODULE["start_without_steam"] - game = [str(ge), "waitforexitandrun", "/games/Game.exe", "-windowed"] - self.assertEqual(start(game, {}, "3384071994"), (game, {})) - on = {"DROIDDECK_NO_STEAM": "1"} - self.assertEqual(start(game, on, "3384071994"), (game, dict(on, UMU_ID="0"))) - self.assertEqual(start(game, on, "570940")[1]["UMU_ID"], "570940") - self.assertEqual(start(game, dict(on, UMU_ID="umu-1"), "570940")[1]["UMU_ID"], "umu-1") - plain = [str(valve)] + game[1:] - self.assertEqual(start(plain, on, "570940"), ([str(valve), "runinprefix", "/games/Game.exe", "-windowed"], on)) - for other in ([str(ge), "run", "/x.exe"], [str(ge), "waitforexitandrun"], [str(valve), "getcompatpath", "/x"]): - self.assertEqual(start(other, on, "570940"), (other, on)) - - class ForceSsbsTest(unittest.TestCase): def test_libssbs_is_preloaded_only_when_asked_and_only_into_arm64_proton(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/tests/test_login1.py b/tools/tests/test_login1.py index a2fb42121..9150d2d78 100644 --- a/tools/tests/test_login1.py +++ b/tools/tests/test_login1.py @@ -23,6 +23,7 @@ IFACE = NAME + ".Manager" @unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject") class SleepHandshakeTest(unittest.TestCase): ARGS = [] + AGENT_EXIT = None def setUp(self): self.directory = tempfile.TemporaryDirectory() @@ -37,7 +38,16 @@ class SleepHandshakeTest(unittest.TestCase): lambda bus, sender, path, interface, name, body: self.signals.append(body.unpack()[0])) env = dict(os.environ, BL_LAUNCH_DIR=str(self.dir), DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"]) - self.process = subprocess.Popen(["/usr/bin/python3", str(SCRIPT), *self.ARGS], env=env, + script = SCRIPT + if self.AGENT_EXIT is not None: + bin_dir = self.dir / "bin" + bin_dir.mkdir() + script = bin_dir / SCRIPT.name + script.write_text(SCRIPT.read_text()) + agent = bin_dir / "droiddeck-agent" + agent.write_text("#!/bin/sh\necho \"$@\" >> \"%s\"\nexit %d\n" % (self.dir / "agent-calls", self.AGENT_EXIT)) + agent.chmod(0o755) + self.process = subprocess.Popen(["/usr/bin/python3", str(script), *self.ARGS], env=env, stdout=subprocess.DEVNULL) self.wait_for(lambda: (self.dir / "steam-sleep-ready").exists()) @@ -220,6 +230,53 @@ class SleepHandshakeTest(unittest.TestCase): self.call(IFACE, "PowerOff", GLib.Variant("(b)", (True,))) +class FakeAgentCase(unittest.TestCase): + ARGS = [] + setUp = SleepHandshakeTest.setUp + tearDown = SleepHandshakeTest.tearDown + wait_for = SleepHandshakeTest.wait_for + call = SleepHandshakeTest.call + suspend = SleepHandshakeTest.suspend + state = SleepHandshakeTest.state + request_wake = SleepHandshakeTest.request_wake + + def asked(self): + path = self.dir / "agent-calls" + return path.read_text().split() if path.exists() else [] + + +@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject") +class AwakeSteamTest(FakeAgentCase): + AGENT_EXIT = 5 + + def test_resuming_an_awake_steam_sends_no_sleep_wake_pair(self): + self.request_wake() + self.wait_for(lambda: self.asked()) + deadline = time.monotonic() + 1.5 + while time.monotonic() < deadline: + self.wait_for(lambda: True) + time.sleep(0.05) + self.assertEqual([], self.signals) + self.assertEqual(["--steam-asleep"], self.asked()) + + def test_a_pending_sleep_still_wakes_without_asking_steam(self): + token = self.suspend() + self.state(token, "paused") + self.request_wake() + self.wait_for(lambda: self.signals == [True, False]) + self.assertEqual([], self.asked()) + + +@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject") +class AsleepSteamTest(FakeAgentCase): + AGENT_EXIT = 0 + + def test_resuming_a_sleeping_steam_replays_the_pair(self): + self.request_wake() + self.wait_for(lambda: self.signals == [True, False]) + self.assertEqual(["--steam-asleep"], self.asked()) + + class WithNetworkManagerTest(SleepHandshakeTest): ARGS = ["--with-netmanager"] diff --git a/tools/tests/test_output_refresh.py b/tools/tests/test_output_refresh.py index 84ad5113a..7b252649e 100644 --- a/tools/tests/test_output_refresh.py +++ b/tools/tests/test_output_refresh.py @@ -65,6 +65,7 @@ static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; } #define pointer_event(...) ((void)0) #define pointer_button(...) ((void)0) #define scroll_event(...) ((void)0) +#define axis_event(...) ((void)0) #define pointer_delta(...) ((void)0) #define deliver_touch(m) ((void)0) #define deliver_pointer(m) (pointers++) diff --git a/tools/tests/test_session_run_status.py b/tools/tests/test_session_run_status.py new file mode 100644 index 000000000..b4c1914b7 --- /dev/null +++ b/tools/tests/test_session_run_status.py @@ -0,0 +1,74 @@ +import os +import re +import shutil +import signal +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + + +def run_program_function(): + match = re.search(r"^ run_program\(\) \{\n.*?^ \}\n", SESSION.read_text(), re.M | re.S) + return match.group(0) + + +class RunStatusTest(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, True) + + def run_with(self, command, launch_dir=True): + env = dict(os.environ) + env.pop("BL_LAUNCH_DIR", None) + if launch_dir: + env["BL_LAUNCH_DIR"] = str(self.dir) + return subprocess.run(["bash", "-c", run_program_function() + "run_program " + command + "; echo unreachable"], + env=env, capture_output=True, text=True, timeout=30) + + def test_the_program_status_is_kept_for_the_app(self): + run = self.run_with("sh -c 'exit 7'") + self.assertEqual(7, run.returncode) + self.assertNotIn("unreachable", run.stdout) + self.assertEqual("7\n", (self.dir / "run-status").read_text()) + self.assertFalse((self.dir / "run-status.tmp").exists()) + + def test_a_clean_exit_is_recorded_as_zero(self): + self.assertEqual(0, self.run_with("true").returncode) + self.assertEqual("0\n", (self.dir / "run-status").read_text()) + + def test_nothing_is_written_without_a_launch_dir(self): + self.assertEqual(3, self.run_with("sh -c 'exit 3'", launch_dir=False).returncode) + self.assertEqual([], os.listdir(self.dir)) + + def signalled(self, sig, program): + env = dict(os.environ, BL_LAUNCH_DIR=str(self.dir)) + ready = self.dir / "ready" + proc = subprocess.Popen(["bash", "-c", run_program_function() + "run_program " + program], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.addCleanup(proc.kill) + deadline = time.monotonic() + 10 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(ready.exists()) + proc.send_signal(sig) + return proc.wait(timeout=10) + + def test_a_stop_signal_reaches_the_program(self): + program = ("python3 -c 'import signal, sys, time; signal.signal(signal.SIGTERM, lambda *a: sys.exit(9)); " + "open(sys.argv[1], \"w\").close(); time.sleep(30)' %s" % (self.dir / "ready")) + self.assertEqual(9, self.signalled(signal.SIGTERM, program)) + self.assertEqual("9\n", (self.dir / "run-status").read_text()) + + def test_an_interrupt_reaches_the_program(self): + program = ("python3 -c 'import sys, time; open(sys.argv[1], \"w\").close(); time.sleep(30)' %s" + % (self.dir / "ready")) + self.assertEqual(130, self.signalled(signal.SIGINT, program)) + self.assertEqual("130\n", (self.dir / "run-status").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py index 9dfd10f2e..0065bdbeb 100644 --- a/tools/tests/test_steam_cold_launch.py +++ b/tools/tests/test_steam_cold_launch.py @@ -8,15 +8,8 @@ import unittest SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" SOURCE = SESSION.read_text() -END = SOURCE.index( - ' if [ -s "$HOME/.bl-autolaunch" ]; then', - SOURCE.index(" _bl_launch_ready="), -) -START = SOURCE.rfind( - ' if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then', - 0, - END, -) +START = SOURCE.index(" steam_hold_launch() {") +END = SOURCE.index(' if [ -s "$HOME/.bl-autolaunch" ]; then', START) assert START >= 0 LAUNCH_BLOCK = textwrap.dedent(SOURCE[START:END]) @@ -103,6 +96,20 @@ class SteamColdLaunchTest(unittest.TestCase): self.assertEqual("1", self.agent_count.read_text().strip()) self.assertFalse(self.sleep_args.exists()) + def test_a_restart_for_a_game_waits_for_its_overview_too(self): + game_id = str((3551182975 << 32) | 0x02000000) + loop = SOURCE[SOURCE.index(' if [ -s "$HOME/.bl-restart-game" ]; then'):SOURCE.index(' if [ -f "$HOME/.bl-restart-once" ]; then')] + self.assertIn('steam_hold_launch "$_bl_again" &', loop) + self.assertNotIn("set -- \"steam://rungameid/", loop) + script = self.block + '\nsteam_hold_launch "%s"\n' % game_id + env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), + PATH=str(self.bin) + os.pathsep + os.environ["PATH"], + AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), + AGENT_COUNT=str(self.agent_count), AGENT_STATUS="1 0") + result = subprocess.run(["bash", "-c", script, "restart"], env=env, text=True, capture_output=True, timeout=10, check=True) + self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) + self.assertEqual(["--wait-steam-shortcut-overview", game_id, "45"], self.agent_args.read_text().splitlines()) + def test_non_game_steam_urls_remain_on_the_original_startup_arguments(self): url = "steam://open/library" result = self.run_launch(url, 0) diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index e1c63ce58..b45126164 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -84,6 +84,17 @@ class ImportsTest(unittest.TestCase): imports.watch(str(self.steam), str(listing)) identify.assert_not_called() + def test_native_programs_stay_shortcuts_and_are_never_looked_up(self): + self.snapshot() + game = dict(self.game, native=True) + self.assertEqual(([game], {}), imports.route(self.steam, self.acct, [game])) + self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists()) + listing = self.root / 'games.json' + listing.write_text(json.dumps([game])) + with patch.object(imports, 'identify') as identify: + imports.watch(str(self.steam), str(listing)) + identify.assert_not_called() + def test_migrates_only_unchanged_legacy_import_manifests(self): self.snapshot() imports.route(self.steam, self.acct, [self.game]) diff --git a/tools/tests/test_steam_shortcut_recovery.py b/tools/tests/test_steam_shortcut_recovery.py new file mode 100644 index 000000000..a5148c3c2 --- /dev/null +++ b/tools/tests/test_steam_shortcut_recovery.py @@ -0,0 +1,174 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" +SOURCE = SESSION.read_text() +START = SOURCE.index(" steam_other_game_running() {") +END = SOURCE.index(" steam_bring_forward() {", START) +FUNCTIONS = textwrap.dedent(SOURCE[START:END]) +GAME = str((3333273269 << 32) | 0x02000000) + + +class SteamShortcutRecoveryTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.launch = self.root / "session" + self.launch.mkdir() + self.bin = self.root / "bin" + self.bin.mkdir() + steam = self.root / "steam/steamrtarm64" + steam.mkdir(parents=True) + self.steam_log = self.root / "steam-args" + (steam / "steam").write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$STEAM_LOG\"\n") + (steam / "steam").chmod(0o755) + self.agent = self.bin / "droiddeck-agent" + self.agent.write_text( + "#!/bin/sh\n" + "n=$(cat \"$AGENT_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$AGENT_COUNT\"\n" + "i=0; last=0\n" + "for s in $AGENT_STATES; do i=$((i + 1)); last=$s; [ \"$i\" -eq \"$n\" ] && exit \"$s\"; done\n" + "exit \"$last\"\n") + self.agent.chmod(0o755) + + def launch_game(self, states, focusable="769", extra=None): + xprop = self.bin / "xprop" + xprop.write_text("#!/bin/sh\necho 'GAMESCOPE_FOCUSABLE_APPS(CARDINAL) = %s'\n" % focusable) + xprop.chmod(0o755) + script = "nap() { :; }\nsteam_root=%s\n%s\nsteam_launch_game %s\n" % ( + self.root / "steam", FUNCTIONS.replace("/usr/local/bin/droiddeck-agent", str(self.agent)), GAME) + env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch), + PATH=str(self.bin) + os.pathsep + os.environ["PATH"], STEAM_LOG=str(self.steam_log), + AGENT_COUNT=str(self.root / "agent-count"), AGENT_STATES=states, **(extra or {})) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + steam = self.steam_log.read_text().splitlines() if self.steam_log.exists() else [] + restart = self.root / ".bl-restart-game" + return steam, restart.read_text().strip() if restart.exists() else None, result.stdout + + def test_a_shortcut_that_stays_known_launches_once(self): + steam, restart, _ = self.launch_game("0") + self.assertEqual(["steam://rungameid/" + GAME], steam) + self.assertIsNone(restart) + self.assertEqual("9", (self.root / "agent-count").read_text().strip()) + + def test_a_shortcut_dropped_while_launching_restarts_steam_for_it(self): + steam, restart, out = self.launch_game("0 0 4") + self.assertEqual(["steam://rungameid/" + GAME, "-shutdown"], steam) + self.assertEqual(GAME, restart) + self.assertIn("dropped shortcut", out) + + def test_a_shortcut_already_lost_restarts_steam_without_launching(self): + steam, restart, out = self.launch_game("4") + self.assertEqual(["-shutdown"], steam) + self.assertEqual(GAME, restart) + self.assertIn("lost shortcut", out) + + def test_steam_is_restarted_only_once_for_the_same_game(self): + steam, restart, _ = self.launch_game("4") + self.assertEqual(GAME, restart) + (self.root / ".bl-restart-game").unlink() + self.steam_log.unlink() + (self.root / "agent-count").unlink() + steam, restart, out = self.launch_game("4") + self.assertEqual(["steam://rungameid/" + GAME], steam) + self.assertIsNone(restart) + self.assertIn("already restarted", out) + + def test_a_ready_shortcut_allows_a_later_restart_again(self): + self.launch_game("4") + (self.root / ".bl-restart-game").unlink() + (self.root / "agent-count").unlink() + self.launch_game("0") + self.assertFalse((self.root / ".bl-restarted-for").exists()) + + def test_a_running_game_is_never_cut_off_by_a_restart(self): + steam, restart, _ = self.launch_game("0 4", focusable="769,3703311242") + self.assertEqual(["steam://rungameid/" + GAME], steam) + self.assertIsNone(restart) + steam, restart, _ = self.launch_game("4", focusable="769,3703311242") + self.assertIsNone(restart) + + def test_the_open_launcher_does_not_count_as_a_running_game(self): + (self.launch / "epic-open").write_text("2702387189\n") + steam, restart, _ = self.launch_game("0 4", focusable="769,2702387189") + self.assertEqual(GAME, restart) + + def test_the_launcher_without_its_steam_id_still_does_not_count_as_a_running_game(self): + (self.launch / "epic-open").write_text("1\n") + launcher = {"BL_EPIC_APP_ID": "2702387189"} + steam, restart, _ = self.launch_game("0 4", focusable="769,2702387189", extra=launcher) + self.assertEqual(GAME, restart) + for marker in (".bl-restart-game", ".bl-restarted-for", "agent-count"): + (self.root / marker).unlink(missing_ok=True) + steam, restart, _ = self.launch_game("4", focusable="769,2702387189,3703311242", extra=launcher) + self.assertIsNone(restart) + + def test_steam_games_are_launched_without_watching(self): + steam, restart, _ = self.launch_game("3") + self.assertEqual(["steam://rungameid/" + GAME], steam) + self.assertIsNone(restart) + self.assertEqual("1", (self.root / "agent-count").read_text().strip()) + + def check_exits(self, listings, states, beats): + pgrep = self.bin / "pgrep" + pgrep.write_text( + "#!/bin/bash\n" + "n=$(cat \"$PGREP_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$PGREP_COUNT\"\n" + "IFS='|' read -r -a runs <<< \"$PGREP_RUNS\"\n" + "line=${runs[$((n - 1))]:-}\n" + "[ -n \"$line\" ] || exit 1\n" + "printf '%s\\n' \"$line\"\n") + pgrep.chmod(0o755) + script = "steam_root=%s\n%s\ndeclare -A _bl_ran=()\n_bl_exited=\n%s" % ( + self.root / "steam", FUNCTIONS.replace("/usr/local/bin/droiddeck-agent", str(self.agent)), + "steam_check_exits\n" * beats) + env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch), + PATH=str(self.bin) + os.pathsep + os.environ["PATH"], STEAM_LOG=str(self.steam_log), + AGENT_COUNT=str(self.root / "agent-count"), AGENT_STATES=states, + PGREP_COUNT=str(self.root / "pgrep-count"), PGREP_RUNS="|".join(listings)) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + steam = self.steam_log.read_text().splitlines() if self.steam_log.exists() else [] + agent = self.root / "agent-count" + return steam, (self.root / ".bl-restart-lost").exists(), int(agent.read_text()) if agent.exists() else 0, result.stdout + + RUNNING = "41 /root/.local/share/Steam/steamrtarm64/reaper SteamLaunch AppId=3333273269 -- /x/.droiddeck-launch.bat" + + def test_a_shortcut_dropped_when_its_game_exits_restarts_steam_once_nothing_runs(self): + steam, marker, asked, out = self.check_exits([self.RUNNING, "", "", ""], "4", 3) + self.assertEqual(["-shutdown"], steam) + self.assertTrue(marker) + self.assertEqual(1, asked) + self.assertIn("3333273269", out) + + def test_a_shortcut_still_listed_after_its_game_exits_is_left_alone(self): + steam, marker, asked, _ = self.check_exits([self.RUNNING, "", "", ""], "0", 4) + self.assertEqual([], steam) + self.assertFalse(marker) + self.assertEqual(1, asked) + + def test_the_check_waits_a_quiet_beat_after_the_exit(self): + steam, _, asked, _ = self.check_exits([self.RUNNING, ""], "4", 2) + self.assertEqual([], steam) + self.assertEqual(0, asked) + + def test_a_game_started_meanwhile_is_never_cut_off(self): + steam, marker, _, _ = self.check_exits([self.RUNNING, "", "", self.RUNNING], "4", 3) + self.assertEqual([], steam) + self.assertFalse(marker) + + def test_steam_games_are_not_checked(self): + game = "41 /root/.local/share/Steam/steamrtarm64/reaper SteamLaunch AppId=1245620 -- /x/eldenring.exe" + steam, _, asked, _ = self.check_exits([game, "", "", ""], "4", 3) + self.assertEqual([], steam) + self.assertEqual(0, asked) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_shortcuts.py b/tools/tests/test_steam_shortcuts.py index a00dbc9e7..60a2998a6 100644 --- a/tools/tests/test_steam_shortcuts.py +++ b/tools/tests/test_steam_shortcuts.py @@ -147,6 +147,18 @@ class ShortcutsTest(unittest.TestCase): self.assertEqual({'1': 'Favorites', '0': shortcuts.TAG}, entry['tags']) self.assertEqual({'0': shortcuts.TAG}, shortcuts.merge(shortcuts.shortcut(self.game()), dict(shortcuts.shortcut(self.game()), tags={}), None, 0)[0]['tags']) + def test_a_copy_the_running_client_added_of_an_added_game_is_dropped(self): + self.run_writer(self.game()) + path = self.config / 'shortcuts.vdf' + data = shortcuts.parse(path.read_bytes())['shortcuts'] + live = dict(shortcuts.shortcut(self.game(appid=0x91234567)), tags={}) + own = dict(shortcuts.shortcut(self.game(exe='Other.exe', appid=0x92345678)), tags={}) + renamed = dict(shortcuts.shortcut(self.game(appid=0x93456789, name='My copy')), tags={}) + data.update({'7': live, '8': own, '9': renamed}) + path.write_bytes(shortcuts.write({'shortcuts': data})) + entries = self.run_writer(self.game()) + self.assertEqual({APPID, 0x92345678, 0x93456789}, set(entries)) + def test_a_damaged_record_is_started_over(self): self.run_writer(self.game()) (self.config / shortcuts.RECORD).write_text('{not json') diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 88e9f4156..1acf493ac 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -241,7 +241,7 @@ class CloudHooks(unittest.TestCase): self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh) self.assertNotIn('exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh) self.assertIn('[ $? -eq 10 ] && BL_STORE_EXIT=1', compat.BL_STORE_SETUP) - self.assertIn('droiddeck-store-launch --exited "${BL_STORE_ARGS[@]}"', compat.BL_STORE_SETUP) + self.assertIn('droiddeck-store-launch --exited --code "$rc" "${BL_STORE_ARGS[@]}"', compat.BL_STORE_SETUP) self.assertIn('exec "$@"', compat.BL_STORE_SETUP)