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:
MaxsTechReview authored and GitHub committed 2026-10-17 14:40:39 -04:00
1 parent 83eda433c8
commit 97f976ef6c
107 files changed
+8860 -371

No files matched your search

+1 -1
View File
@@ -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.
+1
View File
@@ -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"
+18 -7
View File
@@ -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)
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+22 -1
View File
@@ -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>
+23 -1
View File
@@ -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))
}
}
+8
View File
@@ -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 \
+198
View File
@@ -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
+4
View File
@@ -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", {})
+1 -1
View File
@@ -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']]
+59
View File
@@ -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()
+47
View File
@@ -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
+56
View File
@@ -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):
+6 -22
View File
@@ -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:
+58 -1
View File
@@ -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