mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
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 "<game> 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.
This commit is contained in:
1 parent
83eda433c8
commit
97f976ef6c
107 files changed
+8860
-371
No files matched your search
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
|||||||
|
|
||||||
## Requirements and install
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:localeConfig="@xml/locales_config"
|
android:localeConfig="@xml/locales_config"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -2841,10 +2841,19 @@ static void deliver_key(const struct input_msg *m) {
|
|||||||
key_event((uint32_t)m->p1, m->p2);
|
key_event((uint32_t)m->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
|
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));
|
: 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 wl_client *client = wl_resource_get_client(target->resource);
|
||||||
struct seat_pointer *sp;
|
struct seat_pointer *sp;
|
||||||
if (!pointer_for(client)) return;
|
if (!pointer_for(client)) return;
|
||||||
@@ -2853,17 +2862,18 @@ static void scroll_event(int steps) {
|
|||||||
uint32_t t = now_ms();
|
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));
|
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) {
|
for_each_pointer_of(client, sp) {
|
||||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION)
|
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
|
||||||
wl_pointer_send_axis_value120(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps * 120);
|
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
|
||||||
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));
|
|
||||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||||
wl_pointer_send_frame(sp->ptr);
|
wl_pointer_send_frame(sp->ptr);
|
||||||
}
|
}
|
||||||
wl_display_flush_clients(g_display);
|
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 int update_modifiers(uint32_t evdev, int pressed) {
|
||||||
static const struct { uint32_t evdev, mask; } mod_keys[] = {
|
static const struct { uint32_t evdev, mask; } mod_keys[] = {
|
||||||
{ 42, 0x1 }, { 54, 0x1 },
|
{ 42, 0x1 }, { 54, 0x1 },
|
||||||
@@ -2957,6 +2967,7 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
|||||||
schedule_render();
|
schedule_render();
|
||||||
break;
|
break;
|
||||||
case 10: break;
|
case 10: break;
|
||||||
|
case 11: axis_event(m.p1, m.p2); break;
|
||||||
default: deliver_pointer(&m); break;
|
default: deliver_pointer(&m); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var frameGenLabel by mutableStateOf("")
|
private var frameGenLabel by mutableStateOf("")
|
||||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||||
private var showRemove by mutableStateOf(false)
|
private var showRemove by mutableStateOf(false)
|
||||||
|
private var pendingSwitch by mutableStateOf<Intent?>(null)
|
||||||
|
private var switchingTo: Intent? = null
|
||||||
|
private var switchPoll: Runnable? = null
|
||||||
|
private var switchDeadline = 0L
|
||||||
private var showResetSettings by mutableStateOf(false)
|
private var showResetSettings by mutableStateOf(false)
|
||||||
private var lowSpace by mutableStateOf<Long?>(null)
|
private var lowSpace by mutableStateOf<Long?>(null)
|
||||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(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()
|
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, game)) return launchWithoutSteam(game, returnHome)
|
||||||
if (SessionState.running) {
|
if (SessionState.running) {
|
||||||
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
|
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()
|
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)
|
}, 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<Library.SteamGame>) {
|
private fun handleGameLink(games: List<Library.SteamGame>) {
|
||||||
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
|
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
|
||||||
val id = pendingGameLink ?: return
|
val id = pendingGameLink ?: return
|
||||||
@@ -666,6 +824,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onEpicLauncher = { openEpicLauncher() },
|
||||||
|
onUninstallEpicLauncher = { uninstallEpicLauncher() },
|
||||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||||
onExportGameFile = { g -> pickGameExport(g) },
|
onExportGameFile = { g -> pickGameExport(g) },
|
||||||
onSyncGameFiles = { pickGameExport(null) },
|
onSyncGameFiles = { pickGameExport(null) },
|
||||||
@@ -927,6 +1087,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
onDismiss = { showNonAdreno = null },
|
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(
|
if (showRemove) ConfirmDialog(
|
||||||
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
||||||
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
|
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() }
|
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<String>()
|
val wanted = ArrayList<String>()
|
||||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||||
@@ -1111,6 +1284,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
refreshSecondScreenDisplays()
|
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() {
|
override fun onDestroy() {
|
||||||
updates.unregister()
|
updates.unregister()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -1877,6 +2063,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val MEDIA_SETTLE_MS = 1500L
|
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 ZIP_EXT = listOf("zip")
|
||||||
private val WCP_EXT = listOf("wcp")
|
private val WCP_EXT = listOf("wcp")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import com.droiddeck.launcher.stores.Store
|
|||||||
import com.droiddeck.launcher.stores.StoreAchievements
|
import com.droiddeck.launcher.stores.StoreAchievements
|
||||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||||
import com.droiddeck.launcher.stores.StoreLaunchRequests
|
import com.droiddeck.launcher.stores.StoreLaunchRequests
|
||||||
|
import com.droiddeck.launcher.stores.epic.EpicClient
|
||||||
import com.droiddeck.launcher.ui.SessionAchievements
|
import com.droiddeck.launcher.ui.SessionAchievements
|
||||||
import com.droiddeck.launcher.gpu.Cardboard
|
import com.droiddeck.launcher.gpu.Cardboard
|
||||||
import com.droiddeck.launcher.gpu.FrameGen
|
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.SessionClipboard
|
||||||
import com.droiddeck.launcher.input.OnScreenControls
|
import com.droiddeck.launcher.input.OnScreenControls
|
||||||
import com.droiddeck.launcher.input.PadBridge
|
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.SteamControllerBle
|
||||||
import com.droiddeck.launcher.input.PointerGestures
|
import com.droiddeck.launcher.input.PointerGestures
|
||||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||||
import com.droiddeck.launcher.input.SecondScreenMode
|
import com.droiddeck.launcher.input.SecondScreenMode
|
||||||
import com.droiddeck.launcher.input.TouchpadGestures
|
import com.droiddeck.launcher.input.TouchpadGestures
|
||||||
|
import com.droiddeck.launcher.input.ScrollTouchGestures
|
||||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||||
import com.droiddeck.launcher.session.LoadingState
|
import com.droiddeck.launcher.session.LoadingState
|
||||||
import com.droiddeck.launcher.session.PerfHud
|
import com.droiddeck.launcher.session.PerfHud
|
||||||
@@ -183,6 +187,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
private var watching = true
|
private var watching = true
|
||||||
private lateinit var touchpad: TouchpadGestures
|
private lateinit var touchpad: TouchpadGestures
|
||||||
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
|
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 touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||||
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
|
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
|
||||||
private var cursorVisible by mutableStateOf(false)
|
private var cursorVisible by mutableStateOf(false)
|
||||||
@@ -198,6 +209,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
if (pointerLocked == locked) return@post
|
if (pointerLocked == locked) return@post
|
||||||
pointerLocked = locked
|
pointerLocked = locked
|
||||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
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).
|
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
|
||||||
if (locked && ::touchpad.isInitialized) {
|
if (locked && ::touchpad.isInitialized) {
|
||||||
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
|
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
|
||||||
@@ -363,6 +376,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
Log.i(TAG, "input device $deviceId disconnected")
|
Log.i(TAG, "input device $deviceId disconnected")
|
||||||
joyConVr?.refresh()
|
joyConVr?.refresh()
|
||||||
padBridge?.onDeviceRemoved(deviceId)
|
padBridge?.onDeviceRemoved(deviceId)
|
||||||
|
if (::padMouse.isInitialized) padMouse.reset()
|
||||||
refreshControls()
|
refreshControls()
|
||||||
updatePointerCapture()
|
updatePointerCapture()
|
||||||
updateHandCamera()
|
updateHandCamera()
|
||||||
@@ -465,6 +479,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||||
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
|
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)
|
val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL)
|
||||||
root.pointerIcon = noCursor
|
root.pointerIcon = noCursor
|
||||||
surfaceView.pointerIcon = noCursor
|
surfaceView.pointerIcon = noCursor
|
||||||
@@ -614,6 +638,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
if (found == null) achievementsOpen = false
|
if (found == null) achievementsOpen = false
|
||||||
}
|
}
|
||||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
|
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(
|
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||||
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) },
|
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) },
|
||||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||||
@@ -1224,6 +1251,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
val nextTouchMode = SessionPrefs.touchMode(this)
|
val nextTouchMode = SessionPrefs.touchMode(this)
|
||||||
if (touchMode != nextTouchMode) {
|
if (touchMode != nextTouchMode) {
|
||||||
if (::touchpad.isInitialized) touchpad.cancel()
|
if (::touchpad.isInitialized) touchpad.cancel()
|
||||||
|
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||||
if (CompositorHost.isStarted) {
|
if (CompositorHost.isStarted) {
|
||||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
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 ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
|
||||||
if (joyConVr?.onKeyEvent(event) == true) return true
|
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 (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
||||||
val down = event.action == KeyEvent.ACTION_DOWN
|
val down = event.action == KeyEvent.ACTION_DOWN
|
||||||
@@ -1818,6 +1847,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||||
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||||
if (joyConVr?.onMotionEvent(event) == true) return true
|
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 (padBridge?.onMotionEvent(event) == true) return true
|
||||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||||
return super.dispatchGenericMotionEvent(event)
|
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) {
|
private fun moveLockedPointer(x: Float, y: Float) {
|
||||||
val rect = drawnRect() ?: return
|
val rect = drawnRect() ?: return
|
||||||
val scale = SessionState.outputSize.first / rect.width()
|
val scale = SessionState.outputSize.first / rect.width()
|
||||||
@@ -2147,6 +2238,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
}
|
}
|
||||||
if (next == routedTouchMode) return
|
if (next == routedTouchMode) return
|
||||||
if (::touchpad.isInitialized) touchpad.cancel()
|
if (::touchpad.isInitialized) touchpad.cancel()
|
||||||
|
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||||
routedTouchMode = next
|
routedTouchMode = next
|
||||||
@@ -2180,6 +2272,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
|
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
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 (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
|
||||||
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") {
|
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") {
|
||||||
val wasActive = threeFingerTap.active
|
val wasActive = threeFingerTap.active
|
||||||
@@ -2429,6 +2527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
onScreenControls?.releaseAll()
|
onScreenControls?.releaseAll()
|
||||||
releasePads()
|
releasePads()
|
||||||
touchpad.cancel()
|
touchpad.cancel()
|
||||||
|
scrollTouch.cancel()
|
||||||
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
||||||
keyboard?.hide()
|
keyboard?.hide()
|
||||||
sessionClipboard.stop()
|
sessionClipboard.stop()
|
||||||
@@ -2481,6 +2580,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||||
refreshSecondScreenDisplays()
|
refreshSecondScreenDisplays()
|
||||||
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||||
|
startedScreens++
|
||||||
SessionService.setActivityVisible(this, true)
|
SessionService.setActivityVisible(this, true)
|
||||||
Glasses.rescan()
|
Glasses.rescan()
|
||||||
updateGlassesScreen()
|
updateGlassesScreen()
|
||||||
@@ -2493,8 +2593,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
updateHandCamera()
|
updateHandCamera()
|
||||||
displayManager.unregisterDisplayListener(displayListener)
|
displayManager.unregisterDisplayListener(displayListener)
|
||||||
closeSecondScreen(reset = false)
|
closeSecondScreen(reset = false)
|
||||||
|
startedScreens = (startedScreens - 1).coerceAtLeast(0)
|
||||||
updateGlassesScreen()
|
updateGlassesScreen()
|
||||||
SessionService.setActivityVisible(this, false)
|
if (startedScreens == 0) SessionService.setActivityVisible(this, false)
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2559,6 +2660,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
|
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
|
||||||
releaseDrawerDirection()
|
releaseDrawerDirection()
|
||||||
onScreenControls?.releaseAll()
|
onScreenControls?.releaseAll()
|
||||||
|
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||||
|
if (::padMouse.isInitialized) padMouse.reset()
|
||||||
resumed = false
|
resumed = false
|
||||||
updatePointerCapture()
|
updatePointerCapture()
|
||||||
updatePadMotion()
|
updatePadMotion()
|
||||||
@@ -2873,6 +2976,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
|
|
||||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||||
super.onWindowFocusChanged(hasFocus)
|
super.onWindowFocusChanged(hasFocus)
|
||||||
|
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
|
||||||
if (runtimeRemovalBlocked) return
|
if (runtimeRemovalBlocked) return
|
||||||
if (hasFocus && !pipUi) {
|
if (hasFocus && !pipUi) {
|
||||||
goFullscreen()
|
goFullscreen()
|
||||||
@@ -2986,9 +3090,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||||||
const val EXTRA_STEAMVR = "steamVr"
|
const val EXTRA_STEAMVR = "steamVr"
|
||||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||||
private const val TAG = "SessionActivity"
|
private const val TAG = "SessionActivity"
|
||||||
|
private var startedScreens = 0
|
||||||
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
|
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 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 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_HAT_THRESHOLD = 0.5f
|
||||||
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
||||||
private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f
|
private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f
|
||||||
|
|||||||
@@ -136,8 +136,12 @@ object AgentGuest {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
fun steamEval(context: Context, expression: String, timeoutMs: Long): JSONObject =
|
||||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
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")
|
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)")
|
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||||
val dir = dir(context)
|
val dir = dir(context)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.droiddeck.launcher.core
|
package com.droiddeck.launcher.core
|
||||||
|
|
||||||
|
import com.droiddeck.launcher.session.SessionPrefs
|
||||||
|
|
||||||
object GameEnvironment {
|
object GameEnvironment {
|
||||||
data class Config(
|
data class Config(
|
||||||
val shared: Map<String, String?> = emptyMap(),
|
val shared: Map<String, String?> = emptyMap(),
|
||||||
@@ -83,4 +85,34 @@ object GameEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val CUSTOM_FEX_PRESET = "__custom__"
|
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<String, String> {
|
||||||
|
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__"
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import com.droiddeck.launcher.session.GameStorage
|
import com.droiddeck.launcher.session.GameStorage
|
||||||
import com.droiddeck.launcher.session.SessionPrefs
|
import com.droiddeck.launcher.session.SessionPrefs
|
||||||
|
import com.droiddeck.launcher.stores.Store
|
||||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -296,13 +297,16 @@ object AddedGames {
|
|||||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
||||||
run {
|
run {
|
||||||
val sidecar = StoreGameSidecar.read(folder)
|
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 ranking = rank(folder)
|
||||||
val candidates = ranking.exes
|
val candidates = ranking.exes
|
||||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||||
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.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 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)
|
val foundGuest = guestPath(context, launch)
|
||||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||||
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
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 }
|
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||||
}.getOrNull()
|
}.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 {
|
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
@@ -382,7 +389,8 @@ object AddedGames {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeListing(context: Context, games: List<Game>): File {
|
fun writeListing(context: Context, games: List<Game>,
|
||||||
|
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
|
||||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||||
val json = StringBuilder("[")
|
val json = StringBuilder("[")
|
||||||
games.forEachIndexed { i, g ->
|
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('}')
|
if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}')
|
||||||
json.append('}')
|
json.append('}')
|
||||||
}
|
}
|
||||||
|
extra.forEachIndexed { i, entry ->
|
||||||
|
if (games.isNotEmpty() || i > 0) json.append(',')
|
||||||
|
json.append(entry.toString())
|
||||||
|
}
|
||||||
file.writeText(json.append(']').toString())
|
file.writeText(json.append(']').toString())
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ object Library {
|
|||||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||||
}
|
}
|
||||||
const val ADDED = "added"
|
const val ADDED = "added"
|
||||||
|
const val EPIC_LAUNCHER = "epic-launcher"
|
||||||
|
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||||
const val SOURCE_STEAM = "steam"
|
const val SOURCE_STEAM = "steam"
|
||||||
|
|
||||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
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 ||
|
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER ||
|
||||||
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) {
|
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) {
|
||||||
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
|
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<String, String>()
|
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public final class PadBridge {
|
|||||||
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
|
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||||
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
|
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_MIN_MS = 80;
|
||||||
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
|
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
|
||||||
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
|
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 final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
private boolean open;
|
private boolean open;
|
||||||
private volatile boolean selectSteam;
|
private volatile boolean selectSteam;
|
||||||
|
private TouchRoute touchRoute;
|
||||||
|
private final PadState routedTouch = new PadState();
|
||||||
private boolean systemGuidePressed;
|
private boolean systemGuidePressed;
|
||||||
private boolean systemQamPressed;
|
private boolean systemQamPressed;
|
||||||
private boolean qamChordActive;
|
private boolean qamChordActive;
|
||||||
@@ -313,26 +316,11 @@ public final class PadBridge {
|
|||||||
}
|
}
|
||||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||||
boolean wiredXboxLayout = device != null
|
boolean wired = wiredXboxLayout(event);
|
||||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
pad.rightX = rightX(event, wired);
|
||||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
pad.rightY = rightY(event, wired);
|
||||||
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
|
float lt = leftTrigger(event, wired);
|
||||||
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
|
float rt = rightTrigger(event, wired);
|
||||||
&& 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);
|
|
||||||
}
|
|
||||||
pad.leftTrigger = lt;
|
pad.leftTrigger = lt;
|
||||||
pad.rightTrigger = rt;
|
pad.rightTrigger = rt;
|
||||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||||
@@ -349,7 +337,36 @@ public final class PadBridge {
|
|||||||
return true;
|
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<PadState> mutation) {
|
public synchronized void applyTouch(java.util.function.Consumer<PadState> 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);
|
mutation.accept(state);
|
||||||
activeControllerId = NO_CONTROLLER;
|
activeControllerId = NO_CONTROLLER;
|
||||||
slotControllers.set(0, NO_CONTROLLER);
|
slotControllers.set(0, NO_CONTROLLER);
|
||||||
@@ -380,6 +397,7 @@ public final class PadBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void releaseAll() {
|
public synchronized void releaseAll() {
|
||||||
|
routedTouch.clear();
|
||||||
releaseBackPaddles();
|
releaseBackPaddles();
|
||||||
state.clear();
|
state.clear();
|
||||||
publish();
|
publish();
|
||||||
@@ -607,6 +625,37 @@ public final class PadBridge {
|
|||||||
writer.writePad(effectiveState);
|
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) {
|
private static float axis(MotionEvent event, int axis) {
|
||||||
float value = event.getAxisValue(axis);
|
float value = event.getAxisValue(axis);
|
||||||
return Math.abs(value) < DEAD_ZONE ? 0f : value;
|
return Math.abs(value) < DEAD_ZONE ? 0f : value;
|
||||||
|
|||||||
@@ -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<Int, Sticks>()
|
||||||
|
private val touch = Sticks()
|
||||||
|
private val total = Sticks()
|
||||||
|
private val triggerAxes = HashMap<Int, Boolean>()
|
||||||
|
private val wiredLayout = HashMap<Int, Boolean>()
|
||||||
|
private val down = HashMap<Int, MutableSet<Int>>()
|
||||||
|
private val sent = HashMap<Int, IntArray>()
|
||||||
|
private val held = LinkedHashMap<Int, Int>()
|
||||||
|
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<Float, Float> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import java.nio.file.attribute.BasicFileAttributes;
|
|||||||
import java.util.function.LongConsumer;
|
import java.util.function.LongConsumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
final class RuntimeFileTree {
|
public final class RuntimeFileTree {
|
||||||
private RuntimeFileTree() {}
|
private RuntimeFileTree() {}
|
||||||
|
|
||||||
static volatile Predicate<Path> refuseForTest;
|
static volatile Predicate<Path> refuseForTest;
|
||||||
@@ -35,7 +35,7 @@ final class RuntimeFileTree {
|
|||||||
if (progress != null) progress.accept(++removed[0]);
|
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});
|
return deleteWhatCan(root.toPath(), progress, new long[]{0});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ object GameEnvironmentStore {
|
|||||||
return config
|
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
|
@Synchronized
|
||||||
fun setGameNoSteam(context: Context, scope: String, on: Boolean): GameEnvironment.Config {
|
fun setGameNoSteam(context: Context, scope: String, on: Boolean): GameEnvironment.Config {
|
||||||
val config = GameEnvironment.withGameNoSteam(read(context), scope, on)
|
val config = GameEnvironment.withGameNoSteam(read(context), scope, on)
|
||||||
|
|||||||
@@ -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<String>, 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>): 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<String, String>()
|
||||||
|
|
||||||
|
fun remember(command: Command) {
|
||||||
|
if (command.program == PROTON_RUN) command.args.getOrNull(command.args.indexOf("--app") + 1)?.let { names[it] = command.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ object SessionFiles {
|
|||||||
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
|
"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-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-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-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-steam-games" to "usr/local/bin/droiddeck-steam-games",
|
||||||
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
|
"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")
|
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
|
||||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
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("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(
|
val optional = (arrayOf(
|
||||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||||
|
|||||||
@@ -610,6 +610,36 @@ object SessionPrefs {
|
|||||||
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
|
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
|
||||||
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
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<String, *>, from: String, to: String): Map<String, Pair<String, Any?>> =
|
||||||
|
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) {
|
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||||
val p = prefs(context)
|
val p = prefs(context)
|
||||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class SessionService : Service() {
|
|||||||
private var steamDownloadsInBackground = false
|
private var steamDownloadsInBackground = false
|
||||||
private var activityVisible = true
|
private var activityVisible = true
|
||||||
private var xrActivityVisible = false
|
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 screenOn = true
|
||||||
private var manualPauseRequested = false
|
private var manualPauseRequested = false
|
||||||
private var steamSleepToken: String? = null
|
private var steamSleepToken: String? = null
|
||||||
@@ -183,6 +184,15 @@ class SessionService : Service() {
|
|||||||
updateSuspendPolicy()
|
updateSuspendPolicy()
|
||||||
return START_NOT_STICKY
|
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 -> {
|
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||||
if (!SessionState.running) return START_NOT_STICKY
|
if (!SessionState.running) return START_NOT_STICKY
|
||||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||||
@@ -219,6 +229,7 @@ class SessionService : Service() {
|
|||||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||||
activityVisible = true
|
activityVisible = true
|
||||||
xrActivityVisible = false
|
xrActivityVisible = false
|
||||||
|
signInHold = false
|
||||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||||
manualPauseRequested = false
|
manualPauseRequested = false
|
||||||
suspendDownloadNow = false
|
suspendDownloadNow = false
|
||||||
@@ -325,6 +336,7 @@ class SessionService : Service() {
|
|||||||
.onFailure { Log.w(TAG, "forced components", it) }
|
.onFailure { Log.w(TAG, "forced components", it) }
|
||||||
}
|
}
|
||||||
addClientEnvironment(guest, steamHere)
|
addClientEnvironment(guest, steamHere)
|
||||||
|
if (steamHere) guest.add("BL_EPIC_APP_ID=" + com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)
|
||||||
val fastPathAt = guest.size
|
val fastPathAt = guest.size
|
||||||
|
|
||||||
val pulse = startAudio(guest, sessionDir)
|
val pulse = startAudio(guest, sessionDir)
|
||||||
@@ -350,7 +362,15 @@ class SessionService : Service() {
|
|||||||
addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||||
SessionState.padBridge?.syncConnectedControllers()
|
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 added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
|
||||||
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
|
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
|
||||||
guest.add("BL_ADDED_GAMES=" + listing.path)
|
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")
|
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
|
||||||
return@start
|
return@start
|
||||||
}
|
}
|
||||||
Log.i(TAG, "session ended: $status")
|
val program = programStatus(sessionRoot)
|
||||||
stopSession(status ?: -1)
|
Log.i(TAG, "session ended: $status" + if (program != null) " (program $program)" else "")
|
||||||
|
stopSession(program ?: status ?: -1)
|
||||||
}, null)
|
}, null)
|
||||||
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
|
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
|
||||||
if (gen != sessionGen || !SessionState.running) {
|
if (gen != sessionGen || !SessionState.running) {
|
||||||
@@ -579,8 +600,11 @@ class SessionService : Service() {
|
|||||||
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
|
.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<String>, sessionDir: File): PulseAudioComponent {
|
private fun startAudio(guest: MutableList<String>, 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)
|
SessionPrefs.directAudio(this)
|
||||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
@@ -1005,10 +1029,15 @@ class SessionService : Service() {
|
|||||||
|
|
||||||
private var launchWatcher: android.os.FileObserver? = null
|
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) {
|
private fun watchLaunchRequests(dir: File) {
|
||||||
launchWatcher?.stopWatching()
|
launchWatcher?.stopWatching()
|
||||||
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
|
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() }
|
).forEach { File(dir, it).delete() }
|
||||||
val gen = sessionGen
|
val gen = sessionGen
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -1335,6 +1364,7 @@ class SessionService : Service() {
|
|||||||
updateDownloadMonitoring()
|
updateDownloadMonitoring()
|
||||||
val stoppedGen = sessionGen
|
val stoppedGen = sessionGen
|
||||||
val stoppedSession = SessionState.sessionId
|
val stoppedSession = SessionState.sessionId
|
||||||
|
val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC
|
||||||
SessionState.stopRequested = false
|
SessionState.stopRequested = false
|
||||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||||
GpuClockPin.stop(this)
|
GpuClockPin.stop(this)
|
||||||
@@ -1375,15 +1405,25 @@ class SessionService : Service() {
|
|||||||
if (prootPid > 1) teardown(prootPid)
|
if (prootPid > 1) teardown(prootPid)
|
||||||
killGuestLeftovers()
|
killGuestLeftovers()
|
||||||
finishSessionStop(status, stoppedGen)
|
finishSessionStop(status, stoppedGen)
|
||||||
|
if (launcherEnded) importLauncherGames()
|
||||||
}, "session-teardown").start()
|
}, "session-teardown").start()
|
||||||
} else {
|
} 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 (prootPid > 1) acquireLocks()
|
||||||
if (controller != null) controller.closeAndResume(finishAfterTeardown) else finishAfterTeardown()
|
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?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
if (pipTask || xrActivityVisible) {
|
if (pipTask || xrActivityVisible) {
|
||||||
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
|
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_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_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
|
||||||
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
|
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_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY"
|
||||||
private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY"
|
private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY"
|
||||||
private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED"
|
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_DESKTOP = "lxqt"
|
||||||
const val MODE_RUN = "run"
|
const val MODE_RUN = "run"
|
||||||
const val EXTRA_PROGRAM = "program"
|
const val EXTRA_PROGRAM = "program"
|
||||||
|
private const val RUN_STATUS = "run-status"
|
||||||
const val EXTRA_PROGRAM_ARGS = "programArgs"
|
const val EXTRA_PROGRAM_ARGS = "programArgs"
|
||||||
const val EXTRA_STEAM_UI = "steamUi"
|
const val EXTRA_STEAM_UI = "steamUi"
|
||||||
const val EXTRA_STEAM_URL = "steamUrl"
|
const val EXTRA_STEAM_URL = "steamUrl"
|
||||||
@@ -1655,6 +1698,11 @@ class SessionService : Service() {
|
|||||||
context.startService(Intent(context, SessionService::class.java).setAction(action))
|
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) {
|
fun resume(context: Context) {
|
||||||
if (!SessionState.running) return
|
if (!SessionState.running) return
|
||||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
|
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
|
||||||
|
|||||||
@@ -211,11 +211,14 @@ internal class SessionSuspendController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (record in ordered) send(record, SIGCONT)
|
for (record in ordered) send(record, SIGCONT)
|
||||||
repeat(8) {
|
val waiting = ordered.toMutableList()
|
||||||
|
repeat(RESTORE_CHECKS) { check ->
|
||||||
Thread.sleep(25L)
|
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) {
|
private fun send(record: ProcessRecord, signal: Int) {
|
||||||
@@ -242,5 +245,6 @@ internal class SessionSuspendController(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val SIGSTOP = 19
|
const val SIGSTOP = 19
|
||||||
const val SIGCONT = 18
|
const val SIGCONT = 18
|
||||||
|
const val RESTORE_CHECKS = 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,47 +3,34 @@ package com.droiddeck.launcher.stores
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.droiddeck.launcher.agent.AgentGuest
|
||||||
import com.droiddeck.launcher.frontend.AddedGames
|
import com.droiddeck.launcher.frontend.AddedGames
|
||||||
import com.droiddeck.launcher.frontend.Library
|
import com.droiddeck.launcher.frontend.Library
|
||||||
import com.droiddeck.launcher.frontend.LogoPosition
|
import com.droiddeck.launcher.frontend.LogoPosition
|
||||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
|
||||||
import com.droiddeck.launcher.session.SessionService
|
import com.droiddeck.launcher.session.SessionService
|
||||||
import com.droiddeck.launcher.session.SessionState
|
import com.droiddeck.launcher.session.SessionState
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
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 {
|
object SteamLiveShortcuts {
|
||||||
private const val TAG = "SteamLive"
|
private const val TAG = "SteamLive"
|
||||||
private const val TAG_NAME = "droiddeck-app"
|
private const val TAG_NAME = "droiddeck-app"
|
||||||
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
|
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<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
|
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
|
||||||
val wanted = games.filter(include)
|
val wanted = games.filter(include)
|
||||||
if (wanted.isEmpty() || !clientRunning()) return
|
if (wanted.isEmpty() || !clientRunning()) return
|
||||||
val list = JSONArray()
|
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 = """
|
val js = """
|
||||||
(async () => {
|
(async () => {
|
||||||
const want = ${list};
|
const want = ${list};
|
||||||
const have = await SteamClient.Apps.GetAllShortcuts();
|
|
||||||
const strip = s => (s || "").replace(/^"|"$/g, "");
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const w of want) {
|
for (const w of want) {
|
||||||
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
|
if (await ${KNOWN}(w.appid, w.name, w.exe)) continue;
|
||||||
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
|
if (await ${ADD}(w.name, w.exe, w.dir, true)) added++;
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
return added;
|
return added;
|
||||||
})()
|
})()
|
||||||
@@ -53,6 +40,36 @@ object SteamLiveShortcuts {
|
|||||||
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
|
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) {
|
fun remove(context: Context, appId: Long) {
|
||||||
if (!clientRunning()) return
|
if (!clientRunning()) return
|
||||||
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
|
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")
|
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? {
|
private fun evaluate(context: Context, expression: String): String? {
|
||||||
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
|
val answer = try {
|
||||||
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
|
AgentGuest.steamEval(context, expression, EVAL_TIMEOUT_MS)
|
||||||
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
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
|
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 {
|
private val KNOWN = """(async (appid, name, exe) => {
|
||||||
val c = URL(url).openConnection() as HttpURLConnection
|
const store = globalThis.appStore;
|
||||||
c.connectTimeout = 3000; c.readTimeout = 3000
|
if (typeof store?.GetAppOverviewByAppID !== 'function' || !store.m_bIsInitialized) throw new Error("appStore is not ready");
|
||||||
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
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 ADD = """(async (name, exe, dir, compat) => {
|
||||||
private val socket: Socket
|
const id = await SteamClient.Apps.AddShortcut(name, exe, dir, "");
|
||||||
private val out: OutputStream
|
if (!id) return 0;
|
||||||
private val input: DataInputStream
|
try { SteamClient.Apps.SetShortcutName(id, name); } catch (e) {}
|
||||||
|
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
|
||||||
init {
|
if (compat) { try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {} }
|
||||||
val u = URL(url.replaceFirst("ws://", "http://"))
|
for (let i = 0; i < 40; i++) {
|
||||||
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
|
try { if (globalThis.appStore.GetAppOverviewByAppID(id)) break; } catch (e) {}
|
||||||
out = socket.getOutputStream()
|
await new Promise(r => setTimeout(r, 250));
|
||||||
input = DataInputStream(socket.getInputStream())
|
}
|
||||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
return id;
|
||||||
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() } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ object StoreInstallRoot {
|
|||||||
private const val ON_VOLUME = "Games"
|
private const val ON_VOLUME = "Games"
|
||||||
const val GUEST_EXTERNAL = "/mnt/droiddeck-stores"
|
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)
|
val freeBytes: Long get() = availableBytes(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,16 @@ object StoreInstallRoot {
|
|||||||
fun targets(context: Context): List<Target> {
|
fun targets(context: Context): List<Target> {
|
||||||
val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false)
|
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) }
|
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)
|
fun installRoot(context: Context): File = internalRoot(context)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ object StoreInstalls {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "registration: ${e.message}")
|
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 = {}) {
|
fun uninstall(context: Context, game: InstalledStoreGame, progress: (Float) -> Unit = {}, removed: () -> Unit = {}) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ object StoreLaunch {
|
|||||||
private const val TAG = "StoreLaunch"
|
private const val TAG = "StoreLaunch"
|
||||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||||
|
const val EPIC_ARGS = ".droiddeck-epic-args"
|
||||||
|
|
||||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
|
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 args = sidecar.args.joinToString(" ") { quoteArg(it) }
|
||||||
val run = "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "")
|
val run = "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "")
|
||||||
if (sidecar.store == Store.EPIC) {
|
if (sidecar.store == Store.EPIC) {
|
||||||
|
lines += "if exist \"%~dp0$EPIC_ARGS\" goto client"
|
||||||
lines += "set \"$CODE_VAR=\""
|
lines += "set \"$CODE_VAR=\""
|
||||||
lines += "if exist \"%~dp0$EPIC_CODE\" set /p $CODE_VAR=<\"%~dp0$EPIC_CODE\""
|
lines += "if exist \"%~dp0$EPIC_CODE\" set /p $CODE_VAR=<\"%~dp0$EPIC_CODE\""
|
||||||
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
||||||
@@ -38,6 +40,13 @@ object StoreLaunch {
|
|||||||
lines += "goto :eof"
|
lines += "goto :eof"
|
||||||
lines += ":signed"
|
lines += ":signed"
|
||||||
lines += "set \"$CODE_VAR=\" & $run -AUTH_LOGIN=unused -AUTH_PASSWORD=%$CODE_VAR% -AUTH_TYPE=exchangecode"
|
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 {
|
} else {
|
||||||
lines += run
|
lines += run
|
||||||
}
|
}
|
||||||
@@ -52,6 +61,7 @@ object StoreLaunch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val CODE_VAR = "DD_X"
|
private const val CODE_VAR = "DD_X"
|
||||||
|
private const val CLIENT_VAR = "DD_A"
|
||||||
|
|
||||||
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
|
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
|
||||||
if (sidecar.launcher != LAUNCHER) return
|
if (sidecar.launcher != LAUNCHER) return
|
||||||
|
|||||||
@@ -88,6 +88,41 @@ object StoreLaunchRequests {
|
|||||||
JSONObject().put("ok", false).put("reason", e.javaClass.simpleName)
|
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")
|
else -> JSONObject().put("ok", false).put("reason", "unknown-op")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "StoreLogin"
|
private const val TAG = "StoreLogin"
|
||||||
const val EXTRA_STORE = "store"
|
const val EXTRA_STORE = "store"
|
||||||
|
const val EXTRA_CLIENT_URL = "client_url"
|
||||||
private const val KEY_STATE = "oauth_state"
|
private const val KEY_STATE = "oauth_state"
|
||||||
@Volatile internal var floodColor: Int? = null
|
@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 {
|
fun generateState(): String {
|
||||||
val bytes = ByteArray(24)
|
val bytes = ByteArray(24)
|
||||||
@@ -56,6 +57,7 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var store: Store? = null
|
private var store: Store? = null
|
||||||
|
private var clientUrl: String? = null
|
||||||
private var flow: Flow? = null
|
private var flow: Flow? = null
|
||||||
private var webViewRef: WebView? = null
|
private var webViewRef: WebView? = null
|
||||||
private var popupWebView: WebView? = null
|
private var popupWebView: WebView? = null
|
||||||
@@ -78,8 +80,9 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
|
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
|
||||||
|
clientUrl = intent.getStringExtra(EXTRA_CLIENT_URL)?.takeIf { store == Store.EPIC }
|
||||||
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
|
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
|
||||||
flow = store?.let { LoginFlows.forStore(it, oauthState!!) }
|
flow = newFlow(oauthState!!)
|
||||||
floodColor?.let { c ->
|
floodColor?.let { c ->
|
||||||
floodColor = null
|
floodColor = null
|
||||||
flooded = true
|
flooded = true
|
||||||
@@ -109,6 +112,12 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
webView.loadUrl(flow!!.startUrl)
|
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() {
|
override fun finish() {
|
||||||
super.finish()
|
super.finish()
|
||||||
@Suppress("DEPRECATION") if (flooded) overridePendingTransition(0, 0)
|
@Suppress("DEPRECATION") if (flooded) overridePendingTransition(0, 0)
|
||||||
@@ -187,7 +196,7 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
captured.set(false)
|
captured.set(false)
|
||||||
val fresh = generateState()
|
val fresh = generateState()
|
||||||
oauthState = fresh
|
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) }
|
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) }
|
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() {
|
override fun onDestroy() {
|
||||||
popupWebView?.let { popupWebView = null; destroyPopupView(it) }
|
popupWebView?.let { popupWebView = null; destroyPopupView(it) }
|
||||||
webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() }
|
webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() }
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
|||||||
|
|
||||||
override fun signOut(context: Context) {
|
override fun signOut(context: Context) {
|
||||||
runCatching { EpicLibrary.forget(context) }
|
runCatching { EpicLibrary.forget(context) }
|
||||||
|
runCatching { EpicClient.signOut(context) }
|
||||||
|
EpicClient.forgetWebSignIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun syncLibrary(context: Context, force: Boolean) {
|
override fun syncLibrary(context: Context, force: Boolean) {
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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<Library.SteamGame>, launcher: Library.SteamGame?): List<Library.SteamGame> {
|
||||||
|
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<AddedGames.Game>, 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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Install> {
|
||||||
|
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<String>, 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<Install>, previous: JSONObject? = null): JSONObject {
|
||||||
|
val entries = linkedMapOf<String, JSONObject>()
|
||||||
|
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<Pair<String, String>>()
|
||||||
|
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<AddedGames.Game>()
|
||||||
|
val addedExes = AddedExes.list(app)
|
||||||
|
var changed = false
|
||||||
|
var scanComplete = true
|
||||||
|
val items = LinkedHashMap<String, JSONObject>()
|
||||||
|
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<List<JSONObject>, Boolean> {
|
||||||
|
if (!file.isFile) return emptyList<JSONObject>() to true
|
||||||
|
val lines = runCatching { file.readLines() }.getOrNull() ?: return emptyList<JSONObject>() to false
|
||||||
|
val latest = LinkedHashMap<String, JSONObject>()
|
||||||
|
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}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,26 +59,41 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.droiddeck.launcher.frontend.Library
|
import com.droiddeck.launcher.frontend.Library
|
||||||
|
import com.droiddeck.launcher.stores.epic.EpicClient
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
|
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<java.io.File?>(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 narrow = LocalNarrowPane.current
|
||||||
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
||||||
GamesDialogs(a, s.steamGames)
|
GamesDialogs(a, s.steamGames)
|
||||||
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
|
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 ->
|
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)) }
|
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)) {
|
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(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)) }
|
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
|
||||||
if (!s.shortcutPicker) Rise(2) {
|
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)
|
GameFileFolderActions(s, a)
|
||||||
}
|
}
|
||||||
@@ -87,12 +102,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
|||||||
val host = rememberMenuHost()
|
val host = rememberMenuHost()
|
||||||
if (games.size == 1) {
|
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)) {
|
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) {
|
Rise(0) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
|
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
|
||||||
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
|
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))
|
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
|
val pal = LocalPalette.current
|
||||||
Row(modifier = modifier) {
|
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())
|
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
|
||||||
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
|
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
|
||||||
Column(
|
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)) {
|
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
|
||||||
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
|
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)
|
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||||
LaunchSettings(s, a, host, current)
|
LaunchSettings(s, a, host, current)
|
||||||
@@ -142,9 +157,14 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) {
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@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 {
|
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 ->
|
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
|
||||||
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(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
|
@Composable
|
||||||
private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) {
|
private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) {
|
||||||
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
|
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
|
@Composable
|
||||||
private fun GameList(
|
private fun GameList(
|
||||||
games: List<Library.SteamGame>, current: Library.SteamGame,
|
games: List<Library.SteamGame>, count: Int, current: Library.SteamGame,
|
||||||
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier,
|
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
val colors = MaterialTheme.colorScheme
|
val colors = MaterialTheme.colorScheme
|
||||||
@@ -300,7 +336,7 @@ private fun GameList(
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.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<Int>() }
|
val seen = remember { HashSet<Int>() }
|
||||||
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
|
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
for (g in games) key(g.appId) {
|
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)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
SourceChip(g.source, small = true)
|
SourceChip(g.source, small = true)
|
||||||
Text(
|
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,
|
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -511,6 +548,7 @@ private fun playedSpan(lastPlayed: Long): String? {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun libraryLabel(g: Library.SteamGame): String = when {
|
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.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)
|
g.source != Library.ADDED -> stringResource(R.string.games_store_storage)
|
||||||
else -> ""
|
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) {
|
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||||
val colors = MaterialTheme.colorScheme
|
val colors = MaterialTheme.colorScheme
|
||||||
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
|
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(
|
if (image != null) AsyncImage(
|
||||||
model = image, contentDescription = null, contentScale = ContentScale.Crop,
|
model = image, contentDescription = null, contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.matchParentSize()
|
modifier = Modifier.matchParentSize()
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ class FrontEndActions(
|
|||||||
val onSgdbKey: (String) -> Unit = {},
|
val onSgdbKey: (String) -> Unit = {},
|
||||||
val onStopGameFileSync: () -> Unit = {},
|
val onStopGameFileSync: () -> Unit = {},
|
||||||
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
|
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
|
||||||
|
val onEpicLauncher: () -> Unit = {},
|
||||||
|
val onUninstallEpicLauncher: () -> Unit = {},
|
||||||
val onDesktop: () -> Unit,
|
val onDesktop: () -> Unit,
|
||||||
val onEmulator: (Library.Emulator) -> Unit,
|
val onEmulator: (Library.Emulator) -> Unit,
|
||||||
val onFlatpakApp: (String, String) -> Unit = { _, _ -> },
|
val onFlatpakApp: (String, String) -> Unit = { _, _ -> },
|
||||||
|
|||||||
@@ -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
|
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
|
||||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
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 ->
|
add { m ->
|
||||||
val back = stringResource(R.string.hop_from_games)
|
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")) {
|
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 (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 }
|
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 ->
|
if (cardStore != null && game?.gameFiles != null) add { m ->
|
||||||
Box(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"
|
private const val EPIC_RESOLVE_URL = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Faccount%2Fpersonal"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -299,6 +299,15 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
|||||||
s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true,
|
s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true,
|
||||||
onChange = a.onZramCompression,
|
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)) {
|
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
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.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
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
|
@Composable
|
||||||
internal fun StepOutScope.StepTitle(text: String) {
|
internal fun StepOutScope.StepTitle(text: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import com.droiddeck.launcher.stores.download.DownloadQueue
|
|||||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||||
import com.droiddeck.launcher.stores.download.DownloadState
|
import com.droiddeck.launcher.stores.download.DownloadState
|
||||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
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.formatBytes
|
||||||
import com.droiddeck.launcher.stores.formatSpeed
|
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)
|
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))
|
Spacer(Modifier.weight(1f))
|
||||||
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
|
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) }
|
CancelAction { DownloadQueue.clear(ctx, d.key) }
|
||||||
}
|
}
|
||||||
DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) {
|
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 -> {}
|
DownloadState.CANCELLED -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
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.SdCard
|
||||||
import androidx.compose.material.icons.outlined.Smartphone
|
import androidx.compose.material.icons.outlined.Smartphone
|
||||||
import androidx.compose.material3.Icon
|
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 defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0)
|
||||||
val focus = remember { List(targets.size) { FocusRequester() } }
|
val focus = remember { List(targets.size) { FocusRequester() } }
|
||||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus[defaultIndex] } }
|
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) {
|
Rise(0) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -65,7 +70,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rise(1) {
|
Rise(1) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
targets.forEachIndexed { i, t ->
|
targets.forEachIndexed { i, t ->
|
||||||
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
|
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
|
||||||
val freeBytes = t.freeBytes
|
val freeBytes = t.freeBytes
|
||||||
@@ -80,8 +85,8 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
|||||||
TargetRow(
|
TargetRow(
|
||||||
label = if (t.removable) t.label else stringResource(R.string.stores_target_internal),
|
label = if (t.removable) t.label else stringResource(R.string.stores_target_internal),
|
||||||
free = freeText,
|
free = freeText,
|
||||||
removable = t.removable, focus = focus[i],
|
removable = t.removable, folder = t.folder, focus = focus[i],
|
||||||
warning = isLow,
|
warning = isLow, modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
|
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
|
||||||
StoresState.install(ctx, item, t.root)
|
StoresState.install(ctx, item, t.root)
|
||||||
@@ -94,22 +99,24 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 colors = MaterialTheme.colorScheme
|
||||||
val pal = LocalPalette.current
|
val pal = LocalPalette.current
|
||||||
val src = remember { MutableInteractionSource() }
|
val src = remember { MutableInteractionSource() }
|
||||||
val hot = rememberHot(src)
|
val hot = rememberHot(src)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focus).clip(Shape12)
|
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))
|
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
|
||||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onPick)
|
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onPick)
|
||||||
.controllerConfirm(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))
|
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))
|
||||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(free, fontSize = 12.sp, color = if (warning) colors.error else colors.onSurfaceVariant, maxLines = 1)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.togetherWith
|
|||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.SdCard
|
||||||
import androidx.compose.material.icons.outlined.Smartphone
|
import androidx.compose.material.icons.outlined.Smartphone
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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.boundsInRoot
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||||
|
import com.droiddeck.launcher.stores.epic.EpicClient
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -176,6 +178,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
|||||||
val grids = remember { HashMap<PaneKey, LazyGridState>() }
|
val grids = remember { HashMap<PaneKey, LazyGridState>() }
|
||||||
val grid = grids.getOrPut(pane) { LazyGridState() }
|
val grid = grids.getOrPut(pane) { LazyGridState() }
|
||||||
LaunchedEffect(Unit) { StoresState.refresh(ctx) }
|
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) }
|
LaunchedEffect(chip, StoresState.accounts[store]) { if (store != null && StoresState.isSignedIn(store)) StoresState.open(ctx, store) }
|
||||||
val ff = LocalFrontFocus.current
|
val ff = LocalFrontFocus.current
|
||||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||||
@@ -425,7 +437,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
|||||||
StepOut(
|
StepOut(
|
||||||
open = StoresMotion.stepOpen, pill = ask.anchor.translate(-hostAt), side = ask.side, accent = ask.accent,
|
open = StoresMotion.stepOpen, pill = ask.anchor.translate(-hostAt), side = ask.side, accent = ask.accent,
|
||||||
onDismiss = { StoresMotion.fold() }, onClosed = { StoresMotion.closed(ask) },
|
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)
|
val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0)
|
||||||
StoresMotion.ask(StepAsk(
|
StoresMotion.ask(StepAsk(
|
||||||
anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 12.dp, items = 1 + targets.size,
|
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 },
|
onDismiss = { if (StoresState.pendingInstall === item) StoresState.pendingInstall = null },
|
||||||
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) },
|
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) },
|
||||||
) {
|
) {
|
||||||
StepTitle(title)
|
StepTitle(title)
|
||||||
StepList {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
targets.forEachIndexed { i, t ->
|
targets.forEachIndexed { i, t ->
|
||||||
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
|
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
|
||||||
val freeBytes = t.freeBytes
|
val freeBytes = t.freeBytes
|
||||||
@@ -473,12 +486,13 @@ private fun InstallWhere(item: CatalogItem) {
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
|
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
|
||||||
}
|
}
|
||||||
StepChoice(
|
StepTile(
|
||||||
label = if (t.removable) t.label else internalLabel,
|
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,
|
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,
|
||||||
trailing = trailingText,
|
detail = trailingText,
|
||||||
|
warning = isLow,
|
||||||
enabled = open,
|
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)
|
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
|
||||||
StoresMotion.markInstall(from, label)
|
StoresMotion.markInstall(from, label)
|
||||||
@@ -889,6 +903,10 @@ private fun Storefront(
|
|||||||
SortButton(s, a)
|
SortButton(s, a)
|
||||||
ViewButton(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) }
|
SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) }
|
||||||
val name = StoresState.accounts[store] ?: ""
|
val name = StoresState.accounts[store] ?: ""
|
||||||
SignOutButton(store, name)
|
SignOutButton(store, name)
|
||||||
|
|||||||
@@ -457,8 +457,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">خيارات بيئة أخرى لكل لعبة لـ Proton وDXVK وVKD3D-Proton والمزامنة.</string>
|
<string name="comp_game_environment_other_hint">خيارات بيئة أخرى لكل لعبة لـ Proton وDXVK وVKD3D-Proton والمزامنة.</string>
|
||||||
<string name="comp_game_saved_next">تم حفظ ملف تعريف %1$s للتشغيل التالي للعبة.</string>
|
<string name="comp_game_saved_next">تم حفظ ملف تعريف %1$s للتشغيل التالي للعبة.</string>
|
||||||
<string name="game_no_steam_title">بدء اللعبة بدون Steam</string>
|
<string name="game_no_steam_title">بدء اللعبة بدون Steam</string>
|
||||||
<string name="game_no_steam_detail">لعبة تُغلق فورًا: شغّلها بدون إعداد Steam الخاص بـ Proton. تكون ميزات Steam، مثل الطبقة العلوية، معطّلة لها.</string>
|
<string name="game_no_steam_detail">يفتحها في Proton/FEX بدون عميل Steam؛ ميزات Steam معطّلة.</string>
|
||||||
<string name="game_no_steam_on">تشغيل</string>
|
<string name="game_no_steam_on">تشغيل</string>
|
||||||
|
<string name="game_steam_with">يبدأ مع Steam</string>
|
||||||
|
<string name="game_steam_without">يبدأ بدون Steam</string>
|
||||||
|
<string name="game_settings_card">الإعدادات</string>
|
||||||
|
<string name="game_settings_menu_title">إعدادات %1$s</string>
|
||||||
|
<string name="game_settings_default">الافتراضي</string>
|
||||||
|
<string name="game_settings_default_detail">يعيد كل ما تم تغييره لهذه اللعبة إلى إعدادات التطبيق.</string>
|
||||||
|
<string name="game_sync_custom">متغيرات مزامنة مخصصة</string>
|
||||||
<string name="comp_game_proton_live">تم حفظ اختيار Proton لـ %1$s وهو متاح في Steam الآن.</string>
|
<string name="comp_game_proton_live">تم حفظ اختيار Proton لـ %1$s وهو متاح في Steam الآن.</string>
|
||||||
<string name="comp_game_proton_next">تم حفظ اختيار Proton لـ %1$s. أعد تشغيل Steam قبل تشغيل اللعبة.</string>
|
<string name="comp_game_proton_next">تم حفظ اختيار Proton لـ %1$s. أعد تشغيل Steam قبل تشغيل اللعبة.</string>
|
||||||
<string name="comp_game_proton_not_runnable">لا يمكن لـ %1$s استخدام Proton المحدد على هذا الجهاز.</string>
|
<string name="comp_game_proton_not_runnable">لا يمكن لـ %1$s استخدام Proton المحدد على هذا الجهاز.</string>
|
||||||
@@ -2056,6 +2063,20 @@
|
|||||||
<string name="epic_eos">تسجيل الدخول إلى Epic (EOS)</string>
|
<string name="epic_eos">تسجيل الدخول إلى Epic (EOS)</string>
|
||||||
<string name="epic_offline">التشغيل دون اتصال</string>
|
<string name="epic_offline">التشغيل دون اتصال</string>
|
||||||
<string name="epic_resolve">إصلاح تسجيل الدخول إلى Epic</string>
|
<string name="epic_resolve">إصلاح تسجيل الدخول إلى Epic</string>
|
||||||
|
<string name="epic_client_version">الإصدار %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">ثبّت بيئة تشغيل Linux أولًا</string>
|
||||||
|
<string name="epic_client_checking">جارٍ التحقق من آخر إصدار…</string>
|
||||||
|
<string name="epic_client_unpacking">جارٍ فك الضغط…</string>
|
||||||
|
<string name="epic_client_unreachable">لم يستجب خادم التنزيل</string>
|
||||||
|
<string name="epic_client_needs_fex">يحتاج مشغّل Epic Games إلى FEX الذي يقوم Steam بتنزيله. افتح المشغّل مرة أخرى بعد بدء Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">فشل التنزيل: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">تعذّر فك ضغط المُشغِّل: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">لم يقبل Epic Games Launcher تسجيل الدخول. شغّله مرة أخرى من المُشغِّل.</string>
|
||||||
|
<string name="epic_client_uninstall_body">يزيل المشغّل ويحرّر مساحته. تبقى ألعاب Epic مثبتة وتعمل، ويُحتفظ بتسجيل الدخول لحين تثبيته مرة أخرى.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">ستُغلق الجلسة الجارية أولاً.</string>
|
||||||
|
<string name="epic_client_uninstalled">تمت إزالة Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">تعذّرت إزالة بعض ملفات المشغّل. حاول مرة أخرى.</string>
|
||||||
|
<string name="epic_client_uninstalling">انتظر حتى ينتهي إلغاء تثبيت Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">ملفات الحفظ السحابية</string>
|
<string name="store_cloud_saves">ملفات الحفظ السحابية</string>
|
||||||
<string name="cloud_upload">الرفع إلى السحابة</string>
|
<string name="cloud_upload">الرفع إلى السحابة</string>
|
||||||
<string name="cloud_download">التنزيل من السحابة</string>
|
<string name="cloud_download">التنزيل من السحابة</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Други настройки на средата за Proton, DXVK, VKD3D-Proton и синхронизация за отделни игри.</string>
|
<string name="comp_game_environment_other_hint">Други настройки на средата за Proton, DXVK, VKD3D-Proton и синхронизация за отделни игри.</string>
|
||||||
<string name="comp_game_saved_next">Профилът на %1$s е запазен за следващото стартиране на играта.</string>
|
<string name="comp_game_saved_next">Профилът на %1$s е запазен за следващото стартиране на играта.</string>
|
||||||
<string name="game_no_steam_title">Старт без Steam</string>
|
<string name="game_no_steam_title">Старт без Steam</string>
|
||||||
<string name="game_no_steam_detail">За игра, която се затваря веднага: стартира я без настройката на Steam в Proton. Функции на Steam, като оверлея, са изключени за нея.</string>
|
<string name="game_no_steam_detail">Отваря го в Proton/FEX без клиента на Steam; функциите на Steam са изключени.</string>
|
||||||
<string name="game_no_steam_on">Включено</string>
|
<string name="game_no_steam_on">Включено</string>
|
||||||
|
<string name="game_steam_with">Стартира със Steam</string>
|
||||||
|
<string name="game_steam_without">Стартира без Steam</string>
|
||||||
|
<string name="game_settings_card">Настройки</string>
|
||||||
|
<string name="game_settings_menu_title">Настройки на %1$s</string>
|
||||||
|
<string name="game_settings_default">По подразбиране</string>
|
||||||
|
<string name="game_settings_default_detail">Връща всичко, променено за тази игра, към настройките на приложението.</string>
|
||||||
|
<string name="game_sync_custom">Персонализирани променливи за синхронизация</string>
|
||||||
<string name="comp_game_proton_live">Изборът на Proton за %1$s е запазен и вече е достъпен в Steam.</string>
|
<string name="comp_game_proton_live">Изборът на Proton за %1$s е запазен и вече е достъпен в Steam.</string>
|
||||||
<string name="comp_game_proton_next">Изборът на Proton за %1$s е запазен. Рестартирайте Steam, преди да стартирате играта.</string>
|
<string name="comp_game_proton_next">Изборът на Proton за %1$s е запазен. Рестартирайте Steam, преди да стартирате играта.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s не може да използва избрания Proton на това устройство.</string>
|
<string name="comp_game_proton_not_runnable">%1$s не може да използва избрания Proton на това устройство.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Вход в Epic (EOS)</string>
|
<string name="epic_eos">Вход в Epic (EOS)</string>
|
||||||
<string name="epic_offline">Стартирай офлайн</string>
|
<string name="epic_offline">Стартирай офлайн</string>
|
||||||
<string name="epic_resolve">Разреши входа в Epic</string>
|
<string name="epic_resolve">Разреши входа в Epic</string>
|
||||||
|
<string name="epic_client_version">Версия %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Първо инсталирайте средата на Linux</string>
|
||||||
|
<string name="epic_client_checking">Проверка за последната версия…</string>
|
||||||
|
<string name="epic_client_unpacking">Разархивиране…</string>
|
||||||
|
<string name="epic_client_unreachable">Сървърът за изтегляне не отговори</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher се нуждае от FEX, който Steam изтегля. Отворете отново лаунчъра, след като Steam стартира.</string>
|
||||||
|
<string name="epic_client_download_failed">Изтеглянето се провали: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Разархивирането на програмата не бе успешно: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher не прие влизането. Стартирайте го отново от програмата.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Премахва лаунчера и освобождава мястото му. Игрите ви от Epic остават инсталирани и работят, а входът се запазва за следващото инсталиране.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Текущата сесия първо ще се затвори.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher е деинсталиран</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Някои файлове на лаунчера не можаха да бъдат премахнати. Опитайте отново.</string>
|
||||||
|
<string name="epic_client_uninstalling">Изчакайте Epic Games Launcher да се деинсталира</string>
|
||||||
<string name="store_cloud_saves">Облачни запазвания</string>
|
<string name="store_cloud_saves">Облачни запазвания</string>
|
||||||
<string name="cloud_upload">Качи в облака</string>
|
<string name="cloud_upload">Качи в облака</string>
|
||||||
<string name="cloud_download">Изтегли от облака</string>
|
<string name="cloud_download">Изтегли от облака</string>
|
||||||
|
|||||||
@@ -453,8 +453,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Další možnosti prostředí Proton, DXVK, VKD3D-Proton a synchronizace pro jednotlivé hry.</string>
|
<string name="comp_game_environment_other_hint">Další možnosti prostředí Proton, DXVK, VKD3D-Proton a synchronizace pro jednotlivé hry.</string>
|
||||||
<string name="comp_game_saved_next">Profil %1$s je uložen pro další spuštění hry.</string>
|
<string name="comp_game_saved_next">Profil %1$s je uložen pro další spuštění hry.</string>
|
||||||
<string name="game_no_steam_title">Spustit bez Steamu</string>
|
<string name="game_no_steam_title">Spustit bez Steamu</string>
|
||||||
<string name="game_no_steam_detail">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é.</string>
|
<string name="game_no_steam_detail">Otevře ji v Protonu/FEX bez klienta Steamu; funkce Steamu jsou vypnuté.</string>
|
||||||
<string name="game_no_steam_on">Zapnuto</string>
|
<string name="game_no_steam_on">Zapnuto</string>
|
||||||
|
<string name="game_steam_with">Spouští se se Steamem</string>
|
||||||
|
<string name="game_steam_without">Spouští se bez Steamu</string>
|
||||||
|
<string name="game_settings_card">Nastavení</string>
|
||||||
|
<string name="game_settings_menu_title">Nastavení %1$s</string>
|
||||||
|
<string name="game_settings_default">Výchozí</string>
|
||||||
|
<string name="game_settings_default_detail">Vrátí vše, co se u této hry změnilo, na nastavení aplikace.</string>
|
||||||
|
<string name="game_sync_custom">Vlastní proměnné synchronizace</string>
|
||||||
<string name="comp_game_proton_live">Výběr Protonu pro %1$s je uložen a hned dostupný ve Steamu.</string>
|
<string name="comp_game_proton_live">Výběr Protonu pro %1$s je uložen a hned dostupný ve Steamu.</string>
|
||||||
<string name="comp_game_proton_next">Výběr Protonu pro %1$s je uložen. Před spuštěním hry restartujte Steam.</string>
|
<string name="comp_game_proton_next">Výběr Protonu pro %1$s je uložen. Před spuštěním hry restartujte Steam.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s nemůže na tomto zařízení použít vybraný Proton.</string>
|
<string name="comp_game_proton_not_runnable">%1$s nemůže na tomto zařízení použít vybraný Proton.</string>
|
||||||
@@ -2014,6 +2021,20 @@
|
|||||||
<string name="epic_eos">Přihlášení do Epic (EOS)</string>
|
<string name="epic_eos">Přihlášení do Epic (EOS)</string>
|
||||||
<string name="epic_offline">Spustit offline</string>
|
<string name="epic_offline">Spustit offline</string>
|
||||||
<string name="epic_resolve">Vyřešit přihlášení do Epic</string>
|
<string name="epic_resolve">Vyřešit přihlášení do Epic</string>
|
||||||
|
<string name="epic_client_version">Verze %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Nejprve nainstalujte běhové prostředí Linuxu</string>
|
||||||
|
<string name="epic_client_checking">Kontrola nejnovější verze…</string>
|
||||||
|
<string name="epic_client_unpacking">Rozbaluje se…</string>
|
||||||
|
<string name="epic_client_unreachable">Stahovací server neodpověděl</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher potřebuje FEX, který stáhne Steam. Po spuštění Steamu otevřete launcher znovu.</string>
|
||||||
|
<string name="epic_client_download_failed">Stažení se nezdařilo: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Spouštěč se nepodařilo rozbalit: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher přihlášení nepřijal. Spusťte ho znovu ze spouštěče.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Nejprve se ukončí běžící relace.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher odinstalován</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Některé soubory spouštěče nešlo odebrat. Zkuste to znovu.</string>
|
||||||
|
<string name="epic_client_uninstalling">Počkejte, až se Epic Games Launcher dokončí odinstalaci</string>
|
||||||
<string name="store_cloud_saves">Cloudové uložené pozice</string>
|
<string name="store_cloud_saves">Cloudové uložené pozice</string>
|
||||||
<string name="cloud_upload">Odeslat do cloudu</string>
|
<string name="cloud_upload">Odeslat do cloudu</string>
|
||||||
<string name="cloud_download">Stáhnout z cloudu</string>
|
<string name="cloud_download">Stáhnout z cloudu</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Andre miljøindstillinger pr. spil for Proton, DXVK, VKD3D-Proton og synkronisering.</string>
|
<string name="comp_game_environment_other_hint">Andre miljøindstillinger pr. spil for Proton, DXVK, VKD3D-Proton og synkronisering.</string>
|
||||||
<string name="comp_game_saved_next">%1$s’s profil er gemt til næste spilstart.</string>
|
<string name="comp_game_saved_next">%1$s’s profil er gemt til næste spilstart.</string>
|
||||||
<string name="game_no_steam_title">Start uden Steam</string>
|
<string name="game_no_steam_title">Start uden Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Åbner det i Proton/FEX uden Steam-klienten; Steam-funktioner er slået fra.</string>
|
||||||
<string name="game_no_steam_on">Til</string>
|
<string name="game_no_steam_on">Til</string>
|
||||||
|
<string name="game_steam_with">Starter med Steam</string>
|
||||||
|
<string name="game_steam_without">Starter uden Steam</string>
|
||||||
|
<string name="game_settings_card">Indstillinger</string>
|
||||||
|
<string name="game_settings_menu_title">Indstillinger for %1$s</string>
|
||||||
|
<string name="game_settings_default">Standard</string>
|
||||||
|
<string name="game_settings_default_detail">Sætter alt, der er ændret for dette spil, tilbage til appens indstillinger.</string>
|
||||||
|
<string name="game_sync_custom">Brugerdefinerede synkroniseringsvariabler</string>
|
||||||
<string name="comp_game_proton_live">%1$s’s Proton-valg er gemt og tilgængeligt i Steam nu.</string>
|
<string name="comp_game_proton_live">%1$s’s Proton-valg er gemt og tilgængeligt i Steam nu.</string>
|
||||||
<string name="comp_game_proton_next">%1$s’s Proton-valg er gemt. Genstart Steam, inden du starter spillet.</string>
|
<string name="comp_game_proton_next">%1$s’s Proton-valg er gemt. Genstart Steam, inden du starter spillet.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s kan ikke bruge den valgte Proton på denne enhed.</string>
|
<string name="comp_game_proton_not_runnable">%1$s kan ikke bruge den valgte Proton på denne enhed.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic-login (EOS)</string>
|
<string name="epic_eos">Epic-login (EOS)</string>
|
||||||
<string name="epic_offline">Start offline</string>
|
<string name="epic_offline">Start offline</string>
|
||||||
<string name="epic_resolve">Løs Epic-login</string>
|
<string name="epic_resolve">Løs Epic-login</string>
|
||||||
|
<string name="epic_client_version">Version %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installer Linux-runtimen først</string>
|
||||||
|
<string name="epic_client_checking">Kontrollerer den nyeste version…</string>
|
||||||
|
<string name="epic_client_unpacking">Udpakker…</string>
|
||||||
|
<string name="epic_client_unreachable">Downloadserveren svarede ikke</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher skal bruge FEX, som Steam henter. Åbn launcheren igen, når Steam er startet.</string>
|
||||||
|
<string name="epic_client_download_failed">Download mislykkedes: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Kunne ikke udpakke launcheren: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher accepterede ikke login. Start den igen fra launcheren.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Fjerner launcheren og frigør dens plads. Dine Epic-spil forbliver installeret og virker stadig, og login gemmes til næste installation.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Den kørende session lukkes først.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher er afinstalleret</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Nogle launcherfiler kunne ikke fjernes. Prøv igen.</string>
|
||||||
|
<string name="epic_client_uninstalling">Vent, til Epic Games Launcher er færdig med at blive afinstalleret</string>
|
||||||
<string name="store_cloud_saves">Cloud-gemte spil</string>
|
<string name="store_cloud_saves">Cloud-gemte spil</string>
|
||||||
<string name="cloud_upload">Upload til cloud</string>
|
<string name="cloud_upload">Upload til cloud</string>
|
||||||
<string name="cloud_download">Download fra cloud</string>
|
<string name="cloud_download">Download fra cloud</string>
|
||||||
|
|||||||
@@ -446,8 +446,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Weitere spielspezifische Umgebungsoptionen für Proton, DXVK, VKD3D-Proton und Synchronisation.</string>
|
<string name="comp_game_environment_other_hint">Weitere spielspezifische Umgebungsoptionen für Proton, DXVK, VKD3D-Proton und Synchronisation.</string>
|
||||||
<string name="comp_game_saved_next">Das Profil von %1$s ist für den nächsten Spielstart gespeichert.</string>
|
<string name="comp_game_saved_next">Das Profil von %1$s ist für den nächsten Spielstart gespeichert.</string>
|
||||||
<string name="game_no_steam_title">Ohne Steam starten</string>
|
<string name="game_no_steam_title">Ohne Steam starten</string>
|
||||||
<string name="game_no_steam_detail">Für ein Spiel, das sich sofort schließt: startet es ohne Protons Steam-Einrichtung. Steam-Funktionen wie das Overlay sind dafür deaktiviert.</string>
|
<string name="game_no_steam_detail">Öffnet es in Proton/FEX ohne den Steam-Client; Steam-Funktionen sind aus.</string>
|
||||||
<string name="game_no_steam_on">An</string>
|
<string name="game_no_steam_on">An</string>
|
||||||
|
<string name="game_steam_with">Startet mit Steam</string>
|
||||||
|
<string name="game_steam_without">Startet ohne Steam</string>
|
||||||
|
<string name="game_settings_card">Einstellungen</string>
|
||||||
|
<string name="game_settings_menu_title">Einstellungen für %1$s</string>
|
||||||
|
<string name="game_settings_default">Standard</string>
|
||||||
|
<string name="game_settings_default_detail">Setzt alles, was für dieses Spiel geändert wurde, auf die Einstellungen der App zurück.</string>
|
||||||
|
<string name="game_sync_custom">Eigene Sync-Variablen</string>
|
||||||
<string name="comp_game_proton_live">Die Proton-Auswahl für %1$s ist gespeichert und jetzt in Steam verfügbar.</string>
|
<string name="comp_game_proton_live">Die Proton-Auswahl für %1$s ist gespeichert und jetzt in Steam verfügbar.</string>
|
||||||
<string name="comp_game_proton_next">Die Proton-Auswahl für %1$s ist gespeichert. Starte Steam neu, bevor du das Spiel startest.</string>
|
<string name="comp_game_proton_next">Die Proton-Auswahl für %1$s ist gespeichert. Starte Steam neu, bevor du das Spiel startest.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s kann das gewählte Proton auf diesem Gerät nicht verwenden.</string>
|
<string name="comp_game_proton_not_runnable">%1$s kann das gewählte Proton auf diesem Gerät nicht verwenden.</string>
|
||||||
@@ -1961,6 +1968,20 @@
|
|||||||
<string name="epic_eos">Epic-Anmeldung (EOS)</string>
|
<string name="epic_eos">Epic-Anmeldung (EOS)</string>
|
||||||
<string name="epic_offline">Offline starten</string>
|
<string name="epic_offline">Offline starten</string>
|
||||||
<string name="epic_resolve">Epic-Anmeldung klären</string>
|
<string name="epic_resolve">Epic-Anmeldung klären</string>
|
||||||
|
<string name="epic_client_version">Version %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installiere zuerst die Linux-Laufzeit</string>
|
||||||
|
<string name="epic_client_checking">Neueste Version wird geprüft…</string>
|
||||||
|
<string name="epic_client_unpacking">Wird entpackt…</string>
|
||||||
|
<string name="epic_client_unreachable">Der Download-Server hat nicht geantwortet</string>
|
||||||
|
<string name="epic_client_needs_fex">Der Epic Games Launcher braucht FEX, das Steam herunterlädt. Öffne den Launcher erneut, sobald Steam gestartet ist.</string>
|
||||||
|
<string name="epic_client_download_failed">Download fehlgeschlagen: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Der Launcher konnte nicht entpackt werden: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Der Epic Games Launcher hat die Anmeldung nicht akzeptiert. Starte ihn über den Launcher erneut.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Die laufende Sitzung wird zuerst beendet.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher deinstalliert</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Einige Launcher-Dateien konnten nicht entfernt werden. Versuche es erneut.</string>
|
||||||
|
<string name="epic_client_uninstalling">Warte, bis der Epic Games Launcher deinstalliert ist</string>
|
||||||
<string name="store_cloud_saves">Cloud-Spielstände</string>
|
<string name="store_cloud_saves">Cloud-Spielstände</string>
|
||||||
<string name="cloud_upload">In die Cloud hochladen</string>
|
<string name="cloud_upload">In die Cloud hochladen</string>
|
||||||
<string name="cloud_download">Aus der Cloud herunterladen</string>
|
<string name="cloud_download">Aus der Cloud herunterladen</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Άλλες επιλογές περιβάλλοντος Proton, DXVK, VKD3D-Proton και συγχρονισμού ανά παιχνίδι.</string>
|
<string name="comp_game_environment_other_hint">Άλλες επιλογές περιβάλλοντος Proton, DXVK, VKD3D-Proton και συγχρονισμού ανά παιχνίδι.</string>
|
||||||
<string name="comp_game_saved_next">Το προφίλ του %1$s αποθηκεύτηκε για την επόμενη εκκίνηση του παιχνιδιού.</string>
|
<string name="comp_game_saved_next">Το προφίλ του %1$s αποθηκεύτηκε για την επόμενη εκκίνηση του παιχνιδιού.</string>
|
||||||
<string name="game_no_steam_title">Εκκίνηση χωρίς το Steam</string>
|
<string name="game_no_steam_title">Εκκίνηση χωρίς το Steam</string>
|
||||||
<string name="game_no_steam_detail">Για παιχνίδι που κλείνει αμέσως: το εκκινεί χωρίς τη ρύθμιση του Steam στο Proton. Λειτουργίες του Steam, όπως το overlay, είναι απενεργοποιημένες για αυτό.</string>
|
<string name="game_no_steam_detail">Το ανοίγει στο Proton/FEX χωρίς το πρόγραμμα Steam· οι λειτουργίες του Steam είναι ανενεργές.</string>
|
||||||
<string name="game_no_steam_on">Ενεργό</string>
|
<string name="game_no_steam_on">Ενεργό</string>
|
||||||
|
<string name="game_steam_with">Ξεκινά με το Steam</string>
|
||||||
|
<string name="game_steam_without">Ξεκινά χωρίς το Steam</string>
|
||||||
|
<string name="game_settings_card">Ρυθμίσεις</string>
|
||||||
|
<string name="game_settings_menu_title">Ρυθμίσεις %1$s</string>
|
||||||
|
<string name="game_settings_default">Προεπιλογή</string>
|
||||||
|
<string name="game_settings_default_detail">Επαναφέρει ό,τι άλλαξε για αυτό το παιχνίδι στις ρυθμίσεις της εφαρμογής.</string>
|
||||||
|
<string name="game_sync_custom">Προσαρμοσμένες μεταβλητές συγχρονισμού</string>
|
||||||
<string name="comp_game_proton_live">Η επιλογή Proton του %1$s αποθηκεύτηκε και είναι διαθέσιμη στο Steam τώρα.</string>
|
<string name="comp_game_proton_live">Η επιλογή Proton του %1$s αποθηκεύτηκε και είναι διαθέσιμη στο Steam τώρα.</string>
|
||||||
<string name="comp_game_proton_next">Η επιλογή Proton του %1$s αποθηκεύτηκε. Κάντε επανεκκίνηση του Steam πριν εκκινήσετε το παιχνίδι.</string>
|
<string name="comp_game_proton_next">Η επιλογή Proton του %1$s αποθηκεύτηκε. Κάντε επανεκκίνηση του Steam πριν εκκινήσετε το παιχνίδι.</string>
|
||||||
<string name="comp_game_proton_not_runnable">Το %1$s δεν μπορεί να χρησιμοποιήσει το επιλεγμένο Proton σε αυτή τη συσκευή.</string>
|
<string name="comp_game_proton_not_runnable">Το %1$s δεν μπορεί να χρησιμοποιήσει το επιλεγμένο Proton σε αυτή τη συσκευή.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Σύνδεση Epic (EOS)</string>
|
<string name="epic_eos">Σύνδεση Epic (EOS)</string>
|
||||||
<string name="epic_offline">Εκκίνηση εκτός σύνδεσης</string>
|
<string name="epic_offline">Εκκίνηση εκτός σύνδεσης</string>
|
||||||
<string name="epic_resolve">Επίλυση σύνδεσης Epic</string>
|
<string name="epic_resolve">Επίλυση σύνδεσης Epic</string>
|
||||||
|
<string name="epic_client_version">Έκδοση %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Εγκαταστήστε πρώτα το runtime Linux</string>
|
||||||
|
<string name="epic_client_checking">Έλεγχος για την πιο πρόσφατη έκδοση…</string>
|
||||||
|
<string name="epic_client_unpacking">Αποσυμπίεση…</string>
|
||||||
|
<string name="epic_client_unreachable">Ο διακομιστής λήψης δεν αποκρίθηκε</string>
|
||||||
|
<string name="epic_client_needs_fex">Το Epic Games Launcher χρειάζεται το FEX, το οποίο κατεβάζει το Steam. Ανοίξτε ξανά το launcher αφού ξεκινήσει το Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Η λήψη απέτυχε: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Δεν ήταν δυνατή η αποσυμπίεση του launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Το Epic Games Launcher δεν δέχτηκε τη σύνδεση. Εκκινήστε το ξανά από το launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Αφαιρεί τον εκκινητή και ελευθερώνει τον χώρο του. Τα παιχνίδια Epic παραμένουν εγκατεστημένα και λειτουργούν, και η σύνδεση διατηρείται για την επόμενη εγκατάσταση.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Η τρέχουσα συνεδρία κλείνει πρώτα.</string>
|
||||||
|
<string name="epic_client_uninstalled">Το Epic Games Launcher απεγκαταστάθηκε</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Δεν ήταν δυνατή η αφαίρεση ορισμένων αρχείων του εκκινητή. Δοκιμάστε ξανά.</string>
|
||||||
|
<string name="epic_client_uninstalling">Περιμένετε να ολοκληρωθεί η απεγκατάσταση του Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">Αποθηκεύσεις στο cloud</string>
|
<string name="store_cloud_saves">Αποθηκεύσεις στο cloud</string>
|
||||||
<string name="cloud_upload">Μεταφόρτωση στο cloud</string>
|
<string name="cloud_upload">Μεταφόρτωση στο cloud</string>
|
||||||
<string name="cloud_download">Λήψη από το cloud</string>
|
<string name="cloud_download">Λήψη από το cloud</string>
|
||||||
|
|||||||
@@ -448,8 +448,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Otras opciones de entorno de Proton, DXVK, VKD3D-Proton y sincronización para cada juego.</string>
|
<string name="comp_game_environment_other_hint">Otras opciones de entorno de Proton, DXVK, VKD3D-Proton y sincronización para cada juego.</string>
|
||||||
<string name="comp_game_saved_next">El perfil de %1$s se ha guardado para el próximo inicio del juego.</string>
|
<string name="comp_game_saved_next">El perfil de %1$s se ha guardado para el próximo inicio del juego.</string>
|
||||||
<string name="game_no_steam_title">Iniciar sin Steam</string>
|
<string name="game_no_steam_title">Iniciar sin Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Lo abre en Proton/FEX sin el cliente de Steam; las funciones de Steam quedan desactivadas.</string>
|
||||||
<string name="game_no_steam_on">Activado</string>
|
<string name="game_no_steam_on">Activado</string>
|
||||||
|
<string name="game_steam_with">Se inicia con Steam</string>
|
||||||
|
<string name="game_steam_without">Se inicia sin Steam</string>
|
||||||
|
<string name="game_settings_card">Ajustes</string>
|
||||||
|
<string name="game_settings_menu_title">Ajustes de %1$s</string>
|
||||||
|
<string name="game_settings_default">Predeterminado</string>
|
||||||
|
<string name="game_settings_default_detail">Devuelve todo lo cambiado para este juego a los ajustes de la aplicación.</string>
|
||||||
|
<string name="game_sync_custom">Variables de sincronización personalizadas</string>
|
||||||
<string name="comp_game_proton_live">La elección de Proton de %1$s se ha guardado y ya está disponible en Steam.</string>
|
<string name="comp_game_proton_live">La elección de Proton de %1$s se ha guardado y ya está disponible en Steam.</string>
|
||||||
<string name="comp_game_proton_next">La elección de Proton de %1$s se ha guardado. Reinicia Steam antes de iniciar el juego.</string>
|
<string name="comp_game_proton_next">La elección de Proton de %1$s se ha guardado. Reinicia Steam antes de iniciar el juego.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s no puede usar el Proton elegido en este dispositivo.</string>
|
<string name="comp_game_proton_not_runnable">%1$s no puede usar el Proton elegido en este dispositivo.</string>
|
||||||
@@ -1990,6 +1997,20 @@
|
|||||||
<string name="epic_eos">Inicio de sesión de Epic (EOS)</string>
|
<string name="epic_eos">Inicio de sesión de Epic (EOS)</string>
|
||||||
<string name="epic_offline">Iniciar sin conexión</string>
|
<string name="epic_offline">Iniciar sin conexión</string>
|
||||||
<string name="epic_resolve">Solucionar el inicio de sesión de Epic</string>
|
<string name="epic_resolve">Solucionar el inicio de sesión de Epic</string>
|
||||||
|
<string name="epic_client_version">Versión %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Instala primero el runtime de Linux</string>
|
||||||
|
<string name="epic_client_checking">Comprobando la versión más reciente…</string>
|
||||||
|
<string name="epic_client_unpacking">Descomprimiendo…</string>
|
||||||
|
<string name="epic_client_unreachable">El servidor de descargas no respondió</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher necesita FEX, que descarga Steam. Vuelve a abrir el launcher cuando Steam se haya iniciado.</string>
|
||||||
|
<string name="epic_client_download_failed">La descarga falló: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">No se pudo descomprimir el launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher no aceptó el inicio de sesión. Vuelve a iniciarlo desde el launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Primero se cierra la sesión en curso.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher desinstalado</string>
|
||||||
|
<string name="epic_client_uninstall_failed">No se pudieron quitar algunos archivos del launcher. Inténtalo de nuevo.</string>
|
||||||
|
<string name="epic_client_uninstalling">Espera a que termine de desinstalarse Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">Partidas en la nube</string>
|
<string name="store_cloud_saves">Partidas en la nube</string>
|
||||||
<string name="cloud_upload">Subir a la nube</string>
|
<string name="cloud_upload">Subir a la nube</string>
|
||||||
<string name="cloud_download">Descargar de la nube</string>
|
<string name="cloud_download">Descargar de la nube</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Muut pelikohtaiset Proton-, DXVK-, VKD3D-Proton- ja synkronointiympäristön asetukset.</string>
|
<string name="comp_game_environment_other_hint">Muut pelikohtaiset Proton-, DXVK-, VKD3D-Proton- ja synkronointiympäristön asetukset.</string>
|
||||||
<string name="comp_game_saved_next">%1$s:n profiili on tallennettu seuraavaa pelin käynnistystä varten.</string>
|
<string name="comp_game_saved_next">%1$s:n profiili on tallennettu seuraavaa pelin käynnistystä varten.</string>
|
||||||
<string name="game_no_steam_title">Käynnistä ilman Steamia</string>
|
<string name="game_no_steam_title">Käynnistä ilman Steamia</string>
|
||||||
<string name="game_no_steam_detail">Pelille, joka sulkeutuu heti: käynnistää sen ilman Protonin Steam-asetuksia. Steamin ominaisuudet, kuten overlay, on pois käytöstä sille.</string>
|
<string name="game_no_steam_detail">Avaa sen Protonissa/FEXissä ilman Steam-asiakasta; Steamin ominaisuudet ovat pois päältä.</string>
|
||||||
<string name="game_no_steam_on">Päällä</string>
|
<string name="game_no_steam_on">Päällä</string>
|
||||||
|
<string name="game_steam_with">Käynnistyy Steamin kanssa</string>
|
||||||
|
<string name="game_steam_without">Käynnistyy ilman Steamia</string>
|
||||||
|
<string name="game_settings_card">Asetukset</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s – asetukset</string>
|
||||||
|
<string name="game_settings_default">Oletus</string>
|
||||||
|
<string name="game_settings_default_detail">Palauttaa kaiken tälle pelille muutetun sovelluksen asetuksiin.</string>
|
||||||
|
<string name="game_sync_custom">Mukautetut synkronointimuuttujat</string>
|
||||||
<string name="comp_game_proton_live">%1$s:n Proton-valinta on tallennettu ja käytettävissä Steamissa nyt.</string>
|
<string name="comp_game_proton_live">%1$s:n Proton-valinta on tallennettu ja käytettävissä Steamissa nyt.</string>
|
||||||
<string name="comp_game_proton_next">%1$s:n Proton-valinta on tallennettu. Käynnistä Steam uudelleen ennen pelin käynnistämistä.</string>
|
<string name="comp_game_proton_next">%1$s:n Proton-valinta on tallennettu. Käynnistä Steam uudelleen ennen pelin käynnistämistä.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s ei voi käyttää valittua Protonia tällä laitteella.</string>
|
<string name="comp_game_proton_not_runnable">%1$s ei voi käyttää valittua Protonia tällä laitteella.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic-kirjautuminen (EOS)</string>
|
<string name="epic_eos">Epic-kirjautuminen (EOS)</string>
|
||||||
<string name="epic_offline">Käynnistä offline-tilassa</string>
|
<string name="epic_offline">Käynnistä offline-tilassa</string>
|
||||||
<string name="epic_resolve">Ratkaise Epic-kirjautuminen</string>
|
<string name="epic_resolve">Ratkaise Epic-kirjautuminen</string>
|
||||||
|
<string name="epic_client_version">Versio %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Asenna Linux-ajoympäristö ensin</string>
|
||||||
|
<string name="epic_client_checking">Tarkistetaan uusinta versiota…</string>
|
||||||
|
<string name="epic_client_unpacking">Puretaan…</string>
|
||||||
|
<string name="epic_client_unreachable">Latauspalvelin ei vastannut</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher tarvitsee FEXin, jonka Steam lataa. Avaa launcher uudelleen, kun Steam on käynnistynyt.</string>
|
||||||
|
<string name="epic_client_download_failed">Lataus epäonnistui: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Launcherin purkaminen epäonnistui: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher ei hyväksynyt kirjautumista. Käynnistä se uudelleen launcherista.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Poistaa käynnistimen ja vapauttaa sen tilan. Epic-pelisi pysyvät asennettuina ja toimivat, ja kirjautuminen säilyy seuraavaa asennusta varten.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Käynnissä oleva istunto suljetaan ensin.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher poistettu</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Joitakin käynnistimen tiedostoja ei voitu poistaa. Yritä uudelleen.</string>
|
||||||
|
<string name="epic_client_uninstalling">Odota, että Epic Games Launcherin asennuksen poisto valmistuu</string>
|
||||||
<string name="store_cloud_saves">Pilvitallennukset</string>
|
<string name="store_cloud_saves">Pilvitallennukset</string>
|
||||||
<string name="cloud_upload">Lähetä pilveen</string>
|
<string name="cloud_upload">Lähetä pilveen</string>
|
||||||
<string name="cloud_download">Lataa pilvestä</string>
|
<string name="cloud_download">Lataa pilvestä</string>
|
||||||
|
|||||||
@@ -448,8 +448,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Autres options d\'environnement Proton, DXVK, VKD3D-Proton et de synchronisation propres à ce jeu.</string>
|
<string name="comp_game_environment_other_hint">Autres options d\'environnement Proton, DXVK, VKD3D-Proton et de synchronisation propres à ce jeu.</string>
|
||||||
<string name="comp_game_saved_next">Le profil de %1$s est enregistré pour le prochain lancement du jeu.</string>
|
<string name="comp_game_saved_next">Le profil de %1$s est enregistré pour le prochain lancement du jeu.</string>
|
||||||
<string name="game_no_steam_title">Démarrer sans Steam</string>
|
<string name="game_no_steam_title">Démarrer sans Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">L\'ouvre dans Proton/FEX sans le client Steam ; les fonctions Steam sont désactivées.</string>
|
||||||
<string name="game_no_steam_on">Activé</string>
|
<string name="game_no_steam_on">Activé</string>
|
||||||
|
<string name="game_steam_with">Démarre avec Steam</string>
|
||||||
|
<string name="game_steam_without">Démarre sans Steam</string>
|
||||||
|
<string name="game_settings_card">Paramètres</string>
|
||||||
|
<string name="game_settings_menu_title">Paramètres de %1$s</string>
|
||||||
|
<string name="game_settings_default">Par défaut</string>
|
||||||
|
<string name="game_settings_default_detail">Remet tout ce qui a été modifié pour ce jeu aux paramètres de l\'application.</string>
|
||||||
|
<string name="game_sync_custom">Variables de synchronisation personnalisées</string>
|
||||||
<string name="comp_game_proton_live">Le choix de Proton pour %1$s est enregistré et disponible dès maintenant dans Steam.</string>
|
<string name="comp_game_proton_live">Le choix de Proton pour %1$s est enregistré et disponible dès maintenant dans Steam.</string>
|
||||||
<string name="comp_game_proton_next">Le choix de Proton pour %1$s est enregistré. Redémarrez Steam avant de lancer le jeu.</string>
|
<string name="comp_game_proton_next">Le choix de Proton pour %1$s est enregistré. Redémarrez Steam avant de lancer le jeu.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s ne peut pas utiliser le Proton sélectionné sur cet appareil.</string>
|
<string name="comp_game_proton_not_runnable">%1$s ne peut pas utiliser le Proton sélectionné sur cet appareil.</string>
|
||||||
@@ -1990,6 +1997,20 @@
|
|||||||
<string name="epic_eos">Connexion Epic (EOS)</string>
|
<string name="epic_eos">Connexion Epic (EOS)</string>
|
||||||
<string name="epic_offline">Lancer hors ligne</string>
|
<string name="epic_offline">Lancer hors ligne</string>
|
||||||
<string name="epic_resolve">Corriger la connexion Epic</string>
|
<string name="epic_resolve">Corriger la connexion Epic</string>
|
||||||
|
<string name="epic_client_version">Version %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Veuillez d\'abord installer l\'environnement d\'exécution Linux</string>
|
||||||
|
<string name="epic_client_checking">Vérification de la dernière version…</string>
|
||||||
|
<string name="epic_client_unpacking">Décompression…</string>
|
||||||
|
<string name="epic_client_unreachable">Le serveur de téléchargement n\'a pas répondu</string>
|
||||||
|
<string name="epic_client_needs_fex">L\'Epic Games Launcher a besoin de FEX, que Steam télécharge. Rouvrez le launcher une fois Steam démarré.</string>
|
||||||
|
<string name="epic_client_download_failed">Échec du téléchargement : %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Impossible de décompresser le launcher : %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher n\'a pas accepté la connexion. Relancez-le depuis le launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">La session en cours se ferme d’abord.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher désinstallé</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Certains fichiers du lanceur n’ont pas pu être supprimés. Réessayez.</string>
|
||||||
|
<string name="epic_client_uninstalling">Attendez la fin de la désinstallation d\'Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">Sauvegardes cloud</string>
|
<string name="store_cloud_saves">Sauvegardes cloud</string>
|
||||||
<string name="cloud_upload">Envoyer vers le cloud</string>
|
<string name="cloud_upload">Envoyer vers le cloud</string>
|
||||||
<string name="cloud_download">Télécharger depuis le cloud</string>
|
<string name="cloud_download">Télécharger depuis le cloud</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">हर गेम के लिए अन्य Proton, DXVK, VKD3D-Proton और सिंक्रोनाइज़ेशन एनवायरनमेंट विकल्प।</string>
|
<string name="comp_game_environment_other_hint">हर गेम के लिए अन्य Proton, DXVK, VKD3D-Proton और सिंक्रोनाइज़ेशन एनवायरनमेंट विकल्प।</string>
|
||||||
<string name="comp_game_saved_next">%1$s की प्रोफ़ाइल अगली बार गेम शुरू होने के लिए सेव है।</string>
|
<string name="comp_game_saved_next">%1$s की प्रोफ़ाइल अगली बार गेम शुरू होने के लिए सेव है।</string>
|
||||||
<string name="game_no_steam_title">Steam के बिना शुरू करें</string>
|
<string name="game_no_steam_title">Steam के बिना शुरू करें</string>
|
||||||
<string name="game_no_steam_detail">ऐसे गेम के लिए जो तुरंत बंद हो जाता है: इसे Proton के Steam सेटअप के बिना शुरू करता है। इसके लिए ओवरले जैसी Steam सुविधाएं बंद रहती हैं।</string>
|
<string name="game_no_steam_detail">इसे Steam क्लाइंट के बिना Proton/FEX में खोलता है; Steam सुविधाएँ बंद रहती हैं।</string>
|
||||||
<string name="game_no_steam_on">चालू</string>
|
<string name="game_no_steam_on">चालू</string>
|
||||||
|
<string name="game_steam_with">Steam के साथ शुरू होता है</string>
|
||||||
|
<string name="game_steam_without">Steam के बिना शुरू होता है</string>
|
||||||
|
<string name="game_settings_card">सेटिंग</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s सेटिंग</string>
|
||||||
|
<string name="game_settings_default">डिफ़ॉल्ट</string>
|
||||||
|
<string name="game_settings_default_detail">इस गेम के लिए बदली गई हर चीज़ को ऐप की सेटिंग पर वापस लाता है।</string>
|
||||||
|
<string name="game_sync_custom">कस्टम सिंक वेरिएबल</string>
|
||||||
<string name="comp_game_proton_live">%1$s का Proton चुनाव सेव है और अभी Steam में उपलब्ध है।</string>
|
<string name="comp_game_proton_live">%1$s का Proton चुनाव सेव है और अभी Steam में उपलब्ध है।</string>
|
||||||
<string name="comp_game_proton_next">%1$s का Proton चुनाव सेव है। गेम शुरू करने से पहले Steam रीस्टार्ट करें।</string>
|
<string name="comp_game_proton_next">%1$s का Proton चुनाव सेव है। गेम शुरू करने से पहले Steam रीस्टार्ट करें।</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s इस डिवाइस पर चुना गया Proton इस्तेमाल नहीं कर सकता।</string>
|
<string name="comp_game_proton_not_runnable">%1$s इस डिवाइस पर चुना गया Proton इस्तेमाल नहीं कर सकता।</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic साइन-इन (EOS)</string>
|
<string name="epic_eos">Epic साइन-इन (EOS)</string>
|
||||||
<string name="epic_offline">ऑफ़लाइन लॉन्च करें</string>
|
<string name="epic_offline">ऑफ़लाइन लॉन्च करें</string>
|
||||||
<string name="epic_resolve">Epic साइन-इन हल करें</string>
|
<string name="epic_resolve">Epic साइन-इन हल करें</string>
|
||||||
|
<string name="epic_client_version">वर्शन %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">पहले Linux रनटाइम इंस्टॉल करें</string>
|
||||||
|
<string name="epic_client_checking">नवीनतम वर्शन जांचा जा रहा है…</string>
|
||||||
|
<string name="epic_client_unpacking">अनपैक हो रहा है…</string>
|
||||||
|
<string name="epic_client_unreachable">डाउनलोड सर्वर ने जवाब नहीं दिया</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher को FEX चाहिए, जिसे Steam डाउनलोड करता है। Steam शुरू होने के बाद launcher फिर से खोलें।</string>
|
||||||
|
<string name="epic_client_download_failed">डाउनलोड विफल: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">लॉन्चर को अनपैक नहीं किया जा सका: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher ने साइन-इन स्वीकार नहीं किया। इसे लॉन्चर से फिर से शुरू करें।</string>
|
||||||
|
<string name="epic_client_uninstall_body">लॉन्चर हटाता है और उसकी जगह खाली करता है। आपके Epic गेम इंस्टॉल रहते हैं और चलते रहते हैं, और साइन-इन अगली बार इंस्टॉल करने के लिए रखा जाता है।</string>
|
||||||
|
<string name="epic_client_uninstall_stops">पहले चल रहा सेशन बंद होगा।</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher अनइंस्टॉल हो गया</string>
|
||||||
|
<string name="epic_client_uninstall_failed">लॉन्चर की कुछ फ़ाइलें हटाई नहीं जा सकीं। फिर से कोशिश करें।</string>
|
||||||
|
<string name="epic_client_uninstalling">Epic Games Launcher की अनइंस्टॉल पूरी होने तक प्रतीक्षा करें</string>
|
||||||
<string name="store_cloud_saves">क्लाउड सेव</string>
|
<string name="store_cloud_saves">क्लाउड सेव</string>
|
||||||
<string name="cloud_upload">क्लाउड पर अपलोड करें</string>
|
<string name="cloud_upload">क्लाउड पर अपलोड करें</string>
|
||||||
<string name="cloud_download">क्लाउड से डाउनलोड करें</string>
|
<string name="cloud_download">क्लाउड से डाउनलोड करें</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Egyéb, játékonkénti Proton-, DXVK-, VKD3D-Proton- és szinkronizációs környezeti beállítások.</string>
|
<string name="comp_game_environment_other_hint">Egyéb, játékonkénti Proton-, DXVK-, VKD3D-Proton- és szinkronizációs környezeti beállítások.</string>
|
||||||
<string name="comp_game_saved_next">A %1$s profilja mentve a játék következő indítására.</string>
|
<string name="comp_game_saved_next">A %1$s profilja mentve a játék következő indítására.</string>
|
||||||
<string name="game_no_steam_title">Indítás Steam nélkül</string>
|
<string name="game_no_steam_title">Indítás Steam nélkül</string>
|
||||||
<string name="game_no_steam_detail">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á.</string>
|
<string name="game_no_steam_detail">Proton/FEX alatt nyitja meg a Steam kliens nélkül; a Steam funkciói ki vannak kapcsolva.</string>
|
||||||
<string name="game_no_steam_on">Be</string>
|
<string name="game_no_steam_on">Be</string>
|
||||||
|
<string name="game_steam_with">Steammel indul</string>
|
||||||
|
<string name="game_steam_without">Steam nélkül indul</string>
|
||||||
|
<string name="game_settings_card">Beállítások</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s beállításai</string>
|
||||||
|
<string name="game_settings_default">Alapértelmezett</string>
|
||||||
|
<string name="game_settings_default_detail">Mindent, amit ennél a játéknál módosítottál, visszaállít az alkalmazás beállításaira.</string>
|
||||||
|
<string name="game_sync_custom">Egyéni szinkronizálási változók</string>
|
||||||
<string name="comp_game_proton_live">A %1$s Proton-választása mentve, és már elérhető a Steamben.</string>
|
<string name="comp_game_proton_live">A %1$s Proton-választása mentve, és már elérhető a Steamben.</string>
|
||||||
<string name="comp_game_proton_next">A %1$s Proton-választása mentve. Indítsa újra a Steamet a játék elindítása előtt.</string>
|
<string name="comp_game_proton_next">A %1$s Proton-választása mentve. Indítsa újra a Steamet a játék elindítása előtt.</string>
|
||||||
<string name="comp_game_proton_not_runnable">A %1$s nem tudja használni a kiválasztott Protont ezen az eszközön.</string>
|
<string name="comp_game_proton_not_runnable">A %1$s nem tudja használni a kiválasztott Protont ezen az eszközön.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic bejelentkezés (EOS)</string>
|
<string name="epic_eos">Epic bejelentkezés (EOS)</string>
|
||||||
<string name="epic_offline">Indítás offline</string>
|
<string name="epic_offline">Indítás offline</string>
|
||||||
<string name="epic_resolve">Epic bejelentkezés megoldása</string>
|
<string name="epic_resolve">Epic bejelentkezés megoldása</string>
|
||||||
|
<string name="epic_client_version">%1$s verzió</string>
|
||||||
|
<string name="epic_client_no_runtime">Előbb telepítse a Linux futtatókörnyezetet</string>
|
||||||
|
<string name="epic_client_checking">A legújabb verzió ellenőrzése…</string>
|
||||||
|
<string name="epic_client_unpacking">Kicsomagolás…</string>
|
||||||
|
<string name="epic_client_unreachable">A letöltési szerver nem válaszolt</string>
|
||||||
|
<string name="epic_client_needs_fex">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.</string>
|
||||||
|
<string name="epic_client_download_failed">Letöltés sikertelen: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Nem sikerült kicsomagolni a launchert: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Az Epic Games Launcher nem fogadta el a bejelentkezést. Indítsa el újra a launcherből.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Előbb bezárul a futó munkamenet.</string>
|
||||||
|
<string name="epic_client_uninstalled">Az Epic Games Launcher eltávolítva</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Az indító néhány fájlját nem sikerült eltávolítani. Próbáld újra.</string>
|
||||||
|
<string name="epic_client_uninstalling">Várd meg, amíg az Epic Games Launcher eltávolítása befejeződik</string>
|
||||||
<string name="store_cloud_saves">Felhőmentések</string>
|
<string name="store_cloud_saves">Felhőmentések</string>
|
||||||
<string name="cloud_upload">Feltöltés a felhőbe</string>
|
<string name="cloud_upload">Feltöltés a felhőbe</string>
|
||||||
<string name="cloud_download">Letöltés a felhőből</string>
|
<string name="cloud_download">Letöltés a felhőből</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Opsi lingkungan Proton, DXVK, VKD3D-Proton, dan sinkronisasi lain per gim.</string>
|
<string name="comp_game_environment_other_hint">Opsi lingkungan Proton, DXVK, VKD3D-Proton, dan sinkronisasi lain per gim.</string>
|
||||||
<string name="comp_game_saved_next">Profil %1$s tersimpan untuk peluncuran gim berikutnya.</string>
|
<string name="comp_game_saved_next">Profil %1$s tersimpan untuk peluncuran gim berikutnya.</string>
|
||||||
<string name="game_no_steam_title">Mulai tanpa Steam</string>
|
<string name="game_no_steam_title">Mulai tanpa Steam</string>
|
||||||
<string name="game_no_steam_detail">Untuk game yang langsung tertutup: menjalankannya tanpa pengaturan Steam dari Proton. Fitur Steam seperti overlay dimatikan untuknya.</string>
|
<string name="game_no_steam_detail">Membukanya di Proton/FEX tanpa klien Steam; fitur Steam nonaktif.</string>
|
||||||
<string name="game_no_steam_on">Aktif</string>
|
<string name="game_no_steam_on">Aktif</string>
|
||||||
|
<string name="game_steam_with">Dimulai dengan Steam</string>
|
||||||
|
<string name="game_steam_without">Dimulai tanpa Steam</string>
|
||||||
|
<string name="game_settings_card">Setelan</string>
|
||||||
|
<string name="game_settings_menu_title">Setelan %1$s</string>
|
||||||
|
<string name="game_settings_default">Default</string>
|
||||||
|
<string name="game_settings_default_detail">Mengembalikan semua yang diubah untuk game ini ke setelan aplikasi.</string>
|
||||||
|
<string name="game_sync_custom">Variabel sinkronisasi kustom</string>
|
||||||
<string name="comp_game_proton_live">Pilihan Proton %1$s tersimpan dan sudah tersedia di Steam sekarang.</string>
|
<string name="comp_game_proton_live">Pilihan Proton %1$s tersimpan dan sudah tersedia di Steam sekarang.</string>
|
||||||
<string name="comp_game_proton_next">Pilihan Proton %1$s tersimpan. Mulai ulang Steam sebelum meluncurkan gim.</string>
|
<string name="comp_game_proton_next">Pilihan Proton %1$s tersimpan. Mulai ulang Steam sebelum meluncurkan gim.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s tidak dapat menggunakan Proton yang dipilih di perangkat ini.</string>
|
<string name="comp_game_proton_not_runnable">%1$s tidak dapat menggunakan Proton yang dipilih di perangkat ini.</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Masuk Epic (EOS)</string>
|
<string name="epic_eos">Masuk Epic (EOS)</string>
|
||||||
<string name="epic_offline">Jalankan secara offline</string>
|
<string name="epic_offline">Jalankan secara offline</string>
|
||||||
<string name="epic_resolve">Selesaikan masuk Epic</string>
|
<string name="epic_resolve">Selesaikan masuk Epic</string>
|
||||||
|
<string name="epic_client_version">Versi %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Instal runtime Linux terlebih dahulu</string>
|
||||||
|
<string name="epic_client_checking">Memeriksa versi terbaru…</string>
|
||||||
|
<string name="epic_client_unpacking">Mengekstrak…</string>
|
||||||
|
<string name="epic_client_unreachable">Server unduhan tidak merespons</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher memerlukan FEX, yang diunduh oleh Steam. Buka launcher lagi setelah Steam dimulai.</string>
|
||||||
|
<string name="epic_client_download_failed">Unduhan gagal: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Tidak dapat mengekstrak launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher tidak menerima proses masuk. Mulai lagi dari launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Menghapus peluncur dan mengosongkan ruangnya. Game Epic Anda tetap terpasang dan berfungsi, dan proses masuk disimpan untuk saat memasangnya lagi.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Sesi yang berjalan ditutup terlebih dahulu.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher dicopot</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Beberapa file peluncur tidak dapat dihapus. Coba lagi.</string>
|
||||||
|
<string name="epic_client_uninstalling">Tunggu hingga Epic Games Launcher selesai dicopot</string>
|
||||||
<string name="store_cloud_saves">Simpanan cloud</string>
|
<string name="store_cloud_saves">Simpanan cloud</string>
|
||||||
<string name="cloud_upload">Unggah ke cloud</string>
|
<string name="cloud_upload">Unggah ke cloud</string>
|
||||||
<string name="cloud_download">Unduh dari cloud</string>
|
<string name="cloud_download">Unduh dari cloud</string>
|
||||||
|
|||||||
@@ -448,8 +448,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Altre opzioni d\'ambiente per gioco di Proton, DXVK, VKD3D-Proton e sincronizzazione.</string>
|
<string name="comp_game_environment_other_hint">Altre opzioni d\'ambiente per gioco di Proton, DXVK, VKD3D-Proton e sincronizzazione.</string>
|
||||||
<string name="comp_game_saved_next">Il profilo di %1$s è salvato per il prossimo avvio del gioco.</string>
|
<string name="comp_game_saved_next">Il profilo di %1$s è salvato per il prossimo avvio del gioco.</string>
|
||||||
<string name="game_no_steam_title">Avvia senza Steam</string>
|
<string name="game_no_steam_title">Avvia senza Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Lo apre in Proton/FEX senza il client di Steam; le funzioni di Steam sono disattivate.</string>
|
||||||
<string name="game_no_steam_on">Attivo</string>
|
<string name="game_no_steam_on">Attivo</string>
|
||||||
|
<string name="game_steam_with">Si avvia con Steam</string>
|
||||||
|
<string name="game_steam_without">Si avvia senza Steam</string>
|
||||||
|
<string name="game_settings_card">Impostazioni</string>
|
||||||
|
<string name="game_settings_menu_title">Impostazioni di %1$s</string>
|
||||||
|
<string name="game_settings_default">Predefinito</string>
|
||||||
|
<string name="game_settings_default_detail">Riporta tutto ciò che è stato cambiato per questo gioco alle impostazioni dell\'app.</string>
|
||||||
|
<string name="game_sync_custom">Variabili di sincronizzazione personalizzate</string>
|
||||||
<string name="comp_game_proton_live">La scelta di Proton per %1$s è salvata e già disponibile in Steam.</string>
|
<string name="comp_game_proton_live">La scelta di Proton per %1$s è salvata e già disponibile in Steam.</string>
|
||||||
<string name="comp_game_proton_next">La scelta di Proton per %1$s è salvata. Riavvia Steam prima di avviare il gioco.</string>
|
<string name="comp_game_proton_next">La scelta di Proton per %1$s è salvata. Riavvia Steam prima di avviare il gioco.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s non può usare il Proton selezionato su questo dispositivo.</string>
|
<string name="comp_game_proton_not_runnable">%1$s non può usare il Proton selezionato su questo dispositivo.</string>
|
||||||
@@ -1982,6 +1989,20 @@
|
|||||||
<string name="epic_eos">Accesso Epic (EOS)</string>
|
<string name="epic_eos">Accesso Epic (EOS)</string>
|
||||||
<string name="epic_offline">Avvia offline</string>
|
<string name="epic_offline">Avvia offline</string>
|
||||||
<string name="epic_resolve">Risolvi l\'accesso Epic</string>
|
<string name="epic_resolve">Risolvi l\'accesso Epic</string>
|
||||||
|
<string name="epic_client_version">Versione %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installa prima il runtime Linux</string>
|
||||||
|
<string name="epic_client_checking">Controllo della versione più recente…</string>
|
||||||
|
<string name="epic_client_unpacking">Estrazione…</string>
|
||||||
|
<string name="epic_client_unreachable">Il server di download non ha risposto</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher ha bisogno di FEX, che viene scaricato da Steam. Riapri il launcher dopo l\'avvio di Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Download non riuscito: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Impossibile estrarre il launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher non ha accettato l\'accesso. Avvialo di nuovo dal launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Prima viene chiusa la sessione in corso.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher disinstallato</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Non è stato possibile rimuovere alcuni file del launcher. Riprova.</string>
|
||||||
|
<string name="epic_client_uninstalling">Attendi che Epic Games Launcher finisca di disinstallarsi</string>
|
||||||
<string name="store_cloud_saves">Salvataggi nel cloud</string>
|
<string name="store_cloud_saves">Salvataggi nel cloud</string>
|
||||||
<string name="cloud_upload">Carica nel cloud</string>
|
<string name="cloud_upload">Carica nel cloud</string>
|
||||||
<string name="cloud_download">Scarica dal cloud</string>
|
<string name="cloud_download">Scarica dal cloud</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">その他のゲームごとのProton、DXVK、VKD3D-Proton、同期の環境オプション。</string>
|
<string name="comp_game_environment_other_hint">その他のゲームごとのProton、DXVK、VKD3D-Proton、同期の環境オプション。</string>
|
||||||
<string name="comp_game_saved_next">%1$sのプロファイルを保存しました。次回のゲーム起動時に有効になります。</string>
|
<string name="comp_game_saved_next">%1$sのプロファイルを保存しました。次回のゲーム起動時に有効になります。</string>
|
||||||
<string name="game_no_steam_title">Steamなしで起動</string>
|
<string name="game_no_steam_title">Steamなしで起動</string>
|
||||||
<string name="game_no_steam_detail">すぐに終了してしまうゲーム用:ProtonのSteamセットアップなしで起動します。オーバーレイなどのSteam機能はこのゲームでは無効になります。</string>
|
<string name="game_no_steam_detail">Steam クライアントなしで Proton/FEX で開きます。Steam の機能はオフになります。</string>
|
||||||
<string name="game_no_steam_on">オン</string>
|
<string name="game_no_steam_on">オン</string>
|
||||||
|
<string name="game_steam_with">Steam と一緒に起動</string>
|
||||||
|
<string name="game_steam_without">Steam なしで起動</string>
|
||||||
|
<string name="game_settings_card">設定</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s の設定</string>
|
||||||
|
<string name="game_settings_default">デフォルト</string>
|
||||||
|
<string name="game_settings_default_detail">このゲーム用に変更した内容をすべてアプリの設定に戻します。</string>
|
||||||
|
<string name="game_sync_custom">カスタム同期変数</string>
|
||||||
<string name="comp_game_proton_live">%1$sのProtonの選択を保存しました。Steamですぐに使用できます。</string>
|
<string name="comp_game_proton_live">%1$sのProtonの選択を保存しました。Steamですぐに使用できます。</string>
|
||||||
<string name="comp_game_proton_next">%1$sのProtonの選択を保存しました。ゲームを起動する前にSteamを再起動してください。</string>
|
<string name="comp_game_proton_next">%1$sのProtonの選択を保存しました。ゲームを起動する前にSteamを再起動してください。</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$sは、選択したProtonをこのデバイスで使用できません。</string>
|
<string name="comp_game_proton_not_runnable">%1$sは、選択したProtonをこのデバイスで使用できません。</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Epicログイン(EOS)</string>
|
<string name="epic_eos">Epicログイン(EOS)</string>
|
||||||
<string name="epic_offline">オフラインで起動</string>
|
<string name="epic_offline">オフラインで起動</string>
|
||||||
<string name="epic_resolve">Epicログインの問題を解決</string>
|
<string name="epic_resolve">Epicログインの問題を解決</string>
|
||||||
|
<string name="epic_client_version">バージョン %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">先にLinux実行環境をインストールしてください</string>
|
||||||
|
<string name="epic_client_checking">最新バージョンを確認中…</string>
|
||||||
|
<string name="epic_client_unpacking">展開中…</string>
|
||||||
|
<string name="epic_client_unreachable">ダウンロードサーバーが応答しませんでした</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher には FEX が必要です。FEX は Steam がダウンロードします。Steam の起動後にもう一度ランチャーを開いてください。</string>
|
||||||
|
<string name="epic_client_download_failed">ダウンロードに失敗しました:%1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">ランチャーを展開できませんでした:%1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcherがサインインを受け付けませんでした。ランチャーからもう一度起動してください。</string>
|
||||||
|
<string name="epic_client_uninstall_body">ランチャーを削除して容量を空けます。Epicのゲームはインストールされたまま動作し、サインインは再インストール時のために保持されます。</string>
|
||||||
|
<string name="epic_client_uninstall_stops">実行中のセッションを先に終了します。</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcherをアンインストールしました</string>
|
||||||
|
<string name="epic_client_uninstall_failed">ランチャーの一部のファイルを削除できませんでした。もう一度お試しください。</string>
|
||||||
|
<string name="epic_client_uninstalling">Epic Games Launcher のアンインストールが終わるまでお待ちください</string>
|
||||||
<string name="store_cloud_saves">クラウドセーブ</string>
|
<string name="store_cloud_saves">クラウドセーブ</string>
|
||||||
<string name="cloud_upload">クラウドにアップロード</string>
|
<string name="cloud_upload">クラウドにアップロード</string>
|
||||||
<string name="cloud_download">クラウドからダウンロード</string>
|
<string name="cloud_download">クラウドからダウンロード</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">기타 게임별 Proton, DXVK, VKD3D-Proton 및 동기화 환경 옵션입니다.</string>
|
<string name="comp_game_environment_other_hint">기타 게임별 Proton, DXVK, VKD3D-Proton 및 동기화 환경 옵션입니다.</string>
|
||||||
<string name="comp_game_saved_next">%1$s의 프로필이 저장되었습니다. 다음 게임 실행 시 적용됩니다.</string>
|
<string name="comp_game_saved_next">%1$s의 프로필이 저장되었습니다. 다음 게임 실행 시 적용됩니다.</string>
|
||||||
<string name="game_no_steam_title">Steam 없이 시작</string>
|
<string name="game_no_steam_title">Steam 없이 시작</string>
|
||||||
<string name="game_no_steam_detail">바로 닫히는 게임의 경우: Proton의 Steam 설정 없이 실행합니다. 오버레이 같은 Steam 기능은 이 게임에서 꺼집니다.</string>
|
<string name="game_no_steam_detail">Steam 클라이언트 없이 Proton/FEX에서 엽니다. Steam 기능은 꺼집니다.</string>
|
||||||
<string name="game_no_steam_on">켜짐</string>
|
<string name="game_no_steam_on">켜짐</string>
|
||||||
|
<string name="game_steam_with">Steam과 함께 시작</string>
|
||||||
|
<string name="game_steam_without">Steam 없이 시작</string>
|
||||||
|
<string name="game_settings_card">설정</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s 설정</string>
|
||||||
|
<string name="game_settings_default">기본값</string>
|
||||||
|
<string name="game_settings_default_detail">이 게임에서 변경한 모든 항목을 앱 설정으로 되돌립니다.</string>
|
||||||
|
<string name="game_sync_custom">사용자 지정 동기화 변수</string>
|
||||||
<string name="comp_game_proton_live">%1$s의 Proton 선택이 저장되었으며 지금 Steam에서 사용할 수 있습니다.</string>
|
<string name="comp_game_proton_live">%1$s의 Proton 선택이 저장되었으며 지금 Steam에서 사용할 수 있습니다.</string>
|
||||||
<string name="comp_game_proton_next">%1$s의 Proton 선택이 저장되었습니다. 게임을 실행하기 전에 Steam을 다시 시작하세요.</string>
|
<string name="comp_game_proton_next">%1$s의 Proton 선택이 저장되었습니다. 게임을 실행하기 전에 Steam을 다시 시작하세요.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s은(는) 이 기기에서 선택한 Proton을 사용할 수 없습니다.</string>
|
<string name="comp_game_proton_not_runnable">%1$s은(는) 이 기기에서 선택한 Proton을 사용할 수 없습니다.</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Epic 로그인(EOS)</string>
|
<string name="epic_eos">Epic 로그인(EOS)</string>
|
||||||
<string name="epic_offline">오프라인으로 실행</string>
|
<string name="epic_offline">오프라인으로 실행</string>
|
||||||
<string name="epic_resolve">Epic 로그인 문제 해결</string>
|
<string name="epic_resolve">Epic 로그인 문제 해결</string>
|
||||||
|
<string name="epic_client_version">버전 %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">먼저 Linux 런타임을 설치하세요</string>
|
||||||
|
<string name="epic_client_checking">최신 버전 확인 중…</string>
|
||||||
|
<string name="epic_client_unpacking">압축 해제 중…</string>
|
||||||
|
<string name="epic_client_unreachable">다운로드 서버가 응답하지 않았습니다</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher에는 Steam이 다운로드하는 FEX가 필요합니다. Steam이 시작된 후 런처를 다시 여세요.</string>
|
||||||
|
<string name="epic_client_download_failed">다운로드 실패: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">런처 압축을 해제할 수 없습니다: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher가 로그인을 받아들이지 않았습니다. 런처에서 다시 시작하세요.</string>
|
||||||
|
<string name="epic_client_uninstall_body">런처를 삭제하고 공간을 확보합니다. Epic 게임은 설치된 상태로 계속 작동하며, 로그인은 다시 설치할 때를 위해 유지됩니다.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">실행 중인 세션이 먼저 종료됩니다.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher를 제거했습니다</string>
|
||||||
|
<string name="epic_client_uninstall_failed">일부 런처 파일을 삭제하지 못했습니다. 다시 시도하세요.</string>
|
||||||
|
<string name="epic_client_uninstalling">Epic Games Launcher 제거가 끝날 때까지 기다려 주세요</string>
|
||||||
<string name="store_cloud_saves">클라우드 저장</string>
|
<string name="store_cloud_saves">클라우드 저장</string>
|
||||||
<string name="cloud_upload">클라우드에 업로드</string>
|
<string name="cloud_upload">클라우드에 업로드</string>
|
||||||
<string name="cloud_download">클라우드에서 다운로드</string>
|
<string name="cloud_download">클라우드에서 다운로드</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Pilihan persekitaran Proton, DXVK, VKD3D-Proton dan penyegerakan lain bagi setiap permainan.</string>
|
<string name="comp_game_environment_other_hint">Pilihan persekitaran Proton, DXVK, VKD3D-Proton dan penyegerakan lain bagi setiap permainan.</string>
|
||||||
<string name="comp_game_saved_next">Profil %1$s disimpan untuk pelancaran permainan seterusnya.</string>
|
<string name="comp_game_saved_next">Profil %1$s disimpan untuk pelancaran permainan seterusnya.</string>
|
||||||
<string name="game_no_steam_title">Mulakan tanpa Steam</string>
|
<string name="game_no_steam_title">Mulakan tanpa Steam</string>
|
||||||
<string name="game_no_steam_detail">Untuk permainan yang terus tertutup: memulakannya tanpa persediaan Steam oleh Proton. Ciri Steam seperti overlay dimatikan untuknya.</string>
|
<string name="game_no_steam_detail">Membukanya dalam Proton/FEX tanpa klien Steam; ciri Steam dimatikan.</string>
|
||||||
<string name="game_no_steam_on">Hidup</string>
|
<string name="game_no_steam_on">Hidup</string>
|
||||||
|
<string name="game_steam_with">Bermula dengan Steam</string>
|
||||||
|
<string name="game_steam_without">Bermula tanpa Steam</string>
|
||||||
|
<string name="game_settings_card">Tetapan</string>
|
||||||
|
<string name="game_settings_menu_title">Tetapan %1$s</string>
|
||||||
|
<string name="game_settings_default">Lalai</string>
|
||||||
|
<string name="game_settings_default_detail">Mengembalikan semua yang diubah untuk permainan ini kepada tetapan apl.</string>
|
||||||
|
<string name="game_sync_custom">Pemboleh ubah segerak tersuai</string>
|
||||||
<string name="comp_game_proton_live">Pilihan Proton %1$s disimpan dan tersedia dalam Steam sekarang.</string>
|
<string name="comp_game_proton_live">Pilihan Proton %1$s disimpan dan tersedia dalam Steam sekarang.</string>
|
||||||
<string name="comp_game_proton_next">Pilihan Proton %1$s disimpan. Mulakan semula Steam sebelum melancarkan permainan.</string>
|
<string name="comp_game_proton_next">Pilihan Proton %1$s disimpan. Mulakan semula Steam sebelum melancarkan permainan.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s tidak boleh menggunakan Proton yang dipilih pada peranti ini.</string>
|
<string name="comp_game_proton_not_runnable">%1$s tidak boleh menggunakan Proton yang dipilih pada peranti ini.</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Log masuk Epic (EOS)</string>
|
<string name="epic_eos">Log masuk Epic (EOS)</string>
|
||||||
<string name="epic_offline">Lancarkan secara luar talian</string>
|
<string name="epic_offline">Lancarkan secara luar talian</string>
|
||||||
<string name="epic_resolve">Selesaikan log masuk Epic</string>
|
<string name="epic_resolve">Selesaikan log masuk Epic</string>
|
||||||
|
<string name="epic_client_version">Versi %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Pasang runtime Linux dahulu</string>
|
||||||
|
<string name="epic_client_checking">Menyemak versi terkini…</string>
|
||||||
|
<string name="epic_client_unpacking">Menyahpak…</string>
|
||||||
|
<string name="epic_client_unreachable">Pelayan muat turun tidak memberi respons</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher memerlukan FEX, yang dimuat turun oleh Steam. Buka semula pelancar selepas Steam bermula.</string>
|
||||||
|
<string name="epic_client_download_failed">Muat turun gagal: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Tidak dapat menyahpak pelancar: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher tidak menerima log masuk itu. Mulakan semula daripada pelancar.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Membuang pelancar dan mengosongkan ruangnya. Permainan Epic anda kekal dipasang dan berfungsi, dan log masuk disimpan untuk apabila anda memasangnya semula.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Sesi yang sedang berjalan ditutup dahulu.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher dinyahpasang</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Sesetengah fail pelancar tidak dapat dibuang. Cuba lagi.</string>
|
||||||
|
<string name="epic_client_uninstalling">Tunggu sehingga Epic Games Launcher selesai dinyahpasang</string>
|
||||||
<string name="store_cloud_saves">Simpanan awan</string>
|
<string name="store_cloud_saves">Simpanan awan</string>
|
||||||
<string name="cloud_upload">Muat naik ke awan</string>
|
<string name="cloud_upload">Muat naik ke awan</string>
|
||||||
<string name="cloud_download">Muat turun daripada awan</string>
|
<string name="cloud_download">Muat turun daripada awan</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Andre spillspesifikke Proton-, DXVK-, VKD3D-Proton- og synkroniseringsalternativer.</string>
|
<string name="comp_game_environment_other_hint">Andre spillspesifikke Proton-, DXVK-, VKD3D-Proton- og synkroniseringsalternativer.</string>
|
||||||
<string name="comp_game_saved_next">Profilen til %1$s er lagret for neste spilløkt.</string>
|
<string name="comp_game_saved_next">Profilen til %1$s er lagret for neste spilløkt.</string>
|
||||||
<string name="game_no_steam_title">Start uten Steam</string>
|
<string name="game_no_steam_title">Start uten Steam</string>
|
||||||
<string name="game_no_steam_detail">For et spill som lukkes med én gang: starter det uten Protons Steam-oppsett. Steam-funksjoner som overlay er slått av for det.</string>
|
<string name="game_no_steam_detail">Åpner det i Proton/FEX uten Steam-klienten; Steam-funksjoner er av.</string>
|
||||||
<string name="game_no_steam_on">På</string>
|
<string name="game_no_steam_on">På</string>
|
||||||
|
<string name="game_steam_with">Starter med Steam</string>
|
||||||
|
<string name="game_steam_without">Starter uten Steam</string>
|
||||||
|
<string name="game_settings_card">Innstillinger</string>
|
||||||
|
<string name="game_settings_menu_title">Innstillinger for %1$s</string>
|
||||||
|
<string name="game_settings_default">Standard</string>
|
||||||
|
<string name="game_settings_default_detail">Setter alt som er endret for dette spillet, tilbake til appens innstillinger.</string>
|
||||||
|
<string name="game_sync_custom">Egendefinerte synkroniseringsvariabler</string>
|
||||||
<string name="comp_game_proton_live">Proton-valget til %1$s er lagret og tilgjengelig i Steam nå.</string>
|
<string name="comp_game_proton_live">Proton-valget til %1$s er lagret og tilgjengelig i Steam nå.</string>
|
||||||
<string name="comp_game_proton_next">Proton-valget til %1$s er lagret. Start Steam på nytt før du starter spillet.</string>
|
<string name="comp_game_proton_next">Proton-valget til %1$s er lagret. Start Steam på nytt før du starter spillet.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s kan ikke bruke den valgte Protonen på denne enheten.</string>
|
<string name="comp_game_proton_not_runnable">%1$s kan ikke bruke den valgte Protonen på denne enheten.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic-innlogging (EOS)</string>
|
<string name="epic_eos">Epic-innlogging (EOS)</string>
|
||||||
<string name="epic_offline">Start frakoblet</string>
|
<string name="epic_offline">Start frakoblet</string>
|
||||||
<string name="epic_resolve">Løs Epic-innlogging</string>
|
<string name="epic_resolve">Løs Epic-innlogging</string>
|
||||||
|
<string name="epic_client_version">Versjon %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installer Linux-kjøretiden først</string>
|
||||||
|
<string name="epic_client_checking">Ser etter nyeste versjon…</string>
|
||||||
|
<string name="epic_client_unpacking">Pakker ut…</string>
|
||||||
|
<string name="epic_client_unreachable">Nedlastingsserveren svarte ikke</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher trenger FEX, som Steam laster ned. Åpne launcheren igjen når Steam har startet.</string>
|
||||||
|
<string name="epic_client_download_failed">Nedlasting mislyktes: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Kunne ikke pakke ut launcheren: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher godtok ikke innloggingen. Start den på nytt fra launcheren.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Fjerner startprogrammet og frigjør plassen. Epic-spillene dine forblir installert og fungerer, og påloggingen beholdes til neste installasjon.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Den kjørende økten lukkes først.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher er avinstallert</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Noen filer for startprogrammet kunne ikke fjernes. Prøv igjen.</string>
|
||||||
|
<string name="epic_client_uninstalling">Vent til Epic Games Launcher er ferdig avinstallert</string>
|
||||||
<string name="store_cloud_saves">Skylagringer</string>
|
<string name="store_cloud_saves">Skylagringer</string>
|
||||||
<string name="cloud_upload">Last opp til skyen</string>
|
<string name="cloud_upload">Last opp til skyen</string>
|
||||||
<string name="cloud_download">Last ned fra skyen</string>
|
<string name="cloud_download">Last ned fra skyen</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Overige omgevingsopties per game voor Proton, DXVK, VKD3D-Proton en synchronisatie.</string>
|
<string name="comp_game_environment_other_hint">Overige omgevingsopties per game voor Proton, DXVK, VKD3D-Proton en synchronisatie.</string>
|
||||||
<string name="comp_game_saved_next">Het profiel van %1$s is opgeslagen voor de volgende start van de game.</string>
|
<string name="comp_game_saved_next">Het profiel van %1$s is opgeslagen voor de volgende start van de game.</string>
|
||||||
<string name="game_no_steam_title">Starten zonder Steam</string>
|
<string name="game_no_steam_title">Starten zonder Steam</string>
|
||||||
<string name="game_no_steam_detail">Voor een game die meteen sluit: start deze zonder Protons Steam-setup. Steam-functies zoals de overlay staan uit voor deze game.</string>
|
<string name="game_no_steam_detail">Opent het in Proton/FEX zonder de Steam-client; Steam-functies staan uit.</string>
|
||||||
<string name="game_no_steam_on">Aan</string>
|
<string name="game_no_steam_on">Aan</string>
|
||||||
|
<string name="game_steam_with">Start met Steam</string>
|
||||||
|
<string name="game_steam_without">Start zonder Steam</string>
|
||||||
|
<string name="game_settings_card">Instellingen</string>
|
||||||
|
<string name="game_settings_menu_title">Instellingen voor %1$s</string>
|
||||||
|
<string name="game_settings_default">Standaard</string>
|
||||||
|
<string name="game_settings_default_detail">Zet alles wat voor deze game is gewijzigd terug naar de instellingen van de app.</string>
|
||||||
|
<string name="game_sync_custom">Aangepaste synchronisatievariabelen</string>
|
||||||
<string name="comp_game_proton_live">De Proton-keuze van %1$s is opgeslagen en nu beschikbaar in Steam.</string>
|
<string name="comp_game_proton_live">De Proton-keuze van %1$s is opgeslagen en nu beschikbaar in Steam.</string>
|
||||||
<string name="comp_game_proton_next">De Proton-keuze van %1$s is opgeslagen. Start Steam opnieuw voordat je de game start.</string>
|
<string name="comp_game_proton_next">De Proton-keuze van %1$s is opgeslagen. Start Steam opnieuw voordat je de game start.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s kan de gekozen Proton op dit apparaat niet gebruiken.</string>
|
<string name="comp_game_proton_not_runnable">%1$s kan de gekozen Proton op dit apparaat niet gebruiken.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic-aanmelding (EOS)</string>
|
<string name="epic_eos">Epic-aanmelding (EOS)</string>
|
||||||
<string name="epic_offline">Offline starten</string>
|
<string name="epic_offline">Offline starten</string>
|
||||||
<string name="epic_resolve">Epic-aanmelding oplossen</string>
|
<string name="epic_resolve">Epic-aanmelding oplossen</string>
|
||||||
|
<string name="epic_client_version">Versie %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installeer eerst de Linux-runtime</string>
|
||||||
|
<string name="epic_client_checking">Nieuwste versie controleren…</string>
|
||||||
|
<string name="epic_client_unpacking">Uitpakken…</string>
|
||||||
|
<string name="epic_client_unreachable">De downloadserver reageerde niet</string>
|
||||||
|
<string name="epic_client_needs_fex">De Epic Games Launcher heeft FEX nodig, dat Steam downloadt. Open de launcher opnieuw zodra Steam is gestart.</string>
|
||||||
|
<string name="epic_client_download_failed">Downloaden mislukt: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Kan de launcher niet uitpakken: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher accepteerde de aanmelding niet. Start het opnieuw vanuit de launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">De lopende sessie wordt eerst gesloten.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher verwijderd</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Sommige launcherbestanden konden niet worden verwijderd. Probeer het opnieuw.</string>
|
||||||
|
<string name="epic_client_uninstalling">Wacht tot Epic Games Launcher klaar is met verwijderen</string>
|
||||||
<string name="store_cloud_saves">Cloudsaves</string>
|
<string name="store_cloud_saves">Cloudsaves</string>
|
||||||
<string name="cloud_upload">Uploaden naar cloud</string>
|
<string name="cloud_upload">Uploaden naar cloud</string>
|
||||||
<string name="cloud_download">Downloaden vanaf cloud</string>
|
<string name="cloud_download">Downloaden vanaf cloud</string>
|
||||||
|
|||||||
@@ -453,8 +453,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Inne opcje środowiska Proton, DXVK, VKD3D-Proton i synchronizacji dla poszczególnych gier.</string>
|
<string name="comp_game_environment_other_hint">Inne opcje środowiska Proton, DXVK, VKD3D-Proton i synchronizacji dla poszczególnych gier.</string>
|
||||||
<string name="comp_game_saved_next">Profil gry %1$s zapisano na następne uruchomienie gry.</string>
|
<string name="comp_game_saved_next">Profil gry %1$s zapisano na następne uruchomienie gry.</string>
|
||||||
<string name="game_no_steam_title">Uruchom bez Steam</string>
|
<string name="game_no_steam_title">Uruchom bez Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Otwiera w Proton/FEX bez klienta Steam; funkcje Steam są wyłączone.</string>
|
||||||
<string name="game_no_steam_on">Wł.</string>
|
<string name="game_no_steam_on">Wł.</string>
|
||||||
|
<string name="game_steam_with">Uruchamia się ze Steam</string>
|
||||||
|
<string name="game_steam_without">Uruchamia się bez Steam</string>
|
||||||
|
<string name="game_settings_card">Ustawienia</string>
|
||||||
|
<string name="game_settings_menu_title">Ustawienia: %1$s</string>
|
||||||
|
<string name="game_settings_default">Domyślne</string>
|
||||||
|
<string name="game_settings_default_detail">Przywraca wszystko, co zmieniono dla tej gry, do ustawień aplikacji.</string>
|
||||||
|
<string name="game_sync_custom">Własne zmienne synchronizacji</string>
|
||||||
<string name="comp_game_proton_live">Wybór Proton dla %1$s zapisano i jest już dostępny w Steam.</string>
|
<string name="comp_game_proton_live">Wybór Proton dla %1$s zapisano i jest już dostępny w Steam.</string>
|
||||||
<string name="comp_game_proton_next">Wybór Proton dla %1$s zapisano. Uruchom ponownie Steam przed uruchomieniem gry.</string>
|
<string name="comp_game_proton_next">Wybór Proton dla %1$s zapisano. Uruchom ponownie Steam przed uruchomieniem gry.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s nie może używać wybranego Proton na tym urządzeniu.</string>
|
<string name="comp_game_proton_not_runnable">%1$s nie może używać wybranego Proton na tym urządzeniu.</string>
|
||||||
@@ -2014,6 +2021,20 @@
|
|||||||
<string name="epic_eos">Logowanie Epic (EOS)</string>
|
<string name="epic_eos">Logowanie Epic (EOS)</string>
|
||||||
<string name="epic_offline">Uruchom offline</string>
|
<string name="epic_offline">Uruchom offline</string>
|
||||||
<string name="epic_resolve">Rozwiąż logowanie Epic</string>
|
<string name="epic_resolve">Rozwiąż logowanie Epic</string>
|
||||||
|
<string name="epic_client_version">Wersja %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Najpierw zainstaluj środowisko Linux</string>
|
||||||
|
<string name="epic_client_checking">Sprawdzanie najnowszej wersji…</string>
|
||||||
|
<string name="epic_client_unpacking">Rozpakowywanie…</string>
|
||||||
|
<string name="epic_client_unreachable">Serwer pobierania nie odpowiedział</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher wymaga FEX, który pobiera Steam. Otwórz launcher ponownie po uruchomieniu Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Pobieranie nie powiodło się: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Nie udało się rozpakować launchera: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher nie przyjął logowania. Uruchom go ponownie z launchera.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Usuwa launcher i zwalnia jego miejsce. Twoje gry z Epic pozostają zainstalowane i działają, a logowanie zostaje zachowane na potrzeby ponownej instalacji.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Najpierw zostanie zamknięta bieżąca sesja.</string>
|
||||||
|
<string name="epic_client_uninstalled">Odinstalowano Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Nie udało się usunąć niektórych plików launchera. Spróbuj ponownie.</string>
|
||||||
|
<string name="epic_client_uninstalling">Poczekaj, aż Epic Games Launcher zostanie odinstalowany</string>
|
||||||
<string name="store_cloud_saves">Zapisy w chmurze</string>
|
<string name="store_cloud_saves">Zapisy w chmurze</string>
|
||||||
<string name="cloud_upload">Wyślij do chmury</string>
|
<string name="cloud_upload">Wyślij do chmury</string>
|
||||||
<string name="cloud_download">Pobierz z chmury</string>
|
<string name="cloud_download">Pobierz z chmury</string>
|
||||||
|
|||||||
@@ -448,8 +448,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e sincronização.</string>
|
<string name="comp_game_environment_other_hint">Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e sincronização.</string>
|
||||||
<string name="comp_game_saved_next">O perfil de %1$s foi guardado para o próximo arranque do jogo.</string>
|
<string name="comp_game_saved_next">O perfil de %1$s foi guardado para o próximo arranque do jogo.</string>
|
||||||
<string name="game_no_steam_title">Iniciar sem o Steam</string>
|
<string name="game_no_steam_title">Iniciar sem o Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Abre no Proton/FEX sem o cliente do Steam; as funcionalidades do Steam ficam desligadas.</string>
|
||||||
<string name="game_no_steam_on">Ativado</string>
|
<string name="game_no_steam_on">Ativado</string>
|
||||||
|
<string name="game_steam_with">Inicia com o Steam</string>
|
||||||
|
<string name="game_steam_without">Inicia sem o Steam</string>
|
||||||
|
<string name="game_settings_card">Definições</string>
|
||||||
|
<string name="game_settings_menu_title">Definições de %1$s</string>
|
||||||
|
<string name="game_settings_default">Predefinição</string>
|
||||||
|
<string name="game_settings_default_detail">Repõe tudo o que foi alterado neste jogo nas definições da aplicação.</string>
|
||||||
|
<string name="game_sync_custom">Variáveis de sincronização personalizadas</string>
|
||||||
<string name="comp_game_proton_live">A escolha de Proton de %1$s foi guardada e já está disponível no Steam.</string>
|
<string name="comp_game_proton_live">A escolha de Proton de %1$s foi guardada e já está disponível no Steam.</string>
|
||||||
<string name="comp_game_proton_next">A escolha de Proton de %1$s foi guardada. Reinicie o Steam antes de iniciar o jogo.</string>
|
<string name="comp_game_proton_next">A escolha de Proton de %1$s foi guardada. Reinicie o Steam antes de iniciar o jogo.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s não pode utilizar o Proton selecionado neste dispositivo.</string>
|
<string name="comp_game_proton_not_runnable">%1$s não pode utilizar o Proton selecionado neste dispositivo.</string>
|
||||||
@@ -1982,6 +1989,20 @@
|
|||||||
<string name="epic_eos">Início de sessão Epic (EOS)</string>
|
<string name="epic_eos">Início de sessão Epic (EOS)</string>
|
||||||
<string name="epic_offline">Iniciar offline</string>
|
<string name="epic_offline">Iniciar offline</string>
|
||||||
<string name="epic_resolve">Resolver início de sessão Epic</string>
|
<string name="epic_resolve">Resolver início de sessão Epic</string>
|
||||||
|
<string name="epic_client_version">Versão %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Instale primeiro o ambiente de execução Linux</string>
|
||||||
|
<string name="epic_client_checking">A verificar a versão mais recente…</string>
|
||||||
|
<string name="epic_client_unpacking">A descompactar…</string>
|
||||||
|
<string name="epic_client_unreachable">O servidor de transferência não respondeu</string>
|
||||||
|
<string name="epic_client_needs_fex">O Epic Games Launcher precisa do FEX, que o Steam transfere. Abra novamente o launcher depois de o Steam iniciar.</string>
|
||||||
|
<string name="epic_client_download_failed">Falha na transferência: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Não foi possível descompactar o launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">O Epic Games Launcher não aceitou o início de sessão. Inicia-o novamente a partir do launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">A sessão em curso é fechada primeiro.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher desinstalado</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Não foi possível remover alguns ficheiros do launcher. Tenta novamente.</string>
|
||||||
|
<string name="epic_client_uninstalling">Aguarde que o Epic Games Launcher termine de ser desinstalado</string>
|
||||||
<string name="store_cloud_saves">Dados guardados na nuvem</string>
|
<string name="store_cloud_saves">Dados guardados na nuvem</string>
|
||||||
<string name="cloud_upload">Carregar para a nuvem</string>
|
<string name="cloud_upload">Carregar para a nuvem</string>
|
||||||
<string name="cloud_download">Transferir da nuvem</string>
|
<string name="cloud_download">Transferir da nuvem</string>
|
||||||
|
|||||||
@@ -448,8 +448,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e de sincronização.</string>
|
<string name="comp_game_environment_other_hint">Outras opções de ambiente por jogo do Proton, DXVK, VKD3D-Proton e de sincronização.</string>
|
||||||
<string name="comp_game_saved_next">O perfil de %1$s foi salvo para a próxima vez que o jogo for iniciado.</string>
|
<string name="comp_game_saved_next">O perfil de %1$s foi salvo para a próxima vez que o jogo for iniciado.</string>
|
||||||
<string name="game_no_steam_title">Iniciar sem o Steam</string>
|
<string name="game_no_steam_title">Iniciar sem o Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Abre no Proton/FEX sem o cliente do Steam; os recursos do Steam ficam desligados.</string>
|
||||||
<string name="game_no_steam_on">Ativado</string>
|
<string name="game_no_steam_on">Ativado</string>
|
||||||
|
<string name="game_steam_with">Inicia com o Steam</string>
|
||||||
|
<string name="game_steam_without">Inicia sem o Steam</string>
|
||||||
|
<string name="game_settings_card">Configurações</string>
|
||||||
|
<string name="game_settings_menu_title">Configurações de %1$s</string>
|
||||||
|
<string name="game_settings_default">Padrão</string>
|
||||||
|
<string name="game_settings_default_detail">Volta tudo o que foi alterado neste jogo para as configurações do app.</string>
|
||||||
|
<string name="game_sync_custom">Variáveis de sincronização personalizadas</string>
|
||||||
<string name="comp_game_proton_live">A escolha de Proton de %1$s foi salva e já está disponível na Steam.</string>
|
<string name="comp_game_proton_live">A escolha de Proton de %1$s foi salva e já está disponível na Steam.</string>
|
||||||
<string name="comp_game_proton_next">A escolha de Proton de %1$s foi salva. Reinicie a Steam antes de iniciar o jogo.</string>
|
<string name="comp_game_proton_next">A escolha de Proton de %1$s foi salva. Reinicie a Steam antes de iniciar o jogo.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s não pode usar o Proton selecionado neste dispositivo.</string>
|
<string name="comp_game_proton_not_runnable">%1$s não pode usar o Proton selecionado neste dispositivo.</string>
|
||||||
@@ -1982,6 +1989,20 @@
|
|||||||
<string name="epic_eos">Login da Epic (EOS)</string>
|
<string name="epic_eos">Login da Epic (EOS)</string>
|
||||||
<string name="epic_offline">Iniciar offline</string>
|
<string name="epic_offline">Iniciar offline</string>
|
||||||
<string name="epic_resolve">Resolver login da Epic</string>
|
<string name="epic_resolve">Resolver login da Epic</string>
|
||||||
|
<string name="epic_client_version">Versão %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Instale primeiro o runtime do Linux</string>
|
||||||
|
<string name="epic_client_checking">Verificando a versão mais recente…</string>
|
||||||
|
<string name="epic_client_unpacking">Descompactando…</string>
|
||||||
|
<string name="epic_client_unreachable">O servidor de download não respondeu</string>
|
||||||
|
<string name="epic_client_needs_fex">O Epic Games Launcher precisa do FEX, que o Steam baixa. Abra o launcher novamente depois que o Steam iniciar.</string>
|
||||||
|
<string name="epic_client_download_failed">Falha no download: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Não foi possível descompactar o launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">O Epic Games Launcher não aceitou o login. Inicie-o novamente a partir do launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">A sessão em andamento será fechada primeiro.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher desinstalado</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Não foi possível remover alguns arquivos do launcher. Tente novamente.</string>
|
||||||
|
<string name="epic_client_uninstalling">Aguarde o Epic Games Launcher terminar de ser desinstalado</string>
|
||||||
<string name="store_cloud_saves">Saves na nuvem</string>
|
<string name="store_cloud_saves">Saves na nuvem</string>
|
||||||
<string name="cloud_upload">Enviar para a nuvem</string>
|
<string name="cloud_upload">Enviar para a nuvem</string>
|
||||||
<string name="cloud_download">Baixar da nuvem</string>
|
<string name="cloud_download">Baixar da nuvem</string>
|
||||||
|
|||||||
@@ -451,8 +451,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Alte opțiuni de mediu Proton, DXVK, VKD3D-Proton și sincronizare, per joc.</string>
|
<string name="comp_game_environment_other_hint">Alte opțiuni de mediu Proton, DXVK, VKD3D-Proton și sincronizare, per joc.</string>
|
||||||
<string name="comp_game_saved_next">Profilul %1$s este salvat pentru următoarea lansare a jocului.</string>
|
<string name="comp_game_saved_next">Profilul %1$s este salvat pentru următoarea lansare a jocului.</string>
|
||||||
<string name="game_no_steam_title">Pornește fără Steam</string>
|
<string name="game_no_steam_title">Pornește fără Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Îl deschide în Proton/FEX fără clientul Steam; funcțiile Steam sunt oprite.</string>
|
||||||
<string name="game_no_steam_on">Activat</string>
|
<string name="game_no_steam_on">Activat</string>
|
||||||
|
<string name="game_steam_with">Pornește cu Steam</string>
|
||||||
|
<string name="game_steam_without">Pornește fără Steam</string>
|
||||||
|
<string name="game_settings_card">Setări</string>
|
||||||
|
<string name="game_settings_menu_title">Setări %1$s</string>
|
||||||
|
<string name="game_settings_default">Implicit</string>
|
||||||
|
<string name="game_settings_default_detail">Readuce tot ce s-a schimbat pentru acest joc la setările aplicației.</string>
|
||||||
|
<string name="game_sync_custom">Variabile de sincronizare personalizate</string>
|
||||||
<string name="comp_game_proton_live">Alegerea Proton pentru %1$s este salvată și disponibilă în Steam acum.</string>
|
<string name="comp_game_proton_live">Alegerea Proton pentru %1$s este salvată și disponibilă în Steam acum.</string>
|
||||||
<string name="comp_game_proton_next">Alegerea Proton pentru %1$s este salvată. Repornește Steam înainte de a lansa jocul.</string>
|
<string name="comp_game_proton_next">Alegerea Proton pentru %1$s este salvată. Repornește Steam înainte de a lansa jocul.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s nu poate folosi Proton-ul selectat pe acest dispozitiv.</string>
|
<string name="comp_game_proton_not_runnable">%1$s nu poate folosi Proton-ul selectat pe acest dispozitiv.</string>
|
||||||
@@ -1993,6 +2000,20 @@
|
|||||||
<string name="epic_eos">Autentificare Epic (EOS)</string>
|
<string name="epic_eos">Autentificare Epic (EOS)</string>
|
||||||
<string name="epic_offline">Lansează offline</string>
|
<string name="epic_offline">Lansează offline</string>
|
||||||
<string name="epic_resolve">Rezolvă autentificarea Epic</string>
|
<string name="epic_resolve">Rezolvă autentificarea Epic</string>
|
||||||
|
<string name="epic_client_version">Versiunea %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Instalează mai întâi mediul de execuție Linux</string>
|
||||||
|
<string name="epic_client_checking">Se verifică cea mai recentă versiune…</string>
|
||||||
|
<string name="epic_client_unpacking">Se dezarhivează…</string>
|
||||||
|
<string name="epic_client_unreachable">Serverul de descărcare nu a răspuns</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher are nevoie de FEX, pe care îl descarcă Steam. Deschide din nou launcherul după ce pornește Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Descărcarea a eșuat: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Launcherul nu a putut fi dezarhivat: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher nu a acceptat autentificarea. Pornește-l din nou din launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Elimină lansatorul și îi eliberează spațiul. Jocurile Epic rămân instalate și funcționează, iar autentificarea se păstrează pentru o reinstalare.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Sesiunea în curs se închide mai întâi.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher a fost dezinstalat</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Unele fișiere ale lansatorului nu au putut fi eliminate. Încearcă din nou.</string>
|
||||||
|
<string name="epic_client_uninstalling">Așteaptă să se termine dezinstalarea Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">Salvări în cloud</string>
|
<string name="store_cloud_saves">Salvări în cloud</string>
|
||||||
<string name="cloud_upload">Încarcă în cloud</string>
|
<string name="cloud_upload">Încarcă în cloud</string>
|
||||||
<string name="cloud_download">Descarcă din cloud</string>
|
<string name="cloud_download">Descarcă din cloud</string>
|
||||||
|
|||||||
@@ -450,8 +450,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Другие параметры среды Proton, DXVK, VKD3D-Proton и синхронизации для отдельной игры.</string>
|
<string name="comp_game_environment_other_hint">Другие параметры среды Proton, DXVK, VKD3D-Proton и синхронизации для отдельной игры.</string>
|
||||||
<string name="comp_game_saved_next">Профиль %1$s сохранен и применится при следующем запуске игры.</string>
|
<string name="comp_game_saved_next">Профиль %1$s сохранен и применится при следующем запуске игры.</string>
|
||||||
<string name="game_no_steam_title">Запуск без Steam</string>
|
<string name="game_no_steam_title">Запуск без Steam</string>
|
||||||
<string name="game_no_steam_detail">Для игры, которая сразу закрывается: запускает её без настройки Steam от Proton. Функции Steam, такие как оверлей, для неё отключены.</string>
|
<string name="game_no_steam_detail">Открывает в Proton/FEX без клиента Steam; функции Steam отключены.</string>
|
||||||
<string name="game_no_steam_on">Включено</string>
|
<string name="game_no_steam_on">Включено</string>
|
||||||
|
<string name="game_steam_with">Запускается со Steam</string>
|
||||||
|
<string name="game_steam_without">Запускается без Steam</string>
|
||||||
|
<string name="game_settings_card">Настройки</string>
|
||||||
|
<string name="game_settings_menu_title">Настройки %1$s</string>
|
||||||
|
<string name="game_settings_default">По умолчанию</string>
|
||||||
|
<string name="game_settings_default_detail">Возвращает всё, что изменено для этой игры, к настройкам приложения.</string>
|
||||||
|
<string name="game_sync_custom">Свои переменные синхронизации</string>
|
||||||
<string name="comp_game_proton_live">Выбор Proton для %1$s сохранен и уже доступен в Steam.</string>
|
<string name="comp_game_proton_live">Выбор Proton для %1$s сохранен и уже доступен в Steam.</string>
|
||||||
<string name="comp_game_proton_next">Выбор Proton для %1$s сохранен. Перезапустите Steam перед запуском игры.</string>
|
<string name="comp_game_proton_next">Выбор Proton для %1$s сохранен. Перезапустите Steam перед запуском игры.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s не может использовать выбранный Proton на этом устройстве.</string>
|
<string name="comp_game_proton_not_runnable">%1$s не может использовать выбранный Proton на этом устройстве.</string>
|
||||||
@@ -2011,6 +2018,20 @@
|
|||||||
<string name="epic_eos">Вход в Epic (EOS)</string>
|
<string name="epic_eos">Вход в Epic (EOS)</string>
|
||||||
<string name="epic_offline">Запускать офлайн</string>
|
<string name="epic_offline">Запускать офлайн</string>
|
||||||
<string name="epic_resolve">Устранить проблему входа в Epic</string>
|
<string name="epic_resolve">Устранить проблему входа в Epic</string>
|
||||||
|
<string name="epic_client_version">Версия %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Сначала установите среду выполнения Linux</string>
|
||||||
|
<string name="epic_client_checking">Проверка последней версии…</string>
|
||||||
|
<string name="epic_client_unpacking">Распаковка…</string>
|
||||||
|
<string name="epic_client_unreachable">Сервер загрузки не ответил</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher нужен FEX, который скачивает Steam. Откройте лаунчер снова после запуска Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Ошибка загрузки: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Не удалось распаковать лаунчер: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher не принял вход. Запустите его снова из лаунчера.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Удаляет лаунчер и освобождает место. Ваши игры Epic остаются установленными и работают, а вход сохраняется для повторной установки.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Сначала будет закрыт текущий сеанс.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher удалён</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Не удалось удалить некоторые файлы лаунчера. Попробуйте ещё раз.</string>
|
||||||
|
<string name="epic_client_uninstalling">Дождитесь завершения удаления Epic Games Launcher</string>
|
||||||
<string name="store_cloud_saves">Облачные сохранения</string>
|
<string name="store_cloud_saves">Облачные сохранения</string>
|
||||||
<string name="cloud_upload">Загрузить в облако</string>
|
<string name="cloud_upload">Загрузить в облако</string>
|
||||||
<string name="cloud_download">Скачать из облака</string>
|
<string name="cloud_download">Скачать из облака</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Övriga miljöalternativ per spel för Proton, DXVK, VKD3D-Proton och synkronisering.</string>
|
<string name="comp_game_environment_other_hint">Övriga miljöalternativ per spel för Proton, DXVK, VKD3D-Proton och synkronisering.</string>
|
||||||
<string name="comp_game_saved_next">%1$s:s profil är sparad för nästa spelstart.</string>
|
<string name="comp_game_saved_next">%1$s:s profil är sparad för nästa spelstart.</string>
|
||||||
<string name="game_no_steam_title">Starta utan Steam</string>
|
<string name="game_no_steam_title">Starta utan Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Öppnar det i Proton/FEX utan Steam-klienten; Steam-funktioner är av.</string>
|
||||||
<string name="game_no_steam_on">På</string>
|
<string name="game_no_steam_on">På</string>
|
||||||
|
<string name="game_steam_with">Startar med Steam</string>
|
||||||
|
<string name="game_steam_without">Startar utan Steam</string>
|
||||||
|
<string name="game_settings_card">Inställningar</string>
|
||||||
|
<string name="game_settings_menu_title">Inställningar för %1$s</string>
|
||||||
|
<string name="game_settings_default">Standard</string>
|
||||||
|
<string name="game_settings_default_detail">Återställer allt som ändrats för det här spelet till appens inställningar.</string>
|
||||||
|
<string name="game_sync_custom">Anpassade synkvariabler</string>
|
||||||
<string name="comp_game_proton_live">%1$s:s Proton-val är sparat och tillgängligt i Steam nu.</string>
|
<string name="comp_game_proton_live">%1$s:s Proton-val är sparat och tillgängligt i Steam nu.</string>
|
||||||
<string name="comp_game_proton_next">%1$s:s Proton-val är sparat. Starta om Steam innan du startar spelet.</string>
|
<string name="comp_game_proton_next">%1$s:s Proton-val är sparat. Starta om Steam innan du startar spelet.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s kan inte använda den valda Proton på den här enheten.</string>
|
<string name="comp_game_proton_not_runnable">%1$s kan inte använda den valda Proton på den här enheten.</string>
|
||||||
@@ -1972,6 +1979,20 @@
|
|||||||
<string name="epic_eos">Epic-inloggning (EOS)</string>
|
<string name="epic_eos">Epic-inloggning (EOS)</string>
|
||||||
<string name="epic_offline">Starta offline</string>
|
<string name="epic_offline">Starta offline</string>
|
||||||
<string name="epic_resolve">Lös Epic-inloggningen</string>
|
<string name="epic_resolve">Lös Epic-inloggningen</string>
|
||||||
|
<string name="epic_client_version">Version %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Installera Linux-körmiljön först</string>
|
||||||
|
<string name="epic_client_checking">Kontrollerar senaste versionen…</string>
|
||||||
|
<string name="epic_client_unpacking">Packar upp…</string>
|
||||||
|
<string name="epic_client_unreachable">Nedladdningsservern svarade inte</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher behöver FEX, som Steam laddar ner. Öppna launchern igen när Steam har startat.</string>
|
||||||
|
<string name="epic_client_download_failed">Nedladdningen misslyckades: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Det gick inte att packa upp launchern: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher accepterade inte inloggningen. Starta den igen från launchern.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Tar bort startprogrammet och frigör utrymmet. Dina Epic-spel förblir installerade och fungerar, och inloggningen sparas tills du installerar det igen.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Den pågående sessionen stängs först.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher har avinstallerats</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Vissa filer för startprogrammet kunde inte tas bort. Försök igen.</string>
|
||||||
|
<string name="epic_client_uninstalling">Vänta tills Epic Games Launcher har avinstallerats</string>
|
||||||
<string name="store_cloud_saves">Molnsparfiler</string>
|
<string name="store_cloud_saves">Molnsparfiler</string>
|
||||||
<string name="cloud_upload">Ladda upp till molnet</string>
|
<string name="cloud_upload">Ladda upp till molnet</string>
|
||||||
<string name="cloud_download">Hämta från molnet</string>
|
<string name="cloud_download">Hämta från molnet</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">ตัวเลือกสภาพแวดล้อมอื่น ๆ ของ Proton, DXVK, VKD3D-Proton และการซิงค์เฉพาะเกม</string>
|
<string name="comp_game_environment_other_hint">ตัวเลือกสภาพแวดล้อมอื่น ๆ ของ Proton, DXVK, VKD3D-Proton และการซิงค์เฉพาะเกม</string>
|
||||||
<string name="comp_game_saved_next">บันทึกโปรไฟล์ของ %1$s ไว้สำหรับการเปิดเกมครั้งถัดไป</string>
|
<string name="comp_game_saved_next">บันทึกโปรไฟล์ของ %1$s ไว้สำหรับการเปิดเกมครั้งถัดไป</string>
|
||||||
<string name="game_no_steam_title">เริ่มโดยไม่ใช้ Steam</string>
|
<string name="game_no_steam_title">เริ่มโดยไม่ใช้ Steam</string>
|
||||||
<string name="game_no_steam_detail">สำหรับเกมที่ปิดตัวทันที ใช้เริ่มเกมโดยไม่ตั้งค่า Steam ของ Proton ฟีเจอร์ของ Steam เช่นโอเวอร์เลย์จะถูกปิดสำหรับเกมนี้</string>
|
<string name="game_no_steam_detail">เปิดใน Proton/FEX โดยไม่ใช้ไคลเอนต์ Steam ฟีเจอร์ของ Steam จะปิดอยู่</string>
|
||||||
<string name="game_no_steam_on">เปิด</string>
|
<string name="game_no_steam_on">เปิด</string>
|
||||||
|
<string name="game_steam_with">เริ่มพร้อม Steam</string>
|
||||||
|
<string name="game_steam_without">เริ่มโดยไม่ใช้ Steam</string>
|
||||||
|
<string name="game_settings_card">การตั้งค่า</string>
|
||||||
|
<string name="game_settings_menu_title">การตั้งค่า %1$s</string>
|
||||||
|
<string name="game_settings_default">ค่าเริ่มต้น</string>
|
||||||
|
<string name="game_settings_default_detail">คืนทุกอย่างที่เปลี่ยนสำหรับเกมนี้กลับเป็นการตั้งค่าของแอป</string>
|
||||||
|
<string name="game_sync_custom">ตัวแปรซิงก์แบบกำหนดเอง</string>
|
||||||
<string name="comp_game_proton_live">บันทึกการเลือก Proton ของ %1$s แล้ว และพร้อมใช้งานใน Steam ตอนนี้</string>
|
<string name="comp_game_proton_live">บันทึกการเลือก Proton ของ %1$s แล้ว และพร้อมใช้งานใน Steam ตอนนี้</string>
|
||||||
<string name="comp_game_proton_next">บันทึกการเลือก Proton ของ %1$s แล้ว รีสตาร์ท Steam ก่อนเปิดเกม</string>
|
<string name="comp_game_proton_next">บันทึกการเลือก Proton ของ %1$s แล้ว รีสตาร์ท Steam ก่อนเปิดเกม</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s ไม่สามารถใช้ Proton ที่เลือกบนอุปกรณ์นี้ได้</string>
|
<string name="comp_game_proton_not_runnable">%1$s ไม่สามารถใช้ Proton ที่เลือกบนอุปกรณ์นี้ได้</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">ลงชื่อเข้าใช้ Epic (EOS)</string>
|
<string name="epic_eos">ลงชื่อเข้าใช้ Epic (EOS)</string>
|
||||||
<string name="epic_offline">เปิดแบบออฟไลน์</string>
|
<string name="epic_offline">เปิดแบบออฟไลน์</string>
|
||||||
<string name="epic_resolve">แก้ไขการลงชื่อเข้าใช้ Epic</string>
|
<string name="epic_resolve">แก้ไขการลงชื่อเข้าใช้ Epic</string>
|
||||||
|
<string name="epic_client_version">เวอร์ชัน %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">ติดตั้งรันไทม์ Linux ก่อน</string>
|
||||||
|
<string name="epic_client_checking">กำลังตรวจสอบเวอร์ชันล่าสุด…</string>
|
||||||
|
<string name="epic_client_unpacking">กำลังแตกไฟล์…</string>
|
||||||
|
<string name="epic_client_unreachable">เซิร์ฟเวอร์ดาวน์โหลดไม่ตอบสนอง</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher ต้องใช้ FEX ซึ่ง Steam จะดาวน์โหลดให้ เปิด launcher อีกครั้งหลังจาก Steam เริ่มทำงานแล้ว</string>
|
||||||
|
<string name="epic_client_download_failed">ดาวน์โหลดล้มเหลว: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">แตกไฟล์ตัวเรียกใช้งานไม่ได้: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher ไม่ยอมรับการลงชื่อเข้าใช้ เริ่มใหม่อีกครั้งจากตัวเรียกใช้งาน</string>
|
||||||
|
<string name="epic_client_uninstall_body">ลบตัวเปิดเกมและคืนพื้นที่ เกม Epic ของคุณยังติดตั้งอยู่และเล่นได้ และการลงชื่อเข้าใช้จะถูกเก็บไว้สำหรับการติดตั้งครั้งถัดไป</string>
|
||||||
|
<string name="epic_client_uninstall_stops">เซสชันที่กำลังทำงานจะถูกปิดก่อน</string>
|
||||||
|
<string name="epic_client_uninstalled">ถอนการติดตั้ง Epic Games Launcher แล้ว</string>
|
||||||
|
<string name="epic_client_uninstall_failed">ไม่สามารถลบไฟล์ของตัวเปิดเกมบางไฟล์ได้ ลองอีกครั้ง</string>
|
||||||
|
<string name="epic_client_uninstalling">รอให้ถอนการติดตั้ง Epic Games Launcher เสร็จก่อน</string>
|
||||||
<string name="store_cloud_saves">เซฟบนคลาวด์</string>
|
<string name="store_cloud_saves">เซฟบนคลาวด์</string>
|
||||||
<string name="cloud_upload">อัปโหลดขึ้นคลาวด์</string>
|
<string name="cloud_upload">อัปโหลดขึ้นคลาวด์</string>
|
||||||
<string name="cloud_download">ดาวน์โหลดจากคลาวด์</string>
|
<string name="cloud_download">ดาวน์โหลดจากคลาวด์</string>
|
||||||
|
|||||||
@@ -446,8 +446,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Oyuna özel diğer Proton, DXVK, VKD3D-Proton ve senkronizasyon ortam seçenekleri.</string>
|
<string name="comp_game_environment_other_hint">Oyuna özel diğer Proton, DXVK, VKD3D-Proton ve senkronizasyon ortam seçenekleri.</string>
|
||||||
<string name="comp_game_saved_next">%1$s profili kaydedildi; oyun bir sonraki başlatıldığında uygulanır.</string>
|
<string name="comp_game_saved_next">%1$s profili kaydedildi; oyun bir sonraki başlatıldığında uygulanır.</string>
|
||||||
<string name="game_no_steam_title">Steam olmadan başlat</string>
|
<string name="game_no_steam_title">Steam olmadan başlat</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Steam istemcisi olmadan Proton/FEX\'te açar; Steam özellikleri kapalıdır.</string>
|
||||||
<string name="game_no_steam_on">Açık</string>
|
<string name="game_no_steam_on">Açık</string>
|
||||||
|
<string name="game_steam_with">Steam ile başlar</string>
|
||||||
|
<string name="game_steam_without">Steam olmadan başlar</string>
|
||||||
|
<string name="game_settings_card">Ayarlar</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s Ayarları</string>
|
||||||
|
<string name="game_settings_default">Varsayılan</string>
|
||||||
|
<string name="game_settings_default_detail">Bu oyun için değiştirilen her şeyi uygulamanın ayarlarına döndürür.</string>
|
||||||
|
<string name="game_sync_custom">Özel eşitleme değişkenleri</string>
|
||||||
<string name="comp_game_proton_live">%1$s için Proton seçimi kaydedildi ve şimdi Steam\'de kullanılabilir.</string>
|
<string name="comp_game_proton_live">%1$s için Proton seçimi kaydedildi ve şimdi Steam\'de kullanılabilir.</string>
|
||||||
<string name="comp_game_proton_next">%1$s için Proton seçimi kaydedildi. Oyunu başlatmadan önce Steam\'i yeniden başlatın.</string>
|
<string name="comp_game_proton_next">%1$s için Proton seçimi kaydedildi. Oyunu başlatmadan önce Steam\'i yeniden başlatın.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s bu cihazda seçili Proton\'u kullanamaz.</string>
|
<string name="comp_game_proton_not_runnable">%1$s bu cihazda seçili Proton\'u kullanamaz.</string>
|
||||||
@@ -1961,6 +1968,20 @@
|
|||||||
<string name="epic_eos">Epic oturum açma (EOS)</string>
|
<string name="epic_eos">Epic oturum açma (EOS)</string>
|
||||||
<string name="epic_offline">Çevrimdışı başlat</string>
|
<string name="epic_offline">Çevrimdışı başlat</string>
|
||||||
<string name="epic_resolve">Epic oturum açmayı düzelt</string>
|
<string name="epic_resolve">Epic oturum açmayı düzelt</string>
|
||||||
|
<string name="epic_client_version">Sürüm %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Önce Linux çalışma zamanını yükleyin</string>
|
||||||
|
<string name="epic_client_checking">En son sürüm kontrol ediliyor…</string>
|
||||||
|
<string name="epic_client_unpacking">Ayıklanıyor…</string>
|
||||||
|
<string name="epic_client_unreachable">İndirme sunucusu yanıt vermedi</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher, Steam\'in indirdiği FEX\'e ihtiyaç duyar. Steam başladıktan sonra başlatıcıyı yeniden açın.</string>
|
||||||
|
<string name="epic_client_download_failed">İndirme başarısız oldu: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Başlatıcı ayıklanamadı: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher oturum açmayı kabul etmedi. Başlatıcıdan yeniden başlatın.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Önce çalışan oturum kapatılır.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher kaldırıldı</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Başlatıcının bazı dosyaları kaldırılamadı. Tekrar deneyin.</string>
|
||||||
|
<string name="epic_client_uninstalling">Epic Games Launcher\'ın kaldırılmasının bitmesini bekleyin</string>
|
||||||
<string name="store_cloud_saves">Bulut kayıtları</string>
|
<string name="store_cloud_saves">Bulut kayıtları</string>
|
||||||
<string name="cloud_upload">Buluta yükle</string>
|
<string name="cloud_upload">Buluta yükle</string>
|
||||||
<string name="cloud_download">Buluttan indir</string>
|
<string name="cloud_download">Buluttan indir</string>
|
||||||
|
|||||||
@@ -453,8 +453,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Інші параметри середовища Proton, DXVK, VKD3D-Proton і синхронізації для окремої гри.</string>
|
<string name="comp_game_environment_other_hint">Інші параметри середовища Proton, DXVK, VKD3D-Proton і синхронізації для окремої гри.</string>
|
||||||
<string name="comp_game_saved_next">Профіль %1$s збережено для наступного запуску гри.</string>
|
<string name="comp_game_saved_next">Профіль %1$s збережено для наступного запуску гри.</string>
|
||||||
<string name="game_no_steam_title">Запустити без Steam</string>
|
<string name="game_no_steam_title">Запустити без Steam</string>
|
||||||
<string name="game_no_steam_detail">Для гри, яка відразу закривається: запускає її без налаштування Steam від Proton. Функції Steam, як-от оверлей, для неї вимкнені.</string>
|
<string name="game_no_steam_detail">Відкриває в Proton/FEX без клієнта Steam; функції Steam вимкнено.</string>
|
||||||
<string name="game_no_steam_on">Увімкнено</string>
|
<string name="game_no_steam_on">Увімкнено</string>
|
||||||
|
<string name="game_steam_with">Запускається зі Steam</string>
|
||||||
|
<string name="game_steam_without">Запускається без Steam</string>
|
||||||
|
<string name="game_settings_card">Налаштування</string>
|
||||||
|
<string name="game_settings_menu_title">Налаштування %1$s</string>
|
||||||
|
<string name="game_settings_default">За замовчуванням</string>
|
||||||
|
<string name="game_settings_default_detail">Повертає все, що змінено для цієї гри, до налаштувань застосунку.</string>
|
||||||
|
<string name="game_sync_custom">Власні змінні синхронізації</string>
|
||||||
<string name="comp_game_proton_live">Вибір Proton для %1$s збережено, і він уже доступний у Steam.</string>
|
<string name="comp_game_proton_live">Вибір Proton для %1$s збережено, і він уже доступний у Steam.</string>
|
||||||
<string name="comp_game_proton_next">Вибір Proton для %1$s збережено. Перезапустіть Steam перед запуском гри.</string>
|
<string name="comp_game_proton_next">Вибір Proton для %1$s збережено. Перезапустіть Steam перед запуском гри.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s не може використовувати вибраний Proton на цьому пристрої.</string>
|
<string name="comp_game_proton_not_runnable">%1$s не може використовувати вибраний Proton на цьому пристрої.</string>
|
||||||
@@ -2014,6 +2021,20 @@
|
|||||||
<string name="epic_eos">Вхід Epic (EOS)</string>
|
<string name="epic_eos">Вхід Epic (EOS)</string>
|
||||||
<string name="epic_offline">Запускати офлайн</string>
|
<string name="epic_offline">Запускати офлайн</string>
|
||||||
<string name="epic_resolve">Виправити вхід Epic</string>
|
<string name="epic_resolve">Виправити вхід Epic</string>
|
||||||
|
<string name="epic_client_version">Версія %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Спершу встановіть середовище Linux</string>
|
||||||
|
<string name="epic_client_checking">Перевірка найновішої версії…</string>
|
||||||
|
<string name="epic_client_unpacking">Розпакування…</string>
|
||||||
|
<string name="epic_client_unreachable">Сервер завантаження не відповів</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher потребує FEX, який завантажує Steam. Відкрийте лаунчер знову після запуску Steam.</string>
|
||||||
|
<string name="epic_client_download_failed">Помилка завантаження: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Не вдалося розпакувати лаунчер: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher не прийняв вхід. Запустіть його знову з лаунчера.</string>
|
||||||
|
<string name="epic_client_uninstall_body">Видаляє лаунчер і звільняє місце. Ваші ігри Epic залишаються встановленими й працюють, а вхід зберігається для повторного встановлення.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Спершу буде закрито поточний сеанс.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher видалено</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Не вдалося видалити деякі файли лаунчера. Спробуйте ще раз.</string>
|
||||||
|
<string name="epic_client_uninstalling">Зачекайте, доки Epic Games Launcher буде видалено</string>
|
||||||
<string name="store_cloud_saves">Хмарні збереження</string>
|
<string name="store_cloud_saves">Хмарні збереження</string>
|
||||||
<string name="cloud_upload">Завантажити в хмару</string>
|
<string name="cloud_upload">Завантажити в хмару</string>
|
||||||
<string name="cloud_download">Завантажити з хмари</string>
|
<string name="cloud_download">Завантажити з хмари</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">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.</string>
|
<string name="comp_game_environment_other_hint">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.</string>
|
||||||
<string name="comp_game_saved_next">Hồ sơ của %1$s được lưu cho lần khởi chạy game kế tiếp.</string>
|
<string name="comp_game_saved_next">Hồ sơ của %1$s được lưu cho lần khởi chạy game kế tiếp.</string>
|
||||||
<string name="game_no_steam_title">Khởi động không cần Steam</string>
|
<string name="game_no_steam_title">Khởi động không cần Steam</string>
|
||||||
<string name="game_no_steam_detail">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.</string>
|
<string name="game_no_steam_detail">Mở trong Proton/FEX mà không có ứng dụng Steam; các tính năng Steam bị tắt.</string>
|
||||||
<string name="game_no_steam_on">Bật</string>
|
<string name="game_no_steam_on">Bật</string>
|
||||||
|
<string name="game_steam_with">Khởi động cùng Steam</string>
|
||||||
|
<string name="game_steam_without">Khởi động không cần Steam</string>
|
||||||
|
<string name="game_settings_card">Thiết lập</string>
|
||||||
|
<string name="game_settings_menu_title">Thiết lập %1$s</string>
|
||||||
|
<string name="game_settings_default">Mặc định</string>
|
||||||
|
<string name="game_settings_default_detail">Đưa mọi thứ đã thay đổi cho trò chơi này về thiết lập của ứng dụng.</string>
|
||||||
|
<string name="game_sync_custom">Biến đồng bộ tùy chỉnh</string>
|
||||||
<string name="comp_game_proton_live">Lựa chọn Proton của %1$s đã được lưu và khả dụng ngay trong Steam.</string>
|
<string name="comp_game_proton_live">Lựa chọn Proton của %1$s đã được lưu và khả dụng ngay trong Steam.</string>
|
||||||
<string name="comp_game_proton_next">Lựa chọn Proton của %1$s đã được lưu. Khởi động lại Steam trước khi chạy game.</string>
|
<string name="comp_game_proton_next">Lựa chọn Proton của %1$s đã được lưu. Khởi động lại Steam trước khi chạy game.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s không thể dùng Proton đã chọn trên thiết bị này.</string>
|
<string name="comp_game_proton_not_runnable">%1$s không thể dùng Proton đã chọn trên thiết bị này.</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Đăng nhập Epic (EOS)</string>
|
<string name="epic_eos">Đăng nhập Epic (EOS)</string>
|
||||||
<string name="epic_offline">Khởi chạy ngoại tuyến</string>
|
<string name="epic_offline">Khởi chạy ngoại tuyến</string>
|
||||||
<string name="epic_resolve">Giải quyết đăng nhập Epic</string>
|
<string name="epic_resolve">Giải quyết đăng nhập Epic</string>
|
||||||
|
<string name="epic_client_version">Phiên bản %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Cài runtime Linux trước</string>
|
||||||
|
<string name="epic_client_checking">Đang kiểm tra phiên bản mới nhất…</string>
|
||||||
|
<string name="epic_client_unpacking">Đang giải nén…</string>
|
||||||
|
<string name="epic_client_unreachable">Máy chủ tải xuống không phản hồi</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher cần FEX, do Steam tải xuống. Hãy mở lại launcher sau khi Steam đã khởi động.</string>
|
||||||
|
<string name="epic_client_download_failed">Tải xuống thất bại: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Không thể giải nén launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher không nhận đăng nhập. Khởi động lại từ launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">Phiên đang chạy sẽ được đóng trước.</string>
|
||||||
|
<string name="epic_client_uninstalled">Đã gỡ cài đặt Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Không thể xóa một số tệp của trình khởi chạy. Hãy thử lại.</string>
|
||||||
|
<string name="epic_client_uninstalling">Hãy chờ Epic Games Launcher gỡ cài đặt xong</string>
|
||||||
<string name="store_cloud_saves">Tệp lưu trên cloud</string>
|
<string name="store_cloud_saves">Tệp lưu trên cloud</string>
|
||||||
<string name="cloud_upload">Tải lên cloud</string>
|
<string name="cloud_upload">Tải lên cloud</string>
|
||||||
<string name="cloud_download">Tải xuống từ cloud</string>
|
<string name="cloud_download">Tải xuống từ cloud</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">其他按游戏设置的 Proton、DXVK、VKD3D-Proton 及同步环境选项。</string>
|
<string name="comp_game_environment_other_hint">其他按游戏设置的 Proton、DXVK、VKD3D-Proton 及同步环境选项。</string>
|
||||||
<string name="comp_game_saved_next">%1$s 的配置档案已保存,将在下次启动游戏时生效。</string>
|
<string name="comp_game_saved_next">%1$s 的配置档案已保存,将在下次启动游戏时生效。</string>
|
||||||
<string name="game_no_steam_title">不启动 Steam 直接开始</string>
|
<string name="game_no_steam_title">不启动 Steam 直接开始</string>
|
||||||
<string name="game_no_steam_detail">适用于一开就立即关闭的游戏:不经过 Proton 的 Steam 设置来启动。叠加层等 Steam 功能对该游戏会关闭。</string>
|
<string name="game_no_steam_detail">在不启动 Steam 客户端的情况下用 Proton/FEX 打开;Steam 功能关闭。</string>
|
||||||
<string name="game_no_steam_on">开</string>
|
<string name="game_no_steam_on">开</string>
|
||||||
|
<string name="game_steam_with">随 Steam 启动</string>
|
||||||
|
<string name="game_steam_without">不通过 Steam 启动</string>
|
||||||
|
<string name="game_settings_card">设置</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s 设置</string>
|
||||||
|
<string name="game_settings_default">默认</string>
|
||||||
|
<string name="game_settings_default_detail">将为此游戏更改的所有内容恢复为应用的设置。</string>
|
||||||
|
<string name="game_sync_custom">自定义同步变量</string>
|
||||||
<string name="comp_game_proton_live">%1$s 的 Proton 选择已保存,现已在 Steam 中可用。</string>
|
<string name="comp_game_proton_live">%1$s 的 Proton 选择已保存,现已在 Steam 中可用。</string>
|
||||||
<string name="comp_game_proton_next">%1$s 的 Proton 选择已保存。启动游戏前请重启 Steam。</string>
|
<string name="comp_game_proton_next">%1$s 的 Proton 选择已保存。启动游戏前请重启 Steam。</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s 无法在此设备上使用所选的 Proton。</string>
|
<string name="comp_game_proton_not_runnable">%1$s 无法在此设备上使用所选的 Proton。</string>
|
||||||
@@ -2027,6 +2034,20 @@
|
|||||||
<string name="epic_eos">Epic 登录(EOS)</string>
|
<string name="epic_eos">Epic 登录(EOS)</string>
|
||||||
<string name="epic_offline">离线启动</string>
|
<string name="epic_offline">离线启动</string>
|
||||||
<string name="epic_resolve">解决 Epic 登录问题</string>
|
<string name="epic_resolve">解决 Epic 登录问题</string>
|
||||||
|
<string name="epic_client_version">版本 %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">请先安装 Linux 运行时</string>
|
||||||
|
<string name="epic_client_checking">正在检查最新版本…</string>
|
||||||
|
<string name="epic_client_unpacking">正在解包…</string>
|
||||||
|
<string name="epic_client_unreachable">下载服务器未响应</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher 需要 FEX,FEX 由 Steam 下载。请在 Steam 启动后再次打开启动器。</string>
|
||||||
|
<string name="epic_client_download_failed">下载失败:%1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">无法解包启动器:%1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher 未接受登录。请从启动器重新启动它。</string>
|
||||||
|
<string name="epic_client_uninstall_body">移除启动器并释放其空间。你的 Epic 游戏仍保持安装并可正常运行,登录信息也会保留,以便重新安装时使用。</string>
|
||||||
|
<string name="epic_client_uninstall_stops">将先关闭正在运行的会话。</string>
|
||||||
|
<string name="epic_client_uninstalled">已卸载 Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">部分启动器文件无法删除。请重试。</string>
|
||||||
|
<string name="epic_client_uninstalling">请等待 Epic Games Launcher 卸载完成</string>
|
||||||
<string name="store_cloud_saves">云存档</string>
|
<string name="store_cloud_saves">云存档</string>
|
||||||
<string name="cloud_upload">上传到云端</string>
|
<string name="cloud_upload">上传到云端</string>
|
||||||
<string name="cloud_download">从云端下载</string>
|
<string name="cloud_download">从云端下载</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">其他個別遊戲的 Proton、DXVK、VKD3D-Proton 及同步環境選項。</string>
|
<string name="comp_game_environment_other_hint">其他個別遊戲的 Proton、DXVK、VKD3D-Proton 及同步環境選項。</string>
|
||||||
<string name="comp_game_saved_next">已儲存 %1$s 的設定檔,下次啟動遊戲時生效。</string>
|
<string name="comp_game_saved_next">已儲存 %1$s 的設定檔,下次啟動遊戲時生效。</string>
|
||||||
<string name="game_no_steam_title">不啟動 Steam 直接開始</string>
|
<string name="game_no_steam_title">不啟動 Steam 直接開始</string>
|
||||||
<string name="game_no_steam_detail">適用於一開就立即關閉的遊戲:不經過 Proton 的 Steam 設定來啟動。疊加層等 Steam 功能對該遊戲會關閉。</string>
|
<string name="game_no_steam_detail">在不啟動 Steam 用戶端的情況下用 Proton/FEX 開啟;Steam 功能關閉。</string>
|
||||||
<string name="game_no_steam_on">開</string>
|
<string name="game_no_steam_on">開</string>
|
||||||
|
<string name="game_steam_with">隨 Steam 啟動</string>
|
||||||
|
<string name="game_steam_without">不經 Steam 啟動</string>
|
||||||
|
<string name="game_settings_card">設定</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s 設定</string>
|
||||||
|
<string name="game_settings_default">預設</string>
|
||||||
|
<string name="game_settings_default_detail">將為此遊戲更改的所有內容還原為應用程式的設定。</string>
|
||||||
|
<string name="game_sync_custom">自訂同步變數</string>
|
||||||
<string name="comp_game_proton_live">已儲存 %1$s 的 Proton 選擇,現已可在 Steam 中使用。</string>
|
<string name="comp_game_proton_live">已儲存 %1$s 的 Proton 選擇,現已可在 Steam 中使用。</string>
|
||||||
<string name="comp_game_proton_next">已儲存 %1$s 的 Proton 選擇。請先重新啟動 Steam,再啟動遊戲。</string>
|
<string name="comp_game_proton_next">已儲存 %1$s 的 Proton 選擇。請先重新啟動 Steam,再啟動遊戲。</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s 無法在此裝置上使用所選的 Proton。</string>
|
<string name="comp_game_proton_not_runnable">%1$s 無法在此裝置上使用所選的 Proton。</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Epic 登入(EOS)</string>
|
<string name="epic_eos">Epic 登入(EOS)</string>
|
||||||
<string name="epic_offline">離線啟動</string>
|
<string name="epic_offline">離線啟動</string>
|
||||||
<string name="epic_resolve">解決 Epic 登入問題</string>
|
<string name="epic_resolve">解決 Epic 登入問題</string>
|
||||||
|
<string name="epic_client_version">版本 %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">請先安裝 Linux 運行環境</string>
|
||||||
|
<string name="epic_client_checking">正在檢查最新版本…</string>
|
||||||
|
<string name="epic_client_unpacking">正在解壓…</string>
|
||||||
|
<string name="epic_client_unreachable">下載伺服器沒有回應</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher 需要 FEX,FEX 由 Steam 下載。請在 Steam 啟動後再次開啟啟動器。</string>
|
||||||
|
<string name="epic_client_download_failed">下載失敗:%1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">無法解壓啟動器:%1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher 沒有接受登入。請從啟動器重新啟動它。</string>
|
||||||
|
<string name="epic_client_uninstall_body">移除啟動器並釋放其空間。你的 Epic 遊戲會保持安裝並可正常運作,登入資料亦會保留,方便重新安裝時使用。</string>
|
||||||
|
<string name="epic_client_uninstall_stops">會先關閉正在運行的工作階段。</string>
|
||||||
|
<string name="epic_client_uninstalled">已解除安裝 Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">部分啟動器檔案無法刪除。請再試一次。</string>
|
||||||
|
<string name="epic_client_uninstalling">請等待 Epic Games Launcher 解除安裝完成</string>
|
||||||
<string name="store_cloud_saves">雲端存檔</string>
|
<string name="store_cloud_saves">雲端存檔</string>
|
||||||
<string name="cloud_upload">上載至雲端</string>
|
<string name="cloud_upload">上載至雲端</string>
|
||||||
<string name="cloud_download">從雲端下載</string>
|
<string name="cloud_download">從雲端下載</string>
|
||||||
|
|||||||
@@ -447,8 +447,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">其他個別遊戲的 Proton、DXVK、VKD3D-Proton 與同步環境選項。</string>
|
<string name="comp_game_environment_other_hint">其他個別遊戲的 Proton、DXVK、VKD3D-Proton 與同步環境選項。</string>
|
||||||
<string name="comp_game_saved_next">已儲存 %1$s 的設定檔,下次啟動遊戲時生效。</string>
|
<string name="comp_game_saved_next">已儲存 %1$s 的設定檔,下次啟動遊戲時生效。</string>
|
||||||
<string name="game_no_steam_title">不啟動 Steam 直接開始</string>
|
<string name="game_no_steam_title">不啟動 Steam 直接開始</string>
|
||||||
<string name="game_no_steam_detail">適用於一開就立即關閉的遊戲:不經過 Proton 的 Steam 設定來啟動。疊加層等 Steam 功能對這款遊戲會關閉。</string>
|
<string name="game_no_steam_detail">在不啟動 Steam 用戶端的情況下用 Proton/FEX 開啟;Steam 功能關閉。</string>
|
||||||
<string name="game_no_steam_on">開</string>
|
<string name="game_no_steam_on">開</string>
|
||||||
|
<string name="game_steam_with">隨 Steam 啟動</string>
|
||||||
|
<string name="game_steam_without">不經 Steam 啟動</string>
|
||||||
|
<string name="game_settings_card">設定</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s 設定</string>
|
||||||
|
<string name="game_settings_default">預設</string>
|
||||||
|
<string name="game_settings_default_detail">將為此遊戲變更的所有內容還原為應用程式的設定。</string>
|
||||||
|
<string name="game_sync_custom">自訂同步變數</string>
|
||||||
<string name="comp_game_proton_live">已儲存 %1$s 的 Proton 選擇,現在即可在 Steam 中使用。</string>
|
<string name="comp_game_proton_live">已儲存 %1$s 的 Proton 選擇,現在即可在 Steam 中使用。</string>
|
||||||
<string name="comp_game_proton_next">已儲存 %1$s 的 Proton 選擇。啟動遊戲前請重新啟動 Steam。</string>
|
<string name="comp_game_proton_next">已儲存 %1$s 的 Proton 選擇。啟動遊戲前請重新啟動 Steam。</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s 無法在這部裝置上使用所選的 Proton。</string>
|
<string name="comp_game_proton_not_runnable">%1$s 無法在這部裝置上使用所選的 Proton。</string>
|
||||||
@@ -1951,6 +1958,20 @@
|
|||||||
<string name="epic_eos">Epic 登入(EOS)</string>
|
<string name="epic_eos">Epic 登入(EOS)</string>
|
||||||
<string name="epic_offline">離線啟動</string>
|
<string name="epic_offline">離線啟動</string>
|
||||||
<string name="epic_resolve">解決 Epic 登入問題</string>
|
<string name="epic_resolve">解決 Epic 登入問題</string>
|
||||||
|
<string name="epic_client_version">版本 %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">請先安裝 Linux 執行環境</string>
|
||||||
|
<string name="epic_client_checking">正在檢查最新版本…</string>
|
||||||
|
<string name="epic_client_unpacking">正在解壓縮…</string>
|
||||||
|
<string name="epic_client_unreachable">下載伺服器沒有回應</string>
|
||||||
|
<string name="epic_client_needs_fex">Epic Games Launcher 需要 FEX,FEX 由 Steam 下載。請在 Steam 啟動後再次開啟啟動器。</string>
|
||||||
|
<string name="epic_client_download_failed">下載失敗:%1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">無法解壓縮啟動器:%1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">Epic Games Launcher 沒有接受登入。請從啟動器重新啟動它。</string>
|
||||||
|
<string name="epic_client_uninstall_body">移除啟動器並釋放其空間。你的 Epic 遊戲會保持安裝並可正常運作,登入資訊也會保留,以便重新安裝時使用。</string>
|
||||||
|
<string name="epic_client_uninstall_stops">會先關閉正在執行的工作階段。</string>
|
||||||
|
<string name="epic_client_uninstalled">已解除安裝 Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_uninstall_failed">部分啟動器檔案無法刪除。請再試一次。</string>
|
||||||
|
<string name="epic_client_uninstalling">請等待 Epic Games Launcher 解除安裝完成</string>
|
||||||
<string name="store_cloud_saves">雲端存檔</string>
|
<string name="store_cloud_saves">雲端存檔</string>
|
||||||
<string name="cloud_upload">上傳到雲端</string>
|
<string name="cloud_upload">上傳到雲端</string>
|
||||||
<string name="cloud_download">從雲端下載</string>
|
<string name="cloud_download">從雲端下載</string>
|
||||||
|
|||||||
@@ -449,8 +449,15 @@
|
|||||||
<string name="comp_game_environment_other_hint">Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options.</string>
|
<string name="comp_game_environment_other_hint">Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options.</string>
|
||||||
<string name="comp_game_saved_next">%1$s\'s profile is saved for the next game launch.</string>
|
<string name="comp_game_saved_next">%1$s\'s profile is saved for the next game launch.</string>
|
||||||
<string name="game_no_steam_title">Start without Steam</string>
|
<string name="game_no_steam_title">Start without Steam</string>
|
||||||
<string name="game_no_steam_detail">For a game that closes right away: starts it without Proton\'s Steam setup. Steam features such as the overlay are off for it.</string>
|
<string name="game_no_steam_detail">Opens it in Proton/FEX without the Steam client; Steam features are off.</string>
|
||||||
<string name="game_no_steam_on">On</string>
|
<string name="game_no_steam_on">On</string>
|
||||||
|
<string name="game_steam_with">Starts with Steam</string>
|
||||||
|
<string name="game_steam_without">Starts without Steam</string>
|
||||||
|
<string name="game_settings_card">Settings</string>
|
||||||
|
<string name="game_settings_menu_title">%1$s Settings</string>
|
||||||
|
<string name="game_settings_default">Default</string>
|
||||||
|
<string name="game_settings_default_detail">Puts everything changed for this game back to the app\'s settings.</string>
|
||||||
|
<string name="game_sync_custom">Custom sync variables</string>
|
||||||
<string name="comp_game_proton_live">%1$s\'s Proton choice is saved and available in Steam now.</string>
|
<string name="comp_game_proton_live">%1$s\'s Proton choice is saved and available in Steam now.</string>
|
||||||
<string name="comp_game_proton_next">%1$s\'s Proton choice is saved. Restart Steam before launching the game.</string>
|
<string name="comp_game_proton_next">%1$s\'s Proton choice is saved. Restart Steam before launching the game.</string>
|
||||||
<string name="comp_game_proton_not_runnable">%1$s cannot use the selected Proton on this device.</string>
|
<string name="comp_game_proton_not_runnable">%1$s cannot use the selected Proton on this device.</string>
|
||||||
@@ -2011,6 +2018,21 @@
|
|||||||
<string name="epic_eos">Epic sign-in (EOS)</string>
|
<string name="epic_eos">Epic sign-in (EOS)</string>
|
||||||
<string name="epic_offline">Launch offline</string>
|
<string name="epic_offline">Launch offline</string>
|
||||||
<string name="epic_resolve">Resolve Epic sign-in</string>
|
<string name="epic_resolve">Resolve Epic sign-in</string>
|
||||||
|
<string name="epic_client_title" translatable="false">Epic Games Launcher</string>
|
||||||
|
<string name="epic_client_version">Version %1$s</string>
|
||||||
|
<string name="epic_client_no_runtime">Install the Linux runtime first</string>
|
||||||
|
<string name="epic_client_checking">Checking for the latest version…</string>
|
||||||
|
<string name="epic_client_unpacking">Unpacking…</string>
|
||||||
|
<string name="epic_client_unreachable">The download server did not answer</string>
|
||||||
|
<string name="epic_client_needs_fex">The Epic Games Launcher needs FEX, which Steam downloads. Open the launcher again after Steam has started.</string>
|
||||||
|
<string name="epic_client_download_failed">Download failed: %1$s</string>
|
||||||
|
<string name="epic_client_unpack_failed">Could not unpack the launcher: %1$s</string>
|
||||||
|
<string name="epic_client_sign_in_failed">The Epic Games Launcher did not take the sign-in. Start it again from the launcher.</string>
|
||||||
|
<string name="epic_client_uninstall_body">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.</string>
|
||||||
|
<string name="epic_client_uninstall_stops">The running session closes first.</string>
|
||||||
|
<string name="epic_client_uninstalled">Epic Games Launcher uninstalled</string>
|
||||||
|
<string name="epic_client_uninstall_failed">Some launcher files could not be removed. Try again.</string>
|
||||||
|
<string name="epic_client_uninstalling">Wait for the Epic Games Launcher to finish uninstalling</string>
|
||||||
<string name="store_cloud_saves">Cloud saves</string>
|
<string name="store_cloud_saves">Cloud saves</string>
|
||||||
<string name="cloud_upload">Upload to cloud</string>
|
<string name="cloud_upload">Upload to cloud</string>
|
||||||
<string name="cloud_download">Download from cloud</string>
|
<string name="cloud_download">Download from cloud</string>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -122,4 +122,26 @@ class GameEnvironmentTest {
|
|||||||
assertEquals("value", compatibility.games.getValue("42")["CUSTOM"])
|
assertEquals("value", compatibility.games.getValue("42")["CUSTOM"])
|
||||||
assertEquals("COMPATIBILITY", GameEnvironment.gameFexPreset(compatibility, "42"))
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,23 @@ class AddedGamesStoreScanTest {
|
|||||||
assertEquals(Store.EPIC.id, game.source)
|
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() {
|
@Test fun anOlderSidecarsSteamSwitchIsIgnoredEveryInstallIsListed() {
|
||||||
val folder = install(Store.AMAZON, "amzn1.adg.product.x", "Yakuza 0", "Yakuza0.exe")
|
val folder = install(Store.AMAZON, "amzn1.adg.product.x", "Yakuza 0", "Yakuza0.exe")
|
||||||
val file = StoreGameSidecar.file(folder)
|
val file = StoreGameSidecar.file(folder)
|
||||||
|
|||||||
@@ -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<String>()
|
||||||
|
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<String>(), 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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<String>(), 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<Int, Pair<Float, Float>>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Pair<String, Any?>>(), 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,12 +113,34 @@ class StoreGameSidecarTest {
|
|||||||
assertFalse(text.contains("DD_AUTH"))
|
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() {
|
@Test fun gogLauncherHasNoEpicBlockAndIsOnlyWrittenWhenNeeded() {
|
||||||
val plain = StoreGameSidecar(Store.GOG, "1", "T", "game.exe")
|
val plain = StoreGameSidecar(Store.GOG, "1", "T", "game.exe")
|
||||||
val text = StoreLaunch.launcherText(plain.copy(args = listOf("-windowed")))
|
val text = StoreLaunch.launcherText(plain.copy(args = listOf("-windowed")))
|
||||||
assertFalse(text.contains("exchangecode"))
|
assertFalse(text.contains("exchangecode"))
|
||||||
assertTrue(text.contains("\"%~dp0game.exe\" -windowed\r\n"))
|
assertTrue(text.contains("\"%~dp0game.exe\" -windowed\r\n"))
|
||||||
assertFalse(text.contains("DD_X"))
|
assertFalse(text.contains("DD_X"))
|
||||||
|
assertFalse(text.contains("droiddeck-epic-args"))
|
||||||
assertEquals("\"two words\"", StoreLaunch.quoteArg("two words"))
|
assertEquals("\"two words\"", StoreLaunch.quoteArg("two words"))
|
||||||
assertEquals("-x=\"a b\"", StoreLaunch.quoteArg("-x=\"a b\""))
|
assertEquals("-x=\"a b\"", StoreLaunch.quoteArg("-x=\"a b\""))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ class StoreInstallRootTest {
|
|||||||
assertFalse(StoreInstallRoot.targets(app).first().removable)
|
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() {
|
@Test fun anExistingInstallWinsOverAFreshFolderUnderTheChosenRoot() {
|
||||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||||
val existing = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "Old Name").apply { mkdirs() }
|
val existing = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "Old Name").apply { mkdirs() }
|
||||||
|
|||||||
@@ -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<String>(), EpicClient.expiredCookies(null))
|
||||||
|
assertEquals(emptyList<String>(), 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<org.json.JSONObject>() 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,14 @@ for target in x86_64/faultreport i386/faultreport x86_64/thunkaudit; do
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
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)
|
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,-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 \
|
gcc -m64 -shared -nostdlib -Wl,-z,nodelete -Wl,--no-as-needed \
|
||||||
|
|||||||
@@ -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.top<below||r.bottom>innerHeight||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<score&&!windowButton(c)&&!hidden(c)){score=d;best=c;}}return best;};"
|
||||||
|
"const settle=()=>{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);
|
||||||
|
}
|
||||||
@@ -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)
|
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):
|
def game_pids(app_id):
|
||||||
"""Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their
|
"""Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their
|
||||||
environment, and Proton's wine processes inherit it)."""
|
environment, and Proton's wine processes inherit it)."""
|
||||||
@@ -858,6 +883,28 @@ def main(argv):
|
|||||||
return 3
|
return 3
|
||||||
print("droiddeck-agent: Steam overview is ready for shortcut app %d" % prepared["appId"])
|
print("droiddeck-agent: Steam overview is ready for shortcut app %d" % prepared["appId"])
|
||||||
return 0
|
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 <cgameid> [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)
|
serve(Path(base) / "agent", focus="--no-focus" not in argv)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
[ -n "${LD_PRELOAD:-}" ] && export DROIDDECK_STEAM_PRELOAD="$LD_PRELOAD"
|
||||||
|
unset LD_PRELOAD LD_LIBRARY_PATH
|
||||||
|
exec /usr/bin/python3 -I /usr/local/bin/droiddeck-epic run
|
||||||
@@ -563,13 +563,32 @@ def want_fex():
|
|||||||
write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n")
|
write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
XATTR_ONLY_NOISE = re.compile(r"^(proot (error|warning)|Failed to exec memfd|Further error messages of this type are suppressed)")
|
||||||
|
|
||||||
|
|
||||||
|
def only_xattrs_failed(errors):
|
||||||
|
seen = False
|
||||||
|
for line in errors.splitlines():
|
||||||
|
if "write_xattr" in line:
|
||||||
|
seen = True
|
||||||
|
elif re.search(r"fail|could not|cannot|error", line, re.I) and not XATTR_ONLY_NOISE.match(line):
|
||||||
|
return False
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def extract(image):
|
def extract(image):
|
||||||
image = os.path.abspath(image)
|
image = os.path.abspath(image)
|
||||||
clear_extracted()
|
clear_extracted()
|
||||||
x86 = elf_arch(image) in X86.values()
|
x86 = elf_arch(image) in X86.values()
|
||||||
if os.access(URUNTIME, os.X_OK):
|
if os.access(URUNTIME, os.X_OK):
|
||||||
status = subprocess.call([URUNTIME, "--appimage-extract"], stdout=subprocess.DEVNULL,
|
run = subprocess.run([URUNTIME, "--appimage-extract"], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
env=dict(os.environ, TARGET_APPIMAGE=image))
|
env=dict(os.environ, TARGET_APPIMAGE=image))
|
||||||
|
errors = run.stderr.decode("utf-8", "replace")
|
||||||
|
sys.stderr.write(errors)
|
||||||
|
status = run.returncode
|
||||||
|
if status == 2 and only_xattrs_failed(errors) and extracted() and os.path.lexists("squashfs-root/AppRun"):
|
||||||
|
say("uruntime could not keep the image's extended attributes; the files are unpacked")
|
||||||
|
status = 0
|
||||||
if status == 0 and extracted():
|
if status == 0 and extracted():
|
||||||
if x86:
|
if x86:
|
||||||
want_fex()
|
want_fex()
|
||||||
@@ -586,7 +605,7 @@ def extract(image):
|
|||||||
unsquashfs = shutil.which("unsquashfs")
|
unsquashfs = shutil.which("unsquashfs")
|
||||||
if unsquashfs:
|
if unsquashfs:
|
||||||
clear_extracted()
|
clear_extracted()
|
||||||
status = subprocess.call([unsquashfs, "-q", "-n", "-o", str(appimage_offset(image)), "-d", "squashfs-root", image],
|
status = subprocess.call([unsquashfs, "-q", "-n", "-no-xattrs", "-o", str(appimage_offset(image)), "-d", "squashfs-root", image],
|
||||||
stdout=subprocess.DEVNULL)
|
stdout=subprocess.DEVNULL)
|
||||||
if status == 0:
|
if status == 0:
|
||||||
if x86:
|
if x86:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SYNC_KEYS = ("BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
|
SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
|
||||||
SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
|
SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
|
||||||
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
|
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
|
||||||
|
|
||||||
@@ -251,36 +251,6 @@ def force_ssbs(command, env):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def umu_capable(proton):
|
|
||||||
"""Whether this Proton's script has umu's launch without Steam: GE-Proton, Proton-CachyOS and
|
|
||||||
DroidDeck-Proton run umu.exe in place of steam.exe when UMU_ID is set; Valve's Proton does not."""
|
|
||||||
try:
|
|
||||||
with open(proton, encoding="utf-8", errors="replace") as source:
|
|
||||||
text = source.read()
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return "UMU_ID" in text and "umu.exe" in text
|
|
||||||
|
|
||||||
|
|
||||||
def start_without_steam(command, env, app_id):
|
|
||||||
"""DROIDDECK_NO_STEAM=1 (a game's Start without Steam): launch the game without Proton's
|
|
||||||
steam.exe, for games that close right away because of what it sets up. A Proton with umu's
|
|
||||||
launch gets UMU_ID - Proton still sets up the prefix, and a Steam game keeps its protonfixes
|
|
||||||
under its own id; any other Proton runs the game straight in the prefix (runinprefix)."""
|
|
||||||
if env.get("DROIDDECK_NO_STEAM", "").lower() not in ("1", "on", "true", "yes"):
|
|
||||||
return command, env
|
|
||||||
if len(command) < 3 or command[1] != "waitforexitandrun":
|
|
||||||
return command, env
|
|
||||||
if umu_capable(command[0]):
|
|
||||||
if not env.get("UMU_ID"):
|
|
||||||
steam_game = app_id is not None and int(app_id) < 2**31
|
|
||||||
env = dict(env, UMU_ID=app_id if steam_game else "0")
|
|
||||||
print("droiddeck-game-env: starting without Steam (umu.exe in place of steam.exe, UMU_ID=%s)" % env["UMU_ID"], file=sys.stderr)
|
|
||||||
return command, env
|
|
||||||
print("droiddeck-game-env: starting without Steam (this Proton has no umu launch: runinprefix)", file=sys.stderr)
|
|
||||||
return [command[0], "runinprefix"] + command[2:], env
|
|
||||||
|
|
||||||
|
|
||||||
EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGNORECASE)
|
EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
@@ -340,7 +310,6 @@ def main():
|
|||||||
if mic_enabled == "0":
|
if mic_enabled == "0":
|
||||||
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
|
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
|
||||||
env = force_ssbs(command, env)
|
env = force_ssbs(command, env)
|
||||||
command, env = start_without_steam(command, env, app_id)
|
|
||||||
sync = load_sync()
|
sync = load_sync()
|
||||||
launch, launch_env = select_sync(command, env, sync)
|
launch, launch_env = select_sync(command, env, sync)
|
||||||
if launch[0] != command[0]:
|
if launch[0] != command[0]:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import importlib.util
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@@ -24,6 +26,8 @@ INTERFACE = NAME + ".Manager"
|
|||||||
UPOWER = "org.freedesktop.UPower"
|
UPOWER = "org.freedesktop.UPower"
|
||||||
UPOWER_ROOT = "/org/freedesktop/UPower"
|
UPOWER_ROOT = "/org/freedesktop/UPower"
|
||||||
NATIVE_PREPARE_MS = 1500
|
NATIVE_PREPARE_MS = 1500
|
||||||
|
AGENT = Path(__file__).resolve().with_name("droiddeck-agent")
|
||||||
|
ASLEEP_WAIT = 6
|
||||||
INTROSPECTION = """
|
INTROSPECTION = """
|
||||||
<node>
|
<node>
|
||||||
<interface name="org.freedesktop.login1.Manager">
|
<interface name="org.freedesktop.login1.Manager">
|
||||||
@@ -80,6 +84,7 @@ class LoginManager:
|
|||||||
if name == "CanSuspend":
|
if name == "CanSuspend":
|
||||||
reply.return_value(GLib.Variant("(s)", ("yes",)))
|
reply.return_value(GLib.Variant("(s)", ("yes",)))
|
||||||
elif name in ("Suspend", "SuspendWithFlags"):
|
elif name in ("Suspend", "SuspendWithFlags"):
|
||||||
|
print("== login1: %s.%s asked by %s" % (interface.rsplit(".", 1)[-1], name, sender), flush=True)
|
||||||
if self.pending:
|
if self.pending:
|
||||||
reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing")
|
reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing")
|
||||||
return
|
return
|
||||||
@@ -149,7 +154,7 @@ class LoginManager:
|
|||||||
if file.get_basename() == self.wake.name or (other and other.get_basename() == self.wake.name):
|
if file.get_basename() == self.wake.name or (other and other.get_basename() == self.wake.name):
|
||||||
if self.wake.exists():
|
if self.wake.exists():
|
||||||
self.wake.unlink(missing_ok=True)
|
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):
|
if file.get_basename() == self.state.name or (other and other.get_basename() == self.state.name):
|
||||||
self.check_state()
|
self.check_state()
|
||||||
|
|
||||||
@@ -171,6 +176,19 @@ class LoginManager:
|
|||||||
self.resume()
|
self.resume()
|
||||||
return False
|
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):
|
def resume(self, force=False):
|
||||||
if self.pending is None:
|
if self.pending is None:
|
||||||
if force:
|
if force:
|
||||||
@@ -195,6 +213,15 @@ class LoginManager:
|
|||||||
self.notify(False)
|
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():
|
def load_netmanager():
|
||||||
path = Path(__file__).resolve().with_name("droiddeck-netmanager")
|
path = Path(__file__).resolve().with_name("droiddeck-netmanager")
|
||||||
loader = importlib.machinery.SourceFileLoader("droiddeck_netmanager", str(path))
|
loader = importlib.machinery.SourceFileLoader("droiddeck_netmanager", str(path))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Runs a Windows program with the Proton the Steam client has chosen for it.
|
"""Runs a Windows program with the Proton the Steam client has chosen for it.
|
||||||
|
|
||||||
droiddeck-proton-run [--window] PROGRAM [ARGS...]
|
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
|
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
|
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
|
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.
|
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.
|
--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 json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import runpy
|
import runpy
|
||||||
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
@@ -249,6 +254,81 @@ def locate(program, root):
|
|||||||
os.path.dirname(program), root, "own")
|
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):
|
def steam_launches(target, program, root):
|
||||||
"""Whether a launch option of the Steam game starts [program]: then Steam should start it."""
|
"""Whether a launch option of the Steam game starts [program]: then Steam should start it."""
|
||||||
if target.kind != "steam":
|
if target.kind != "steam":
|
||||||
@@ -292,24 +372,52 @@ def arguments(program, args):
|
|||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
window = False
|
window = False
|
||||||
|
app, start, options = None, None, ""
|
||||||
while argv and argv[0].startswith("--"):
|
while argv and argv[0].startswith("--"):
|
||||||
option = argv.pop(0)
|
option = argv.pop(0)
|
||||||
if option == "--window":
|
if option == "--window":
|
||||||
window = True
|
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 == "--":
|
elif option == "--":
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
say("unknown option %s" % option)
|
say("unknown option %s" % option)
|
||||||
return 2
|
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...]")
|
say("usage: droiddeck-proton-run [--window] PROGRAM [ARGS...]")
|
||||||
return 2
|
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
|
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)
|
available = tools(root)
|
||||||
name, why = choose_tool(target.appid, mapping(root), available)
|
name, why = choose_tool(target.appid, mapping(root), available)
|
||||||
if name is None:
|
if name is None:
|
||||||
@@ -318,13 +426,15 @@ def main(argv):
|
|||||||
folder, command = available[name]
|
folder, command = available[name]
|
||||||
os.makedirs(target.compatdata, exist_ok=True)
|
os.makedirs(target.compatdata, exist_ok=True)
|
||||||
env = environment(os.environ, target, folder, root, window)
|
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:
|
try:
|
||||||
os.chdir(target.start)
|
os.chdir(target.start)
|
||||||
except OSError:
|
except OSError:
|
||||||
os.chdir(os.path.dirname(program))
|
os.chdir(os.path.dirname(program))
|
||||||
entry = os.path.join(folder, command)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -301,6 +301,24 @@ case $mode in
|
|||||||
taskset -cp "$cores" $$ >/dev/null 2>&1 || true
|
taskset -cp "$cores" $$ >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 || true
|
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=()
|
fex=()
|
||||||
if [ -x /usr/local/bin/droiddeck-fex ]; then
|
if [ -x /usr/local/bin/droiddeck-fex ]; then
|
||||||
case $(od -An -tu2 -j18 -N2 "$target" 2>/dev/null | tr -d ' ') in
|
case $(od -An -tu2 -j18 -N2 "$target" 2>/dev/null | tr -d ' ') in
|
||||||
@@ -311,17 +329,17 @@ case $mode in
|
|||||||
*.appimage)
|
*.appimage)
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
chmod +x "$target" 2>/dev/null
|
chmod +x "$target" 2>/dev/null
|
||||||
exec "${fex[@]}" "$target" "$@"
|
run_program "${fex[@]}" "$target" "$@"
|
||||||
;;
|
;;
|
||||||
*.sh)
|
*.sh)
|
||||||
if [ -x /usr/local/bin/droiddeck-fex ]; then
|
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
|
fi
|
||||||
exec bash "$target" "$@"
|
run_program bash "$target" "$@"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
chmod +x "$target" 2>/dev/null
|
chmod +x "$target" 2>/dev/null
|
||||||
cd "$(dirname "$target")" && exec "${fex[@]}" "$target" "$@"
|
cd "$(dirname "$target")" && run_program "${fex[@]}" "$target" "$@"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -495,6 +513,9 @@ case $mode in
|
|||||||
if [ "$pv" != "$app" ]; then
|
if [ "$pv" != "$app" ]; then
|
||||||
app=$pv
|
app=$pv
|
||||||
echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)"
|
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
|
fi
|
||||||
[ "$restore" = 1 ] || continue
|
[ "$restore" = 1 ] || continue
|
||||||
case $pv in ''|*[!0-9]*) prev=; continue ;; esac
|
case $pv in ''|*[!0-9]*) prev=; continue ;; esac
|
||||||
@@ -514,6 +535,44 @@ case $mode in
|
|||||||
esac
|
esac
|
||||||
done < <(xprop -spy -root "${atoms[@]}" 2>/dev/null)
|
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
|
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
|
||||||
_bl_held=
|
_bl_held=
|
||||||
_bl_uninstall=
|
_bl_uninstall=
|
||||||
@@ -539,44 +598,7 @@ case $mode in
|
|||||||
fi
|
fi
|
||||||
if [ -n "$_bl_held" ]; then
|
if [ -n "$_bl_held" ]; then
|
||||||
set -- "${_bl_rest[@]}"
|
set -- "${_bl_rest[@]}"
|
||||||
(
|
steam_hold_launch "$_bl_held" &
|
||||||
_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
|
|
||||||
) &
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -s "$HOME/.bl-autolaunch" ]; then
|
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[*]} $*"
|
echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-ozone-platform=x11 ${_bl_cef[*]} $*"
|
||||||
steam_updates=0
|
steam_updates=0
|
||||||
rm -f "$HOME/.bl-stop-requested" "$STEAMOS_STEAM_SHUTDOWN_SENTINEL" "$STEAMOS_STEAM_REBOOT_SENTINEL"
|
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() {
|
steam_bring_forward() {
|
||||||
local end=$((SECONDS + 600)) behind=0 line name value focused other n
|
local end=$((SECONDS + 600)) behind=0 line name value focused other n
|
||||||
while [ "$SECONDS" -lt "$end" ]; do
|
while [ "$SECONDS" -lt "$end" ]; do
|
||||||
@@ -766,6 +863,8 @@ BUSCONF
|
|||||||
rm -f "$lossless_asked"
|
rm -f "$lossless_asked"
|
||||||
[ "${BL_LOSSLESS_OWNED:-0}" != 1 ] && lossless_left=60
|
[ "${BL_LOSSLESS_OWNED:-0}" != 1 ] && lossless_left=60
|
||||||
declare -A _bl_pinned=()
|
declare -A _bl_pinned=()
|
||||||
|
declare -A _bl_ran=()
|
||||||
|
_bl_exited=
|
||||||
compat_stamp=$HOME/.bl-compat-beat
|
compat_stamp=$HOME/.bl-compat-beat
|
||||||
seed_stamp=$HOME/.bl-seed-beat
|
seed_stamp=$HOME/.bl-seed-beat
|
||||||
rm -f "$compat_stamp" "$seed_stamp"
|
rm -f "$compat_stamp" "$seed_stamp"
|
||||||
@@ -827,7 +926,7 @@ BUSCONF
|
|||||||
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
|
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
|
||||||
}; then
|
}; then
|
||||||
echo "== steam: launching requested game $game_id in the running client"
|
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
|
[ -n "${forward_pid:-}" ] && kill "$forward_pid" 2>/dev/null
|
||||||
steam_bring_forward &
|
steam_bring_forward &
|
||||||
forward_pid=$!
|
forward_pid=$!
|
||||||
@@ -842,6 +941,7 @@ BUSCONF
|
|||||||
tick=$((tick + 1))
|
tick=$((tick + 1))
|
||||||
[ "$((tick % 10))" -eq 0 ] || continue
|
[ "$((tick % 10))" -eq 0 ] || continue
|
||||||
beat=$((beat + 1))
|
beat=$((beat + 1))
|
||||||
|
steam_check_exits
|
||||||
if [ "$beat" -le 720 ] && [ -n "${BL_CLIENT_CPUS:-}" ]; then
|
if [ "$beat" -le 720 ] && [ -n "${BL_CLIENT_CPUS:-}" ]; then
|
||||||
for _d in /proc/[0-9]*; do
|
for _d in /proc/[0-9]*; do
|
||||||
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
|
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
|
||||||
@@ -887,8 +987,9 @@ BUSCONF
|
|||||||
done
|
done
|
||||||
) &
|
) &
|
||||||
stop_watcher=$!
|
stop_watcher=$!
|
||||||
|
rm -f "$HOME/.bl-restart-game" "$HOME/.bl-restarted-for"
|
||||||
while :; do
|
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
|
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: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
|
||||||
echo "$steam_channel" > "$steam_beta"
|
echo "$steam_channel" > "$steam_beta"
|
||||||
@@ -939,6 +1040,25 @@ BUSCONF
|
|||||||
set --
|
set --
|
||||||
continue
|
continue
|
||||||
fi
|
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
|
if [ -f "$HOME/.bl-restart-once" ]; then
|
||||||
echo "== steam: restarting once so the new compatibility layer is in the client's lists"
|
echo "== steam: restarting once so the new compatibility layer is in the client's lists"
|
||||||
set --
|
set --
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ def unimported(steam_root, snapshot, candidates):
|
|||||||
|
|
||||||
|
|
||||||
def watch(steam_root, listing):
|
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:
|
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
|
||||||
return
|
return
|
||||||
candidates, sources = {}, {}
|
candidates, sources = {}, {}
|
||||||
@@ -408,6 +408,9 @@ def route(steam_root, acct, games):
|
|||||||
print(f"droiddeck-steam-games: store import not undone ({error})")
|
print(f"droiddeck-steam-games: store import not undone ({error})")
|
||||||
shortcuts.append(game)
|
shortcuts.append(game)
|
||||||
continue
|
continue
|
||||||
|
if game.get("native"):
|
||||||
|
shortcuts.append(game)
|
||||||
|
continue
|
||||||
folder = game.get("folder", "")
|
folder = game.get("folder", "")
|
||||||
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
|
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
|
||||||
stamp = source_stamp(game)
|
stamp = source_stamp(game)
|
||||||
|
|||||||
@@ -180,12 +180,14 @@ def merge(fresh, current, state, seen):
|
|||||||
def reconcile(existing, wanted, seen, record):
|
def reconcile(existing, wanted, seen, record):
|
||||||
games, away = record["games"], dict(record["away"])
|
games, away = record["games"], dict(record["away"])
|
||||||
kept, ours, placed = [], [], {}
|
kept, ours, placed = [], [], {}
|
||||||
|
claimed = {(unquoted(fresh["Exe"]), fresh["AppName"]) for fresh in wanted.values()}
|
||||||
for value in existing.values():
|
for value in existing.values():
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
continue
|
continue
|
||||||
tags = value.get("tags")
|
tags = value.get("tags")
|
||||||
if not isinstance(tags, dict) or TAG not in tags.values():
|
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
|
continue
|
||||||
appid = value.get("appid")
|
appid = value.get("appid")
|
||||||
if not isinstance(appid, int) or appid in placed:
|
if not isinstance(appid, int) or appid in placed:
|
||||||
@@ -342,7 +344,7 @@ def main():
|
|||||||
spec.loader.exec_module(compat)
|
spec.loader.exec_module(compat)
|
||||||
cfg = os.path.join(steam_root, "config", "config.vdf")
|
cfg = os.path.join(steam_root, "config", "config.vdf")
|
||||||
if os.path.isfile(cfg) and (ours or routes):
|
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")))
|
protect=compat.adopt_extras(os.path.join(steam_root, "compatibilitytools.d")))
|
||||||
print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL)
|
print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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
|
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.
|
.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
|
For an Epic game, once the Epic Games Launcher is installed (droiddeck-epic), the game is started
|
||||||
request goes through files under $BL_LAUNCH_DIR/stores (the session root, the same path on both
|
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):
|
sides):
|
||||||
|
|
||||||
req/<id>.json {"op": "epic-code", "store": "epic", "id": "<app name>"} (tmp + rename)
|
req/<id>.json {"op": "epic-code", "store": "epic", "id": "<app name>"} (tmp + rename)
|
||||||
@@ -38,6 +40,11 @@ from pathlib import Path
|
|||||||
LAUNCHER = ".droiddeck-launch.bat"
|
LAUNCHER = ".droiddeck-launch.bat"
|
||||||
SIDECAR = ".droiddeck-store.json"
|
SIDECAR = ".droiddeck-store.json"
|
||||||
EPIC_CODE = ".droiddeck-epic-code"
|
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
|
TIMEOUT = 5.0
|
||||||
STALE_SECONDS = 300
|
STALE_SECONDS = 300
|
||||||
SERVER_WAIT = 8.0
|
SERVER_WAIT = 8.0
|
||||||
@@ -98,6 +105,30 @@ def drop_stale(folder, now=None):
|
|||||||
return False
|
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):
|
def ask(channel, request, timeout=TIMEOUT):
|
||||||
"""Writes [request] under channel/req and waits for channel/resp; the answer, or None on 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"
|
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
|
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."""
|
"""After the game: ask the app to upload what changed, without waiting for it."""
|
||||||
found = find_store_game(argv)
|
found = find_store_game(argv)
|
||||||
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
|
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]):
|
if found is None or not launch_dir or not cloud_on(found[1]):
|
||||||
return 0
|
return 0
|
||||||
store, game = str(found[1].get("store")), str(found[1].get("id"))
|
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):
|
def main(argv):
|
||||||
if argv and argv[0] == "--exited":
|
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)
|
found = find_store_game(argv)
|
||||||
if found is None:
|
if found is None:
|
||||||
return 0
|
return 0
|
||||||
@@ -341,9 +385,27 @@ def main(argv):
|
|||||||
if store == "gog":
|
if store == "gog":
|
||||||
prepare_gog(sidecar, compat_data, launch_dir)
|
prepare_gog(sidecar, compat_data, launch_dir)
|
||||||
if store == "epic":
|
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)
|
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:
|
try:
|
||||||
(folder / EPIC_CODE).unlink()
|
(folder / EPIC_CODE).unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ bl_run() {
|
|||||||
if [ -n "${BL_STORE_EXIT:-}" ]; then
|
if [ -n "${BL_STORE_EXIT:-}" ]; then
|
||||||
"$@"
|
"$@"
|
||||||
rc=$?
|
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
|
exit $rc
|
||||||
fi
|
fi
|
||||||
exec "$@"
|
exec "$@"
|
||||||
@@ -660,6 +660,26 @@ def launch_programs(config):
|
|||||||
return found
|
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):
|
def appinfo_entries(path):
|
||||||
import struct
|
import struct
|
||||||
try:
|
try:
|
||||||
@@ -751,6 +771,7 @@ def appinfo_entries(path):
|
|||||||
"type": str(common.get("type", "")).lower(),
|
"type": str(common.get("type", "")).lower(),
|
||||||
"recommended": str(recommended),
|
"recommended": str(recommended),
|
||||||
"launch": launch_programs(info.get("config")),
|
"launch": launch_programs(info.get("config")),
|
||||||
|
"launch_options": launch_options(info.get("config")),
|
||||||
}
|
}
|
||||||
if app == STEAM_PLAY_MANIFESTS:
|
if app == STEAM_PLAY_MANIFESTS:
|
||||||
extended = info.get("extended", {})
|
extended = info.get("extended", {})
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def required():
|
|||||||
for arch in ['x86_64', 'i386']:
|
for arch in ['x86_64', 'i386']:
|
||||||
paths += ['assets/linuxfs/' + arch + '/' + lib for lib in ['libfakeinput.so', 'libblsession.so']]
|
paths += ['assets/linuxfs/' + arch + '/' + lib for lib in ['libfakeinput.so', 'libblsession.so']]
|
||||||
paths += ['assets/linuxfs/x86_64/' + lib for lib in
|
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/i386/libfaultreport.so']
|
||||||
paths += ['assets/linuxfs/usr/local/lib/droiddeck-msitools/' + name for name in
|
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']]
|
['msiinfo', 'cabextract', '7z', '7z.so', 'libmsi-1.0.so.0', 'libgsf-1.so.114', 'libgcab-1.0.so.0']]
|
||||||
|
|||||||
@@ -516,6 +516,65 @@ class ProtonRunTest(Temp):
|
|||||||
self.assertEqual(env["SteamAppId"], "413150")
|
self.assertEqual(env["SteamAppId"], "413150")
|
||||||
self.assertTrue((self.root / "steamapps/compatdata/413150").is_dir())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -291,6 +291,53 @@ class AgentTest(unittest.TestCase):
|
|||||||
self.assertFalse(result["supported"])
|
self.assertFalse(result["supported"])
|
||||||
evaluate.assert_not_called()
|
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):
|
def test_cdp_exception(self):
|
||||||
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
|
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
|
||||||
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
|
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -317,6 +317,35 @@ class ExtractTest(FexTestCase):
|
|||||||
self.assertTrue((self.home / ".config/droiddeck/fex-wanted").is_file())
|
self.assertTrue((self.home / ".config/droiddeck/fex-wanted").is_file())
|
||||||
self.assertFalse(json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text())["ready"])
|
self.assertFalse(json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text())["ready"])
|
||||||
|
|
||||||
|
def test_uruntime_that_only_drops_extended_attributes_still_unpacks(self):
|
||||||
|
tool = self.uruntime(
|
||||||
|
"mkdir -p AppDir && touch AppDir/AppRun\n"
|
||||||
|
"echo 'Failed to exec memfd: EACCES: Permission denied. Trying tmpfile in /tmp...' >&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):
|
def test_an_arm64_image_does_not_ask_for_fex(self):
|
||||||
self.write(self.image, elf(183) + b"payload", 0o755)
|
self.write(self.image, elf(183) + b"payload", 0o755)
|
||||||
tool = self.uruntime("mkdir -p squashfs-root\n")
|
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.assertIn("no AppImage runtime for ARM64, no FEX yet and no unsquashfs", said)
|
||||||
self.assertFalse(os.path.lexists("squashfs-root"))
|
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):
|
class LaunchTest(FexTestCase):
|
||||||
def test_the_guest_environment(self):
|
def test_the_guest_environment(self):
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ class GameEnvironmentTest(unittest.TestCase):
|
|||||||
self.assertEqual(env["REMOVE"], "inherited")
|
self.assertEqual(env["REMOVE"], "inherited")
|
||||||
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
|
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):
|
def test_component_profiles_override_general_and_restore_it_for_the_next_game(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -947,28 +953,6 @@ if __name__ == "__main__":
|
|||||||
unittest.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):
|
class ForceSsbsTest(unittest.TestCase):
|
||||||
def test_libssbs_is_preloaded_only_when_asked_and_only_into_arm64_proton(self):
|
def test_libssbs_is_preloaded_only_when_asked_and_only_into_arm64_proton(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ IFACE = NAME + ".Manager"
|
|||||||
@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject")
|
@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject")
|
||||||
class SleepHandshakeTest(unittest.TestCase):
|
class SleepHandshakeTest(unittest.TestCase):
|
||||||
ARGS = []
|
ARGS = []
|
||||||
|
AGENT_EXIT = None
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.directory = tempfile.TemporaryDirectory()
|
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]))
|
lambda bus, sender, path, interface, name, body: self.signals.append(body.unpack()[0]))
|
||||||
env = dict(os.environ, BL_LAUNCH_DIR=str(self.dir),
|
env = dict(os.environ, BL_LAUNCH_DIR=str(self.dir),
|
||||||
DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
|
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)
|
stdout=subprocess.DEVNULL)
|
||||||
self.wait_for(lambda: (self.dir / "steam-sleep-ready").exists())
|
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,)))
|
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):
|
class WithNetworkManagerTest(SleepHandshakeTest):
|
||||||
ARGS = ["--with-netmanager"]
|
ARGS = ["--with-netmanager"]
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user