Merge branch 'main' into stretch-to-panel

This commit is contained in:
Magarto authored and GitHub committed 2026-09-30 09:30:37 +02:00
commit db8928f7ce
76 files changed
+6441 -305

No files matched your search

+32 -1
View File
@@ -454,6 +454,7 @@ jobs:
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
env:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
@@ -472,6 +473,34 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
# What this build is, for the top of the release notes: the merged PR's title and the first
# paragraph of its description, or the commit's subject for a push made without a PR.
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
if [ -n "$pr" ]; then
number=$(jq -r .number <<<"$pr")
title=$(jq -r .title <<<"$pr")
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
else
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
echo "**Pushed to main** without a PR - $subject" > notes-head.md
fi
cat notes-head.md
- name: Publish to DroidDeck-CI
id: publish
env:
@@ -481,7 +510,9 @@ jobs:
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
notes="$(cat notes-head.md)
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
+24 -1
View File
@@ -115,11 +115,32 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/download-artifact@v4
with:
name: droiddeck-pr-signed-standard
path: signed
# What this build is, for the top of the release notes: the PR's title and the first
# paragraph of its description.
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
title=$(jq -r .title <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
cat notes-head.md
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
@@ -140,7 +161,9 @@ jobs:
tag="pr-$PR_NUMBER"
apk="DroidDeck-$LABEL.apk"
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
notes="$(cat notes-head.md)
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
Commit: $HEAD_SHA
APK SHA-256: $sha"
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
+1
View File
@@ -12,6 +12,7 @@ captures/
# copies cannot drift.
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
+1 -1
View File
@@ -11,7 +11,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). 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/The412Banner/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.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). 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/The412Banner/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; see [docs/development/flatpak.md](docs/development/flatpak.md).
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
File diff suppressed because it is too large. Load diff
+10 -3
View File
@@ -435,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
* limit had spaced out, and the game's first frames after that waited on empty slots. */
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
int limit = g_fps_limit;
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t interval = 1000000000LL / limit, now = now_ns();
const int limit = g_fps_limit;
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
* either way. */
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t now = now_ns();
int64_t at = s->next_release_ns + interval;
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
if (at < now) at = now;
@@ -1,12 +1,17 @@
package com.droiddeck.launcher
import android.app.Application
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.session.CrashHandler
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
/**
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
* the socket Flatpak's sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
BwrapSpawner.start(this)
}
}
@@ -38,6 +38,7 @@ import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
@@ -104,12 +105,16 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
/** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */
private var onSavePicked: ((File) -> Unit)? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
private var catalogLoading by mutableStateOf(false)
@@ -126,7 +131,8 @@ class MainActivity : ComponentActivity() {
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -137,6 +143,8 @@ class MainActivity : ComponentActivity() {
private var forceFullscreen by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var appImagesEnabled by mutableStateOf(false)
private var mic by mutableStateOf(false)
// The app's own picker (files/), once per kind of pick: the two driver lists validate
@@ -150,6 +158,42 @@ class MainActivity : ComponentActivity() {
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction("Importing into $name") {
val (written, backup) = GameSaves.import(game(), zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
}
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction("Exporting $name") {
val (zip, count) = GameSaves.export(game(), layout, dir)
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
@@ -173,6 +217,9 @@ class MainActivity : ComponentActivity() {
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) }
}
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
@@ -265,6 +312,8 @@ class MainActivity : ComponentActivity() {
theme = SessionPrefs.theme(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
applyLauncherFullscreen()
setContent {
DroidDeckTheme(theme) {
@@ -308,6 +357,8 @@ class MainActivity : ComponentActivity() {
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
storeEnabled = storeEnabled,
appImagesEnabled = appImagesEnabled,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -327,6 +378,25 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP))
},
onEmulator = { e -> launchProgram(e.program) },
// A Flatpak app from the store, full screen under gamescope like an emulator.
onImportAppImage = {
pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage"))
},
// An imported AppImage, full screen under gamescope like an emulator.
onAppImage = { dir, name ->
Library.flatpakNames[dir] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir)))
},
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id)))
},
onRom = { g ->
val e = emulatorList.first { it.id == g.emulatorId }
startActivity(Intent(this, SessionActivity::class.java)
@@ -347,6 +417,9 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
@@ -379,6 +452,8 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onAppImagesEnabled = { on -> SessionPrefs.setAppImagesEnabled(this, on); appImagesEnabled = on },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -504,8 +579,9 @@ class MainActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
// The microphone is on by default; ask once, with the storage prompt, so voice chat works
// without a trip to the settings. A refusal is not asked again - the toggle asks when used.
// The microphone is off by default. Someone who turned it on before the permission was
// granted is asked once here, with the storage prompt; a refusal is not asked again - the
// toggle asks when used.
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
@@ -657,6 +733,19 @@ class MainActivity : ComponentActivity() {
}, "catalog-desktop").start()
}
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
private fun saveAction(label: String, work: () -> String) {
if (saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "game-saves-action").start()
}
private fun openProtons() {
settingsMode = null
showPerformance = false
@@ -808,6 +897,7 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
@@ -865,6 +955,7 @@ class MainActivity : ComponentActivity() {
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
@@ -896,6 +987,7 @@ class MainActivity : ComponentActivity() {
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
@@ -903,6 +995,7 @@ class MainActivity : ComponentActivity() {
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -947,6 +1040,8 @@ class MainActivity : ComponentActivity() {
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
@@ -997,6 +1092,7 @@ class MainActivity : ComponentActivity() {
tuSysmem = SessionPrefs.tuSysmem(this)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -1014,7 +1110,8 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "Desktop"
SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
@@ -1022,7 +1119,7 @@ class MainActivity : ComponentActivity() {
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
)
@@ -47,7 +47,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHints
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SessionPrefs
@@ -92,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private var padBridge: PadBridge? = null
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
}
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var watching = true
@@ -166,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(-1)
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
@@ -203,8 +215,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
// Game-tier power policy for the whole session: the panel's fastest mode, and the OS told
// it is in gameplay. Logged so a slow device says why.
Log.i(TAG, "perf: " + PerfMode.apply(this))
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
@@ -224,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}
SessionState.deckPadListener = deckPadListener
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
bridge.setOnPlayerInput {
@@ -761,9 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
)
if (!SessionState.running) SessionEvents.record("compositor.started")
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
// period, so a long frame raises CPU clocks now rather than after the load averages up.
PerfHints.arm(this, refreshHz())
// The service owns everything below the compositor. It is started whenever no session is
// running - NOT only when the compositor was just started: the compositor lives for the
// whole process, so the second Play after a session ended used to re-attach the Surface,
@@ -849,9 +863,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return Pair(width and 1.inv(), height and 1.inv())
}
/**
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
*/
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
@@ -928,8 +949,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
}
loading.showEnded(
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
@@ -1271,11 +1297,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
// A choice kept while its display was away (asleep, waking): back as soon as it is.
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
selectedSecondScreenDisplay = displayId
SessionState.secondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
@@ -1297,8 +1326,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
closeSecondScreen(reset = true)
refreshSecondScreenDisplays()
// Not there right now - a panel still waking, say. The choice is kept; the display
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
closeSecondScreen(reset = false)
return
}
var presentation = secondScreenPresentation
@@ -1330,6 +1360,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!presentation.isShowing) presentation.show()
presentation.showMode(mode)
secondScreenMode = mode
SessionState.secondScreenMode = mode
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
} catch (e: Exception) {
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
closeSecondScreen(reset = true)
@@ -1349,7 +1381,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun closeSecondScreen(reset: Boolean) {
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) secondScreenMode = SecondScreenMode.NONE
if (reset) {
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
@@ -1462,15 +1497,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onStart() {
super.onStart()
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
started = false
displayManager.unregisterDisplayListener(displayListener)
closeSecondScreen(reset = true)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
// onStart puts it back.
closeSecondScreen(reset = false)
SessionService.setActivityVisible(this, false)
super.onStop()
}
@@ -1507,6 +1546,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
updateOnScreenControls()
resumed = true
updatePadMotion()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
@@ -1520,6 +1561,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
}
@@ -1573,6 +1616,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = true)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
@@ -144,8 +144,10 @@ object DeviceReport {
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
@@ -104,6 +104,7 @@ public final class FileUtils {
public static String sizeToString(long bytes) {
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
return bytes + " B";
}
}
@@ -64,7 +64,8 @@ object HostProcess {
}
}
private fun pidOf(process: Process): Int = try {
@JvmStatic
fun pidOf(process: Process): Int = try {
val field = process.javaClass.getDeclaredField("pid")
field.isAccessible = true
val pid = field.getInt(process)
@@ -23,6 +23,9 @@ object Library {
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
@@ -163,7 +166,13 @@ object Library {
)
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
fun nameForProgram(program: String?): String? =
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
* Whether a program from the rail runs at the panel's own resolution rather than the session's
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.input
/**
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
*
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
* position, a pad clicked also reports full pressure.
*/
object DeckControls {
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
const val L4 = 1
const val R4 = 2
const val L5 = 4
const val R5 = 8
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
private var controls = 0
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
@Synchronized
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
publish()
}
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
}
@Synchronized
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
/** Everything let go - for when the panel goes away with a finger still on it. */
@Synchronized
fun releaseAll() {
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
pressure.fill(0)
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
}
@@ -67,6 +67,9 @@ public class FakeInputWriter {
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
* button is not, because PadState keeps 10 and 11 for the triggers. */
private static final int SNAPSHOT_IDX_MODE = 10;
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
* libfakeinput's Deck controller reads it from here. */
private static final int SNAPSHOT_IDX_QAM = 11;
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
@@ -92,6 +95,18 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -110,6 +125,7 @@ public class FakeInputWriter {
private volatile boolean destroyed = false;
private final boolean[] prevButtonStates = new boolean[12];
private boolean prevQam;
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
@@ -217,30 +233,40 @@ public class FakeInputWriter {
for (int i = 0; i < 8; i++) {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
private static void releaseRingSlotLocked(int slot) {
RingSlot ringSlot = RING_SLOTS[slot];
if (ringSlot == null) {
return;
}
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
synchronized (ringSlot) {
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
}
ringSlot.ringChannel = null;
}
ringSlot.ringChannel = null;
}
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
RING_SLOTS[slot] = null;
}
@@ -289,9 +315,9 @@ public class FakeInputWriter {
FileChannel channel = null;
try {
raf = new RandomAccessFile(ringFile, "rw");
raf.setLength(RING_SIZE);
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
RingSlot ringSlot = new RingSlot();
@@ -332,6 +358,9 @@ public class FakeInputWriter {
return false;
}
synchronized (ringSlot) {
if (ringSlot.data == null) {
return false;
}
if (!ringSlot.active) {
if (ringSlot.everActivated) {
ringSlot.generation++;
@@ -381,6 +410,9 @@ public class FakeInputWriter {
ByteBuffer source = this.buffer.duplicate();
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) {
return false;
}
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
int sourceLimit = source.limit();
while (source.remaining() >= EVENT_SIZE) {
@@ -416,6 +448,9 @@ public class FakeInputWriter {
buttons |= (1 << i);
}
}
if (this.prevQam) {
buttons |= (1 << SNAPSHOT_IDX_QAM);
}
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
@@ -434,6 +469,58 @@ public class FakeInputWriter {
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
}
/**
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
* controller's axes and units (see PadMotion). Safe from any thread.
*/
public static void writeMotion(int slot, short[] accel, short[] gyro) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
for (int i = 0; i < 3; i++) {
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
}
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
*/
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
nativeStoreFence();
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
ring.putInt(DECK_CONTROLS_OFFSET, controls);
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -512,6 +599,11 @@ public class FakeInputWriter {
}
}
if (prevQam) {
prevQam = false;
hasChanges = true;
}
// Zero all axes
if (prevThumbLX != 0) {
prevThumbLX = 0;
@@ -620,6 +712,11 @@ public class FakeInputWriter {
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
writeButton(SNAPSHOT_IDX_MODE,
state.isDown(PadState.GUIDE));
boolean qam = state.isDown(PadState.QAM);
if (forceResend || qam != prevQam) {
prevQam = qam;
hasChanges = true;
}
// Sticks
int lx = (int) (state.leftX * 32767);
@@ -7,6 +7,8 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.droiddeck.launcher.session.SessionState;
import java.io.File;
/**
@@ -19,7 +21,10 @@ import java.io.File;
* SDL → Big Picture.
*
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
* mapping without the user configuring anything.
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
* Guide-then-A chord the client itself answers.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
@@ -54,6 +59,8 @@ public final class PadBridge {
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
/** The Deck controller's Quick Access button, held for one tap. */
private boolean qamTapPressed;
private int qamChordGeneration;
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
private volatile Runnable onPlayerInput;
@@ -77,6 +84,7 @@ public final class PadBridge {
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
state.clear();
@@ -204,7 +212,8 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted) startQamChord();
// A Deck controller's QAM button is held for as long as the touch one is.
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
else publish();
}
@@ -217,9 +226,23 @@ public final class PadBridge {
publish();
}
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
public synchronized void triggerQam() {
startQamChord();
if (!SessionState.getDeckPad()) {
startQamChord();
return;
}
if (qamTapPressed) return;
qamTapPressed = true;
int generation = qamChordGeneration;
publish();
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
publish();
}
private void startQamChord() {
@@ -258,16 +281,17 @@ public final class PadBridge {
private void publish() {
if (!open && !start()) return;
if (systemGuidePressed || qamChordActive) {
effectiveState.copyFrom(state);
effectiveState.press(PadState.GUIDE, true);
if (qamSyntheticAPressed) {
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
}
writer.writePad(effectiveState);
} else {
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
writer.writePad(state);
return;
}
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
writer.writePad(effectiveState);
}
private static float axis(MotionEvent event, int axis) {
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import kotlin.math.roundToInt
/**
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
*
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
* reports them in the device's natural orientation; they are turned to the screen as it is shown
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
private var thread: HandlerThread? = null
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
@Volatile private var active = false
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
fun start() {
if (thread != null || sensors == null) return
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (gyroscope == null && accelerometer == null) {
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
return
}
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
fun stop() {
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
worker.quitSafely()
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
target[0] = counts(x * scale)
target[1] = counts(-z * scale)
target[2] = counts(y * scale)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
companion object {
private const val TAG = "PadMotion"
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
}
@@ -57,5 +57,7 @@ class PadState {
const val R3 = 9
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
const val GUIDE = 12
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
const val QAM = 13
}
}
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -0,0 +1,181 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
/**
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
?.map { dir ->
Item(
dir.name,
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
/**
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
}
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
internal fun idFor(fileName: String, taken: Set<String>): String {
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
var id = base
var n = 2
while (id in taken) id = "$base-${n++}"
return id
}
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
val image = File(dir, "image.AppImage")
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val guestDir = "$GUEST_DIR/$id"
val out = StringBuilder()
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
"extract", guestDir,
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
}
}
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
private fun describe(dir: File, source: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val text = entry?.let { FileUtils.readString(it) }
fun key(k: String) = text?.let { desktopKey(it, k) }
val name = key("Name") ?: source.name.substringBeforeLast('.')
FileUtils.writeString(File(dir, "name"), name)
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
val iconName = key("Icon")
val candidates = listOfNotNull(
iconName?.let { File(app, "$it.png") },
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
File(app, ".DirIcon"),
)
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
}
private fun isPng(f: File): Boolean = try {
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
} catch (e: Exception) { false }
/** A key of a desktop entry's main group, unlocalised. */
internal fun desktopKey(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
private fun writeMenuEntry(context: Context, dir: File) {
val id = dir.name
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
menuEntry(context, id).delete()
}
private object Files {
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
}
}
@@ -0,0 +1,424 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.os.Process
import android.system.Os
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicInteger
/**
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
*
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
* session's proot rather than inside it - it traces the app once instead of twice. The stand-in
* sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths),
* this builds it under the cache and starts it, then streams the output, the pid and the exit
* status back until one side goes away: the stand-in ending takes the sandbox down with it.
*
* The socket is abstract, so anything on the device could connect: only this app's own uid - the
* uid every program in the runtime already runs as - is served.
*/
object BwrapSpawner {
private const val TAG = "BwrapSpawner"
const val SOCKET = "com.droiddeck.launcher.bwrap"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
/** Listens for sandboxes for the rest of the process's life; later calls do nothing. */
@Synchronized
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val socket = try {
LocalServerSocket(SOCKET)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $SOCKET", e)
return
}
server = socket
FileUtils.delete(File(app.cacheDir, "bwrap"))
Thread({
while (true) {
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() }
}
}, "bwrap-server").apply { isDaemon = true; start() }
}
private fun serve(context: Context, client: LocalSocket) {
val id = seq.incrementAndGet()
val dir = File(context.cacheDir, "bwrap/$id")
client.use {
val out = DataOutputStream(client.outputStream)
try {
val peer = client.peerCredentials
if (peer.uid != Process.myUid()) {
Log.w(TAG, "refused a sandbox for uid ${peer.uid}")
return
}
val input = DataInputStream(client.inputStream)
val kind = input.readUnsignedByte()
val body = ByteArray(input.readInt()).also { input.readFully(it) }
if (kind != 'R'.code) return
val request = JSONObject(String(body, Charsets.UTF_8))
val plan = plan(context, request, dir)
run(context, plan, client, out, id)
} catch (e: Exception) {
Log.e(TAG, "sandbox $id", e)
runCatching {
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
frame(out, 'X', "1".toByteArray())
}
} finally {
FileUtils.delete(dir)
}
}
}
/** What proot is started with: the sandbox root, its binds (host to guest), and the program. */
class Plan(val root: File, val binds: List<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
data class Bind(val host: String, val guest: String) {
val spec: String get() = if (host == guest) host else "$host:$guest"
companion object {
fun parse(spec: String): Bind {
val colon = spec.indexOf(':')
return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1))
}
}
}
/** True when [path] is [dir] or inside it. */
internal fun under(path: String, dir: String): Boolean =
dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/")
/**
* Where a path the session's programs see lives on the host: through the session's own binds
* (the most specific one), or else inside the rootfs.
*/
internal fun hostPath(sessionBinds: List<Bind>, rootfs: String, guest: String): String {
val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length }
?: return rootfs.trimEnd('/') + guest
val rest = guest.substring(bind.guest.length).trimStart('/')
return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest
}
/**
* Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved
* under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what
* Android keeps apps from reading.
*/
internal fun bindsFor(sessionBinds: List<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
val out = ArrayList<Bind>()
out.add(Bind(hostPath(sessionBinds, rootfs, src), dest))
for (b in sessionBinds.sortedBy { it.guest.length }) {
if (b.guest != src && under(b.guest, src)) {
out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/'))))
}
}
return out
}
internal fun plan(context: Context, request: JSONObject, dir: File): Plan {
val rootfs = LinuxRuntime.rootDir(context).path
val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) }
val root = File(dir, "root").apply { mkdirs() }
val binds = ArrayList<Bind>()
var scratch = 0
fun addBind(b: Bind) {
binds.removeAll { it.guest == b.guest }
binds.add(b)
}
fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length }
fun newScratch(): File = File(dir, "x${scratch++}")
/**
* Where [dest] can be created on the host: inside the sandbox root, or inside a directory
* of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the
* GL extension's links). Null inside anything else bound in - the runtime itself, a host
* directory - which must not be written to.
*/
fun writable(dest: String): File? {
val cover = coveringBind(dest) ?: return inRoot(root, dest)
if (!under(cover.host, dir.path)) return null
return inRoot(File(cover.host), dest.substring(cover.guest.length))
}
val ops = request.getJSONArray("ops")
for (i in 0 until ops.length()) {
val op = ops.getJSONObject(i)
val dest = op.optString("dest")
when (op.getString("t")) {
"bind" -> {
val src = op.getString("src")
val all = bindsFor(session, rootfs, src, dest)
if (!File(all.first().host).exists()) {
if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out")
continue
}
all.forEach { addBind(it) }
}
"proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) }
"dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) }
"dir" -> {
val perms = op.optInt("perms", 0x1ed)
val here = writable(dest)
when {
op.optBoolean("empty") && coveringBind(dest) != null ->
addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms)
File(hostPath(binds, root.path, dest)).isDirectory -> {}
else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
}
}
"file" -> {
val data = Base64.decode(op.getString("data"), Base64.DEFAULT)
val here = writable(dest)
val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here
else newScratch().also { addBind(Bind(it.path, dest)) }
f.writeBytes(data)
chmod(f, op.optInt("perms", 0x1b6))
}
"symlink" -> {
val target = op.getString("target")
val link = writable(dest)
if (link != null) {
link.parentFile?.mkdirs()
if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
} else {
// A link inside the runtime's own tree cannot be made; what it points at is
// bound in its place.
val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target)
val host = hostPath(binds, root.path, abs)
if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out")
}
}
}
}
if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) }
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
val env = LinkedHashMap<String, String>()
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
}
/** Where a sandbox finds the runtime's own GPU driver. */
private const val GPU_DIR = "/run/droiddeck-gpu"
/**
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
*/
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
// Beside the sandbox root, in the sandbox's own directory: removed with it.
val icd = File(root.parentFile, "freedreno_icd.json")
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
env["GALLIUM_DRIVER"] = "zink"
}
// The driver's own libraries, found beside it; nothing else is in that directory.
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
return binds
}
/**
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
*/
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
if (libs.isEmpty()) return emptyList()
val binds = ArrayList<Bind>()
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
binds.addAll(bindsFor(session, rootfs, dev, dev))
}
return binds
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
/**
* Browsers sandbox their own children, and neither way works in here. Firefox wants
* namespaces and seccomp from the kernel; its content processes died without them (the
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
*/
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
// Chromium on Wayland asks the render node the compositor names for its DRM version;
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
env.putIfAbsent(name, "1")
}
}
/**
* /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have
* SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw
* with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first
* shader compiled killed the app with SIGILL. Without a part number LLVM targets generic
* ARMv8 plus the "Features" line, which lists what the kernel really enabled.
*/
private fun genericCpuinfo(context: Context): File? = try {
val out = File(context.cacheDir, "cpuinfo-generic")
val text = File("/proc/cpuinfo").readLines()
.filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") }
.joinToString("\n", postfix = "\n")
// Sandboxes start side by side; one must never read another's half-written copy.
if (!out.isFile || out.readText() != text) {
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
staged.writeText(text)
if (!staged.renameTo(out)) staged.delete()
}
out
} catch (e: Exception) {
Log.w(TAG, "cpuinfo", e); null
}
/** [path] with "." and ".." resolved lexically. */
internal fun normalize(path: String): String {
val out = ArrayList<String>()
for (part in path.split('/')) when (part) {
"", "." -> {}
".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1)
else -> out.add(part)
}
return "/" + out.joinToString("/")
}
/**
* [guest] inside the sandbox root, or null when the way there crosses a symlink: one of
* Flatpak's (var/run -> ../run) may point anywhere once followed on the host.
*/
private fun inRoot(root: File, guest: String): File? {
var f = root
for (part in guest.split('/').filter { it.isNotEmpty() }) {
if (part == "..") return null
f = File(f, part)
if (Files.isSymbolicLink(f.toPath())) return null
}
return f
}
private fun chmod(f: File, mode: Int) {
try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ }
}
private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) {
val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd)
// DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot
// say what it does, to logcat in full: for a sandbox that dies without a word.
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
// proot hands its own environment on; the sandbox gets exactly what Flatpak asked for.
cmd.add("/usr/bin/env")
cmd.add("-i")
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
cmd.addAll(plan.argv)
val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root)
builder.environment().apply {
clear()
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
Thread({
try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } }
catch (e: IOException) { /* closed */ }
if (process.isAlive) {
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
process.destroy()
}
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
val buffer = ByteArray(16384)
// The start of what the sandbox says goes to logcat too: a sandbox that fails at once
// (proot refusing a bind, a missing library) says why there even when nothing reads it.
var logged = 0
process.inputStream.use { stream ->
while (true) {
val n = try { stream.read(buffer) } catch (e: IOException) { -1 }
if (n < 0) break
if (logged < 4096 || verbose != null) {
String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() }
.forEach { Log.i(TAG, "sandbox $id: $it") }
logged += n
}
try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break }
}
}
val status = process.waitFor()
com.droiddeck.launcher.session.OrphanReaper.release(pid)
Log.i(TAG, "sandbox $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
}
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
out.writeByte(kind.code)
out.writeInt(payload.size)
out.write(payload)
out.flush()
}
}
@@ -0,0 +1,204 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
/**
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
*
* Every command runs in a proot of its own, outside any session: the store works whether or not
* Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper).
*/
object FlatpakManager {
private const val TAG = "FlatpakManager"
const val FLATPAK = "/usr/bin/flatpak"
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
/** The program the front end starts a Flatpak app through, in a session of its own. */
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
private const val USER_DIR = "root/.local/share/flatpak"
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
@Volatile var busy: String? = null
private set
class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?)
/** Flatpak is in the runtime, with Flathub to install from. */
fun ready(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
return File(root, FLATPAK.substring(1)).isFile &&
File(root, "var/cache/droiddeck-flatpak/installed").isFile &&
(FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true)
}
private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) {
"x86_64" -> "x86_64"
else -> "aarch64"
}
/**
* The installed apps, read from the installation itself: each app's deployed files carry the
* desktop entry and icons it exports, so listing them needs no process in the runtime.
*/
fun installedApps(context: Context): List<App> {
val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app")
val arch = arch()
return apps.listFiles()?.mapNotNull { dir ->
val id = dir.name
val archDir = File(dir, arch)
val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null
val active = File(branch, "active")
val export = File(active, "export/share")
val desktop = FileUtils.readString(File(export, "applications/$id.desktop"))
val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.')
val summary = desktop?.let { entry(it, "Comment") }
App(id, name, icon(export, id), branch.name, summary)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
}
fun isInstalled(context: Context, id: String): Boolean =
File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true
/** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */
private fun entry(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
/** The largest PNG the app exports (the front end draws no SVG). */
private fun icon(export: File, id: String): File? {
val hicolor = File(export, "icons/hicolor")
return hicolor.listFiles()
?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } }
?.filter { it.second.isFile }
?.maxByOrNull { it.first }?.second
}
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
synchronized(this) {
if (busy != null) return null
busy = what
}
try { return block() } finally { busy = null }
}
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
Log.i(TAG, "setup: $line")
when {
line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1)
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
}
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
else -> null
}
} ?: "Another store task is running"
}
/**
* One store operation - install, uninstall or update - with its progress as a stage line and
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
return exclusive("$verb:${id ?: "all"}") {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
}
private fun stage(o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
else -> id.substringAfterLast('.')
}
}
/** The whole transaction's percentage: steps done, plus this one's share. */
private fun overall(o: JSONObject, percent: Int): Int {
val n = o.optInt("n").coerceAtLeast(1)
val i = o.optInt("i").coerceIn(1, n)
return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n)
}
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
/** Installed apps with an update on Flathub, or null when that could not be checked. */
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = null
exclusive("updates") {
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
if (o.optString("e") == "updates") {
val a = o.optJSONArray("apps")
apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet()
}
}
}
return apps
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
/**
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
*/
object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
// The resolver otherwise names the DNS of whatever network the last session was on.
LinuxNetworkLinkComponent(context, root).publish()
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
}
}
@@ -115,6 +115,37 @@ public final class LinuxRuntime {
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
File root = rootDir(context);
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
// A session's binds are the view its programs have; a one-off command's are not recorded.
if (sessionRoot != null) lastBinds = binds;
List<String> cmd = prootPrefix(context, root, "/root");
for (String spec : binds) bind(cmd, spec);
cmd.addAll(guestCommand);
return cmd;
}
/**
* The binds of the last command built: the guest's view of the host, which the Flatpak
* sandboxes started beside a session (BwrapSpawner) translate their paths through.
*/
private static volatile List<String> lastBinds;
public static List<String> lastBinds(Context context) {
List<String> binds = lastBinds;
if (binds != null) return binds;
return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null);
}
/** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */
public static List<String> prootPrefix(Context context, File root, String cwd) {
return prootPrefix(context, root, cwd, false);
}
/**
* As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that
* refuse any other uid. Files it writes still belong to the app.
*/
public static List<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
List<String> cmd = new ArrayList<>();
cmd.add(prootBinary(context).getPath());
cmd.add("--kill-on-exit");
@@ -125,12 +156,24 @@ public final class LinuxRuntime {
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
// itself while still reporting our real ids, so nothing inside sees a different user.
int uid = Process.myUid();
cmd.add("-i");
cmd.add(uid + ":" + uid);
if (fakeRoot) {
cmd.add("-0");
} else {
cmd.add("-i");
cmd.add(uid + ":" + uid);
}
cmd.add("-r");
cmd.add(root.getPath());
cmd.add("-w");
cmd.add("/root");
cmd.add(cwd);
return cmd;
}
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
List<String> cmd = new ArrayList<>();
bind(cmd, "/dev");
bind(cmd, "/proc");
bind(cmd, "/sys");
@@ -142,7 +185,7 @@ public final class LinuxRuntime {
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
bind(cmd, context.getFilesDir().getPath());
bind(cmd, context.getCacheDir().getPath());
bind(cmd, runtimeDir.getPath());
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
if (externalStorage != null && externalStorage.isDirectory()) {
bind(cmd, externalStorage.getPath());
@@ -198,8 +241,9 @@ public final class LinuxRuntime {
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
cmd.addAll(guestCommand);
return cmd;
List<String> specs = new ArrayList<>();
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
return specs;
}
/**
@@ -328,7 +372,7 @@ public final class LinuxRuntime {
}
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
private static String guestUtsname(String hostname) {
static String guestUtsname(String hostname) {
StructUtsname host = Os.uname();
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
@@ -0,0 +1,238 @@
package com.droiddeck.launcher.session
import android.os.Environment
import com.droiddeck.launcher.frontend.Library
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's
* drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
*
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
WINLATOR("Winlator zip", "drive_c/", "xuser"),
}
/** A game, its prefix, and the save folders found in it. */
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
}
/** A folder of saves, relative to the steamuser profile. */
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
private const val USER = "steamuser"
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
private fun backupsDir(): File = File(savesDir(), "backups")
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
// ------------------------------------------------------------------ finding saves
/** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */
private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow")
private const val KEEP_THRESHOLD = 50
private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER")
/**
* Folders under the save roots, one and two deep, whose names match the game's name or its folder's.
* Nested matches keep the outer folder only.
*/
fun locate(prefix: File, game: Library.SteamGame): List<SaveDir> {
val profile = profile(prefix)
if (!profile.isDirectory) return emptyList()
val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() }
if (ids.isEmpty()) return emptyList()
val hits = LinkedHashMap<String, Int>()
fun consider(dir: File) {
val d = ident(dir.name)
if (d.norm.isEmpty()) return
val s = ids.maxOf { score(it, d) }
if (s < KEEP_THRESHOLD) return
val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/')
if ((hits[rel] ?: -1) < s) hits[rel] = s
}
for (root in SAVE_ROOTS) {
File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 ->
consider(d1)
d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider)
}
}
val kept = ArrayList<String>()
for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) {
if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel)
}
return kept.map { rel ->
var files = 0
var bytes = 0L
File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() }
SaveDir(rel, files, bytes)
}.filter { it.files > 0 }
}
private data class Ident(val norm: String, val tokens: Set<String>)
private fun ident(raw: String): Ident {
val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "")
return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet())
}
/** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */
private fun score(a: Ident, b: Ident): Int {
if (a.norm.isEmpty() || b.norm.isEmpty()) return 0
if (a.norm == b.norm) return 100
if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70
if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() &&
a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50
return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0
}
private fun levenshtein(a: String, b: String): Int {
val prev = IntArray(b.length + 1) { it }
val cur = IntArray(b.length + 1)
for (i in 1..a.length) {
cur[0] = i
for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1)
System.arraycopy(cur, 0, prev, 0, cur.size)
}
return prev[b.length]
}
// ------------------------------------------------------------------ export
private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date())
private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" }
/**
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
*/
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
dir.mkdirs()
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
return out to count
}
private fun zipProfile(profile: File, roots: List<String>?, layout: Layout, out: File): Int {
if (!profile.isDirectory) return 0
val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile)
var count = 0
ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos ->
for (top in scope) top.walkTopDown()
.onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } }
.filter { it.isFile }
.forEach { f ->
val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/')
zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel"))
f.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
count++
}
}
return count
}
// ------------------------------------------------------------------ import
/**
* Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
*/
fun import(game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
val driveC = File(prefix, "drive_c")
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
backupsDir().mkdirs()
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup)
if (backedUp == 0) backup.delete()
val base = driveC.canonicalFile
var written = 0
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
remap(e.name)?.let { rel ->
val out = File(driveC, rel).canonicalFile
if (out.path.startsWith(base.path + File.separator)) {
if (e.isDirectory) out.mkdirs()
else {
out.parentFile?.mkdirs()
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING)
written++
}
}
}
zis.closeEntry()
e = zis.nextEntry
}
}
return written to backup.takeIf { it.exists() }
}
/** Which layout a zip was written in, from its first user folder; null when it has none. */
fun layoutOf(zip: File): Layout? = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
val segs = driveCSegments(e.name)
if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true))
return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB
e = zis.nextEntry
}
null
}
}.getOrNull()
private fun looksLikeSaveZip(zip: File): Boolean = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry }
false
}
}.getOrDefault(false)
/** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */
private fun remap(raw: String): String? {
val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null
if (segs.any { it == ".." }) return null
// GameHub also packs its own launcher shortcuts; they are never saves.
if (segs[0].equals("proton_shortcuts", true)) return null
val last = segs.last().lowercase()
if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null
if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER
return segs.joinToString("/")
}
private fun driveCSegments(raw: String): MutableList<String>? {
val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." }
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
return segs.drop(1).toMutableList()
}
}
@@ -13,17 +13,28 @@ import java.io.File
* force-stop the app before every launch. Everything of ours that should be alive between
* sessions is inside the app process, so at session start anything else under our uid is a
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
*
* The one exception is work the app started outside any session and is still waiting on - a
* Flatpak install from the store - registered with [keep]: that process and everything under it
* are left to finish.
*/
object OrphanReaper {
private const val TAG = "OrphanReaper"
private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
/** Spares [pid] and its descendants until [release]. */
fun keep(pid: Int) { if (pid > 0) kept.add(pid) }
fun release(pid: Int) { kept.remove(pid) }
fun reap(reason: String): Int {
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
val spared = descendants(kept)
var killed = 0
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
if (pid == me || pid <= 1) return@forEach
if (pid == me || pid <= 1 || pid in spared) return@forEach
if (uidOf(pid) != uid) return@forEach
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
try {
@@ -38,6 +49,25 @@ object OrphanReaper {
return killed
}
/** [roots] and every process below them, by each process's parent in /proc/<pid>/stat. */
private fun descendants(roots: Set<Int>): Set<Int> {
if (roots.isEmpty()) return emptySet()
val parent = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach }
// "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it.
stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it }
}
val out = HashSet(roots)
var grew = true
while (grew) {
grew = false
for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true
}
return out
}
private fun uidOf(pid: Int): Int {
return try {
File("/proc/$pid/status").useLines { lines ->
@@ -1,87 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PerformanceHintManager
import android.util.Log
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.util.concurrent.Executors
/**
* ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the
* panel's frame period as its target and each presented frame's interval as the actual. When a
* frame runs long the power HAL raises CPU clocks for the next one at once, instead of the
* governor waiting for the load average to climb - the difference between a menu that stutters
* on the first scroll and one that does not.
*
* The session may only carry this process's threads, so the guest (gamescope, the Steam client
* under FEX) cannot join it; they are covered by the app-wide game category and sustained mode.
* Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns
* null and the log says so.
*/
object PerfHints {
private const val TAG = "PerfHints"
@Volatile private var session: PerformanceHintManager.Session? = null
@Volatile private var target = 0L
/** Compositor thread only. */
private var last = 0L
private val main = Handler(Looper.getMainLooper())
/** Reports leave the compositor thread: each one is a binder call to the power HAL. */
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } }
/** Opens the session once the compositor thread exists (it is started just before this). */
fun arm(context: Context, refreshHz: Float) {
if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return }
val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong()
val s = session
if (s != null) {
if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } }
return
}
target = wanted
val app = context.applicationContext
var tries = 0
main.post(object : Runnable {
override fun run() {
if (session != null) return
val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 }
if (tid == 0) {
if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid")
return
}
open(app, tid)
}
})
}
@RequiresApi(31)
private fun open(app: Context, tid: Int) {
val pm = app.getSystemService(PerformanceHintManager::class.java)
if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return }
val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null }
if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return }
last = 0L
session = s
Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs")
}
/** Compositor thread, once per presented game frame; the interval since the last is the actual. */
@JvmStatic
fun onFrame() {
val s = session ?: return
val now = System.nanoTime()
val prev = last
last = now
if (prev == 0L) return
val dt = now - prev
// Under half a millisecond is a double present; over half a second is a pause, not a frame.
if (dt < 500_000L || dt > 500_000_000L) return
worker.execute {
try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null }
}
}
}
@@ -4,7 +4,6 @@ import android.app.Activity
import android.app.GameManager
import android.app.GameState
import android.os.Build
import android.os.PowerManager
import android.util.Log
/**
@@ -13,8 +12,10 @@ import android.util.Log
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
* per-window half, applied when the session activity is created:
*
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
* two minutes, which suits an hour in Big Picture better than a boost that fades;
* - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at
* a level the device can hold indefinitely rather than holding a floor, which cost games and
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
* the game;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
@@ -27,15 +28,7 @@ object PerfMode {
private const val TAG = "PerfMode"
fun apply(a: Activity): String {
val parts = ArrayList<String>(3)
// Sustained performance mode.
parts += if (Build.VERSION.SDK_INT >= 24) {
val pm = a.getSystemService(PowerManager::class.java)
if (pm?.isSustainedPerformanceModeSupported == true) {
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
} else "sustained mode unsupported"
} else "sustained mode needs Android 7"
val parts = ArrayList<String>(2)
// The fastest display mode at the panel's current size.
parts += try {
@@ -42,6 +42,13 @@ object SessionFiles {
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap",
"usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak",
"usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup",
"usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run",
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -8,6 +8,8 @@ object SessionPrefs {
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -28,6 +30,20 @@ object SessionPrefs {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
fun setAppImagesEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
fun setHudEnabled(context: Context, on: Boolean) {
@@ -149,8 +165,8 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
@@ -194,14 +210,28 @@ object SessionPrefs {
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears.
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
@@ -233,8 +263,13 @@ object SessionPrefs {
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
/**
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
* client's interface as many small render passes, each paying GMEM's load/store and binning,
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
*/
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
fun setTuSysmem(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("tuSysmem", on).apply()
@@ -254,11 +289,23 @@ object SessionPrefs {
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */
/**
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
*/
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
fun setZinkLazy(context: Context, on: Boolean) {
@@ -315,8 +362,15 @@ object SessionPrefs {
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
* given another preset.
*/
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
@@ -57,6 +57,8 @@ import java.util.Locale
*/
class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -292,6 +294,8 @@ class SessionService : Service() {
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
@@ -513,7 +517,11 @@ class SessionService : Service() {
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
// compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default).
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
@@ -527,9 +535,12 @@ class SessionService : Service() {
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
// a slice of a core for gamepad navigation the session already has.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -599,6 +610,12 @@ class SessionService : Service() {
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
// not there any more; a client that crashed never took its own down.
fakeInputDir.listFiles()?.forEach { file ->
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
}
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
@@ -606,14 +623,34 @@ class SessionService : Service() {
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
if (uinput) {
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
} else if (SessionState.mode == MODE_STEAM) {
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
// for games the client starts only. Everywhere else (the desktop, a program from the
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
// runs under Steam, so every SDL emulator came up with no controller.
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
}
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
@@ -624,6 +661,7 @@ class SessionService : Service() {
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
@@ -1139,6 +1177,10 @@ class SessionService : Service() {
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
private const val FIRST_VIRTUAL_PAD = 16
const val EXTRA_MODE = "mode"
const val MODE_STEAM = "steam"
@@ -63,6 +63,27 @@ object SessionState {
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
@JvmStatic var hdr = false
/** The pad is presented to the Steam client as a Steam Deck controller this session
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
* the service as the session starts, which can be after the activity has resumed - hence
* [deckPadListener]. */
@Volatile private var deckPadValue = false
@JvmStatic var deckPad: Boolean
get() = deckPadValue
set(value) {
if (deckPadValue == value) return
deckPadValue = value
deckPadListener?.invoke()
}
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
@Volatile
var mode = "steam"
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The session's pad, described to the Steam client as a Steam Deck controller.
*
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
* returns the binds that put it in place:
*
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
* runtime already binds there for the GPU);
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
* which libudev's enumeration leaves a device node out.
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
object SteamDeckPad {
private const val TAG = "SessionService"
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
private const val MAJOR = 240
private const val MINOR = 16
private const val NODE = "hidraw$MINOR"
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
private const val USB = "$GUEST_DEVICES/usb1"
private const val INTERFACE = "$USB/1-1:1.2"
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
return try {
FileUtils.clear(base)
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
fun link(file: File, target: String) {
file.parentFile?.mkdirs()
file.delete()
Os.symlink(target, file.path)
}
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
write(USB, "idVendor", "28de\n")
write(USB, "idProduct", "1205\n")
write(USB, "bcdDevice", "0100\n")
write(USB, "manufacturer", "Valve Software\n")
write(USB, "product", "Steam Deck Controller\n")
write(USB, "serial", "$SERIAL\n")
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
write(INTERFACE, "bInterfaceNumber", "02\n")
write(INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
emptyList()
}
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.AppImageManager
import java.io.File
/** The imported AppImages and the import in progress, for the Desktop page. */
object AppImageState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
private set
/** The file being imported and what the import is doing; null when idle. */
var importing by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
/** Why the last import was refused, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-refresh").start()
}
fun import(context: Context, file: File) {
if (importing != null) return
val app = context.applicationContext
importing = file.name; stage = "Starting…"; lastError = null
Thread({
val problem = try {
AppImageManager.import(app, file) { s -> main.post { stage = s } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val list = AppImageManager.list(app)
main.post {
importing = null; stage = null; items = list
lastError = problem?.let { "${file.name}: $it" }
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
}
}, "appimage-import").start()
}
fun remove(context: Context, id: String) {
val app = context.applicationContext
Thread({
AppImageManager.remove(app, id)
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-remove").start()
}
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.store
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an
* app's AppStream data. Only what this device can install is kept - apps built for aarch64.
*/
object FlathubApi {
private const val TAG = "FlathubApi"
private const val BASE = "https://flathub.org/api/v2"
private const val ARCH = "aarch64"
private const val TIMEOUT_MS = 20_000
class AppSummary(
val id: String, val name: String, val summary: String, val icon: String?,
val developer: String?, val verified: Boolean, val installs: Int,
)
class Screenshot(val thumb: String, val full: String)
class AppDetails(
val id: String, val name: String, val summary: String, val description: String,
val developer: String?, val icon: String?, val version: String?, val license: String?,
val homepage: String?, val screenshots: List<Screenshot>, val categories: List<String>,
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
)
/** A category as Flathub's search filters it, with the name shown for it. */
class Category(val id: String, val label: String)
val categories = listOf(
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
Category("Utility", "Utilities"),
)
/** Front-page collections: popular, trending, recently-updated, recently-added. */
fun collection(name: String, count: Int = 30): List<AppSummary>? {
// The collections are not filtered by architecture; ask for more and keep what fits.
val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null
return hits(body)?.take(count)
}
/** Search, optionally within one category (Flathub's main_categories facet). */
fun search(query: String, category: String? = null, count: Int = 48): List<AppSummary>? {
val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH))
if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category))
val payload = JSONObject().put("query", query).put("filters", filters)
.put("hits_per_page", count).put("page", 1)
val body = post("$BASE/search", payload.toString()) ?: return null
return hits(body)
}
/** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */
fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png"
fun details(id: String): AppDetails? {
val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() }
val shots = app.optJSONArray("screenshots")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null
val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) }
.map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") }
.filter { it.second.startsWith("http") }.sortedBy { it.first }
if (all.isEmpty()) null
else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second,
all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second)
}
} ?: emptyList()
val release = app.optJSONArray("releases")?.optJSONObject(0)
val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
return AppDetails(
id = app.optString("id", id),
name = app.optString("name", id),
summary = app.optString("summary"),
description = plainText(app.optString("description")),
developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
icon = app.optString("icon").takeIf { it.startsWith("http") },
version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" },
license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" },
homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") },
screenshots = shots,
categories = cats,
runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() },
downloadSize = summary?.optLong("download_size") ?: 0L,
installedSize = summary?.optLong("installed_size") ?: 0L,
arches = arches,
)
}
private fun hits(body: String): List<AppSummary>? = try {
val arr = JSONObject(body).getJSONArray("hits")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val arches = o.optJSONArray("arches")
if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null
if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null
AppSummary(
id = o.optString("app_id"),
name = o.optString("name"),
summary = o.optString("summary"),
icon = o.optString("icon").takeIf { it.startsWith("http") },
developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
verified = o.optBoolean("verification_verified"),
installs = o.optInt("installs_last_month"),
)
}.filter { it.id.isNotEmpty() }
} catch (e: Exception) {
Log.w(TAG, "hits: $e"); null
}
/** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */
internal fun plainText(html: String): String = html
.replace(Regex("(?i)<li>\\s*"), "• ")
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
private fun get(url: String): String? = request(url, null)
private fun post(url: String, json: String): String? = request(url, json)
private fun request(url: String, json: String?): String? {
var c: HttpURLConnection? = null
return try {
c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("Accept", "application/json")
if (json != null) {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
outputStream.use { it.write(json.toByteArray()) }
}
}
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
else c.inputStream.bufferedReader().use { it.readText() }
} catch (e: Exception) {
Log.w(TAG, "$url: $e"); null
} finally {
c?.disconnect()
}
}
}
@@ -0,0 +1,142 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.FlatpakManager
/**
* The store's state for the life of the process, so an install carries on - and its progress
* shows - while the user moves around the launcher or plays something meanwhile.
*/
object StoreState {
private val main = Handler(Looper.getMainLooper())
var ready by mutableStateOf(false)
private set
var installed by mutableStateOf<List<FlatpakManager.App>>(emptyList())
private set
var updates by mutableStateOf<Set<String>>(emptySet())
private set
var checkingUpdates by mutableStateOf(false)
private set
/** Flathub has been asked this session, so an empty [updates] means up to date. */
var updatesChecked by mutableStateOf(false)
private set
/** What is running: "setup", an app id, or "update-all"; null when idle. */
var busy by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
var percent by mutableStateOf(-1)
private set
/** The front page's sections, by collection name; absent until loaded, empty list on failure. */
val sections = mutableStateMapOf<String, List<FlathubApi.AppSummary>>()
var sectionsFailed by mutableStateOf(false)
private set
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
val detailsFailed = mutableStateMapOf<String, Boolean>()
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(null)
private set
var searching by mutableStateOf(false)
private set
private var searchSeq = 0
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() }
}, "store-refresh").start()
}
fun loadSections(force: Boolean = false) {
if (!force && sections.isNotEmpty()) return
sectionsFailed = false
Thread({
var failed = false
for ((key, _) in SECTIONS) {
val apps = FlathubApi.collection(key, 18)
if (apps == null) failed = true
main.post { sections[key] = apps ?: emptyList() }
}
main.post { sectionsFailed = failed }
}, "store-sections").start()
}
fun loadDetails(id: String) {
if (details.containsKey(id)) return
detailsFailed.remove(id)
Thread({
val d = FlathubApi.details(id)
main.post { if (d != null) details[id] = d else detailsFailed[id] = true }
}, "store-details").start()
}
fun search(query: String, category: String?) {
val seq = ++searchSeq
searching = true
Thread({
val r = FlathubApi.search(query, category)
main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } }
}, "store-search").start()
}
fun clearSearch() { searchSeq++; searchResults = null; searching = false }
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
if (busy != null) return
val app = context.applicationContext
busy = what; stage = "Starting…"; percent = -1
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post {
busy = null; stage = null; percent = -1
ready = r; installed = list
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
}
}, "store-$what").start()
}
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
fun install(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
fun uninstall(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } }
fun update(context: Context, id: String?, name: String) =
run(context, id ?: "update-all", name) { c, p ->
FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } }
}
fun checkUpdates(context: Context) {
if (checkingUpdates || busy != null) return
val app = context.applicationContext
checkingUpdates = true
Thread({
val u = FlatpakManager.updates(app)
main.post {
checkingUpdates = false
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
}
}, "store-updates").start()
}
}
@@ -0,0 +1,351 @@
package com.droiddeck.launcher.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import kotlin.math.min
/**
* The Steam Deck controller's back grips and trackpads, on the second screen.
*
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
* where something is being touched.
*/
@SuppressLint("ViewConstructor")
class DeckControlsPanel(
context: Context,
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
) : LinearLayout(context) {
private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") }
private val content = FrameLayout(context)
private val tabViews = HashMap<Tab, TextView>()
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(selected)
}
override fun onDetachedFromWindow() {
DeckControls.releaseAll()
super.onDetachedFromWindow()
}
private fun topBar(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(10), dp(12), dp(6))
addView(roundButton("×", "Close second-screen controls", onClose))
val tabs = LinearLayout(context).apply {
orientation = HORIZONTAL
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
val view = TextView(context).apply {
text = tab.label
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
isSingleLine = true
setPadding(dp(14), 0, dp(14), 0)
setOnClickListener { select(tab) }
}
tabViews[tab] = view
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
}
val centre = FrameLayout(context).apply {
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
}
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true))
addView(space(dp(8)))
addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true))
}
private fun select(tab: Tab) {
selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
content.removeAllViews()
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
}
private fun gripsGrid(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
setPadding(dp(12), dp(6), dp(12), dp(12))
fun column(upper: View, lower: View) = LinearLayout(context).apply {
orientation = VERTICAL
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
}
addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
}
private fun bothPads(): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
val pads = LinearLayout(context).apply {
orientation = HORIZONTAL
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
// Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle.
val grips = LinearLayout(context).apply {
orientation = HORIZONTAL
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
row.forEachIndexed { index, (label, bit) ->
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
if (index > 0) leftMargin = dp(6)
if (index < row.size - 1) rightMargin = dp(6)
})
}
}
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
contentDescription = description
textSize = if (wide) 13f else 20f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = if (wide) 0.08f else 0f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0)
minWidth = dp(44)
layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44))
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) }
private fun outline(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
setStroke(dp(1), LINE)
}
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
/** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */
private abstract class Surface(context: Context) : View(context) {
protected val density = resources.displayMetrics.density
protected val rect = RectF()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
color = TEXT_DIM
}
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
val inset = density
rect.set(inset, inset, width - inset, height - inset)
val radius = CORNER * density
fill.color = if (lit) ACCENT_WASH else SURFACE
canvas.drawRoundRect(rect, radius, radius, fill)
stroke.color = if (lit) ACCENT else LINE
stroke.strokeWidth = (if (lit) 2f else 1f) * density
canvas.drawRoundRect(rect, radius, radius, stroke)
}
}
/** A held button: down while a finger is on it. */
private open class HoldButton(
context: Context,
private val label: String,
private val caption: String?,
private val onChange: (Boolean) -> Unit,
) : Surface(context) {
private var down = false
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, down)
val cx = width / 2f
labelPaint.textSize = min(height * 0.3f, 30 * density)
labelPaint.color = if (down) ACCENT else TEXT
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
canvas.drawText(label, cx, labelY, labelPaint)
if (caption != null) {
captionPaint.textSize = 12 * density
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> set(true)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
}
return true
}
private fun set(pressed: Boolean) {
if (down == pressed) return
down = pressed
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onChange(pressed)
invalidate()
}
}
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
private class ClickBar(context: Context, right: Boolean) :
HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) })
/**
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
* while a second finger is down.
*/
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
private var fingerY = 0f
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicked)
val cx = width / 2f
val cy = height / 2f
guide.strokeWidth = density
val reach = min(width, height) / 2f - 24 * density
canvas.drawCircle(cx, cy, reach, guide)
canvas.drawCircle(cx, cy, reach / 2f, guide)
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawCircle(fingerX, fingerY, radius, glow)
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
touch(event.getX(0), event.getY(0))
click(event.pointerCount > 1)
}
MotionEvent.ACTION_POINTER_DOWN -> click(true)
MotionEvent.ACTION_POINTER_UP -> {
// The finger that lifted may be the first; the pad follows whichever stays.
val stays = if (event.actionIndex == 0) 1 else 0
touch(event.getX(stays), event.getY(stays))
click(event.pointerCount - 1 > 1)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
click(false)
touching = false
DeckControls.setPad(right, touching = false)
invalidate()
}
}
return true
}
private fun touch(x: Float, y: Float) {
fingerX = x.coerceIn(0f, width.toFloat())
fingerY = y.coerceIn(0f, height.toFloat())
touching = true
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
invalidate()
}
private fun click(down: Boolean) {
if (clicked == down) return
clicked = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
DeckControls.setClick(right, down)
invalidate()
}
}
companion object {
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
private var selected = Tab.BOTH
private const val CORNER = 20f
private const val CORNER_PILL = 22f
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
}
}
@@ -154,6 +154,11 @@ private fun Content(
SteamHome(s, a, modifier)
return
}
// The Store scrolls and navigates inside itself (its tabs and an app's page).
if (selected == "store" && s.storeEnabled) {
StorePage(s, a, modifier)
return
}
// The Games tab lays out its own list and detail.
if (selected == "games" || selected.startsWith("app:")) {
GamesPage(s, a, selected, onSelect, modifier)
@@ -192,6 +197,8 @@ private fun Content(
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
}
if (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) }
if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) }
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
@@ -120,11 +120,56 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
BusyChip(s)
}
}
/**
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
* either, or open the save folder found for it. The saves are looked for when the menu opens.
*/
@Composable
private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) {
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
val saves by androidx.compose.runtime.produceState<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(null, g.gameId, open) {
if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList())
}
}
val found = saves
val summary = when {
found == null -> "Looking for this game's saves…"
found.isEmpty() -> "No save folder found by name: an export takes the whole user folder."
else -> {
val mb = found.sumOf { it.bytes } / 1048576.0
(if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " +
(if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb))
}
}
Box {
SecondaryButton("Manage saves", compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first ->
MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) {
open = false; a.onSaveImport(g)
}
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB)
}
MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR)
}
found?.firstOrNull()?.let { d ->
MenuItem("Open save folder", checked = false, detail = d.relPath) {
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
}
}
}
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
@@ -234,7 +279,7 @@ private fun lastPlayedText(lastPlayed: Long): String? {
private fun libraryLabel(library: String): String = when (library) {
"internal" -> "Internal storage"
"added" -> "Added game"
Library.ADDED -> "Added game"
else -> library
}
@@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
@@ -145,6 +146,7 @@ internal fun SideRail(
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
@@ -114,6 +114,9 @@ class FrontEndState(
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
val launcherFullscreen: Boolean = true,
/** Beta features the user turns on in Setup: the Flathub Store and AppImage import. */
val storeEnabled: Boolean = false,
val appImagesEnabled: Boolean = false,
)
class FrontEndActions(
@@ -122,6 +125,11 @@ class FrontEndActions(
val onSteamGame: (Library.SteamGame) -> Unit,
val onDesktop: () -> Unit,
val onEmulator: (Library.Emulator) -> Unit,
/** A Flatpak app by id and name, from the Store. */
val onFlatpakApp: (String, String) -> Unit = { _, _ -> },
/** Pick an AppImage to import; open an imported one by its directory and name. */
val onImportAppImage: () -> Unit = {},
val onAppImage: (String, String) -> Unit = { _, _ -> },
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
@@ -133,6 +141,9 @@ class FrontEndActions(
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
val onPerformance: () -> Unit,
val onRoms: () -> Unit,
val onFiles: () -> Unit,
@@ -144,6 +155,8 @@ class FrontEndActions(
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
val onAppImagesEnabled: (Boolean) -> Unit = {},
val onHomeApp: () -> Unit = {},
val onHomeScreen: (Boolean) -> Unit = {},
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
@@ -350,6 +363,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':')
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
@@ -312,6 +312,20 @@ internal fun SetupPanel(
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
}
}
SettingsGroup("Linux apps (beta)") {
ToggleRow(
host, "store-enabled", "Flathub Store",
if (s.storeEnabled) "The Store is in the menu. Some apps may not start; logs are in Download/DroidDeck"
else "Off: install Linux apps and games from Flathub with Flatpak",
s.storeEnabled,
) { a.onStoreEnabled(it) }
ToggleRow(
host, "appimages-enabled", "AppImages",
if (s.appImagesEnabled) "Add AppImage is on the Desktop page. ARM64 (aarch64) AppImages only"
else "Off: import ARM64 AppImages from your storage",
s.appImagesEnabled,
) { a.onAppImagesEnabled(it) }
}
}
else -> {
SettingsGroup("About") {
@@ -71,6 +71,8 @@ class ModeSettings(
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
val steamController: String? = null,
/** Steam only: start a Steam session when DroidDeck opens. */
val runSteamAtStartup: Boolean = false,
/** Steam only: the user's chosen Games folders; null outside Steam. */
@@ -128,6 +130,7 @@ class ModeSettingsActions(
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onPickAddedGamesDir: () -> Unit = {},
val onForgetAddedGamesDir: (path: String) -> Unit = {},
@@ -261,6 +264,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
), s.oscMode,
note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", "Controller", "What your controller is to Steam. Applies next session.",
listOf(
SessionPrefs.CONTROLLER_DECK to "Steam Deck controller",
SessionPrefs.CONTROLLER_XBOX360 to "Xbox 360 controller",
), s.steamController,
note = "Steam Deck controller: Steam reads it as a Deck's own, with its Quick Access button and the device's gyro. " +
"Xbox 360 controller: the plain pad of earlier versions; Quick Access opens with Guide+A.",
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
@@ -37,6 +37,7 @@ fun PerformancePage(
glThread: Boolean,
noGlError: Boolean,
noXalia: Boolean,
gamescopeRealtime: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
@@ -46,6 +47,7 @@ fun PerformancePage(
onGlThread: (Boolean) -> Unit,
onNoGlError: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onGamescopeRealtime: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
@@ -86,7 +88,7 @@ fun PerformancePage(
)
ToggleRow(
host, "zink", "Zink: lazy descriptors",
"Recommended for drivers without descriptor buffers.",
"Lazy, compact descriptors. Recommended for drivers without descriptor buffers.",
zinkLazy, onChange = onZinkLazy,
)
ToggleRow(
@@ -94,16 +96,21 @@ fun PerformancePage(
"Disables per-call GL validation.",
noGlError, onChange = onNoGlError,
)
ToggleRow(
host, "gsrealtime", "gamescope: realtime GPU queue",
"Gives the compositor's GPU work priority over the game's. Can smooth frame pacing, but can cost games GPU time.",
gamescopeRealtime, onChange = onGamescopeRealtime,
)
}
SettingsGroup("Session fixes") {
ToggleRow(
host, "sysmem", "Turnip: sysmem rendering",
"Required on Adreno 710/720/722. May fix corruption on other Adreno GPUs, but can reduce performance.",
"On by default: faster for the Steam interface and most games. Required on Adreno 710/720/722.",
tuSysmem, onChange = onTuSysmem,
)
ToggleRow(
host, "xalia", "Skip Steam's xalia helper",
"Disables Proton's gamepad navigation helper. Try if sessions crash at startup.",
"Disables Proton's gamepad navigation helper, which costs every game CPU time. Turn off only if a game needs it.",
noXalia, onChange = onNoXalia,
)
ToggleRow(
@@ -112,6 +112,7 @@ class SecondScreenPresentation(
when (currentMode) {
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams())
SecondScreenMode.NONE -> Unit
}
}
@@ -357,7 +357,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
ChoiceRow(host, "second-screen-mode", "Shows", null,
listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label },
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
@@ -0,0 +1,556 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.store.FlathubApi
import com.droiddeck.launcher.store.StoreState
// The Store rail section: Flathub's apps for this device's architecture, installed into the
// Linux runtime with Flatpak, and the ones already installed.
private val TABS = listOf("Discover", "Search", "Installed")
@Composable
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
var tab by rememberSaveable { mutableStateOf(0) }
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
LaunchedEffect(Unit) { StoreState.loadSections() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
// a tab's contents - and would be lost with it. Each move says where focus goes next.
val ff = LocalFrontFocus.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } }
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 }
fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 }
BackHandler(enabled = openApp != null) { closeApp() }
val scroll = rememberScrollState()
LaunchedEffect(openApp, tab) { scroll.scrollTo(0) }
LaunchedEffect(focusMove, openApp) {
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
val target = when {
openApp != null -> ff?.primary
focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab]
focusMove?.first == "tab" -> tabFocus[tab]
else -> null
} ?: return@LaunchedEffect
var landed = false
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
}
Column(
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
.bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
) {
val id = openApp
if (id != null) {
AppDetail(s, a, id) { closeApp() }
return@Column
}
Rise(0) {
PageHeader("Store") {
Chip("Flathub · ARM64", ok = false)
if (StoreState.ready) Chip("● Flatpak ready", ok = true)
}
}
if (!s.ready) {
Rise(1) { Note("Install the Linux runtime from Setup first; the store installs apps into it.") }
return@Column
}
if (!StoreState.ready) Rise(1) { SetupCard() }
Rise(2) {
TabStrip(
TABS.mapIndexed { i, t -> if (i == 2 && StoreState.installed.isNotEmpty()) "$t · ${StoreState.installed.size}" else t },
tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
)
}
BusyBar()
when (tab) {
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
else -> Installed(a, onOpen = { openApp = it })
}
}
}
/** Flatpak is not in the runtime yet: one button puts it there. */
@Composable
private fun SetupCard() {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val busy = StoreState.busy == "setup"
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Text("Set up Flatpak", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(
"Adds Flatpak to the Linux runtime and connects it to Flathub, about 20 MB. Apps then install " +
"with the runtimes they need; the first one is the biggest download.",
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
Actions {
PrimaryButton(if (busy) "Setting up…" else "Set up Flatpak", enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
}
}
}
/** The running install, update or setup, whichever page it was started from. */
@Composable
private fun BusyBar() {
val colors = MaterialTheme.colorScheme
val busy = StoreState.busy ?: return
val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name
?: when (busy) { "setup" -> "Flatpak"; "update-all" -> "Updates"; else -> busy.substringAfterLast('.') }
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) {
val stage = StoreState.stage
Text(
"$name · " + (if (stage != null && StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage ?: "Starting…"),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 6.dp),
)
if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
@Composable
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
Rise(3) { SectionTitle("Categories", null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } }
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
Rise(4) { Note("Flathub could not be reached. Check the connection and open the Store again.") }
Actions { SecondaryButton("Try again") { StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
}
}
@Composable
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
val view = androidx.compose.ui.platform.LocalView.current
val run = {
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
StoreState.search(query.trim(), category)
}
Rise(3) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
AdbTextField(
value = query, onValueChange = onQuery, label = "Search Flathub", keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done, placeholder = "Firefox, games, emulators…", modifier = Modifier.weight(1f), onDone = run,
)
PrimaryButton("Search", main = true, onClick = run)
}
}
Rise(4) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton("All", selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
}
}
}
val results = StoreState.searchResults
when {
StoreState.searching -> Rise(5) { SectionTitle("Searching…", null) }
results == null -> Rise(5) { Note("Search Flathub by name, or pick a category. Only apps built for ARM64 are listed.") }
results.isEmpty() -> Rise(5) { Note("Nothing on Flathub for ARM64 matches that.") }
else -> {
Rise(5) { SectionTitle("Results", results.size.toString()) }
Rise(6) { AppGrid(results, first = true, onOpen = onOpen) }
}
}
}
@Composable
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val apps = StoreState.installed
Rise(3) {
Actions {
SecondaryButton(if (StoreState.checkingUpdates) "Checking…" else "Check for updates", enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
}
if (StoreState.updates.isNotEmpty()) PrimaryButton("Update all (${StoreState.updates.size})", enabled = StoreState.busy == null) {
StoreState.update(ctx, null, "Updates")
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip("● Everything is up to date", ok = true)
}
}
if (apps.isEmpty()) {
Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) "No apps yet. Find some under Discover or Search." else "Set up Flatpak to install apps.") } }
return
}
Rise(4) { SectionTitle("Installed", apps.size.toString()) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
apps.forEachIndexed { i, app ->
key(app.id) {
Rise(5 + i.coerceAtMost(6)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
(if (app.id in StoreState.updates) "Update available · " else "") + (app.summary ?: app.id),
fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
PrimaryButton("Open", compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
if (app.id in StoreState.updates) SecondaryButton("Update", compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
SecondaryButton("Details", compact = true) { onOpen(app.id) }
}
}
}
}
}
}
private fun s_busyFor(id: String) = StoreState.busy == id
@Composable
private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
LaunchedEffect(id) { StoreState.loadDetails(id) }
val d = StoreState.details[id]
val local = StoreState.installed.firstOrNull { it.id == id }
val name = d?.name ?: local?.name ?: id
Rise(0) { BackLink("Store", onClick = onBack) }
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) {
AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72)
Column(modifier = Modifier.weight(1f)) {
Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) }
(d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) }
}
}
}
val busyHere = StoreState.busy == id
val installed = local != null
// Remove asks twice: one stray press of A should not cost a download. The second press has
// to come within a few seconds; the app's own data in ~/.var/app stays either way.
var confirmRemove by remember(id) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
Rise(2) {
Actions {
if (installed) {
PrimaryButton("Open", enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
if (id in StoreState.updates) SecondaryButton("Update", enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
SecondaryButton(if (busyHere) "Working…" else if (confirmRemove) "Press again to remove" else "Remove", enabled = StoreState.busy == null) {
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
}
} else {
PrimaryButton(
if (busyHere) "Installing…" else "Install", main = true,
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) { StoreState.install(ctx, id, name) }
if (!StoreState.ready) ActionChip("Set up Flatpak first", ok = false)
}
d?.version?.let { ActionChip("v$it", ok = false) }
if (d != null && d.downloadSize > 0) ActionChip("${FileUtils.sizeToString(d.downloadSize)} download", ok = false)
if (installed) ActionChip("● Installed", ok = true)
}
}
if (busyHere) BusyBar()
if (d == null) {
Rise(3) {
Box(Modifier.padding(top = 14.dp)) {
Note(if (StoreState.detailsFailed[id] == true) "Flathub's page for this app could not be loaded." else "Loading from Flathub…")
}
}
return
}
if (d.arches.isNotEmpty() && "aarch64" !in d.arches) Rise(3) { Box(Modifier.padding(top = 12.dp)) { Note("Flathub has no ARM64 build of this app, so it cannot be installed here.") } }
if (d.screenshots.isNotEmpty()) {
Rise(3) { SectionTitle("Screenshots", null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
d.screenshots.take(6).forEach { shot ->
AsyncImage(
model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.height(if (narrow) 150.dp else 190.dp).aspectRatio(16f / 9f).clip(Shape12).background(colors.surface),
)
}
}
}
}
if (d.description.isNotEmpty()) {
Rise(4) { SectionTitle("About", null) }
Rise(4) { Text(d.description, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onBackground, modifier = Modifier.fillMaxWidth()) }
}
Rise(5) { SectionTitle("Details", null) }
Rise(5) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 16.dp)) {
listOfNotNull(
"App ID" to d.id,
d.license?.let { "License" to it },
d.runtime?.let { "Runtime" to it.replace("/x86_64/", "/aarch64/") },
if (d.installedSize > 0) "Installed size" to FileUtils.sizeToString(d.installedSize) + " (plus its runtime, shared between apps)" else null,
d.homepage?.let { "Website" to it },
).forEach { (k, v) ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(k, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(110.dp))
Text(v, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
}
}
}
}
}
/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */
@Composable
private fun AppGrid(apps: List<FlathubApi.AppSummary>, first: Boolean, onOpen: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
apps.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, app ->
key(app.id) {
AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) }
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale")
val installed = StoreState.installed.any { it.id == app.id }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(app.icon, app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = "Verified", tint = pal.signal, modifier = Modifier.size(14.dp))
}
Text(if (installed) "Installed" else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */
@Composable
private fun AppIcon(model: Any?, name: String, size: Int) {
val colors = MaterialTheme.colorScheme
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(RoundedCornerShape((size / 5).dp)).background(if (model == null) colors.surfaceVariant else Color.Transparent)) {
if (model == null) Icon(Icons.Outlined.Apps, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size((size / 2).dp))
else AsyncImage(model = model, contentDescription = name, contentScale = ContentScale.Fit, modifier = Modifier.size(size.dp))
}
}
/** A category pill: small, focusable, and filled when it is the one in use. */
@Composable
private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.paneItem("pill:$label").clip(shape)
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
)
}
/** The apps installed from the Store, as launch tiles - on the Desktop page beside the emulators. */
@Composable
internal fun InstalledAppsGrid(a: FrontEndActions) {
val ctx = LocalContext.current
LaunchedEffect(Unit) { StoreState.refresh(ctx) }
val apps = StoreState.installed
if (apps.isEmpty()) return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
SectionTitle("Apps from the Store", apps.size.toString())
apps.chunked(columns).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEach { app ->
key(app.id) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}")
.clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(app.summary ?: "Flatpak", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/**
* The user's own AppImages on the Desktop page: an Add button, each one to open, and Remove
* (pressed twice, like the store's). The picker, the check and the extraction live elsewhere
* (MainActivity, AppImageManager); this only shows them.
*/
@Composable
internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
val ctx = LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.store.AppImageState.refresh(ctx) }
val state = com.droiddeck.launcher.store.AppImageState
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
SectionTitle("AppImages", state.items.size.takeIf { it > 0 }?.toString())
Actions {
SecondaryButton(if (state.importing != null) "Importing…" else "Add AppImage", enabled = runtimeReady && state.importing == null) { a.onImportAppImage() }
if (!runtimeReady) ActionChip("Runtime required", ok = false)
}
state.importing?.let { name ->
Text("$name · ${state.stage ?: "Starting…"}", fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
state.lastError?.let { Note(it) }
if (state.items.isEmpty() && state.importing == null && state.lastError == null) {
Note("Add an ARM64 (aarch64) AppImage from your storage. It is unpacked into the Linux runtime once, then opens full screen from here or from the desktop's menu.")
}
state.items.forEach { item ->
key(item.id) {
var confirm by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(end = 12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(item.icon, item.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(item.comment ?: "AppImage", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
SecondaryButton(if (confirm) "Press again to remove" else "Remove", compact = true) {
if (confirm) { confirm = false; com.droiddeck.launcher.store.AppImageState.remove(ctx, item.id) } else confirm = true
}
}
}
}
}
}
@@ -344,7 +344,7 @@ private fun WirelessStepForm(
}
@Composable
private fun AdbTextField(
internal fun AdbTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
@@ -41,6 +41,10 @@ object CompositorHost {
}
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
// The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate
// already being achieved, and on a phone whose vendor picks clocks from that a session
// that has been slowed reads as one that wants to be. (WinNative, WaylandSession.)
WaylandCompositor.nativeSetLayerFrameRate(refreshHz)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
)
@@ -91,7 +91,6 @@ public final class WaylandCompositor {
long smoothed = frameIntervalNanos;
frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4;
}
com.droiddeck.launcher.session.PerfHints.onFrame();
GameListener l = gameListener;
if (l != null) l.onGameFrame();
}
@@ -218,7 +217,7 @@ public final class WaylandCompositor {
* once per tick, so games run unthrottled and the screen shows their latest frame. */
public static native void nativeVsync(long frameTimeNanos);
/** The compositor thread's Linux tid, or 0 before it has started (see PerfHints). */
/** The compositor thread's Linux tid, or 0 before it has started. */
public static native int nativeCompositorTid();
/** Shortcut launches: don't draw explorer's windows (desktop, taskbar, Start menu), matching the
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AppImageManagerTest {
private lateinit var dir: File
@Before fun setUp() { dir = Files.createTempDirectory("appimage").toFile() }
@After fun tearDown() { dir.deleteRecursively() }
/** An ELF header with the AppImage magic at byte 8 and [machine] at byte 18. */
private fun image(machine: Int, type: Int = 2, magic: Boolean = true): File {
val b = ByteArray(64)
b[0] = 0x7f; b[1] = 'E'.code.toByte(); b[2] = 'L'.code.toByte(); b[3] = 'F'.code.toByte()
if (magic) { b[8] = 'A'.code.toByte(); b[9] = 'I'.code.toByte(); b[10] = type.toByte() }
b[18] = (machine and 0xff).toByte(); b[19] = (machine shr 8).toByte()
return File(dir, "x.AppImage").apply { writeBytes(b) }
}
@Test fun acceptsAnArm64TypeTwoImage() = assertNull(AppImageManager.problem(image(183)))
@Test fun refusesX86WithAHint() = assertTrue(AppImageManager.problem(image(62))!!.contains("x86_64"))
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2"))
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage"))
@Test fun refusesSomethingElse() {
val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") }
assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage"))
}
@Test fun idsAreTidyAndUnique() {
assertEquals("some-app-1-2-aarch64", AppImageManager.idFor("Some_App-1.2-aarch64.AppImage", emptySet()))
assertEquals("some-app-2", AppImageManager.idFor("Some App.AppImage", setOf("some-app")))
}
@Test fun readsTheMainDesktopGroup() {
val entry = "[Desktop Entry]\nName=Duck\nName[de]=Ente\nIcon=duck\n[Desktop Action x]\nName=Other\n"
assertEquals("Duck", AppImageManager.desktopKey(entry, "Name"))
assertEquals("duck", AppImageManager.desktopKey(entry, "Icon"))
assertNull(AppImageManager.desktopKey(entry, "Comment"))
}
}
@@ -0,0 +1,127 @@
package com.droiddeck.launcher.runtime
import android.util.Base64
import org.robolectric.RuntimeEnvironment
import com.droiddeck.launcher.runtime.BwrapSpawner.Bind
import java.io.File
import java.nio.file.Files
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
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
@RunWith(RobolectricTestRunner::class)
class BwrapSpawnerTest {
private val session = listOf(
Bind("/dev", "/dev"),
Bind("/cache/shm", "/dev/shm"),
Bind("/proc", "/proc"),
Bind("/rootfs/etc/bannerlator/proc/stat", "/proc/stat"),
Bind("/data/files", "/data/files"),
)
@Test fun underRespectsPathBoundaries() {
assertTrue(BwrapSpawner.under("/usr/lib", "/usr"))
assertTrue(BwrapSpawner.under("/usr", "/usr"))
assertFalse(BwrapSpawner.under("/usrlocal", "/usr"))
assertTrue(BwrapSpawner.under("/anything", "/"))
}
@Test fun hostPathGoesThroughTheMostSpecificSessionBind() {
assertEquals("/cache/shm/x", BwrapSpawner.hostPath(session, "/rootfs", "/dev/shm/x"))
assertEquals("/dev/null", BwrapSpawner.hostPath(session, "/rootfs", "/dev/null"))
assertEquals("/data/files/.wayland-rt/wayland-1", BwrapSpawner.hostPath(session, "/rootfs", "/data/files/.wayland-rt/wayland-1"))
}
@Test fun hostPathFallsBackToTheRootfs() {
assertEquals("/rootfs/root/.local/share/flatpak", BwrapSpawner.hostPath(session, "/rootfs/", "/root/.local/share/flatpak"))
}
@Test fun bindingDevBringsTheSessionsStandInsAlong() {
val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/dev", "/dev")
assertEquals(Bind("/dev", "/dev"), binds.first())
assertTrue(Bind("/cache/shm", "/dev/shm") in binds)
assertFalse(binds.any { it.guest.startsWith("/proc") })
}
@Test fun nestedBindsFollowARenamedDestination() {
val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/proc", "/newproc")
assertTrue(Bind("/rootfs/etc/bannerlator/proc/stat", "/newproc/stat") in binds)
}
@Test fun normalizeResolvesDotsLexically() {
assertEquals("/usr/lib/x", BwrapSpawner.normalize("/usr/lib/GL/../x"))
assertEquals("/", BwrapSpawner.normalize("/../.."))
}
@Test fun bindSpecsRoundTrip() {
assertEquals(Bind("/a", "/b"), Bind.parse("/a:/b"))
assertEquals(Bind("/a", "/a"), Bind.parse("/a"))
assertEquals("/a", Bind("/a", "/a").spec)
assertEquals("/a:/b", Bind("/a", "/b").spec)
}
}
/** The sandbox a Flatpak request describes, built on disk under a temporary directory. */
@RunWith(RobolectricTestRunner::class)
class BwrapSpawnerPlanTest {
private lateinit var dir: File
private lateinit var runtime: File
@Before fun setUp() {
dir = Files.createTempDirectory("bwrap-plan").toFile()
// Flatpak names the runtime by its path inside the rootfs.
runtime = File(LinuxRuntime.rootDir(RuntimeEnvironment.getApplication()), "runtime-files")
File(runtime, "lib").mkdirs()
}
@After fun tearDown() {
dir.deleteRecursively()
runtime.deleteRecursively()
}
private fun op(vararg pairs: Pair<String, Any>) = JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } }
private fun request(vararg ops: JSONObject) = JSONObject()
.put("argv", JSONArray().put("/app/bin/game"))
.put("env", JSONObject().put("FLATPAK_ID", "org.example.Game"))
.put("cwd", "/app")
.put("ops", JSONArray().apply { ops.forEach { put(it) } })
@Test fun buildsTheTreeFlatpakAsksFor() {
val context = RuntimeEnvironment.getApplication()
val info = Base64.encodeToString("[Application]\nname=org.example.Game\n".toByteArray(), Base64.NO_WRAP)
val plan = BwrapSpawner.plan(context, request(
op("t" to "bind", "src" to "/runtime-files", "dest" to "/usr", "ro" to true),
op("t" to "symlink", "target" to "usr/lib", "dest" to "/lib"),
op("t" to "dir", "dest" to "/usr/lib/GL", "empty" to true),
op("t" to "symlink", "target" to "default/lib/libEGL.so", "dest" to "/usr/lib/GL/libEGL.so"),
op("t" to "dir", "dest" to "/tmp"),
op("t" to "file", "dest" to "/.flatpak-info", "data" to info, "perms" to 0x180),
op("t" to "bind", "src" to "/nonexistent-${System.nanoTime()}", "dest" to "/run/x", "try" to true),
), dir)
// The runtime is bound at /usr, not written into.
assertTrue(plan.binds.any { it.guest == "/usr" && File(it.host).canonicalPath == runtime.canonicalPath })
assertFalse(File(runtime, "lib/GL").exists())
// The tmpfs under /usr is a directory of ours, and the link Flatpak makes inside it lands there.
val gl = plan.binds.single { it.guest == "/usr/lib/GL" }
assertTrue(gl.host.startsWith(dir.path))
assertEquals("default/lib/libEGL.so", Files.readSymbolicLink(File(gl.host, "libEGL.so").toPath()).toString())
// Top-level links, directories and files are made in the sandbox root itself.
assertEquals("usr/lib", Files.readSymbolicLink(File(plan.root, "lib").toPath()).toString())
assertTrue(File(plan.root, "tmp").isDirectory)
assertEquals("[Application]\nname=org.example.Game\n", File(plan.root, ".flatpak-info").readText())
// A --bind-try whose source is missing is left out.
assertFalse(plan.binds.any { it.guest == "/run/x" })
assertEquals(listOf("/app/bin/game"), plan.argv)
assertEquals("/app", plan.cwd)
assertEquals("org.example.Game", plan.env["FLATPAK_ID"])
}
}
@@ -49,6 +49,15 @@ class GameEnvironmentStoreTest {
assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS"))
}
@Test fun unchosenFexPresetPublishesPerformanceTso() {
GameEnvironmentStore.save(context, GameEnvironment.Config())
val shared = JSONObject(guest.readText()).getJSONObject("shared")
assertEquals("1", shared.getString("FEX_TSOENABLED"))
assertEquals("0", shared.getString("FEX_HALFBARRIERTSOENABLED"))
assertEquals("1", shared.getString("FEX_X87REDUCEDPRECISION"))
assertEquals("1", shared.getString("FEX_MULTIBLOCK"))
}
@Test fun invalidDataCannotReplaceSavedConfiguration() {
val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok"))
GameEnvironmentStore.save(context, original)
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.store
import com.droiddeck.launcher.runtime.FlatpakManager
import org.junit.Assert.assertEquals
import org.junit.Test
class FlathubApiTest {
@Test fun appstreamDescriptionsBecomeLines() {
val html = "<p>A game &amp; more.</p><ul><li>Fast</li><li>Free</li></ul><p>Enjoy</p>"
assertEquals("A game & more.\n• Fast\n• Free\n\nEnjoy", FlathubApi.plainText(html))
}
@Test fun refsReadAsWords() {
assertEquals("SuperTux", FlatpakManager.refLabel("app/org.supertuxproject.SuperTux/aarch64/stable"))
assertEquals("GNOME runtime 50", FlatpakManager.refLabel("runtime/org.gnome.Platform/aarch64/50"))
assertEquals("Freedesktop runtime 26.08", FlatpakManager.refLabel("runtime/org.freedesktop.Platform/aarch64/26.08"))
assertEquals("graphics drivers", FlatpakManager.refLabel("runtime/org.freedesktop.Platform.GL.default/aarch64/26.08"))
assertEquals("translations", FlatpakManager.refLabel("runtime/org.gnome.Platform.Locale/aarch64/50"))
}
}
+118
View File
@@ -0,0 +1,118 @@
# Flatpak and the Store
Both features are beta and off by default. Setup → Launcher → "Linux apps (beta)" has one toggle for the
Flathub Store, which shows the Store rail item and the Store's apps on the Desktop page, and one for
AppImages, which shows the AppImages section. Turning either off hides it; installed apps stay installed
and remain in the Linux desktop's menu.
The Store rail section installs apps from Flathub into the Linux runtime with Flatpak. Only
ARM64 builds are listed. Everything lives in one per-user installation at
`/root/.local/share/flatpak`, so nothing needs Flatpak's system helper or polkit.
## Putting Flatpak in the runtime
`bannerlator-flatpak-setup` runs under proot's fake root (`-0`), because pacman and pacman-key
refuse any other uid. The runtime's pacman database lists only the base image. The desktop
and emulator packages were unpacked over it without registering. A plain `pacman -S flatpak`
would therefore reinstall about a hundred packages, Mesa among them, over the runtime's KGSL
Turnip build, and run every hook in the image, mkinitcpio included. So pacman, with a
database of its own under `/var/cache/droiddeck-flatpak`, only downloads the nine packages
Flatpak adds to this image and checks their signatures against the Arch Linux ARM keyring.
The script then unpacks them without hooks and checks what the binaries link. Libraries that
the Desktop package normally brings (PyGObject, json-glib, fuse3 and others) are fetched too,
but only when their files are missing. Flathub is added as a per-user remote from a copy of
its `.flatpakrepo` carried in the script, so that step needs no network. Every store command
writes its output to `Download/DroidDeck/flatpak-<verb>.log`, next to the session logs. The
setup script also puts Flatpak's own error text in the failure message the app shows.
## bubblewrap without namespaces
Android gives apps no user namespaces, so `bwrap` cannot work. Flatpak is pointed at
`bannerlator-bwrap` instead (`FLATPAK_BWRAP`, exported by `bannerlator-session` and by the
store's commands). It reads bwrap's command line, including `--args` fds and the `--file` and
`--bind-data` payloads, and then does one of two things:
- A sandbox that remaps nothing runs directly. Flatpak's install triggers (`--ro-bind / /`) and
the D-Bus proxy (every top-level directory bound onto itself) are the cases, and they keep the
file descriptors Flatpak handed them.
- Anything else is described to the app over the abstract socket
`com.droiddeck.launcher.bwrap`, which serves only the app's own uid. `BwrapSpawner` builds the
tree under `cache/bwrap/<n>`: a root directory for top-level links, directories and files,
scratch directories for `--tmpfs` inside a bind, and every `--bind` translated from guest
to host paths through the session's own binds. It then starts it as a proot of its own
beside the session's. Output, the pid (for `--info-fd`) and the exit status flow back, and
the stand-in exiting kills the sandbox.
A proot nested in the session's proot would also work, but every system call then goes
through two tracers, about thirty times slower at a program's start.
The spawner also adds what the rootfs gives its own programs:
- **GPU.** Flathub's Mesa has Turnip only for DRM, and Adreno on Android is KGSL. The runtime's
own `libvulkan_freedreno.so` is bound in with the three libraries the Freedesktop runtime
lacks (`libdisplay-info`, SPIRV-Tools). Vulkan uses it, and Mesa's GL runs on it through Zink
(`MESA_LOADER_DRIVER_OVERRIDE=zink`). This is skipped for an app drawing into a desktop
composited by pixman, where Zink cannot present.
- **Controllers.** The libraries in the rootfs's `/etc/ld.so.preload` (the session shim and the
fake evdev reader) go in through `LD_PRELOAD`, with the session's `dev` directory.
- **Browsers.** Firefox's child sandboxes are switched off (`MOZ_DISABLE_*_SANDBOX`), and
Chromium and Electron apps use zypak's mimic strategy (`ZYPAK_ZYGOTE_STRATEGY_SPAWN=0`). The
Flatpak portal's Spawn refuses a caller whose `/proc/<pid>/root` holds no `.flatpak-info`,
and a proot sandbox's root is the host's.
- **CPU.** `/proc/cpuinfo` without the cores' part numbers. Snapdragon's ARMv9 cores imply SVE2
to LLVM, Qualcomm leaves SVE off, and llvmpipe's first shader died with SIGILL.
## Running apps
The front end starts an app as a run-mode session of `bannerlator-flatpak-run <app-id>`, full
screen under gamescope. Under gamescope it passes `--nosocket=wayland --socket=x11` and sets
`XDG_SESSION_TYPE=x11`. Otherwise Flatpak finds the app compositor's `wayland-0` and the window
opens behind gamescope. gamescope's own Wayland socket is no alternative: Chromium on Wayland
asks the render node it names for a DRM version, which KGSL cannot give, and aborts. The
launcher also starts a session bus when there is none. On the Linux desktop, the apps' exported
menu entries appear in the LXQt menu. While the desktop is composited by pixman, games are
wrapped through `droiddeck-gpu` like the rootfs's own.
## The store
`FlathubApi` reads flathub.org's public API: collections, search filtered to `aarch64`, and
AppStream details. `bannerlator-flatpak` drives libflatpak through PyGObject and prints one
JSON object per line (`op`, `progress`, `error`, `done`). `FlatpakManager` turns those lines
into the store's progress bar. Store commands run in a proot of their own, and
`OrphanReaper` spares them when a session starts.
## AppImages
"Add AppImage" on the Desktop page imports an ARM64, type 2 AppImage from storage. `AppImageManager`
checks its ELF header first, so an x86_64 image is refused with a message saying so. It then copies the
image in, because shared storage is noexec, and extracts it once to `/opt/appimages/user/<id>/app`.
proot has no FUSE, and extracting at every start would cost the whole image each time. The name,
comment and icon come from the image's own desktop entry. A menu entry goes to
`/usr/local/share/applications`, where the desktop's GPU wrapper picks it up.
`bannerlator-appimage-run` starts it:
- `APPIMAGE` stays unset, so apps don't offer to add themselves to the menu or update in place.
- Firefox forks get the same sandbox switches as Flatpaks.
- Electron apps (a `chrome-sandbox` beside them) get `--no-sandbox` and X11.
- A session bus is started when the session has none.
- The launcher waits while anything from the image still runs, because Electron apps relaunch themselves.
Known to fail:
- Obsidian's own AppImage traps (SIGTRAP) after startup. Its Flatpak works.
- Zen's AppImage hangs in Firefox's startup GPU probe. The Firefox Flatpak works.
## x86_64 apps
Flathub lists x86_64-only apps (Heroic, Discord, Spotify, Steam), and the store hides them. Running them
would need an x86 emulator in the sandbox. There's no FEX or box64 package in Arch Linux ARM; Valve's
ARM64 Steam can install its FEX compatibility tool, but only through the Steam client. With a FEX binary
available, the path would be:
1. `flatpak install --arch=x86_64`. Flatpak accepts it, and the x86 runtime is also the x86 root FEX needs.
2. `BwrapSpawner` starts those sandboxes with `proot -q`, through a small wrapper that runs FEXInterpreter.
`-q` passes QEMU-style arguments.
3. GPU drivers would need FEX's thunks, or Mesa runs emulated on the CPU.
x86 AppImages would also need an x86 rootfs for the libraries they don't bundle.
+41
View File
@@ -7,6 +7,47 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it
Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64).
- **Why**: the client read the pad as an Xbox 360 controller, and games read the *same* node
wearing Steam Input's virtual identity (28de:11ff), because the client's own virtual pad needs
`/dev/uinput` and never appeared. Steam Input's layouts therefore never reached a game, and the
QAM was a timed Guide+A chord. InputPlumber itself cannot run in the sandbox (root, uinput, uhid,
udev, D-Bus), so what it provides is emulated in the interposer instead.
- **`/dev/uinput` stand-in** (`FAKE_EVDEV_UINPUT=1`, libfakeinput): a gamepad the client makes
becomes `/dev/input/event16+`, backed by a ring of the app's own format, with the client's name,
ids and bits; its writes are that ring's events. Other devices (virtual keyboard and mouse) are
accepted and dropped. `inputudev.c` describes these nodes to Wine's HID bus from a `.uevent` file
beside each one, and while one exists describes the app's pads as the Xbox 360 pad they are, so
Wine reads only Steam Input's output. Verified: the client makes "Microsoft X-Box 360 pad 0",
re-makes it at game launch, and the game binds it (`Controller 0 uses xinput : true`).
- **Steam Deck controller** (`FAKE_EVDEV_DECK=1`, Steam sessions): `/dev/hidraw16`, found through a
sysfs tree `SteamDeckPad.kt` binds in (usb_device → interface 2 → hid 28de:1205 → hidraw), streams
the Deck's 64-byte state report every 4 ms from ring 0 and answers feature reports as InputPlumber
does. systemd 261's libudev refuses a syspath not on sysfs, so `fstatfs`/`statfs` report sysfs
for that tree. The app's evdev nodes are withdrawn while it is on, and only the `steam` process
sees the Deck. QAM is a real button (snapshot bit 11). Verified: the client lists a Steam Deck
Controller (V1 HID protocol) and runs its handshake (0x83, 0xAE, 0x81/0x87 lizard off, 0x8F).
- **Setting**: Steam page → Touch & controls → **Controller**: *Steam Deck controller* (default) or
*Xbox 360 controller* (the pad of earlier versions, QAM by Guide+A; games still get Steam Input's
virtual pad). Debug switches: `droiddeck-no-uinput` (back to the 28de:11ff disguise),
`droiddeck-no-deck-pad` (forces the Xbox 360 pad).
- **Gyro**: `PadMotion.kt` feeds the handheld's own gyro and accelerometer (4 ms sampling, while
the session is on screen) into an IMU block after ring 0's events, turned to the screen and then
to the Deck's axes and units; the Deck report carries them. Verified: Steam's Gyro Calibration
page moves with the Thor (sh5001 IMU); at rest the accelerometer reads 1 g.
- **Deck grips + trackpads on the second screen** (`DeckControlsPanel.kt`, offered while the pad
is a Deck controller): tabs for the four grips (2x2), either trackpad with a click bar, and both
trackpads over the grips in a row; a second finger on a trackpad clicks it. True black for OLED.
Grips, pad positions, touch, click and pressure go through the same block after ring 0's events
as the gyro (`DeckControls.kt`) into the Deck report.
- **Not yet**: a Bluetooth pad's own IMU (DualSense); the
game's rumble on the virtual pad goes straight to the vibrator rather than back through the client.
- **Prior art checked**: WinNative and Bannerlator never emulated uinput or hidraw; Bannerlator's
`-steamdeck` mode stopped at "the virtual pad never arrives", which is this missing uinput.
## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky
State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds)
+57 -16
View File
@@ -89,7 +89,17 @@ linuxfs_replaced=1
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
# Docker Desktop's VM restarts now and then, and until its engine has loaded its image store it
# answers "No such image" for images it has. The rebuild that follows hangs on the registry
# (the base image's credentials go through docker-credential-desktop), so give a restarting daemon
# up to a minute before deciding the image is really missing.
image_present=0
for _ in $(seq 1 30); do
if inspect_error=$(docker image inspect "${image_name}" 2>&1 >/dev/null); then image_present=1; break; fi
sleep 2
done
if [[ "${image_present}" = 0 ]]; then
echo "Build image ${image_name} not found (${inspect_error:-no error}); building it." >&2
docker build --platform linux/amd64 -t "${image_name}" \
-f "${repo_root}/tools/local-cross.Dockerfile" "${repo_root}"
fi
@@ -164,20 +174,39 @@ if [[ -z "${github_repo}" ]]; then
github_repo=${github_repo%.git}
fi
# Pinned downloads are kept between builds, named by their checksum, so a rebuild fetches nothing
# it already has. A cached file is checked again before use; a bad one is fetched anew.
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache_dir}"
# cached <sha256> <name> <command...>: prints the cached path, running the command (which writes
# to "$out") only when the cache has no file with that checksum.
cached() {
local sha=$1 name=$2 out
shift 2
out="${cache_dir}/${sha}-${name}"
if [[ -f "${out}" ]] && printf '%s %s\n' "${sha}" "${out}" | shasum -a 256 -c - >/dev/null 2>&1; then
echo "${out}"
return 0
fi
rm -f "${out}.part"
out="${out}.part" "$@" >&2
printf '%s %s\n' "${sha}" "${out}.part" | shasum -a 256 -c - >&2
mv "${out}.part" "${out}"
echo "${out}"
}
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive="${staging_dir}/gamescope.tzst"
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
. "${repo_root}/tools/wlroots/release.env"
wlroots_archive="${staging_dir}/wlroots.tzst"
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
wlroots_archive=$(cached "${WLROOTS_SHA256}" wlroots.tzst \
bash -c 'gh release download "$0" -R "$1" -p wlroots.tzst -O "$out"' "${WLROOTS_TAG}" "${github_repo}")
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
fi
@@ -187,9 +216,8 @@ mango_pkgs="${staging_dir}/mango-pkgs"
mkdir -p "${mango_dir}" "${mango_pkgs}"
while read -r package_sha256 package_url; do
[[ -n "${package_url}" ]] || continue
package_archive="${staging_dir}/$(basename "${package_url}")"
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
package_archive=$(cached "${package_sha256}" "$(basename "${package_url}")" \
bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "${package_url}")
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
@@ -201,11 +229,14 @@ install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/b
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
pa_tarball="${cache_dir}/pulseaudio-13.0.tar.gz"
if [[ ! -s "${pa_tarball}" ]] || ! tar -tzf "${pa_tarball}" >/dev/null 2>&1; then
curl -fsSL -o "${pa_tarball}.part" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mv "${pa_tarball}.part" "${pa_tarball}"
fi
mkdir -p "${pa_source}"
tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \
-C "${pa_source}" --strip-components=1
tar -xzf "${pa_tarball}" -C "${pa_source}" --strip-components=1
fi
if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
echo "PulseAudio 13.0 source not found at ${pa_source}; set DROIDDECK_PA13_SOURCE_DIR." >&2
@@ -214,7 +245,17 @@ fi
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since
# the libraries in jniLibs were built.
proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a"
proot_inputs=$(cd "${repo_root}/tools/proot" && find . -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1)
if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
&& "$(cat "${proot_out}/.proot-inputs" 2>/dev/null)" = "${proot_inputs}" ]]; then
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
else
"${repo_root}/tools/proot/build.sh" "${proot_out}"
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
fi
cp -p "${bundle_asset}" "${bundle_backup}"
bundle_dir="${staging_dir}/pulseaudio-bundle"
@@ -229,7 +270,7 @@ install -m755 "${sink_output}/module-aaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst"
tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst"
bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
+13 -2
View File
@@ -14,8 +14,11 @@ library list, before anything is published.
- `0020-color-p3-red-is-wide-gamut.patch` - Armada, verbatim.
- `0100-realtime-queue-and-gamepad-cursor.patch` - this app, two of Armada's ported by hand onto
3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`)
without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following
the X pointer that XTest moves (it sat frozen).
without CAP_SYS_NICE, which proot can never have (a no-op on KGSL Turnip, which has a single
submit-queue priority); and the gamepad-driven cursor sprite following the X pointer that XTest
moves (it sat frozen). The X pointer is asked for only while a cursor image is drawn - every
vblank while shown, every 50 ms while hidden for inactivity - since each ask is a blocking round
trip to Xwayland on the paint thread; with no image, wlserver's position is used as upstream does.
- `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's
pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds
`wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` /
@@ -27,6 +30,14 @@ library list, before anything is published.
down moves nothing - so in the app's touchpad mode the Steam client saw no hover and a click landed
wherever the pointer had last been. The motion now always warps the real pointer as well
(`bAlwaysWarpCursor`), which the other touch modes did already.
- `0112-restore-iconified-game-on-resume.patch` - this app: the Steam menu is an overlay that
takes input without changing the focus window, and a fullscreen wine game minimizes itself when
it loses input. gamescope only takes a window out of iconic when the focus window changes, and
wine will not activate a window it believes iconic, so after Resume the game stayed minimized: a
black screen with its small caption in the top-left corner (Titanfall 2, GE-Proton 11). The
iconify request is remembered and the window goes back to NormalState before input returns to it,
then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the
same restore from outside (the session script's resume watcher).
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
@@ -7,8 +7,8 @@ index 54e4efc..024af02 100644
float queuePriorities = 1.0f;
+ // Without CAP_SYS_NICE gamescope never asks for realtime-priority queues. Under proot on
+ // Android there is no capability to have, but the driver may still grant the priority (Turnip
+ // does on Adreno), and the existing fallback below covers a driver that refuses. Opt in with
+ // Android there is no capability to have; the fallback below covers a driver that refuses. KGSL
+ // Turnip accepts but ignores it (one submit-queue priority, contexts created alike). Opt in with
+ // GAMESCOPE_FORCE_VULKAN_REALTIME=1. (Armada's --force-vulkan-realtime, as an environment switch.)
+ const char *pszForceRealtime = getenv( "GAMESCOPE_FORCE_VULKAN_REALTIME" );
+ const bool useRealtimeVulkanQueue = gamescope::Process::HasCapSysNice() || ( pszForceRealtime && atoi( pszForceRealtime ) != 0 );
@@ -52,27 +52,10 @@ index 54e4efc..024af02 100644
fprintf(stderr, "vkCreateDevice failed with a high-priority queue (compute). Falling back to regular priority (all).\n");
queueCreateInfos[0].pNext = nullptr;
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 18b799e..16f7846 100644
index 10990b8..08afbc4 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2020,23 +2020,41 @@ MouseCursor::MouseCursor(xwayland_ctx_t *ctx)
void MouseCursor::UpdatePosition()
{
+ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing
+ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the
+ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.)
+ Window root_ret, child_ret;
+ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0;
+ unsigned int mask;
+ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ),
+ &root_ret, &child_ret,
+ &root_x, &root_y,
+ &win_x, &win_y,
+ &mask );
+
wlserver_lock();
struct wlr_pointer_constraint_v1 *pConstraint = wlserver.GetCursorConstraint();
@@ -2025,18 +2025,53 @@ void MouseCursor::UpdatePosition()
if ( pConstraint && pConstraint->current.cursor_hint.enabled )
{
m_bConstrained = true;
@@ -84,23 +67,61 @@ index 18b799e..16f7846 100644
}
- else
+ m_bConstrained = false;
+ wlserver_unlock();
+ if ( root_x != m_x || root_y != m_y )
+ // Nothing to draw (the gamepad UI, most games): the position only feeds focus and zoom, and
+ // wlserver's copy is what stock gamescope uses for those. No round trip to X.
+ if ( m_imageEmpty )
{
- m_bConstrained = false;
-
- m_x = wlserver.mouse_surface_cursorx;
- m_y = wlserver.mouse_surface_cursory;
m_x = wlserver.mouse_surface_cursorx;
m_y = wlserver.mouse_surface_cursory;
+ wlserver_unlock();
+ return;
}
wlserver_unlock();
+
+ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing
+ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the
+ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.) That is a blocking
+ // round trip to Xwayland on the paint thread, which waits while Xwayland is busy drawing the
+ // client's interface: once per vblank only while the sprite is shown, and every 50 ms while
+ // it is hidden for inactivity - enough to bring it back within a few frames of a move.
+ if ( wlserver.bCursorHidden )
+ {
+ const uint64_t now = get_time_in_nanos();
+ if ( now - m_ulLastPointerQuery < 50'000'000ul )
+ return;
+ m_ulLastPointerQuery = now;
+ }
+ Window root_ret, child_ret;
+ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0;
+ unsigned int mask;
+ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ),
+ &root_ret, &child_ret,
+ &root_x, &root_y,
+ &win_x, &win_y,
+ &mask );
+
+ if ( root_x != m_x || root_y != m_y )
+ {
+ m_x = root_x;
+ m_y = root_y;
+ if ( !m_imageEmpty )
+ {
+ wlserver.ulLastMovedCursorTime = get_time_in_nanos();
+ wlserver.bCursorHidden = !wlserver.bCursorHasImage;
+ hasRepaintNonBasePlane = true;
+ }
}
- wlserver_unlock();
+ wlserver.ulLastMovedCursorTime = get_time_in_nanos();
+ wlserver.bCursorHidden = !wlserver.bCursorHasImage;
+ hasRepaintNonBasePlane = true;
+ }
}
void MouseCursor::checkSuspension()
diff --git a/src/steamcompmgr.hpp b/src/steamcompmgr.hpp
index c428784..bd62fd1 100644
--- a/src/steamcompmgr.hpp
+++ b/src/steamcompmgr.hpp
@@ -109,6 +109,7 @@ private:
int m_x = 0, m_y = 0;
bool m_bConstrained = false;
+ uint64_t m_ulLastPointerQuery = 0;
int m_hotspotX = 0, m_hotspotY = 0;
gamescope::OwningRc<CVulkanTexture> m_texture;
@@ -0,0 +1,108 @@
From: DroidDeck
Subject: [PATCH] steamcompmgr: restore a game that minimized itself behind the Steam menu
The Steam menu is an overlay that takes input without changing the focus window. A fullscreen
wine game minimizes itself when it loses input (WM_CHANGE_STATE IconicState, which gamescope
fakes by setting WM_STATE). gamescope only puts a window back to NormalState when the focus
window changes, which never happens here, and wine will not activate a window it believes
iconic - so after Resume the game stayed minimized: a black screen with its 160x24 caption in
the top-left corner, 0 fps (seen with Titanfall 2 on GE-Proton 11).
Remember the iconify request, restore NormalState before input returns to that window, and hand
focus over again so wine sees the restore before the activation. GAMESCOPE_RESTORE_FOCUS_WINDOW
on the root window asks for the same thing from outside.
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 08afbc4..372a82c 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -4708,6 +4708,7 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
/* Some games (e.g. DOOM Eternal) don't react well to being put back as
* iconic, so never do that. Only take them out of iconic. */
set_wm_state( ctx, id, ICCCM_NORMAL_STATE );
+ ctx->focus.focusWindow->iconifyRequested = false;
gpuvis_trace_printf( "determine_and_apply_focus focus %lu", id );
@@ -4795,6 +4796,19 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
if ( keyboardFocusWindow && ctx->currentKeyboardFocusWindow && find_win( ctx, ctx->currentKeyboardFocusWindow ) == keyboardFocusWin )
keyboardFocusWindow = ctx->currentKeyboardFocusWindow;
+ // An overlay that takes input (the Steam menu) moves input away without changing the focus window, and a
+ // fullscreen game iconifies itself when it loses input. The focus window never changed, so it is never taken
+ // out of iconic above, and wine will not activate a window it believes iconic: the game stays minimized
+ // (its 160x24 caption in the corner) after Resume. Put it back to NormalState before input returns to it,
+ // then hand focus over again so wine sees the restore first and the activation after it.
+ if ( inputFocus == ctx->focus.focusWindow && ctx->focus.focusWindow->iconifyRequested )
+ {
+ ctx->focus.focusWindow->iconifyRequested = false;
+ xwm_log.infof( "restoring iconified focus window 0x%lx as input returns to it", ctx->focus.focusWindow->xwayland().id );
+ set_wm_state( ctx, ctx->focus.focusWindow->xwayland().id, ICCCM_NORMAL_STATE );
+ ctx->currentKeyboardFocusWindow = None;
+ }
+
if ( ctx->focus.inputFocusWindow != inputFocus ||
ctx->focus.inputFocusMode != inputFocus->inputFocusMode ||
ctx->currentKeyboardFocusWindow != keyboardFocusWindow )
@@ -6559,6 +6573,8 @@ handle_wm_change_state(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, XClientMessag
if (state == ICCCM_ICONIC_STATE) {
xwm_log.debugf("Faking WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id);
set_wm_state( ctx, w->xwayland().id, ICCCM_ICONIC_STATE );
+ w->iconifyRequested = true;
+ MakeFocusDirty();
} else {
xwm_log.debugf("Unhandled WM_CHANGE_STATE to %ld for window 0x%lx", state, w->xwayland().id);
}
@@ -7539,6 +7555,19 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev)
if ( set_sdr_input_gain( bit_cast<float>(val) ) )
hasRepaint = true;
}
+ if ( ev->atom == ctx->atoms.gamescopeRestoreFocusWindow )
+ {
+ // Anything outside (the session's watcher) can ask for the same restore: NormalState on the focus
+ // window and focus handed to it again. Harmless on a window that is not iconic.
+ if ( ctx->focus.focusWindow && get_prop( ctx, ctx->root, ctx->atoms.gamescopeRestoreFocusWindow, 0 ) )
+ {
+ xwm_log.infof( "restore requested for focus window 0x%lx", ctx->focus.focusWindow->xwayland().id );
+ ctx->focus.focusWindow->iconifyRequested = false;
+ set_wm_state( ctx, ctx->focus.focusWindow->xwayland().id, ICCCM_NORMAL_STATE );
+ ctx->currentKeyboardFocusWindow = None;
+ MakeFocusDirty();
+ }
+ }
if ( ev->atom == ctx->atoms.gamescopeForceWindowsFullscreen )
{
ctx->force_windows_fullscreen = !!get_prop( ctx, ctx->root, ctx->atoms.gamescopeForceWindowsFullscreen, 0 );
@@ -9056,6 +9085,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_
ctx->atoms.gamescopeHDRTonemapOperator = XInternAtom( ctx->dpy, "GAMESCOPE_HDR_TONEMAP_OPERATOR", false );
ctx->atoms.gamescopeForceWindowsFullscreen = XInternAtom( ctx->dpy, "GAMESCOPE_FORCE_WINDOWS_FULLSCREEN", false );
+ ctx->atoms.gamescopeRestoreFocusWindow = XInternAtom( ctx->dpy, "GAMESCOPE_RESTORE_FOCUS_WINDOW", false );
ctx->atoms.gamescopeColorLut3DOverride = XInternAtom( ctx->dpy, "GAMESCOPE_COLOR_3DLUT_OVERRIDE", false );
ctx->atoms.gamescopeColorShaperLutOverride = XInternAtom( ctx->dpy, "GAMESCOPE_COLOR_SHAPERLUT_OVERRIDE", false );
diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp
index 66022d7..af93a23 100644
--- a/src/steamcompmgr_shared.hpp
+++ b/src/steamcompmgr_shared.hpp
@@ -134,6 +134,8 @@ struct steamcompmgr_win_t {
bool isFullscreen = false;
bool isSysTrayIcon = false;
+ // The client asked to be iconified (WM_CHANGE_STATE) and has not been put back to NormalState since.
+ bool iconifyRequested = false;
bool sizeHintsSpecified = false;
bool skipTaskbar = false;
bool skipPager = false;
diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp
index 7b39ecb..81910a1 100644
--- a/src/xwayland_ctx.hpp
+++ b/src/xwayland_ctx.hpp
@@ -228,6 +228,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable
Atom gamescopeHDRTonemapOperator;
Atom gamescopeForceWindowsFullscreen;
+ Atom gamescopeRestoreFocusWindow;
Atom gamescopeColorLut3DOverride;
Atom gamescopeColorShaperLutOverride;
+2 -2
View File
@@ -1,2 +1,2 @@
GAMESCOPE_TAG=gamescope-3.16.29-p3
GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b
GAMESCOPE_TAG=gamescope-3.16.29-p4
GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574
+18 -5
View File
@@ -12,6 +12,10 @@ import re
HOME = os.environ.get("HOME", "/root")
SYSTEM_DIR = "/usr/share/applications"
# The user's own AppImages (the front end's Add AppImage).
LOCAL_DIR = "/usr/local/share/applications"
# Flathub's games, as Flatpak exports them; the copies here win over these as they do over the system's.
FLATPAK_DIR = os.path.join(HOME, ".local/share/flatpak/exports/share/applications")
USER_DIR = os.path.join(HOME, ".local/share/applications")
MARK = "X-DroidDeck-GPU=1"
# The client and its games already hand themselves to the app (bannerlator-steam-launch).
@@ -37,6 +41,14 @@ def main_group(text):
return lines[:start + 1], lines[start + 1:end], lines[end:]
def gpu_exec(exec_line):
"""The Exec that runs a program on the GPU; a Flatpak app goes through its launcher, which
keeps it on gamescope's X server instead of the desktop's Wayland socket."""
if exec_line.startswith("/usr/bin/flatpak run ") or exec_line.startswith("flatpak run "):
exec_line = "/usr/local/bin/bannerlator-flatpak-run " + exec_line.split(" run ", 1)[1]
return "droiddeck-gpu " + exec_line
def wrap(text):
head, group, rest = main_group(text)
if group is None:
@@ -59,7 +71,7 @@ def wrap(text):
for l in group:
k = l.split("=", 1)[0].strip() if "=" in l else None
if k == "Exec":
out_group.append("Exec=droiddeck-gpu " + exec_line)
out_group.append("Exec=" + gpu_exec(exec_line))
elif k == "Actions":
continue
else:
@@ -73,7 +85,7 @@ def wrap(text):
wrapped_rest = []
for l in rest:
if l.startswith("Exec=") and not l[5:].startswith("droiddeck-gpu "):
l = "Exec=droiddeck-gpu " + l[5:]
l = "Exec=" + gpu_exec(l[5:])
wrapped_rest.append(l)
if wrapped_rest and wrapped_rest[0].strip():
wrapped_rest.insert(0, "")
@@ -92,8 +104,9 @@ def main():
on = enabled()
os.makedirs(USER_DIR, exist_ok=True)
written = set()
if on and os.path.isdir(SYSTEM_DIR):
for name in sorted(os.listdir(SYSTEM_DIR)):
sources = [d for d in (SYSTEM_DIR, LOCAL_DIR, FLATPAK_DIR) if os.path.isdir(d)] if on else []
for source in sources:
for name in sorted(os.listdir(source)):
if not name.endswith(".desktop") or SKIP.match(name):
continue
target = os.path.join(USER_DIR, name)
@@ -101,7 +114,7 @@ def main():
if os.path.exists(target) and not ours(target):
continue
try:
with open(os.path.join(SYSTEM_DIR, name), encoding="utf-8", errors="replace") as f:
with open(os.path.join(source, name), encoding="utf-8", errors="replace") as f:
text = wrap(f.read())
except OSError:
continue
@@ -0,0 +1,63 @@
#!/bin/bash
# An AppImage imported from the front end (AppImageManager), extracted once at import into
# /opt/appimages/user/<id>/app
# and started from there: proot has no FUSE to mount the image, and extracting it at every
# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time.
# bannerlator-appimage-run /opt/appimages/user/<id> [args...]
set -u
dir=${1:?usage: bannerlator-appimage-run <imported AppImage directory> [args...]}
shift
export APPDIR=$dir/app
# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers
# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the
# one, and there is no image file for the other.
unset APPIMAGE
export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir")
if [ ! -x "$APPDIR/AppRun" ]; then
echo "bannerlator-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2
exit 127
fi
# Browsers sandbox their own children with namespaces and a setuid helper, neither of which proot
# can give them (the Flatpak sandboxes get the same; see BwrapSpawner): Firefox and its forks (Zen,
# LibreWolf) by these switches, Electron and Chromium builds by --no-sandbox. Chromium on Wayland
# also asks the render node for a DRM version KGSL cannot give, so with an X server up it stays on
# X11, which it picks from the session type.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
flags=()
if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then
flags+=(--no-sandbox)
if [ -n "${DISPLAY:-}" ]; then
export XDG_SESSION_TYPE=x11
unset WAYLAND_DISPLAY
flags+=(--ozone-platform=x11)
fi
fi
echo "== appimage $ARGV0 ($APPDIR) ${flags[*]}"
cd "$APPDIR" || exit 1
# Apps expect a session bus; Electron's main process traps (SIGTRAP) on an address it cannot
# parse. A run mode has none, so one is started for the app, as for Flatpak apps.
case ${DBUS_SESSION_BUS_ADDRESS:-} in
unix:*) "$APPDIR/AppRun" "${flags[@]}" "$@" ;;
*) unset DBUS_SESSION_BUS_ADDRESS
if command -v dbus-run-session >/dev/null 2>&1; then
dbus-run-session -- "$APPDIR/AppRun" "${flags[@]}" "$@"
else
"$APPDIR/AppRun" "${flags[@]}" "$@"
fi ;;
esac
rc=$?
# The program a session was started for may hand over to another process and exit: Electron apps
# relaunch themselves (Obsidian on its first start), launchers fork their real program. The
# session ends with this script, and takes the rest with it - so it waits while anything running
# from the image is still up.
image_running() {
local p exe
for p in /proc/[0-9]*; do
exe=$(readlink "$p/exe" 2>/dev/null) || continue
case $exe in "$APPDIR"/*) return 0 ;; esac
done
return 1
}
while image_running; do sleep 2; done
exit $rc
@@ -0,0 +1,318 @@
#!/usr/bin/python3
# bubblewrap for Flatpak (FLATPAK_BWRAP), in a runtime that cannot have it.
#
# bwrap builds each sandbox from mount namespaces, which Android gives no app. proot gives the same
# picture - Flatpak's runtime as /usr, the app as /app, host paths bound where Flatpak asks - but a
# proot nested inside the session's proot runs every system call through two tracers, some thirty
# times slower on a program's start. So a sandbox is described to the app instead, over a socket
# only this app's uid may use, and the app starts it as a proot of its own beside the session's
# (BwrapSpawner). This process stands in for the sandbox until it ends: its output, its pid for
# --info-fd, and its exit status come back here.
#
# Sandboxes that remap nothing - Flatpak's install triggers (`--ro-bind / /` and the directory they
# update) and the D-Bus proxy (every top-level directory bound onto itself) - need no proot at all
# and run here directly, with the file descriptors Flatpak handed them.
import base64
import json
import os
import signal
import socket
import struct
import sys
SOCKET = "\0com.droiddeck.launcher.bwrap"
VERSION = "bubblewrap 0.11.0 (droiddeck)"
# Options and how many arguments each takes; bwrap(1).
NO_ARG = {
"--unshare-user", "--unshare-user-try", "--unshare-ipc", "--unshare-pid", "--unshare-net",
"--unshare-uts", "--unshare-cgroup", "--unshare-cgroup-try", "--unshare-all", "--share-net",
"--disable-userns", "--assert-userns-disabled", "--clearenv", "--new-session",
"--die-with-parent", "--as-pid-1", "--level-prefix", "--tmp-overlay", "--ro-overlay",
}
ONE_ARG = {
"--args", "--argv0", "--userns", "--userns2", "--pidns", "--uid", "--gid", "--hostname",
"--chdir", "--unsetenv", "--lock-file", "--sync-fd", "--remount-ro", "--exec-label",
"--file-label", "--proc", "--dev", "--tmpfs", "--mqueue", "--dir", "--seccomp",
"--add-seccomp-fd", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd",
"--cap-add", "--cap-drop", "--perms", "--size", "--overlay-src",
}
TWO_ARG = {
"--setenv", "--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind",
"--ro-bind-try", "--bind-fd", "--ro-bind-fd", "--file", "--bind-data", "--ro-bind-data",
"--symlink", "--chmod",
}
THREE_ARG = {"--overlay"}
def read_fd(fd):
chunks = []
while True:
b = os.read(fd, 65536)
if not b:
break
chunks.append(b)
os.close(fd)
return b"".join(chunks)
def fd_path(fd):
return os.readlink("/proc/self/fd/%d" % fd)
def expand(argv):
"""argv with every --args FD replaced by the NUL-separated arguments read from FD."""
out = []
i = 0
while i < len(argv):
a = argv[i]
if a == "--args" and i + 1 < len(argv):
data = read_fd(int(argv[i + 1]))
# Each argument ends in a NUL; an empty one (--setenv LD_LIBRARY_PATH "") is kept.
parts = data.split(b"\0")
if parts and parts[-1] == b"":
parts.pop()
out.extend(x.decode("utf-8", "surrogateescape") for x in parts)
i += 2
continue
out.append(a)
i += 1
return out
class Spec:
def __init__(self):
self.ops = []
self.env = dict(os.environ)
self.cwd = None
self.info_fds = []
self.json_fds = []
self.keep_fds = []
self.command = []
self.argv0 = None
def parse(argv):
s = Spec()
perms = None
i = 0
while i < len(argv):
a = argv[i]
if a == "--":
s.command = argv[i + 1:]
break
if not a.startswith("--"):
s.command = argv[i:]
break
if a in ("--version",):
print(VERSION)
sys.exit(0)
if a == "--help":
print("usage: bwrap [OPTIONS...] [--] COMMAND [ARGS...]")
sys.exit(0)
n = 0 if a in NO_ARG else 1 if a in ONE_ARG else 2 if a in TWO_ARG else 3 if a in THREE_ARG else -1
if n < 0:
sys.stderr.write("bwrap: Unknown option %s\n" % a)
sys.exit(1)
args = argv[i + 1:i + 1 + n]
if len(args) < n:
sys.stderr.write("bwrap: %s takes %d arguments\n" % (a, n))
sys.exit(1)
i += 1 + n
op_perms, perms = perms, None
if a == "--perms":
perms = int(args[0], 8)
elif a == "--clearenv":
s.env = {"PWD": os.getcwd()} if "PWD" in os.environ else {}
elif a == "--setenv":
s.env[args[0]] = args[1]
elif a == "--unsetenv":
s.env.pop(args[0], None)
elif a == "--chdir":
s.cwd = args[0]
elif a == "--argv0":
s.argv0 = args[0]
elif a in ("--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", "--ro-bind-try"):
s.ops.append({"t": "bind", "src": args[0], "dest": args[1], "try": a.endswith("-try"),
"ro": a.startswith("--ro")})
elif a in ("--bind-fd", "--ro-bind-fd"):
fd = int(args[0])
s.ops.append({"t": "bind", "src": fd_path(fd), "dest": args[1], "try": False,
"ro": a.startswith("--ro")})
os.close(fd)
elif a in ("--file", "--bind-data", "--ro-bind-data"):
s.ops.append({"t": "file", "dest": args[1], "data": read_fd(int(args[0])),
"perms": op_perms if op_perms is not None else 0o666 if a == "--file" else 0o600})
elif a == "--symlink":
s.ops.append({"t": "symlink", "target": args[0], "dest": args[1]})
elif a == "--dir":
s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755})
elif a in ("--tmpfs", "--mqueue"):
s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755,
"empty": True})
elif a == "--proc":
s.ops.append({"t": "proc", "dest": args[0]})
elif a == "--dev":
s.ops.append({"t": "dev", "dest": args[0]})
elif a in ("--seccomp", "--add-seccomp-fd"):
# proot is the sandbox; there is no filter to install under it.
os.close(int(args[0]))
elif a == "--block-fd":
read_fd(int(args[0]))
elif a == "--sync-fd":
s.keep_fds.append(int(args[0]))
elif a == "--info-fd":
s.info_fds.append(int(args[0]))
elif a == "--json-status-fd":
s.json_fds.append(int(args[0]))
# Everything else - namespaces, capabilities, labels, locks, --remount-ro, --chmod - has
# nothing to act on under proot.
if not s.command:
sys.stderr.write("bwrap: No command specified\n")
sys.exit(1)
return s
def real(path):
try:
return os.path.realpath(path)
except OSError:
return path
def remaps_nothing(s):
"""True when every path in the sandbox is what it is out here already."""
for op in s.ops:
t = op["t"]
if t == "bind":
src = real(op["src"])
if not os.path.exists(src):
if op["try"]:
continue
return False
if src != real(op["dest"]) and not (op["src"] == "/" and op["dest"] == "/"):
return False
elif t == "symlink":
if not os.path.islink(op["dest"]) or os.readlink(op["dest"]) != op["target"]:
return False
elif t == "file":
# The D-Bus proxy's /.flatpak-info names the app to the bus; nothing here reads it.
if op["dest"] != "/.flatpak-info":
return False
elif t == "dir":
if not os.path.isdir(op["dest"]) or op.get("empty") and op["dest"] not in ("/tmp", "/var/tmp", "/run"):
return False
elif t in ("proc", "dev"):
if op["dest"] != "/" + t:
return False
return True
def report_pid(s, pid):
for fd in s.info_fds:
try:
os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n")
os.close(fd)
except OSError:
pass
for fd in s.json_fds:
try:
os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n")
except OSError:
pass
def run_here(s):
report_pid(s, os.getpid())
for fd in s.json_fds:
os.close(fd)
if s.cwd:
os.chdir(s.cwd)
argv = list(s.command)
program = argv[0]
if s.argv0:
argv[0] = s.argv0
try:
os.execvpe(program, argv, s.env)
except OSError as e:
sys.stderr.write("bwrap: execvp %s: %s\n" % (program, e.strerror))
sys.exit(1)
def send(sock, kind, payload):
sock.sendall(kind + struct.pack(">I", len(payload)) + payload)
def recv_exact(sock, n):
buf = b""
while len(buf) < n:
b = sock.recv(n - len(buf))
if not b:
return None
buf += b
return buf
def run_by_app(s):
ops = []
for op in s.ops:
op = dict(op)
if op["t"] == "bind":
# The app resolves no guest symlinks; hand it the path they lead to.
op["src"] = real(op["src"])
if op["t"] == "file":
op["data"] = base64.b64encode(op["data"]).decode()
ops.append(op)
argv = list(s.command)
if s.argv0:
argv[0] = s.argv0
request = {"argv": argv, "env": s.env, "cwd": s.cwd or "/", "ops": ops}
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(SOCKET)
except OSError as e:
sys.stderr.write("bwrap: DroidDeck is not answering (%s); open DroidDeck and try again\n" % e)
sys.exit(1)
send(sock, b"R", json.dumps(request).encode())
# This process is the sandbox as far as Flatpak is concerned: ended, it takes the sandbox down.
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(sig, lambda *_: os._exit(128 + 15))
status = 1
out = sys.stdout.buffer
while True:
head = recv_exact(sock, 5)
if head is None:
sys.stderr.write("bwrap: DroidDeck closed the sandbox\n")
break
kind, size = head[:1], struct.unpack(">I", head[1:])[0]
payload = recv_exact(sock, size) if size else b""
if payload is None:
break
if kind == b"P":
report_pid(s, int(payload))
elif kind == b"O":
out.write(payload)
out.flush()
elif kind == b"E":
sys.stderr.write("bwrap: %s\n" % payload.decode("utf-8", "replace"))
elif kind == b"X":
status = int(payload)
break
for fd in s.json_fds:
try:
os.write(fd, json.dumps({"exit-code": status}).encode() + b"\n")
except OSError:
pass
sys.exit(status)
def main():
s = parse(expand(sys.argv[1:]))
if remaps_nothing(s):
run_here(s)
else:
run_by_app(s)
if __name__ == "__main__":
main()
@@ -0,0 +1,237 @@
#!/usr/bin/python3
# The app's store talks to Flatpak through this: one JSON object per line on stdout, so progress
# reaches the front end as numbers rather than a terminal's redrawn bar. Per-user installation,
# Flathub as the remote, this device's architecture.
#
# bannerlator-flatpak install <app-id> the app and whatever runtimes it needs
# bannerlator-flatpak uninstall <app-id> the app, then runtimes nothing uses any more
# bannerlator-flatpak update [<app-id>] one app, or everything with an update
# bannerlator-flatpak updates what has one (a "updates" line)
#
# Lines: {"e":"op","i":1,"n":3,"kind":"install","ref":"..."} as each step starts,
# {"e":"progress","i":1,"n":3,"percent":45,"status":"..."} while it runs,
# {"e":"error","message":"..."} and {"e":"done"} at the end. Exit status 0 on success.
import json
import os
import sys
os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/bannerlator-bwrap")
import gi # noqa: E402
gi.require_version("Flatpak", "1.0")
from gi.repository import Flatpak, GLib # noqa: E402
REMOTE = "flathub"
def prepare_rootfs():
"""Some runtimes (KDE 6.10) ship etc/resolv.conf as a link to /run/host/monitor/resolv.conf.
Deploying one, Flatpak removes that link only if GIO reports it existing, and GIO follows it:
dangling here, it was left in place and making Flatpak's own link failed ("File exists").
With the target present in the rootfs the link resolves and the deploy goes through."""
target = "/run/host/monitor/resolv.conf"
try:
if not os.path.exists(target):
os.makedirs(os.path.dirname(target), exist_ok=True)
try:
with open("/etc/resolv.conf", "rb") as src:
data = src.read()
except OSError:
data = b""
with open(target, "wb") as dst:
dst.write(data)
except OSError as err:
emit(e="warning", message="could not prepare %s: %s" % (target, err))
def emit(**kw):
sys.stdout.write(json.dumps(kw) + "\n")
sys.stdout.flush()
def installation():
return Flatpak.Installation.new_user(None)
def app_ref(inst, app_id):
"""The installed ref of an app, or None."""
for ref in inst.list_installed_refs_by_kind(Flatpak.RefKind.APP, None):
if ref.get_name() == app_id:
return ref
return None
def transaction(inst):
t = Flatpak.Transaction.new_for_installation(inst, None)
t.set_no_interaction(True)
state = {"i": 0, "n": 0, "error": None}
def on_ready(tr):
state["n"] = len(tr.get_operations())
return True
def on_new_operation(tr, op, progress):
state["i"] += 1
emit(e="op", i=state["i"], n=state["n"], kind=op.get_operation_type().value_nick, ref=op.get_ref())
progress.set_update_frequency(500)
def changed(p):
emit(e="progress", i=state["i"], n=state["n"], percent=p.get_progress(), status=p.get_status() or "")
progress.connect("changed", changed)
def on_operation_error(tr, op, err, details):
# A runtime extension that fails to install (a missing translation, an unneeded GL
# driver) is not worth losing the app over; anything else ends the transaction.
# FLATPAK_TRANSACTION_ERROR_DETAILS_NON_FATAL is 1, but PyGObject does not always hand the
# flags over ("g_value_get_flags: assertion ... failed" and a 0). An extension installed
# alongside an app - its translations, debug data, a GL or codecs runtime - is what Flatpak
# marks non-fatal, as the flatpak CLI does: its failure costs that extension, not the app.
try:
non_fatal = bool(int(details) & 1)
except (TypeError, ValueError):
non_fatal = False
if not non_fatal:
try:
related = bool(op.get_related_to_ops())
except (AttributeError, TypeError):
related = False
ref_id = (op.get_ref() or "").split("/")[1:2]
ref_id = ref_id[0] if ref_id else ""
non_fatal = related or ref_id.endswith((".Locale", ".Debug")) or ".GL." in ref_id or "codecs" in ref_id
emit(e="warning" if non_fatal else "error", message="%s: %s" % (op.get_ref(), err.message))
if not non_fatal:
state["error"] = err.message
return non_fatal
t.connect("ready", on_ready)
t.connect("new-operation", on_new_operation)
t.connect("operation-error", on_operation_error)
# A dependency found on more than one remote comes from the first (Flathub, the only one).
t.connect("choose-remote-for-ref", lambda tr, ref, runtime_ref, remotes: 0)
t.connect("end-of-lifed-with-rebase", lambda *a: False)
t.connect("add-new-remote", lambda *a: False)
return t, state
def run(t, state, report_done=True):
try:
t.run(None)
except GLib.Error as err:
if state["error"] is None and not err.matches(Flatpak.error_quark(), Flatpak.Error.ABORTED):
state["error"] = err.message
if state["error"]:
emit(e="error", message=state["error"])
return 1
if report_done:
emit(e="done")
return 0
def install(app_id):
inst = installation()
arch = Flatpak.get_default_arch()
if app_ref(inst, app_id) is not None:
emit(e="done", note="already installed")
return 0
# Flathub publishes apps on "stable"; libflatpak's default branch is "master".
branch, last = None, None
for candidate in ("stable", "master"):
try:
branch = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, arch, candidate, None).get_branch()
break
except GLib.Error as err:
last = err
if branch is None:
emit(e="error", message="%s is not on Flathub for %s (%s)" % (app_id, arch, last.message if last else "no branch"))
return 1
t, state = transaction(inst)
t.add_install(REMOTE, "app/%s/%s/%s" % (app_id, arch, branch), None)
rc = run(t, state, report_done=False)
# A failure the transaction did not report (an error handler that raised, say) must not read
# as an install: the app is there, or it is not.
if rc == 0 and app_ref(installation(), app_id) is None:
emit(e="error", message="%s did not install; see flatpak-install.log" % app_id)
return 1
if rc == 0:
emit(e="done")
return rc
def uninstall(app_id):
inst = installation()
ref = app_ref(inst, app_id)
if ref is None:
emit(e="done", note="not installed")
return 0
t, state = transaction(inst)
t.add_uninstall(ref.format_ref())
rc = run(t, state)
if rc == 0:
# Runtimes only this app used; Flatpak's own `uninstall --unused`.
try:
unused = inst.list_unused_refs(None, None)
except GLib.Error:
unused = []
if unused:
t, state = transaction(inst)
for r in unused:
t.add_uninstall(r.format_ref())
run(t, state)
return rc
def updatable(inst):
try:
return inst.list_installed_refs_for_update(None)
except GLib.Error as err:
emit(e="error", message=err.message)
return None
def update(app_id=None):
inst = installation()
refs = updatable(inst)
if refs is None:
return 1
if app_id:
refs = [r for r in refs if r.get_name() == app_id or r.get_kind() == Flatpak.RefKind.RUNTIME]
if not refs:
emit(e="done", note="up to date")
return 0
t, state = transaction(inst)
for r in refs:
t.add_update(r.format_ref(), None, None)
return run(t, state)
def updates():
refs = updatable(installation())
if refs is None:
return 1
emit(e="updates", apps=sorted({r.get_name() for r in refs if r.get_kind() == Flatpak.RefKind.APP}),
runtimes=len([r for r in refs if r.get_kind() == Flatpak.RefKind.RUNTIME]))
return 0
def main():
if len(sys.argv) < 2:
sys.stderr.write(__doc__ or "usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n")
return 2
cmd, args = sys.argv[1], sys.argv[2:]
if cmd in ("install", "update"):
prepare_rootfs()
if cmd == "install" and args:
return install(args[0])
if cmd == "uninstall" and args:
return uninstall(args[0])
if cmd == "update":
return update(args[0] if args else None)
if cmd == "updates":
return updates()
sys.stderr.write("usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n")
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,28 @@
#!/bin/bash
# A Flatpak app from the front end, full screen under gamescope like an emulator:
# bannerlator-flatpak-run <app-id> [args...]
set -u
export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap
case ":${XDG_DATA_DIRS:-}:" in
*"/flatpak/exports/share:"*) ;;
*) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;;
esac
# Under gamescope - a program's session (BL_INSIDE), or droiddeck-gpu from the desktop
# (GAMESCOPE_WAYLAND_DISPLAY) - windows have to reach gamescope's X server. Flatpak would hand an
# app wayland-0 (it finds it in the runtime directory even with WAYLAND_DISPLAY unset) and the
# window would open behind gamescope. gamescope's own Wayland socket is no way out: Chromium on
# Wayland asks the render node it names for its DRM version, which KGSL cannot answer, and exits.
# The session is X11 as far as an app can tell; Electron picks its platform from that.
# Its own flatpak options (--branch, --command, from a menu entry) come first, then the app id.
socket=()
if [ -n "${BL_INSIDE:-}" ] || [ -n "${GAMESCOPE_WAYLAND_DISPLAY:-}" ]; then
unset WAYLAND_DISPLAY
export XDG_SESSION_TYPE=x11
socket=(--nosocket=wayland --socket=x11)
fi
echo "== flatpak run ${socket[*]} $*"
# Apps expect a session bus (settings, portals, single-instance checks); a run mode has none.
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && command -v dbus-run-session >/dev/null 2>&1; then
exec dbus-run-session -- flatpak run "${socket[@]}" "$@"
fi
exec flatpak run "${socket[@]}" "$@"
@@ -0,0 +1,121 @@
#!/bin/bash
# Puts Flatpak into the runtime, with Flathub as a per-user remote. Run by the app under proot's
# fake root (pacman and pacman-key refuse any other uid), outside a session.
#
# The runtime's pacman database lists only the base image: the desktop and emulator packages were
# unpacked over it without registering. Asked for flatpak the usual way, pacman "reinstalls" about
# a hundred of those - Mesa among them, over the runtime's own Turnip build with its KGSL backend -
# and then runs every hook in the image, mkinitcpio included. So pacman only fetches and checks the
# few packages Flatpak needs on top of this image, verifying each against the Arch Linux ARM
# keyring, and they are unpacked here without hooks or scripts, like the desktop package itself.
#
# bannerlator-flatpak-setup install or repair; prints "== STEP ..." milestones
set -u
export LANG=C.UTF-8
step() { echo "== STEP $*"; }
fail() { echo "== FAIL $*"; exit 1; }
# Flatpak, the libraries it and appstream add to this image, and the D-Bus proxy flatpak run puts
# between an app and the session bus. bubblewrap is not among them: Android gives an app no user
# namespaces, and bannerlator-bwrap stands in for it (FLATPAK_BWRAP).
PACKAGES="flatpak ostree appstream libxmlb libmalcontent libfyaml accountsservice composefs xdg-dbus-proxy"
# Libraries Flatpak and the store's helper also need, which the Desktop package brings but a
# runtime without it may lack: each is fetched only when its file is not already there.
while read -r pkg probe; do
[ -n "$pkg" ] || continue
compgen -G "$probe" >/dev/null || PACKAGES="$PACKAGES $pkg"
done <<'NEEDS'
json-glib /usr/lib/libjson-glib-1.0.so.0
fuse3 /usr/lib/libfuse3.so.4
fuse-common /etc/fuse.conf
libsodium /usr/lib/libsodium.so.*
libstemmer /usr/lib/libstemmer.so.*
polkit /usr/lib/libpolkit-gobject-1.so.0
mpdecimal /usr/lib/libmpdec.so.*
python /usr/bin/python3
libgirepository /usr/lib/libgirepository-2.0.so.0
gobject-introspection-runtime /usr/lib/girepository-1.0/GObject-2.0.typelib
python-gobject /usr/lib/python3*/site-packages/gi/__init__.py
NEEDS
WORK=/var/cache/droiddeck-flatpak
CONF=$WORK/pacman.conf
mkdir -p "$WORK/pkg" "$WORK/db" || fail "cannot create $WORK"
# The image's pacman.conf names a download user this rootfs does not have (only root exists) and a
# sandbox proot cannot set up. Its one mirror is plain HTTP; these serve the same repos over TLS.
{ for m in https://ca.us.mirror.archlinuxarm.org https://fl.us.mirror.archlinuxarm.org \
https://de3.mirror.archlinuxarm.org https://nl.mirror.archlinuxarm.org; do
echo "Server = $m/\$arch/\$repo"; done
grep '^Server' /etc/pacman.d/mirrorlist 2>/dev/null; } > "$WORK/mirrorlist"
sed -e 's/^DownloadUser/#DownloadUser/' \
-e 's/^\[options\]/[options]\nDisableSandbox/' \
-e "s#^Include *= */etc/pacman.d/mirrorlist#Include = $WORK/mirrorlist#" \
/etc/pacman.conf > "$CONF" || fail "no /etc/pacman.conf"
if [ ! -f /etc/pacman.d/gnupg/pubring.gpg ] && [ ! -f /etc/pacman.d/gnupg/pubring.kbx ]; then
step "Preparing the package keyring"
pacman-key --init >/dev/null 2>&1 || fail "pacman-key --init failed"
pacman-key --populate archlinuxarm >/dev/null 2>&1 || fail "pacman-key --populate failed"
fi
# A database of our own, so the image's stays exactly as it was built: -Sy on it would leave the
# rest of the image a partial upgrade behind the repos.
step "Reading the package lists"
pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy >/dev/null 2>&1 \
|| pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1 | tail -3 \
|| fail "could not reach the Arch Linux ARM mirrors"
[ -f "$WORK/db/sync/extra.db" ] || fail "could not reach the Arch Linux ARM mirrors"
step "Downloading Flatpak"
# -w only downloads; pacman still checks each package's signature against the keyring. -dd: the
# dependencies are already in the image (and checked below by what the binaries actually link).
out=$(pacman --config "$CONF" --dbpath "$WORK/db" --cachedir "$WORK/pkg" --noconfirm -Sw -dd $PACKAGES 2>&1)
rc=$?
echo "$out" | grep -vE '^\s*$' | tail -4
[ $rc -eq 0 ] || fail "download failed"
step "Installing Flatpak"
for name in $PACKAGES; do
ver=$(pacman --config "$CONF" --dbpath "$WORK/db" -Sp --print-format '%v' -dd "$name" 2>/dev/null | tail -1)
file=$(ls "$WORK/pkg/$name-$ver-"*.pkg.tar.* 2>/dev/null | grep -v '\.sig$' | head -1)
[ -n "$file" ] || fail "no package file for $name $ver"
bsdtar -xpf "$file" -C / --exclude .PKGINFO --exclude .BUILDINFO --exclude .MTREE --exclude .INSTALL \
|| fail "could not unpack $name"
echo "$name $ver" >> "$WORK/installed.new"
done
mv -f "$WORK/installed.new" "$WORK/installed"
# Only the newest file of each package is worth keeping for a repair.
find "$WORK/pkg" -type f -mtime +1 -delete 2>/dev/null
# What the binaries link is the real dependency list: anything this image lacks shows here.
missing=$(for f in /usr/bin/flatpak /usr/lib/libflatpak.so.0 /usr/lib/libostree-1.so.1 /usr/bin/xdg-dbus-proxy; do
ldd "$f" 2>&1 | grep -E 'not found|version .* not found'
done | sort -u)
[ -z "$missing" ] || { echo "$missing"; fail "Flatpak is missing libraries"; }
flatpak --version >/dev/null 2>&1 || fail "flatpak does not start"
python3 -c 'import gi; gi.require_version("Flatpak", "1.0"); from gi.repository import Flatpak' 2>/dev/null \
|| fail "the store helper cannot load libflatpak from Python"
step "Adding Flathub"
export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap
# Flathub's descriptor - its URL and the key its commits are signed with - is carried here rather
# than fetched: adding the remote then needs no network, and a descriptor that did not arrive
# whole cannot be what failed. Flathub's own copy: https://dl.flathub.org/repo/flathub.flatpakrepo
cat > "$WORK/flathub.flatpakrepo" <<'REPO'
[Flatpak Repo]
Title=Flathub
Url=https://dl.flathub.org/repo/
Homepage=https://flathub.org/
Comment=Central repository of Flatpak applications
Description=Central repository of Flatpak applications
Icon=https://dl.flathub.org/repo/logo.svg
GPGKey=mQINBFlD2sABEADsiUZUOYBg1UdDaWkEdJYkTSZD68214m8Q1fbrP5AptaUfCl8KYKFMNoAJRBXn9FbE6q6VBzghHXj/rSnA8WPnkbaEWR7xltOqzB1yHpCQ1l8xSfH5N02DMUBSRtD/rOYsBKbaJcOgW0K21sX+BecMY/AI2yADvCJEjhVKrjR9yfRX+NQEhDcbXUFRGt9ZT+TI5yT4xcwbvvTu7aFUR/dH7+wjrQ7lzoGlZGFFrQXSs2WI0WaYHWDeCwymtohXryF8lcWQkhH8UhfNJVBJFgCY8Q6UHkZG0FxMu8xnIDBMjBmSZKwKQn0nwzwM2afskZEnmNPYDI8nuNsSZBZSAw+ThhkdCZHZZRwzmjzyRuLLVFpOj3XryXwZcSefNMPDkZAuWWzPYjxS80cm2hG1WfqrG0Gl8+iX69cbQchb7gbEb0RtqNskTo9DDmO0bNKNnMbzmIJ3/rTbSahKSwtewklqSP/01o0WKZiy+n/RAkUKOFBprjJtWOZkc8SPXV/rnoS2dWsJWQZhuPPtv3tefdDiEyp7ePrfgfKxuHpZES0IZRiFI4J/nAUP5bix+srcIxOVqAam68CbAlPvWTivRUMRVbKjJiGXIOJ78wAMjqPg3QIC0GQ0EPAWwAOzzpdgbnG7TCQetaVV8rSYCuirlPYN+bJIwBtkOC9SWLoPMVZTwQARAQABtC5GbGF0aHViIFJlcG8gU2lnbmluZyBLZXkgPGZsYXRodWJAZmxhdGh1Yi5vcmc+iQJUBBMBCAA+FiEEblwF2XnHba+TwIE1QYTdTZB6fK4FAllD2sACGwMFCRLMAwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQQYTdTZB6fK5RJQ/+Ptd4sWxaiAW91FFk7+wmYOkEe1NY2UDNJjEEz34PNP/1RoxveHDt43kYJQ23OWaPJuZAbu+fWtjRYcMBzOsMCaFcRSHFiDIC9aTp4ux/mo+IEeyarYt/oyKb5t5lta6xaAqg7rwt65jW5/aQjnS4h7eFZ+dAKta7Y/fljNrOznUp81/SMcx4QA5G2Pw0hs4Xrxg59oONOTFGBgA6FF8WQghrpR7SnEe0FSEOVsAjwQ13Cfkfa7b70omXSWp7GWfUzgBKyoWxKTqzMN3RQHjjhPJcsQnrqH5enUu4Pcb2LcMFpzimHnUgb9ft72DP5wxfzHGAWOUiUXHbAekfq5iFks8cha/RST6wkxG3Rf44Zn09aOxh1btMcGL+5xb1G0BuCQnA0fP/kDYIPwh9z22EqwRQOspIcvGeLVkFeIfubxpcMdOfQqQnZtHMCabV5Q/Rk9K1ZGc8M2hlg8gHbXMFch2xJ0Wu72eXbA/UY5MskEeBgawTQnQOK/vNm7t0AJMpWK26Qg6178UmRghmeZDj9uNRc3EI1nSbgvmGlpDmCxaAGqaGL1zW4KPW5yN25/qeqXcgCvUjZLI9PNq3Kvizp1lUrbx7heRiSoazCucvHQ1VHUzcPVLUKKTkoTP8okThnRRRsBcZ1+jI4yMWIDLOCT7IW3FePr+3xyuy5eEo9a25Ag0EWUPa7AEQALT/CmSyZ8LWlRYQZKYw417p7Z2hxqd6TjwkwM3IQ1irumkWcTZBZIbBgrSOg6CcXD2oWydCQHWi9qaxhuhEl2bJL5LskmBcMxVdQeD0LLHd8QUnbnnIby8ocvWN1alPfvJFjCUTrmD22U1ycOzRw2lIe4kiQONbOZtdWrVImQQSndjFlisitbmlWHvHm2lOOYy8+GJB7YffVV193hmnBSJffCy4bvkuLxsI+n1DhOzc7MPV3z6HGk4HiEcF0yyt9tCYhpsxHFdBoq2h771HfAcS0s98EVAqYMFnf9em+4cnYpdI6mhIfS1FQiKl6DBAYA8tT3ggla00DurPo0JwX/zN+PaO5h/6O9aCZwV7G6rbkgMuqMergXaf8oP38gr0z+MqWnkfM63Bodq68GP4l4hd02BoFBbDf38TMuGQB14+twJMdfbAxo2MbgluvQgfwHfZ2ca6gyEY+9s/YD1gugLjV+S6CB51WkFNe1z4tAPgJZNxUcKCbeaHNbthl8Hks/pY9RCEseX/EdfzF18epbSjJMPh4DPQXbUoFwmyuYcoBOPmvZHNl9hK7B/1RP8w1ZrXk8qdupC0SNbafX7270B7lMMVImzZetGsM9ypXJ6llhp3FwW09iseNyGJGPsr/dvTMGDXqOPfU/9SAS1LSTY4K9PbRtdrBE318YX8mIk5ABEBAAGJBHIEGAEIACYWIQRuXAXZecdtr5PAgTVBhN1NkHp8rgUCWUPa7AIbAgUJEswDAAJACRBBhN1NkHp8rsF0IAQZAQgAHRYhBFSmzd2JGfsgQgDYrFYnAunj7X7oBQJZQ9rsAAoJEFYnAunj7X7oR6AP/0KYmiAFeqx14Z43/6s2gt3VhxlSd8bmcVV7oJFbMhdHBIeWBp2BvsUf00I0Zl14ZkwCKfLwbbORC2eIxvzJ+QWjGfPhDmS4XUSmhlXxWnYEveSek5Tde+fmu6lqKM8CHg5BNx4GWIX/vdLi1wWJZyhrUwwICAxkuhKxuP2Z1An48930eslTD2GGcjByc27+9cIZjHKa07I/aLffo04V+oMT9/tgzoquzgpVV4jwekADo2MJjhkkPveSNI420bgT+Q7Fi1l0X1aFUniBvQMsaBa27PngWm6xE2ZYvh7nWCdd5g0c0eLIHxWwzV1lZ4Ryx4ITO/VL25ItECcjhTRdYa64sA62MYSaB0x3eR+SihpgP3wSNPFu3MJo6FKTFdi4CBAEmpWHFW7FcRmd+cQXeFrHLN3iNVWryy0HK/CUEJmiZEmpNiXecl4vPIIuyF0zgSCztQtKoMr+injpmQGC/rF/ELBVZTUSLNB350S0Ztvw0FKWDAJSxFmoxt3xycqvvt47rxTrhi78nkk6jATKGyvP55sO+K7Q7Wh0DXA69hvPrYW2eu8jGCdVGxi6HX7L1qcfEd0378S71dZ3g9o6KKl1OsDWWQ6MJ6FGBZedl/ibRfs8p5+sbCX3lQSjEFy3rx6n0rUrXx8U2qb+RCLzJlmC5MNBOTDJwHPcX6gKsUcXZrEQALmRHoo3SrewO41RCr+5nUlqiqV3AohBMhnQbGzyHf2+drutIaoh7Rj80XRh2bkkuPLwlNPf+bTXwNVGse4bej7B3oV6Ae1N7lTNVF4Qh+1OowtGjmfJPWo0z1s6HFJVxoIof9z58Msvgao0zrKGqaMWaNQ6LUeC9g9Aj/9Uqjbo8X54aLiYs8Z1WNc06jKP+gv8AWLtv6CR+l2kLez1YMDucjm7v6iuCMVAmZdmxhg5I/X2+OM3vBsqPDdQpr2TPDLX3rCrSBiS0gOQ6DwN5N5QeTkxmY/7QO8bgLo/Wzu1iilH4vMKW6LBKCaRx5UEJxKpL4wkgITsYKneIt3NTHo5EOuaYk+y2+Dvt6EQFiuMsdbfUjs3seIHsghX/cbPJa4YUqZAL8C4OtVHaijwGo0ymt9MWvS9yNKMyT0JhN2/BdeOVWrHk7wXXJn/ZjpXilicXKPx4udCF76meE+6N2u/T+RYZ7fP1QMEtNZNmYDOfA6sViuPDfQSHLNbauJBo/n1sRYAsL5mcG22UDchJrlKvmK3EOADCQg+myrm8006LltubNB4wWNzHDJ0Ls2JGzQZCd/xGyVmUiidCBUrD537WdknOYE4FD7P0cHaM9brKJ/M8LkEH0zUlo73bY4XagbnCqve6PvQb5G2Z55qhWphd6f4B6DGed86zJEa/RhS
REPO
if ! flatpak remote-list --user --columns=name 2>/dev/null | grep -qx flathub; then
out=$(flatpak remote-add --user --if-not-exists flathub "$WORK/flathub.flatpakrepo" 2>&1)
rc=$?
echo "$out"
# What Flatpak said goes up with the failure, so the app can show it rather than a guess.
[ $rc -eq 0 ] || fail "could not add the Flathub remote: $(echo "$out" | grep -v '^[[:space:]]*$' | tail -1)"
fi
echo "== DONE $(flatpak --version)"
@@ -73,14 +73,29 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then
fi
unset bl_icd_lib
fi
# The app's gamescope build (tools/gamescope) takes realtime-priority Vulkan queues on this
# The app's gamescope build (tools/gamescope) asks for realtime-priority Vulkan queues on this
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it.
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1}
# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it
# changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context
# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off
# unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1).
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's
# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60
# execs a minute each. read -t on a pipe nothing ever writes to waits in bash itself.
nap() {
[ -n "${bl_nap_fd:-}" ] || exec {bl_nap_fd}<> <(:)
read -r -t "$1" -u "$bl_nap_fd" _ 2>/dev/null || :
}
export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache}
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
# Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the
# user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop.
export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap
export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}
# The cores a program runs on outside Steam (the desktop and everything started from it, a
# program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the
@@ -275,7 +290,7 @@ case $mode in
for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do
taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
done
sleep 5
nap 5
done
) &
fi
@@ -383,8 +398,9 @@ case $mode in
[ -z "${BL_DESKTOP:-}" ] && (
last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1}
while :; do
sleep 1
want=$(cat "$HOME/.droiddeck-fill" 2>/dev/null) || continue
nap 1
want=
{ read -r want || [ -n "$want" ]; } < "$HOME/.droiddeck-fill" 2>/dev/null || continue
case $want in 0|1) ;; *) continue ;; esac
[ "$want" = "$last" ] && continue
if xprop -root -f GAMESCOPE_FORCE_WINDOWS_FULLSCREEN 32c -set GAMESCOPE_FORCE_WINDOWS_FULLSCREEN "$want" 2>/dev/null; then
@@ -393,10 +409,30 @@ case $mode in
fi
done
) &
# Resume from the Steam menu. The menu is an overlay that takes input, and a fullscreen game that
# loses input minimizes itself; gamescope only takes a window out of that state when the focused
# window changes, which the menu never does, so the game stayed minimized after Resume (a black
# screen with its small caption in the top-left corner). gamescope-3.16.29-p4 restores it itself;
# this asks it again a moment after input comes back from the client (app 769) to anything else,
# which covers a game that minimizes late. Harmless when nothing is minimized.
# BL_RESTORE_ON_RESUME=0 turns it off.
[ -z "${BL_DESKTOP:-}" ] && [ "${BL_RESTORE_ON_RESUME:-1}" = 1 ] && (
prev=
xprop -spy -root GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do
app=${line##*= }
case $app in ''|*[!0-9]*) prev=; continue ;; esac
if [ "$prev" = 769 ] && [ "$app" != 769 ]; then
nap 1
xprop -root -f GAMESCOPE_RESTORE_FOCUS_WINDOW 32c -set GAMESCOPE_RESTORE_FOCUS_WINDOW 1 2>/dev/null \
&& echo "== resume: asked gamescope to restore the game window (app $app)"
fi
prev=$app
done
) &
(
beat=0
for _ in $(seq 1 720); do
sleep 5
nap 5
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
beat=$((beat + 1))
if [ "$((beat % 3))" -eq 0 ]; then
@@ -567,7 +603,7 @@ BUSCONF
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
sleep 0.5
nap 0.5
done
) &
stop_watcher=$!
@@ -146,6 +146,26 @@ bl_game_cpus() {
# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these
# titles are told to stay windowed at the session's own size - unless the user's launch options
# already say something about the video mode, which are left as they are.
# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for
# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against
# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime
# container that path never reaches Proton either. (WinNative's winnative-proton-launch.)
BL_LIBPATH_SETUP = """
bl_libpath() {
[ -n "${LD_LIBRARY_PATH:-}" ] || return 0
local kept= dir old_ifs=$IFS
IFS=:
for dir in $LD_LIBRARY_PATH; do
case $dir in
"" | */steamrtarm64 | */steamrtarm64/*) ;;
*) kept=${kept:+$kept:}$dir ;;
esac
done
IFS=$old_ifs
if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi
}
"""
BL_IDTECH3_SETUP = """
bl_idtech3() {
BL_EXTRA_ARGS=()
@@ -233,6 +253,7 @@ for name in %s; do
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
bl_libpath
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
fi
done
@@ -244,7 +265,7 @@ exit 1
# something else.
LAUNCHER_SH = LAUNCHER_SH.replace(
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP, 1)
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot
@@ -280,6 +301,7 @@ bl_components "$here"
bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
bl_libpath
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
@@ -369,6 +391,28 @@ def serialize(tokens):
return "\n".join(out) + "\n"
def write_changed(path, text, mode=None):
"""Writes `text` to `path` only when it differs, through a rename so a reader never sees half a
file. The session runs this script every 15 s for as long as the client does; rewriting files
that have not changed cost a python start's worth of writes each time, and config.vdf is the
client's own, which it may be writing at that moment. Returns whether anything was written."""
try:
with open(path, encoding="utf-8", errors="replace") as f:
same = f.read() == text
except OSError:
same = False
if not same:
tmp = path + ".bannerlator"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
if mode is not None:
os.chmod(tmp, mode)
os.replace(tmp, path)
elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode:
os.chmod(path, mode)
return not same
def first_key(path, section):
"""The single key naming a block inside `section`, or None. Used to keep a third-party tool's
own internal name, so a mapping the user already made against it keeps working."""
@@ -442,14 +486,10 @@ def adopt_extras(tools):
body = (EXTRA_WRAPPER_SH % entry_point).replace(
'here=$(dirname "$0")',
'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP, 1)
with open(wrapper, "w", encoding="utf-8") as f:
f.write(body)
os.chmod(wrapper, 0o755)
with open(manifest, "w", encoding="utf-8") as f:
f.write(EXTRA_MANIFEST_VDF)
with open(compat_vdf, "w", encoding="utf-8") as f:
f.write(EXTRA_COMPAT_TOOL_VDF % (name, entry))
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
write_changed(wrapper, body, 0o755)
write_changed(manifest, EXTRA_MANIFEST_VDF)
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry))
except OSError as e:
print("bannerlator-steam-compat: could not adopt %s (%s)" % (entry, e))
continue
@@ -494,7 +534,8 @@ def register_default(config_path, apps=(), name=None, protect=()):
# Tool names the user may deliberately have chosen (see adopt_extras); never repointed.
keep = {n.lower() for n in protect}
with open(config_path, encoding="utf-8", errors="replace") as f:
tokens = tokenize(f.read())
original = f.read()
tokens = tokenize(original)
steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
if steam is None:
return
@@ -521,10 +562,10 @@ def register_default(config_path, apps=(), name=None, protect=()):
continue
tokens[mapping + 1:mapping + 1] = [
'"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"]
tmp = config_path + ".bannerlator"
with open(tmp, "w", encoding="utf-8") as f:
f.write(serialize(tokens))
os.replace(tmp, config_path)
# Nothing to change is the usual case on the session's beat: the client's own file is left alone.
text = serialize(tokens)
if text != serialize(tokenize(original)):
write_changed(config_path, text)
def find_source(steam_root):
@@ -542,18 +583,14 @@ def build_tool(tool_dir):
"""Our own manifests and the launcher. The depot is found at launch time, not here."""
if os.path.islink(tool_dir):
os.unlink(tool_dir)
elif os.path.isdir(tool_dir):
# Earlier versions mirrored the depot in here as symlinks; start clean.
elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES):
# Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding
# only our own files is kept, and each file rewritten only when it changed.
shutil.rmtree(tool_dir)
os.makedirs(tool_dir)
with open(os.path.join(tool_dir, "toolmanifest.vdf"), "w", encoding="utf-8") as f:
f.write(TOOL_MANIFEST_VDF)
with open(os.path.join(tool_dir, "compatibilitytool.vdf"), "w", encoding="utf-8") as f:
f.write(COMPAT_TOOL_VDF % TOOL)
launcher = os.path.join(tool_dir, LAUNCHER)
with open(launcher, "w", encoding="utf-8") as f:
f.write(LAUNCHER_SH)
os.chmod(launcher, 0o755)
os.makedirs(tool_dir, exist_ok=True)
write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF)
write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % TOOL)
write_changed(os.path.join(tool_dir, LAUNCHER), LAUNCHER_SH, 0o755)
def main():
@@ -135,6 +135,10 @@ def watch(steam_root, listing):
candidates, sources = {}, {}
next_lookup = 0
last_status = None
# The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership
# only moves with a purchase or a new match, so it runs when the matches or their sources
# change, after a failed probe, and otherwise every five minutes - not on every 30 s beat.
probed, next_probe = None, 0
while True:
now = time.monotonic()
for game in games:
@@ -150,13 +154,16 @@ def watch(steam_root, listing):
if now >= next_lookup:
next_lookup = now + 300
status = "waiting for a matching Steam title"
if candidates:
key = (dict(candidates), dict(sources))
if candidates and (key != probed or now >= next_probe):
probed, next_probe = key, now + 300
try:
result = subprocess.run([sys.executable, __file__, "probe", steam_root,
*map(str, sorted(set(candidates.values())))],
capture_output=True, text=True, timeout=15)
if result.returncode != 0:
status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode))
probed = None
else:
line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP="))
snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP="))
@@ -168,6 +175,9 @@ def watch(steam_root, listing):
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
status = "ownership check unavailable: " + str(error)
probed = None
elif candidates:
status = last_status
if status != last_status:
print("bannerlator-steam-games: " + status, flush=True)
last_status = status
+83 -4
View File
@@ -20,6 +20,14 @@
* Only Wine's device host is answered, and only for the session's own nodes: the Steam client
* enumerates the same nodes for itself and would take a pad wearing Steam Input's identity for one
* of its own and ignore it. Every other caller, path and device object goes straight through.
*
* With libfakeinput's /dev/uinput stand-in (FAKE_EVDEV_UINPUT=1) Steam Input's own virtual pads
* exist too, as event16 and up, and those are what Wine is meant to read: each is described with
* the identity the client gave it, from the .uevent file libfakeinput writes beside the node.
* While one exists the app's pads are described as what they are, an Xbox 360 controller, which
* Wine's HID bus leaves alone - the game reads Steam Input's output, not the pad under it, as on
* a Steam Deck. With none (Steam Input off for the game) they keep the virtual identity, so the
* game still has a controller.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
@@ -31,8 +39,11 @@
#include <sys/sysmacros.h>
#include <sys/types.h>
/* The app opens one ring per configured controller; four is what its launcher offers. */
#define PAD_MAX 4
/* The app opens one ring per configured controller; four is what its launcher offers. Nodes from
* UINPUT_BASE up are the virtual pads made through libfakeinput's /dev/uinput stand-in. */
#define RING_MAX 4
#define UINPUT_BASE 16
#define PAD_MAX 64
#define EVENT_MINOR_BASE 64
/* BUS_VIRTUAL, and the identity Steam Input gives the gamepad it presents to a game. */
@@ -41,16 +52,23 @@
"NAME=\"Generic HID Gamepad %d\"\n" \
"PHYS=\"usb-fakeinput/input%d\"\n"
/* The identity libfakeinput gives the pad itself (FAKE_EVDEV_IDENTITY=xbox360). */
#define PAD_PHYSICAL_UEVENT_FORMAT \
"PRODUCT=3/45e/28e/110\n" \
"NAME=\"Xbox 360 Controller (%d)\"\n" \
"PHYS=\"usb-fakeinput/input%d\"\n"
struct pad {
char syspath[64];
char devnode[32];
char sysname[16];
char uevent[128];
char uevent[256];
dev_t devnum;
};
static struct pad pads[PAD_MAX];
static int pad_count;
static pthread_mutex_t virtual_lock = PTHREAD_MUTEX_INITIALIZER;
/*
* The slots the app published rings for, named in FAKE_EVDEV_MEMFD_PATHS as "<slot>=<path>"
@@ -66,7 +84,7 @@ static void discover_pads(void) {
char *end;
long slot = strtol(entry, &end, 10);
if (end != entry && *end == '=' && slot >= 0 && slot < PAD_MAX) {
if (end != entry && *end == '=' && slot >= 0 && slot < RING_MAX) {
struct pad *pad = &pads[slot];
if (pad->devnode[0] == '\0') {
@@ -90,6 +108,66 @@ static void ensure_pads(void) {
pthread_once(&once, discover_pads);
}
static int uinput_enabled(void) {
const char *enabled = getenv("FAKE_EVDEV_UINPUT");
return enabled != NULL && atoi(enabled) != 0;
}
/* Reads the .uevent libfakeinput wrote for virtual pad <node> into buf; 0 when there is none. */
static int read_virtual_uevent(int node, char *buf, size_t size) {
const char *dir = getenv("FAKE_EVDEV_DIR");
char path[PATH_MAX];
FILE *file;
size_t length;
if (dir == NULL) return 0;
snprintf(path, sizeof(path), "%s/.uinput-event%d.uevent", dir, node);
if ((file = fopen(path, "re")) == NULL) return 0;
length = fread(buf, 1, size - 1, file);
fclose(file);
buf[length] = '\0';
return length > 0;
}
static int any_virtual_pad(void) {
char uevent[256];
int node;
for (node = UINPUT_BASE; node < PAD_MAX; node++)
if (read_virtual_uevent(node, uevent, sizeof(uevent))) return 1;
return 0;
}
/*
* Brings the entry for event<node> up to date before Wine resolves it: a virtual pad comes and
* goes with the client, and the app's pads change identity with it.
*/
static void refresh_pad(int node) {
struct pad *pad;
if (node < 0 || node >= PAD_MAX) return;
pad = &pads[node];
pthread_mutex_lock(&virtual_lock);
if (node >= UINPUT_BASE) {
char uevent[sizeof(pad->uevent)];
if (read_virtual_uevent(node, uevent, sizeof(uevent))) {
snprintf(pad->sysname, sizeof(pad->sysname), "event%d", node);
snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%d", node);
memcpy(pad->uevent, uevent, sizeof(uevent));
pad->devnum = makedev(13, EVENT_MINOR_BASE + (unsigned int)node);
snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%d", node);
if (node >= pad_count) pad_count = node + 1;
} else {
pad->devnode[0] = '\0';
}
} else if (pad->devnode[0] != '\0' && uinput_enabled()) {
snprintf(pad->uevent, sizeof(pad->uevent),
any_virtual_pad() ? PAD_PHYSICAL_UEVENT_FORMAT : PAD_UEVENT_FORMAT, node, node);
}
pthread_mutex_unlock(&virtual_lock);
}
/* The pad a synthetic syspath names, or NULL when the path is not one of ours. */
static struct pad *pad_for_syspath(const char *syspath) {
int i;
@@ -123,6 +201,7 @@ static struct pad *pad_for_syslink(const char *path) {
if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0) return NULL;
name = path + sizeof(prefix) - 1;
ensure_pads();
if (strncmp(name, "event", 5) == 0 && name[5] >= '0' && name[5] <= '9') refresh_pad(atoi(name + 5));
for (i = 0; i < pad_count; i++) {
if (pads[i].devnode[0] != '\0' && strcmp(pads[i].sysname, name) == 0) return &pads[i];
}
+25
View File
@@ -45,6 +45,16 @@ Ported from WinNative (`main`, 53836ca9, "Fix/performance and vac"):
with no children or ptracees no longer walks every tracee; the per-stop memory collector is
emptied instead of freed and reallocated.
Added for Flatpak:
- `0010-new-mount-api-enosys.patch` - `open_tree`, `move_mount`, `fspick` and `mount_setattr`
answer `ENOSYS`. They take paths proot never translated, so libglnx's `open_tree(AT_FDCWD, "/")`
handed Flatpak a descriptor for the host's root and it tried to create directories there
(`mkdirat(root): Operation not permitted`); unsupported, libglnx falls back to `openat`.
- `0012-android-hardlink-denial.patch` - Android's SELinux policy denies apps hard links, so
`linkat` fails with `EACCES` and Flatpak could not create its repo (`Creating repo: linkat:
Permission denied`). `O_TMPFILE` answers `EOPNOTSUPP`, so libglnx writes a named temporary file
and renames it, and a denied link answers `EPERM`, on which ostree's checkout copies instead.
Added by DroidDeck:
- `0011-kompat-utsname-only.patch` - `--kernel-release` (the guest's `DroidDeck` hostname) loads
@@ -55,3 +65,18 @@ Added by DroidDeck:
and `setdomainname` and leaves the auxv as the kernel wrote it. On an SD 8 Gen 2 guest this
took a futex ping-pong from 467 to 97 us, `epoll_pwait` from 60 to 0.8 us and `fcntl` from
40-107 to 0.4 us, and `clock_gettime` back to the vDSO.
- `0012-fake_id0-identity-only.patch` - `-i uid:gid` (for Xwayland's setgid/setuid before it runs
xkbcomp) loads fake_id0, whose filter traps every `fstat`/`newfstatat`/`stat` (entry and exit),
every `sendmsg` (all Wayland, X11, Chromium and PulseAudio traffic), `socket`, `getsockopt`, the
`get*id` family and the chown/chmod family. When the ids given are the ones proot really has and
are not 0, every one of those handlers is a no-op; fake_id0 now traces only the `set*id` family
and the xattr permission fixups, and leaves set-user-ID bits alone on `execve` (Android mounts the
app's data `nosuid`, so the kernel would not honour them either), which keeps the ids fixed. On an
x86_64 host build under the same options: `fstat` 41.7 -> 1.3 us, `sendmsg` 18.0 -> 2.5 us.
- `0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch` - every `ioctl` stopped on entry and
exit, which is every GPU submit and wait. The filter now traces only the requests enter.c and
exit.c act on (`TCSETSF`, the four termios2 requests, `FICLONE`) and allows the rest at once, and
the ioctl block is emitted first (after WinNative 53836ca9, which traces only the termios2 ones).
The `faccessat2` exit stop (termux 5ba8b95, for glibc's ENOSYS fallback on kernels before 5.8) and
the `statx` one (emulation on kernels before 4.11) are dropped when the running kernel is newer.
Host build: `ioctl` 31.6 -> 0.5 us; `stat`/`statx`/`faccessat2` about 44 -> 28 us (one stop).
@@ -0,0 +1,105 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <noreply@droiddeck.invalid>
Date: Tue, 29 Sep 2026 08:00:00 -0400
Subject: [PATCH] syscall: answer the new mount API with ENOSYS
open_tree(AT_FDCWD, "/") and its relatives take paths proot never
translates, so libglnx (Flatpak) got a descriptor for the host root and
created directories there. Unsupported, callers fall back to openat.
---
diff --git a/src/syscall/enter.c b/src/syscall/enter.c
index 1d3b3fd..32d2503 100644
--- a/src/syscall/enter.c
+++ b/src/syscall/enter.c
@@ -465,6 +465,15 @@ int translate_syscall_enter(Tracee *tracee)
status = -ENOSYS;
break;
+ /* The new mount API opens and moves paths proot never sees: open_tree(AT_FDCWD, "/")
+ * hands back the host's root. Callers (libglnx, systemd) fall back to openat. */
+ case PR_open_tree:
+ case PR_move_mount:
+ case PR_fspick:
+ case PR_mount_setattr:
+ status = -ENOSYS;
+ break;
+
case PR_inotify_add_watch:
flags = peek_reg(tracee, CURRENT, SYSARG_3);
diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c
index 0b381bb..6ba7b67 100644
--- a/src/syscall/seccomp.c
+++ b/src/syscall/seccomp.c
@@ -457,6 +457,10 @@ static FilteredSysnum proot_sysnums[] = {
{ PR_open, 0 },
{ PR_openat, 0 },
{ PR_openat2, 0 },
+ { PR_open_tree, 0 },
+ { PR_move_mount, 0 },
+ { PR_fspick, 0 },
+ { PR_mount_setattr, 0 },
{ PR_pivot_root, 0 },
{ PR_prctl, 0 },
{ PR_prlimit64, FILTER_SYSEXIT },
diff --git a/src/syscall/sysnums-arm.h b/src/syscall/sysnums-arm.h
index e3f6f6b..ebf0afc 100644
--- a/src/syscall/sysnums-arm.h
+++ b/src/syscall/sysnums-arm.h
@@ -358,6 +358,10 @@ static const Sysnum sysnums_arm[] = {
[ 397 ] = PR_statx,
[ 439 ] = PR_faccessat2,
[ 435 ] = PR_clone3,
+ [ 428 ] = PR_open_tree,
+ [ 429 ] = PR_move_mount,
+ [ 433 ] = PR_fspick,
[ 437 ] = PR_openat2,
+ [ 442 ] = PR_mount_setattr,
[ 452 ] = PR_fchmodat2,
};
diff --git a/src/syscall/sysnums-arm64.h b/src/syscall/sysnums-arm64.h
index b257339..c68a1f8 100644
--- a/src/syscall/sysnums-arm64.h
+++ b/src/syscall/sysnums-arm64.h
@@ -278,8 +278,12 @@ static const Sysnum sysnums_arm64[] = {
[ 289 ] = PR_pkey_alloc,
[ 290 ] = PR_pkey_free,
[ 291 ] = PR_statx,
+ [ 428 ] = PR_open_tree,
+ [ 429 ] = PR_move_mount,
+ [ 433 ] = PR_fspick,
[ 435 ] = PR_clone3,
[ 437 ] = PR_openat2,
+ [ 442 ] = PR_mount_setattr,
[ 439 ] = PR_faccessat2,
[ 452 ] = PR_fchmodat2,
};
diff --git a/src/syscall/sysnums.list b/src/syscall/sysnums.list
index c221ddd..93ea2e2 100644
--- a/src/syscall/sysnums.list
+++ b/src/syscall/sysnums.list
@@ -86,6 +86,7 @@ SYSNUM(flock)
SYSNUM(fork)
SYSNUM(fremovexattr)
SYSNUM(fsetxattr)
+SYSNUM(fspick)
SYSNUM(fstat)
SYSNUM(fstat64)
SYSNUM(fstatat64)
@@ -191,6 +192,8 @@ SYSNUM(mmap)
SYSNUM(mmap2)
SYSNUM(modify_ldt)
SYSNUM(mount)
+SYSNUM(mount_setattr)
+SYSNUM(move_mount)
SYSNUM(move_pages)
SYSNUM(mprotect)
SYSNUM(mpx)
@@ -221,6 +224,7 @@ SYSNUM(oldstat)
SYSNUM(olduname)
SYSNUM(open)
SYSNUM(open_by_handle_at)
+SYSNUM(open_tree)
SYSNUM(openat)
SYSNUM(openat2)
SYSNUM(pause)
@@ -0,0 +1,82 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <noreply@droiddeck.invalid>
Date: Tue, 29 Sep 2026 15:00:00 -0400
Subject: [PATCH] syscall: work around Android's denial of hard links
SELinux denies apps the file link permission, so linkat fails with
EACCES. libostree names its O_TMPFILE files with linkat and failed to
create the Flathub repo ("Creating repo: linkat: Permission denied").
O_TMPFILE now answers EOPNOTSUPP, so libglnx falls back to a named
temporary file and a rename, and a denied link answers EPERM, which
ostree's checkout takes as a filesystem without hard links and copies.
---
diff --git a/src/syscall/enter.c b/src/syscall/enter.c
index 8c3eb2e..4c28e10 100644
--- a/src/syscall/enter.c
+++ b/src/syscall/enter.c
@@ -400,6 +400,13 @@ int translate_syscall_enter(Tracee *tracee)
case PR_open:
flags = peek_reg(tracee, CURRENT, SYSARG_2);
+#ifdef __ANDROID__
+ /* An O_TMPFILE file can only be named by linkat, which Android denies apps. */
+ if ((flags & __O_TMPFILE) != 0) {
+ status = -EOPNOTSUPP;
+ break;
+ }
+#endif
if ( ((flags & O_NOFOLLOW) != 0)
|| ((flags & O_EXCL) != 0 && (flags & O_CREAT) != 0))
@@ -518,6 +525,12 @@ int translate_syscall_enter(Tracee *tracee)
case PR_openat:
dirfd = peek_reg(tracee, CURRENT, SYSARG_1);
flags = peek_reg(tracee, CURRENT, SYSARG_3);
+#ifdef __ANDROID__
+ if ((flags & __O_TMPFILE) != 0) {
+ status = -EOPNOTSUPP;
+ break;
+ }
+#endif
status = get_sysarg_path(tracee, path, SYSARG_2);
if (status < 0)
diff --git a/src/syscall/exit.c b/src/syscall/exit.c
index df41084..5d981db 100644
--- a/src/syscall/exit.c
+++ b/src/syscall/exit.c
@@ -544,6 +544,16 @@ void translate_syscall_exit(Tracee *tracee)
status = handle_statx_syscall(tracee, false);
break;
+#ifdef __ANDROID__
+ /* Android denies apps hard links with EACCES; EPERM is what callers such as
+ * libostree take as "no hard links here" and copy instead. */
+ case PR_link:
+ case PR_linkat:
+ if ((int) syscall_result == -EACCES)
+ poke_reg(tracee, SYSARG_RESULT, -EPERM);
+ goto end;
+#endif
+
case PR_ioctl:
if (peek_reg(tracee, ORIGINAL, SYSARG_2) == _IOW(0x94, 9, int) /* FICLONE */ &&
(int) peek_reg(tracee, CURRENT, SYSARG_RESULT) == -EACCES) {
diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c
index 6d1ba47..8db9f45 100644
--- a/src/syscall/seccomp.c
+++ b/src/syscall/seccomp.c
@@ -362,8 +362,13 @@ static FilteredSysnum proot_sysnums[] = {
{ PR_lchown, 0 },
{ PR_lchown32, 0 },
{ PR_lgetxattr, 0 },
+#ifdef __ANDROID__
+ { PR_link, FILTER_SYSEXIT },
+ { PR_linkat, FILTER_SYSEXIT },
+#else
{ PR_link, 0 },
{ PR_linkat, 0 },
+#endif
{ PR_listxattr, 0 },
{ PR_llistxattr, 0 },
{ PR_lremovexattr, 0 },
@@ -0,0 +1,103 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <droiddeck@users.noreply.github.com>
Date: Tue, 29 Sep 2026 00:00:00 +0000
Subject: [PATCH] fake_id0: only trace the set*id and xattr calls when the ids
are PRoot's own
---
src/extension/fake_id0/config.h | 5 ++++
src/extension/fake_id0/fake_id0.c | 44 ++++++++++++++++++++++++++++++-
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/src/extension/fake_id0/config.h b/src/extension/fake_id0/config.h
index 2e78783..5227ced 100644
--- a/src/extension/fake_id0/config.h
+++ b/src/extension/fake_id0/config.h
@@ -2,6 +2,7 @@
#define FAKE_ID0_CONFIG_H
#include <sys/types.h> /* uid_t, gid_t */
+#include <stdbool.h> /* bool, */
typedef struct {
uid_t ruid;
@@ -15,6 +16,10 @@ typedef struct {
gid_t fsgid;
mode_t umask;
+
+ /* The emulated ids are the ones PRoot really has: see
+ * identity_sysnums in fake_id0.c. */
+ bool identity;
} Config;
#endif /* FAKE_ID0_CONFIG_H */
diff --git a/src/extension/fake_id0/fake_id0.c b/src/extension/fake_id0/fake_id0.c
index 9477f1c..fe08372 100644
--- a/src/extension/fake_id0/fake_id0.c
+++ b/src/extension/fake_id0/fake_id0.c
@@ -352,6 +352,40 @@ static FilteredSysnum filtered_sysnums[] = {
FILTERED_SYSNUM_END,
};
+/* When the emulated ids are the ones PRoot really has, and the tracee
+ * is not root, every handler for the syscalls above but these is a
+ * no-op: stat and get*id report the real ids already, SCM_CREDENTIALS
+ * and SO_PEERCRED carry them, chown swaps an id for itself, and the
+ * permission and chroot fixups only apply to euid 0. The set*id
+ * family is still emulated (Android's app seccomp policy traps it) and
+ * so are the xattr permission fixups, which apply to any euid. An
+ * unprivileged set*id can only take an id the tracee already holds,
+ * and set-user-ID bits are not honoured in this mode (Android mounts
+ * the app's data nosuid, so the kernel would not honour them either),
+ * so the ids never change and the list stays sufficient. */
+static FilteredSysnum identity_sysnums[] = {
+ { PR_fsetxattr, FILTER_SYSEXIT },
+ { PR_lsetxattr, FILTER_SYSEXIT },
+ { PR_setfsgid, FILTER_SYSEXIT },
+ { PR_setfsgid32, FILTER_SYSEXIT },
+ { PR_setfsuid, FILTER_SYSEXIT },
+ { PR_setfsuid32, FILTER_SYSEXIT },
+ { PR_setgid, FILTER_SYSEXIT },
+ { PR_setgid32, FILTER_SYSEXIT },
+ { PR_setregid, FILTER_SYSEXIT },
+ { PR_setregid32, FILTER_SYSEXIT },
+ { PR_setresgid, FILTER_SYSEXIT },
+ { PR_setresgid32, FILTER_SYSEXIT },
+ { PR_setresuid, FILTER_SYSEXIT },
+ { PR_setresuid32, FILTER_SYSEXIT },
+ { PR_setreuid, FILTER_SYSEXIT },
+ { PR_setreuid32, FILTER_SYSEXIT },
+ { PR_setuid, FILTER_SYSEXIT },
+ { PR_setuid32, FILTER_SYSEXIT },
+ { PR_setxattr, FILTER_SYSEXIT },
+ FILTERED_SYSNUM_END,
+};
+
/**
* Restore the @node->mode for the given @node->path.
*
@@ -1051,6 +1085,11 @@ static int handle_sysexit_start(Tracee *tracee, Config *config) {
if ((int) result < 0 || tracee->status < 0 || sysnum != PR_execve)
return 0;
+ /* The auxv already carries the real ids, and set-user-ID bits
+ * are not honoured in this mode: see identity_sysnums. */
+ if (config->identity)
+ return 0;
+
/* This has to be done before PRoot pushes the load
* script into tracee's stack. */
if (!tracee->skip_proot_loader)
@@ -1119,7 +1158,10 @@ int fake_id0_callback(Extension *extension, ExtensionEvent event, intptr_t data1
/* Set the umask to the typical linux value. */
config->umask = 022;
- extension->filtered_sysnums = filtered_sysnums;
+ config->identity = (uid != 0 && uid == (int) getuid() && gid == (int) getgid());
+ extension->filtered_sysnums = config->identity
+ ? identity_sysnums
+ : filtered_sysnums;
return 0;
}
@@ -0,0 +1,169 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <droiddeck@users.noreply.github.com>
Date: Tue, 29 Sep 2026 00:00:00 +0000
Subject: [PATCH] seccomp: trace ioctl by request, drop exit stops the kernel
does not need
---
src/syscall/seccomp.c | 98 ++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 97 insertions(+), 1 deletion(-)
diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c
index 0b381bb..1116ab2 100644
--- a/src/syscall/seccomp.c
+++ b/src/syscall/seccomp.c
@@ -39,6 +39,11 @@
#include <stdint.h> /* uint*_t, UINT*_MAX, */
#include <stdbool.h>
#include <sys/resource.h>
+#include <sys/utsname.h> /* uname(2), */
+#include <stdio.h> /* sscanf(3), */
+#include <asm/ioctls.h> /* TCGETS2, TCSETS*2, */
+#include <asm/termbits.h> /* struct termios2, */
+#include <linux/fs.h> /* FICLONE, */
#include <assert.h> /* assert(3), */
#include "syscall/seccomp.h"
@@ -166,6 +171,46 @@ static int add_trace_prlimit_stack(struct sock_fprog *program, word_t syscall, i
return add_statements(program, LENGTH_TRACE_PRLIMIT_STACK, statements);
}
+/**
+ * Append to @program->filter the statements that trace @syscall
+ * (ioctl) only for the requests enter.c and exit.c act on - the
+ * termios requests Android's policy denies an app on a pty, rewritten
+ * to ones it allows, and FICLONE, whose EACCES is turned into
+ * EOPNOTSUPP - and allow any other request at once. Every GPU submit
+ * and wait is an ioctl, so they no longer stop in the tracer, and the
+ * block is emitted first so they are also spared the rest of the list.
+ * (After WinNative, maxjivi05, 53836ca9.)
+ */
+static int add_trace_ioctl_requests(struct sock_fprog *program, word_t syscall, int flag)
+{
+ const size_t request_offset = offsetof(struct seccomp_data, args[1]);
+
+ #define LENGTH_TRACE_IOCTL 10
+ struct sock_filter statements[LENGTH_TRACE_IOCTL] = {
+ /* Not this syscall: skip the block, the accumulator
+ * still holds the syscall number. */
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, syscall, 0, 9),
+
+ /* Load the request's low word into the accumulator. */
+ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, request_offset),
+
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF, 5, 0),
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCGETS2, 4, 0),
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETS2, 3, 0),
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSW2, 2, 0),
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF2, 1, 0),
+ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, (uint32_t) FICLONE, 0, 1),
+
+ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE + flag),
+ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW)
+ };
+
+ DEBUG_FILTER("FILTER: trace if syscall == %ld and a termios2/TCSETSF/FICLONE request\n",
+ syscall);
+
+ return add_statements(program, LENGTH_TRACE_IOCTL, statements);
+}
+
static size_t trace_length(Sysnum sysnum)
{
switch (sysnum) {
@@ -174,6 +219,8 @@ static size_t trace_length(Sysnum sysnum)
return LENGTH_TRACE_SYSCALL_IF_ARG;
case PR_prlimit64:
return LENGTH_TRACE_PRLIMIT_STACK;
+ case PR_ioctl:
+ return LENGTH_TRACE_IOCTL;
default:
return LENGTH_TRACE_SYSCALL;
}
@@ -191,6 +238,8 @@ static int add_trace(struct sock_fprog *program, Sysnum sysnum, word_t syscall,
return add_trace_syscall_if_arg(program, syscall, 0, RLIMIT_STACK, flag);
case PR_prlimit64:
return add_trace_prlimit_stack(program, syscall, flag);
+ case PR_ioctl:
+ return add_trace_ioctl_requests(program, syscall, flag);
default:
return add_trace_syscall(program, syscall, flag);
}
@@ -329,7 +378,7 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums)
struct sock_fprog program = { .len = 0, .filter = NULL };
size_t section_length;
- size_t i, j, k;
+ size_t i, j, k, pass;
int status;
status = new_program_filter(&program);
@@ -355,8 +404,14 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums)
if (status < 0)
goto end;
+ /* Two passes: ioctl first, it is by far the most frequent
+ * syscall and almost every one of them is allowed. */
+ for (pass = 0; pass < 2; pass++)
for (j = 0; j < seccomp_archs[i].nb_abis; j++) {
for (k = 0; sysnums[k].value != PR_void; k++) {
+ if ((sysnums[k].value == PR_ioctl) != (pass == 0))
+ continue;
+
/* Get the architecture specific syscall number. */
syscall = detranslate_sysnum(seccomp_archs[i].abis[j], sysnums[k].value);
if (syscall == SYSCALL_AVOIDER)
@@ -572,12 +627,53 @@ int enable_syscall_filtering(const Tracee *tracee)
return 0;
}
+/**
+ * Drop the exit stops PRoot only needs on kernels older than the one it
+ * runs on. The faccessat2 exit stop restores the arguments for glibc's
+ * fallback to faccessat on ENOSYS (kernels before 5.8, termux 5ba8b95);
+ * the statx one emulates the call when it fails, which only a kernel
+ * before 4.11 does for want of it - a statx Android's policy traps is
+ * emulated from SIGSYS instead. Both stops re-read and re-translate
+ * the path, and are paid on every call.
+ */
+static void drop_unneeded_exit_stops(void)
+{
+ static bool done = false;
+ struct utsname utsname;
+ int major, minor;
+ size_t i;
+
+ if (done)
+ return;
+ done = true;
+
+ if (uname(&utsname) < 0 || sscanf(utsname.release, "%d.%d", &major, &minor) != 2)
+ return;
+
+ for (i = 0; proot_sysnums[i].value != PR_void; i++) {
+ switch (proot_sysnums[i].value) {
+ case PR_faccessat2:
+ if (major > 5 || (major == 5 && minor >= 8))
+ proot_sysnums[i].flags &= ~FILTER_SYSEXIT;
+ break;
+ case PR_statx:
+ if (major > 4 || (major == 4 && minor >= 11))
+ proot_sysnums[i].flags &= ~FILTER_SYSEXIT;
+ break;
+ default:
+ break;
+ }
+ }
+}
+
static int collect_filtered_sysnums(TALLOC_CTX *context, const Tracee *tracee,
FilteredSysnum **filtered_sysnums)
{
Extension *extension;
int status;
+ drop_unneeded_exit_stops();
+
/* Add the sysnums required by PRoot to the list of filtered
* sysnums. TODO: only if path translation is required. */
status = merge_filtered_sysnums(context, filtered_sysnums, proot_sysnums);