mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
Merge branch 'main' into stretch-to-panel
This commit is contained in:
commit
db8928f7ce
76 files changed
+6441
-305
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.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 & 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"))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +1,2 @@
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p3
|
||||
GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p4
|
||||
GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user